mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
refactor: install.sh
This commit is contained in:
+5
-5
@@ -10,23 +10,23 @@
|
||||
|
||||
**Интерактивный режим** (задаст вопросы в терминале):
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install-server.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
> Скрипт идемпотентен: при повторном запуске он предложит обновить, переконфигурировать или удалить сервер.
|
||||
|
||||
**Неинтерактивный режим** (для автоматизации):
|
||||
```bash
|
||||
# Установка через Docker (UDP, порт бэкенда 51820)
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install-server.sh | \
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | \
|
||||
sudo bash -s -- -y --method docker --mode udp --backend-port 51820
|
||||
|
||||
# Обновление до конкретной версии
|
||||
sudo bash install-server.sh -y --update --version v1.2.3
|
||||
sudo bash install.sh -y --update --version v1.2.3
|
||||
|
||||
# Полное удаление
|
||||
sudo bash install-server.sh -y --uninstall --purge
|
||||
sudo bash install.sh -y --uninstall --purge
|
||||
```
|
||||
*Все доступные флаги:* `sudo bash install-server.sh --help`
|
||||
*Все доступные флаги:* `sudo bash install.sh --help`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
Интерактивный скрипт установит Docker или systemd, настроит файрвол и запустит сервер. Запускать от root:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install-server.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
> Скопируйте параметры клиента, которые скрипт выдаст в конце. Для ручной установки или запуска скрипта без вопросов (non-interactive) см. [Развёртывание (deploy.md)](deploy.md).
|
||||
|
||||
|
||||
@@ -1,615 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Free Turn Proxy Server — установщик. Интерактивный TUI + non-interactive (-y).
|
||||
# Подробности: bash install-server.sh --help
|
||||
|
||||
set -Eeuo pipefail
|
||||
trap 'rc=$?; printf "\n\033[0;31m[x]\033[0m строка %s (код %s): %s\n" "${LINENO}" "${rc}" "${BASH_COMMAND}" >&2; exit "${rc}"' ERR
|
||||
|
||||
REPO="samosvalishe/free-turn-proxy"
|
||||
IMAGE="ghcr.io/${REPO}"
|
||||
APP_DIR="/opt/free-turn-proxy"
|
||||
CONF_FILE="${APP_DIR}/install.conf"
|
||||
SERVICE="free-turn-proxy.service"
|
||||
UNIT_FILE="/etc/systemd/system/${SERVICE}"
|
||||
COMPOSE_FILE="${APP_DIR}/docker-compose.yml"
|
||||
CONTAINER="free-turn-proxy"
|
||||
WT_BACK="Free Turn Proxy • установщик сервера"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${CYAN}[*]${NC} $1"; }
|
||||
success() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
error() { echo -e "${RED}[x]${NC} $1" >&2; exit 1; }
|
||||
|
||||
# Конфиг (перекрывается load_config / мастером / CLI)
|
||||
INSTALL_METHOD="1" # 1=docker 2=systemd
|
||||
VERSION="latest"
|
||||
PROXY_MODE="udp"
|
||||
BACKEND_PORT=""
|
||||
LISTEN_PORT="56000"
|
||||
OBF_PROFILE="rtpopus"
|
||||
OBF_KEY=""
|
||||
CLIENTS_FILE_CONF=""
|
||||
|
||||
GOARCH=""
|
||||
WG_PORT=""
|
||||
HAS_WHIPTAIL=0
|
||||
NONINTERACTIVE=0
|
||||
OPEN_FIREWALL="" # ""=спросить 1=да 0=нет
|
||||
PURGE=0
|
||||
ACTION=""
|
||||
OVERRIDES=()
|
||||
|
||||
valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; }
|
||||
valid_hex64() { [[ "$1" =~ ^[0-9a-fA-F]{64}$ ]]; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI: whiptail | plain (/dev/tty — работает через `curl | bash`)
|
||||
# ---------------------------------------------------------------------------
|
||||
wt() { whiptail --backtitle "$WT_BACK" "$@"; }
|
||||
ui_abort() { info "Отменено."; exit 0; }
|
||||
|
||||
ui_msg() { # TITLE TEXT
|
||||
if [ "$HAS_WHIPTAIL" = 1 ]; then wt --title "$1" --msgbox "$2" 12 72; else echo; warn "$1: $2"; fi
|
||||
}
|
||||
|
||||
ui_input() { # VAR PROMPT DEFAULT
|
||||
local __v="$1" __p="$2" __d="${3:-}" __a
|
||||
if [ "$HAS_WHIPTAIL" = 1 ]; then
|
||||
__a=$(wt --title "Free Turn Proxy" --inputbox "$__p" 10 72 "$__d" 3>&1 1>&2 2>&3) || ui_abort
|
||||
else
|
||||
if [ -n "$__d" ]; then read -r -p "$__p [$__d]: " __a </dev/tty
|
||||
else read -r -p "$__p: " __a </dev/tty; fi
|
||||
__a="${__a:-$__d}"
|
||||
fi
|
||||
printf -v "$__v" '%s' "$__a"
|
||||
}
|
||||
|
||||
ui_yesno() { # PROMPT DEFAULT(Y|N) -> 0/1
|
||||
local __p="$1" __def="${2:-Y}" __a
|
||||
if [ "$HAS_WHIPTAIL" = 1 ]; then
|
||||
if [ "$__def" = "Y" ]; then wt --title "Free Turn Proxy" --yesno "$__p" 10 72
|
||||
else wt --title "Free Turn Proxy" --defaultno --yesno "$__p" 10 72; fi
|
||||
return $?
|
||||
fi
|
||||
local hint; [ "$__def" = "Y" ] && hint="Y/n" || hint="y/N"
|
||||
read -r -p "$__p [$hint]: " __a </dev/tty
|
||||
[[ "${__a:-$__def}" =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
ui_menu() { # VAR PROMPT DEFAULT_TAG tag desc [tag desc...]
|
||||
local __v="$1" __p="$2" __def="$3"; shift 3
|
||||
local tags=() descs=()
|
||||
while [ $# -gt 0 ]; do tags+=("$1"); descs+=("$2"); shift 2; done
|
||||
if [ "$HAS_WHIPTAIL" = 1 ]; then
|
||||
local args=() i sel
|
||||
for i in "${!tags[@]}"; do args+=("${tags[$i]}" "${descs[$i]}"); done
|
||||
sel=$(wt --title "Free Turn Proxy" --default-item "$__def" \
|
||||
--menu "$__p" 17 74 "${#tags[@]}" "${args[@]}" 3>&1 1>&2 2>&3) || ui_abort
|
||||
printf -v "$__v" '%s' "$sel"
|
||||
else
|
||||
local i sel
|
||||
echo; info "$__p"
|
||||
for i in "${!tags[@]}"; do
|
||||
[ "${tags[$i]}" = "$__def" ] && echo -e " ${GREEN}${tags[$i]}${NC}) ${descs[$i]}" \
|
||||
|| echo " ${tags[$i]}) ${descs[$i]}"
|
||||
done
|
||||
while :; do
|
||||
read -r -p "Выбор [${__def}]: " sel </dev/tty
|
||||
sel="${sel:-$__def}"
|
||||
for i in "${!tags[@]}"; do
|
||||
[ "$sel" = "${tags[$i]}" ] && { printf -v "$__v" '%s' "$sel"; return; }
|
||||
done
|
||||
warn "Неверный выбор: $sel"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
ask_port() { # VAR PROMPT DEFAULT
|
||||
local __v="$1" __p="$2" __d="$3"
|
||||
while :; do
|
||||
ui_input "$__v" "$__p" "$__d"
|
||||
valid_port "${!__v}" && break
|
||||
ui_msg "Ошибка" "Порт — число 1–65535. Получено: '${!__v}'"
|
||||
done
|
||||
}
|
||||
|
||||
# Прелоадер: gauge (whiptail) | спиннер (tty) | строка (pipe/NI). Возвращает код задачи.
|
||||
with_progress() { # TITLE CMD ARGS...
|
||||
local title="$1"; shift
|
||||
local log; log="$(mktemp)"
|
||||
local rc=0
|
||||
if [ "$HAS_WHIPTAIL" = 1 ]; then
|
||||
( trap - ERR; "$@" >"$log" 2>&1 ) & local pid=$!
|
||||
{
|
||||
local p=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
echo "$p"; p=$((p+2)); if [ "$p" -gt 95 ]; then p=95; fi; sleep 0.25
|
||||
done
|
||||
echo 100
|
||||
} | wt --gauge "$title" 8 72 0
|
||||
wait "$pid" && rc=0 || rc=$?
|
||||
[ "$rc" -ne 0 ] && wt --title "Ошибка" --scrolltext --msgbox "$(tail -n 40 "$log")" 20 76
|
||||
elif [ -t 1 ]; then
|
||||
( trap - ERR; "$@" >"$log" 2>&1 ) & local pid=$! ch='|/-\' i=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
i=$(((i+1)%4)); printf "\r${CYAN}[*]${NC} %s %s" "$title" "${ch:$i:1}"; sleep 0.2
|
||||
done
|
||||
wait "$pid" && rc=0 || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then printf "\r${GREEN}[+]${NC} %s\033[K\n" "$title"
|
||||
else printf "\r${RED}[x]${NC} %s\033[K\n" "$title"; tail -n 40 "$log" >&2; fi
|
||||
else
|
||||
info "$title..."
|
||||
"$@" >"$log" 2>&1 && rc=0 || rc=$?
|
||||
[ "$rc" -ne 0 ] && tail -n 40 "$log" >&2
|
||||
fi
|
||||
rm -f "$log"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
ui_info_start() { [ "$HAS_WHIPTAIL" = 1 ] && wt --title "Подождите" --infobox "$1" 7 60 || info "$1"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Система
|
||||
# ---------------------------------------------------------------------------
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) GOARCH="amd64" ;;
|
||||
aarch64|arm64) GOARCH="arm64" ;;
|
||||
*) error "Неподдерживаемая архитектура: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pkg_install() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -y -qq >/dev/null 2>&1 || true
|
||||
apt-get install -y -qq "$@" >/dev/null 2>&1 || true
|
||||
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" >/dev/null 2>&1 || true
|
||||
elif command -v yum >/dev/null 2>&1; then yum install -y -q "$@" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_base_deps() {
|
||||
local missing=() b
|
||||
for b in curl jq openssl; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done
|
||||
if [ "${#missing[@]}" -ne 0 ]; then
|
||||
with_progress "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}"
|
||||
missing=()
|
||||
for b in curl jq openssl; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done
|
||||
[ "${#missing[@]}" -ne 0 ] && error "Не удалось установить: ${missing[*]}. Поставьте вручную."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_tui() {
|
||||
command -v whiptail >/dev/null 2>&1 && { HAS_WHIPTAIL=1; return; }
|
||||
if command -v apt-get >/dev/null 2>&1; then pkg_install whiptail; else pkg_install newt; fi
|
||||
command -v whiptail >/dev/null 2>&1 && HAS_WHIPTAIL=1 || { HAS_WHIPTAIL=0; warn "whiptail недоступен — текстовый режим."; }
|
||||
}
|
||||
|
||||
detect_wg_port() {
|
||||
WG_PORT=""
|
||||
command -v wg >/dev/null 2>&1 || return 0
|
||||
if wg show all listen-port >/dev/null 2>&1; then
|
||||
WG_PORT="$(wg show all listen-port 2>/dev/null | head -n1 | awk '{print $2}' || true)"
|
||||
fi
|
||||
if [ -z "$WG_PORT" ] && ls /etc/wireguard/*.conf >/dev/null 2>&1; then
|
||||
WG_PORT="$(grep -i ListenPort /etc/wireguard/*.conf 2>/dev/null | head -n1 | awk -F= '{print $2}' | tr -d ' ' || true)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub API
|
||||
# ---------------------------------------------------------------------------
|
||||
gh_latest_version() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name // empty'; }
|
||||
gh_recent_versions() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases?per_page=6" | jq -r '.[].tag_name'; }
|
||||
|
||||
resolve_asset_url() { # VERSION ASSET
|
||||
local api
|
||||
[ "$1" = "latest" ] && api="https://api.github.com/repos/${REPO}/releases/latest" \
|
||||
|| api="https://api.github.com/repos/${REPO}/releases/tags/$1"
|
||||
curl -s --max-time 15 "$api" | jq -r --arg n "$2" '.assets[] | select(.name==$n) | .browser_download_url'
|
||||
}
|
||||
|
||||
image_tag() { [ "$VERSION" = "latest" ] && echo "latest" || echo "$VERSION"; }
|
||||
|
||||
choose_version() {
|
||||
ui_info_start "Получение списка версий с GitHub..."
|
||||
local latest others t def="${VERSION:-latest}"
|
||||
latest="$(gh_latest_version || true)"
|
||||
others="$([ -n "$latest" ] && gh_recent_versions 2>/dev/null | grep -vx "$latest" | head -4 || true)"
|
||||
|
||||
if [ "$HAS_WHIPTAIL" = 1 ] && [ -n "$latest" ]; then
|
||||
local args=("latest" "последний стабильный (= $latest)") sel
|
||||
while IFS= read -r t; do [ -n "$t" ] && args+=("$t" "релиз $t"); done <<< "$others"
|
||||
args+=("custom" "ввести тег вручную")
|
||||
sel=$(wt --title "Версия" --default-item "$def" --menu "Выберите версию" 17 72 8 "${args[@]}" 3>&1 1>&2 2>&3) || ui_abort
|
||||
[ "$sel" = "custom" ] && ui_input VERSION "Тег (latest или vX.Y.Z)" "$def" || VERSION="$sel"
|
||||
else
|
||||
echo
|
||||
if [ -n "$latest" ]; then
|
||||
echo -e " ${GREEN}latest${NC} (= $latest)"
|
||||
[ -n "$others" ] && while IFS= read -r t; do echo " $t"; done <<< "$others"
|
||||
else
|
||||
warn "Список релизов недоступен — введите тег вручную."
|
||||
fi
|
||||
echo
|
||||
ui_input VERSION "Версия (latest или vX.Y.Z)" "$def"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Конфиг установки
|
||||
# ---------------------------------------------------------------------------
|
||||
is_installed() { [ -f "$CONF_FILE" ] || [ -f "$COMPOSE_FILE" ] || [ -f "$UNIT_FILE" ]; }
|
||||
load_config() { [ -f "$CONF_FILE" ] && . "$CONF_FILE" || true; }
|
||||
|
||||
save_config() {
|
||||
mkdir -p "$APP_DIR"
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
INSTALL_METHOD="$INSTALL_METHOD"
|
||||
VERSION="$VERSION"
|
||||
PROXY_MODE="$PROXY_MODE"
|
||||
BACKEND_PORT="$BACKEND_PORT"
|
||||
LISTEN_PORT="$LISTEN_PORT"
|
||||
OBF_PROFILE="$OBF_PROFILE"
|
||||
OBF_KEY="$OBF_KEY"
|
||||
CLIENTS_FILE_CONF="$CLIENTS_FILE_CONF"
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
}
|
||||
|
||||
apply_overrides() { # CLI бьёт install.conf
|
||||
local kv k v
|
||||
for kv in "${OVERRIDES[@]+"${OVERRIDES[@]}"}"; do k="${kv%%=*}"; v="${kv#*=}"; printf -v "$k" '%s' "$v"; done
|
||||
}
|
||||
|
||||
connect_addr() { echo "127.0.0.1:${BACKEND_PORT}"; }
|
||||
method_label() { [ "$INSTALL_METHOD" = "1" ] && echo "docker" || echo "systemd"; }
|
||||
|
||||
validate_config() {
|
||||
case "$INSTALL_METHOD" in 1|2) ;; *) error "method: docker|systemd (1|2), а не '$INSTALL_METHOD'" ;; esac
|
||||
case "$PROXY_MODE" in udp|tcp) ;; *) error "mode: udp|tcp, а не '$PROXY_MODE'" ;; esac
|
||||
valid_port "$LISTEN_PORT" || error "listen-port невалиден: '$LISTEN_PORT'"
|
||||
[ -z "$BACKEND_PORT" ] && { [ "$PROXY_MODE" = "udp" ] && BACKEND_PORT="51820" || BACKEND_PORT="443"; }
|
||||
valid_port "$BACKEND_PORT" || error "backend-port невалиден: '$BACKEND_PORT'"
|
||||
case "$OBF_PROFILE" in
|
||||
rtpopus)
|
||||
[ -z "$OBF_KEY" ] && { OBF_KEY="$(openssl rand -hex 32)"; info "Сгенерирован ключ: $OBF_KEY"; }
|
||||
valid_hex64 "$OBF_KEY" || error "obf-key — ровно 64 hex-символа" ;;
|
||||
none) OBF_KEY="" ;;
|
||||
*) error "obf: rtpopus|none, а не '$OBF_PROFILE'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Мастер
|
||||
# ---------------------------------------------------------------------------
|
||||
wizard() {
|
||||
local m def_method; [ "$INSTALL_METHOD" = "2" ] && def_method="systemd" || def_method="docker"
|
||||
ui_menu m "Метод установки сервера:" "$def_method" \
|
||||
docker "Docker Compose (рекомендуется)" \
|
||||
systemd "Systemd (бинарь напрямую)"
|
||||
[ "$m" = "docker" ] && INSTALL_METHOD="1" || INSTALL_METHOD="2"
|
||||
|
||||
ui_menu PROXY_MODE "Режим туннеля:" "${PROXY_MODE:-udp}" \
|
||||
udp "UDP-relay — WireGuard / AmneziaWG" \
|
||||
tcp "TCP-forward — Xray / sing-box"
|
||||
|
||||
detect_wg_port
|
||||
local def_backend="$BACKEND_PORT"
|
||||
if [ -z "$def_backend" ]; then
|
||||
if [ -n "$WG_PORT" ]; then def_backend="$WG_PORT"; info "Найден WireGuard на порту $WG_PORT."
|
||||
elif [ "$PROXY_MODE" = "udp" ]; then def_backend="51820"; else def_backend="443"; fi
|
||||
fi
|
||||
ask_port BACKEND_PORT "Порт вашего VPN / бэкенда" "$def_backend"
|
||||
ask_port LISTEN_PORT "Внешний порт (приём Free Turn Proxy)" "${LISTEN_PORT:-56000}"
|
||||
|
||||
local obf_def="Y"; [ "$OBF_PROFILE" = "none" ] && obf_def="N"
|
||||
if ui_yesno "Включить маскировку rtpopus? (рекомендуется)" "$obf_def"; then
|
||||
OBF_PROFILE="rtpopus"
|
||||
if [ -n "$OBF_KEY" ]; then
|
||||
ui_yesno "Сгенерировать НОВЫЙ ключ? (нет — оставить текущий)" "N" && { OBF_KEY="$(openssl rand -hex 32)"; }
|
||||
elif ui_yesno "Сгенерировать случайный ключ?" "Y"; then
|
||||
OBF_KEY="$(openssl rand -hex 32)"
|
||||
else
|
||||
while :; do
|
||||
ui_input OBF_KEY "64-hex ключ обфускации" ""
|
||||
valid_hex64 "$OBF_KEY" && break
|
||||
ui_msg "Ошибка" "Ключ — ровно 64 hex-символа."
|
||||
done
|
||||
fi
|
||||
else
|
||||
OBF_PROFILE="none"; OBF_KEY=""
|
||||
fi
|
||||
|
||||
local auth_def="N"; [ -n "$CLIENTS_FILE_CONF" ] && auth_def="Y"
|
||||
ui_yesno "Включить авторизацию по Client ID?" "$auth_def" \
|
||||
&& CLIENTS_FILE_CONF="${APP_DIR}/clients.json" || CLIENTS_FILE_CONF=""
|
||||
|
||||
choose_version
|
||||
|
||||
ui_yesno "Открыть порт ${LISTEN_PORT}/udp в UFW/iptables?" "Y" && OPEN_FIREWALL=1 || OPEN_FIREWALL=0
|
||||
}
|
||||
|
||||
firewall_open() {
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
ufw allow "${LISTEN_PORT}/udp" >/dev/null 2>&1 || warn "ufw allow не удался."
|
||||
success "Порт ${LISTEN_PORT}/udp открыт (UFW)."
|
||||
elif command -v iptables >/dev/null 2>&1; then
|
||||
if ! iptables -C INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null; then
|
||||
iptables -I INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null || warn "iptables -I не удался."
|
||||
command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1 || true
|
||||
fi
|
||||
success "Порт ${LISTEN_PORT}/udp открыт (iptables)."
|
||||
else
|
||||
warn "Брандмауэр не найден — откройте ${LISTEN_PORT}/udp вручную."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Применение
|
||||
# ---------------------------------------------------------------------------
|
||||
init_clients_file() {
|
||||
[ -n "$CLIENTS_FILE_CONF" ] && [ ! -f "$CLIENTS_FILE_CONF" ] && echo "[]" > "$CLIENTS_FILE_CONF" || true
|
||||
}
|
||||
|
||||
ensure_docker() {
|
||||
command -v docker >/dev/null 2>&1 && return 0
|
||||
if [ "$NONINTERACTIVE" != 1 ] && ! ui_yesno "Docker не найден. Установить?" "Y"; then
|
||||
error "Для метода docker нужен Docker."
|
||||
fi
|
||||
with_progress "Установка Docker" sh -c 'curl -fsSL https://get.docker.com | sh' \
|
||||
|| error "Установка Docker не удалась."
|
||||
command -v docker >/dev/null 2>&1 || error "Docker не появился в PATH."
|
||||
}
|
||||
|
||||
healthcheck_docker() {
|
||||
sleep 2
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ] && { success "Контейнер работает."; return; }
|
||||
warn "Контейнер не запущен. Логи:"
|
||||
docker logs --tail 40 "$CONTAINER" 2>&1 || true
|
||||
error "$CONTAINER не поднялся."
|
||||
}
|
||||
|
||||
apply_docker() {
|
||||
ensure_docker
|
||||
{
|
||||
echo "services:"
|
||||
echo " free-turn-proxy:"
|
||||
echo " image: ${IMAGE}:$(image_tag)"
|
||||
echo " container_name: ${CONTAINER}"
|
||||
echo " network_mode: \"host\""
|
||||
echo " restart: unless-stopped"
|
||||
echo " environment:"
|
||||
echo " - CONNECT_ADDR=$(connect_addr)"
|
||||
echo " - LISTEN_ADDR=0.0.0.0:${LISTEN_PORT}"
|
||||
echo " - MODE=${PROXY_MODE}"
|
||||
echo " - OBF_PROFILE=${OBF_PROFILE}"
|
||||
echo " - OBF_KEY=${OBF_KEY}"
|
||||
if [ -n "$CLIENTS_FILE_CONF" ]; then
|
||||
echo " - CLIENTS_FILE=${CLIENTS_FILE_CONF}"
|
||||
echo " volumes:"
|
||||
echo " - ${CLIENTS_FILE_CONF}:${CLIENTS_FILE_CONF}"
|
||||
fi
|
||||
} > "$COMPOSE_FILE"
|
||||
|
||||
cd "$APP_DIR"
|
||||
with_progress "Загрузка образа ${IMAGE}:$(image_tag)" docker compose pull || error "docker compose pull не удался."
|
||||
with_progress "Запуск контейнера" docker compose up -d || error "docker compose up не удался."
|
||||
healthcheck_docker
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local url
|
||||
url="$(resolve_asset_url "$VERSION" "server-linux-${GOARCH}" || true)"
|
||||
[ -z "$url" ] && error "Не найден server-linux-${GOARCH} в релизе ${VERSION}."
|
||||
with_progress "Скачивание server-linux-${GOARCH} (${VERSION})" \
|
||||
curl -fL -o "${APP_DIR}/server.new" "$url" || error "Не удалось скачать $url"
|
||||
chmod +x "${APP_DIR}/server.new"
|
||||
mv -f "${APP_DIR}/server.new" "${APP_DIR}/server"
|
||||
}
|
||||
|
||||
healthcheck_systemd() {
|
||||
sleep 1
|
||||
systemctl is-active --quiet "$SERVICE" && { success "Служба активна."; return; }
|
||||
warn "Служба не активна. Логи:"
|
||||
journalctl -u "$SERVICE" --no-pager -n 40 2>&1 || true
|
||||
error "$SERVICE не запустилась."
|
||||
}
|
||||
|
||||
apply_systemd() {
|
||||
download_binary
|
||||
local args="-listen 0.0.0.0:${LISTEN_PORT} -connect $(connect_addr) -mode ${PROXY_MODE}"
|
||||
[ "$OBF_PROFILE" != "none" ] && args="$args -obf-profile ${OBF_PROFILE} -obf-key ${OBF_KEY}"
|
||||
[ -n "$CLIENTS_FILE_CONF" ] && args="$args -clients-file ${CLIENTS_FILE_CONF}"
|
||||
cat > "$UNIT_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Free TURN Proxy Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${APP_DIR}/server ${args}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE" >/dev/null 2>&1 || true
|
||||
systemctl restart "$SERVICE" || error "systemctl restart не удался."
|
||||
healthcheck_systemd
|
||||
}
|
||||
|
||||
apply() {
|
||||
mkdir -p "$APP_DIR"
|
||||
init_clients_file
|
||||
if [ "$INSTALL_METHOD" = "1" ]; then apply_docker; else apply_systemd; fi
|
||||
[ "$OPEN_FIREWALL" = "1" ] && firewall_open
|
||||
save_config
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local ext_ip; ext_ip="$(curl -s --max-time 5 ifconfig.me || echo "IP_СЕРВЕРА")"
|
||||
local body="Сервер (peer): ${ext_ip}:${LISTEN_PORT}\nРежим: ${PROXY_MODE}\nВерсия: ${VERSION}\nМетод: $(method_label)"
|
||||
[ "$OBF_PROFILE" != "none" ] && body="${body}\nОбфускация: ${OBF_PROFILE}\nКлюч: ${OBF_KEY}"
|
||||
if [ -n "$CLIENTS_FILE_CONF" ]; then
|
||||
if [ "$INSTALL_METHOD" = "1" ]; then
|
||||
body="${body}\n\nClient ID auth ВКЛ. Добавить клиента:\n docker exec -it ${CONTAINER} /app/server clients add <id>"
|
||||
else
|
||||
body="${body}\n\nClient ID auth ВКЛ. Добавить клиента:\n ${APP_DIR}/server -clients-file ${CLIENTS_FILE_CONF} clients add <id>"
|
||||
fi
|
||||
fi
|
||||
if [ "$HAS_WHIPTAIL" = 1 ]; then
|
||||
wt --title "Готово ✓" --msgbox "$(echo -e "$body")" 18 74
|
||||
else
|
||||
echo ""; success "Готово!"
|
||||
echo "--------------------------------------------------------"
|
||||
echo -e "$body"
|
||||
echo "--------------------------------------------------------"
|
||||
fi
|
||||
echo -e "Документация: https://github.com/${REPO}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Сценарии
|
||||
# ---------------------------------------------------------------------------
|
||||
flow_install() { wizard; validate_config; apply; print_summary; }
|
||||
flow_reconfigure() { wizard; validate_config; apply; print_summary; }
|
||||
|
||||
flow_update() {
|
||||
[ -f "$CONF_FILE" ] || { warn "Конфиг не найден — переконфигурация."; flow_reconfigure; return; }
|
||||
choose_version
|
||||
[ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=0
|
||||
validate_config; apply; print_summary
|
||||
}
|
||||
|
||||
do_uninstall() { # REMOVE_DIR(0|1)
|
||||
if [ "$INSTALL_METHOD" = "1" ]; then
|
||||
[ -f "$COMPOSE_FILE" ] && ( cd "$APP_DIR" && docker compose down ) || warn "docker compose down — пропуск/ошибка."
|
||||
else
|
||||
systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_FILE"; systemctl daemon-reload || true
|
||||
fi
|
||||
success "Служба остановлена и удалена."
|
||||
[ "$1" = "1" ] && { rm -rf "$APP_DIR"; success "Каталог $APP_DIR удалён."; } || info "Каталог сохранён: $APP_DIR"
|
||||
}
|
||||
|
||||
flow_uninstall() {
|
||||
ui_yesno "Удалить Free Turn Proxy Server?" "N" || { info "Отменено."; return; }
|
||||
local rm_dir=0
|
||||
ui_yesno "Удалить каталог ${APP_DIR} (ключи, clients.json, конфиг)?" "N" && rm_dir=1
|
||||
do_uninstall "$rm_dir"
|
||||
}
|
||||
|
||||
menu_existing() {
|
||||
local choice
|
||||
ui_menu choice "Установка найдена (метод: $(method_label), версия: ${VERSION:-?}). Действие:" "update" \
|
||||
update "Обновить версию (конфиг сохранён)" \
|
||||
reconfigure "Переконфигурировать" \
|
||||
uninstall "Удалить" \
|
||||
exit "Выход"
|
||||
case "$choice" in
|
||||
update) flow_update ;; reconfigure) flow_reconfigure ;;
|
||||
uninstall) flow_uninstall ;; exit) info "Выход."; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_noninteractive() {
|
||||
is_installed && load_config || true
|
||||
apply_overrides
|
||||
if [ "${ACTION:-install}" = "uninstall" ]; then do_uninstall "$PURGE"; return; fi
|
||||
validate_config
|
||||
[ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=1
|
||||
info "Применяю конфигурацию (метод: $(method_label))..."
|
||||
apply; print_summary
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Free Turn Proxy Server — установщик.
|
||||
|
||||
sudo bash install-server.sh интерактивно (whiptail/текст)
|
||||
sudo bash install-server.sh -y [опции] non-interactive
|
||||
|
||||
Опции:
|
||||
-y, --yes, --non-interactive без вопросов
|
||||
--method docker|systemd метод (default docker)
|
||||
--mode udp|tcp режим (default udp)
|
||||
--backend-port N порт бэкенда (default udp→51820 / tcp→443)
|
||||
--listen-port N внешний порт (default 56000)
|
||||
--obf rtpopus|none обфускация (default rtpopus)
|
||||
--obf-key HEX64 ключ (нет → сгенерируется)
|
||||
--clients-auth / --no-clients-auth авторизация Client ID (default off)
|
||||
--version latest|vX.Y.Z версия (default latest)
|
||||
--firewall / --no-firewall открывать порт (NI default: да)
|
||||
--update | --reconfigure обновить / переустановить
|
||||
--uninstall [--purge] удалить (--purge — снести каталог)
|
||||
-h, --help
|
||||
|
||||
Примеры:
|
||||
sudo bash install-server.sh -y --method docker --mode udp --backend-port 51820
|
||||
sudo bash install-server.sh -y --update --version v1.2.3
|
||||
sudo bash install-server.sh -y --uninstall --purge
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y|--yes|--non-interactive) NONINTERACTIVE=1 ;;
|
||||
--method)
|
||||
case "${2:-}" in docker|1) OVERRIDES+=("INSTALL_METHOD=1");; systemd|2) OVERRIDES+=("INSTALL_METHOD=2");; *) error "--method: docker|systemd";; esac; shift ;;
|
||||
--mode) OVERRIDES+=("PROXY_MODE=${2:-}"); shift ;;
|
||||
--backend-port) OVERRIDES+=("BACKEND_PORT=${2:-}"); shift ;;
|
||||
--listen-port) OVERRIDES+=("LISTEN_PORT=${2:-}"); shift ;;
|
||||
--obf) OVERRIDES+=("OBF_PROFILE=${2:-}"); shift ;;
|
||||
--obf-key) OVERRIDES+=("OBF_KEY=${2:-}"); shift ;;
|
||||
--clients-auth) OVERRIDES+=("CLIENTS_FILE_CONF=${APP_DIR}/clients.json") ;;
|
||||
--no-clients-auth) OVERRIDES+=("CLIENTS_FILE_CONF=") ;;
|
||||
--version) OVERRIDES+=("VERSION=${2:-}"); shift ;;
|
||||
--firewall) OPEN_FIREWALL=1 ;;
|
||||
--no-firewall) OPEN_FIREWALL=0 ;;
|
||||
--update) ACTION="update"; NONINTERACTIVE=1 ;;
|
||||
--reconfigure) ACTION="reconfigure"; NONINTERACTIVE=1 ;;
|
||||
--uninstall) ACTION="uninstall"; NONINTERACTIVE=1 ;;
|
||||
--purge) PURGE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) error "Неизвестный аргумент: $1 (см. --help)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
banner() {
|
||||
[ "$HAS_WHIPTAIL" = 1 ] && return 0
|
||||
echo -e "${CYAN}"
|
||||
cat << "EOF"
|
||||
______ _____ ____
|
||||
/ ____/_______ ___ /_ __/_ ___________ / __ \_________ _ ____ __
|
||||
/ /_ / ___/ _ \/ _ \ / / / / / / ___/ __ \/ /_/ / ___/ __ \| |/_/ / / /
|
||||
/ __/ / / / __/ __/ / / / /_/ / / / / / / ____/ / / /_/ /> </ /_/ /
|
||||
/_/ /_/ \___/\___/ /_/ \__,_/_/ /_/ /_/_/ /_/ \____/_/|_|\__, /
|
||||
/____/
|
||||
EOF
|
||||
echo -e "${NC}"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
[ "$(id -u)" -ne 0 ] && error "Запустите от root (sudo)."
|
||||
ensure_base_deps
|
||||
detect_arch
|
||||
if [ "$NONINTERACTIVE" = 1 ]; then run_noninteractive; return; fi
|
||||
ensure_tui
|
||||
banner
|
||||
if is_installed; then load_config; menu_existing; else flow_install; fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+941
@@ -0,0 +1,941 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Free Turn Proxy — установщик сервера (gum UI, Material-палитра).
|
||||
#
|
||||
# sudo bash install.sh интерактивный мастер
|
||||
# sudo bash install.sh -y [опции] non-interactive (CI / автоматизация)
|
||||
# sudo bash install.sh --help все опции
|
||||
#
|
||||
# Работает через `curl -fsSL .../install.sh | sudo bash` — интерактивный ввод
|
||||
# читается из /dev/tty. UI рендерит gum (ставится автоматически); при недоступности
|
||||
# gum скрипт молча падает в текстовый режим.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Константы
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
readonly REPO="samosvalishe/free-turn-proxy"
|
||||
readonly IMAGE="ghcr.io/${REPO}"
|
||||
readonly APP_DIR="/opt/free-turn-proxy"
|
||||
readonly CONF_FILE="${APP_DIR}/install.conf"
|
||||
readonly SERVICE="free-turn-proxy.service"
|
||||
readonly UNIT_FILE="/etc/systemd/system/${SERVICE}"
|
||||
readonly COMPOSE_FILE="${APP_DIR}/docker-compose.yml"
|
||||
readonly CONTAINER="free-turn-proxy"
|
||||
readonly GUM_VERSION="0.17.0" # pinned; при сбое скачивания — fallback на latest API
|
||||
|
||||
# WireGuard bootstrap (опциональный, только mode=udp)
|
||||
readonly WG_DIR="/etc/wireguard"
|
||||
readonly WG_IFACE="wg0"
|
||||
readonly WG_CONF="${WG_DIR}/${WG_IFACE}.conf"
|
||||
readonly WG_NET="10.13.13" # /24, .1 = сервер, .2 = первый пир
|
||||
readonly WG_CLIENT_CONF="${APP_DIR}/wireguard-client.conf"
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Material Design 3 — тональная палитра (читаемые на тёмном фоне тона)
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
readonly MD_PRIMARY="#D0BCFF" # акцент: заголовки, курсор, prompt
|
||||
readonly MD_SECONDARY="#CCC2DC" # подписи, dim-текст
|
||||
readonly MD_TERTIARY="#EFB8C8" # курсор выбора
|
||||
readonly MD_SUCCESS="#81C784" # ok
|
||||
readonly MD_ERROR="#F2B8B5" # error
|
||||
# Фиолетовый градиент баннера (Material primary tonal P90→P30).
|
||||
readonly BANNER_GRADIENT=("#EADDFF" "#D0BCFF" "#B69DF8" "#9A82DB" "#7F67BE" "#6750A4")
|
||||
|
||||
# ANSI для plain-fallback (без gum).
|
||||
readonly C_RED='\033[0;31m' C_GREEN='\033[0;32m' C_YELLOW='\033[1;33m' C_CYAN='\033[0;36m' C_NC='\033[0m'
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Конфиг установки (перекрывается load_config → мастером → CLI-overrides)
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
INSTALL_METHOD="docker" # docker | systemd
|
||||
VERSION="latest"
|
||||
PROXY_MODE="udp" # udp | tcp
|
||||
BACKEND_PORT=""
|
||||
LISTEN_PORT="56000"
|
||||
OBF_PROFILE="rtpopus" # rtpopus | none
|
||||
OBF_KEY=""
|
||||
CLIENTS_FILE_CONF="" # пусто = auth выключен
|
||||
WG_SETUP=0 # 1 = поднять WireGuard-сервер на BACKEND_PORT
|
||||
WG_ENDPOINT="127.0.0.1:9000" # адрес локального free-turn-proxy client (-listen) для WG-app
|
||||
|
||||
# Состояние рантайма
|
||||
HAS_GUM=0
|
||||
GOARCH=""
|
||||
WG_PORT=""
|
||||
NONINTERACTIVE=0
|
||||
OPEN_FIREWALL="" # ""=спросить 1=да 0=нет
|
||||
PURGE=0
|
||||
ACTION="" # ""|update|reconfigure|uninstall
|
||||
OVERRIDES=()
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Валидаторы
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; }
|
||||
valid_hex64() { [[ "$1" =~ ^[0-9a-fA-F]{64}$ ]]; }
|
||||
valid_endpoint() { [[ "$1" =~ ^[^[:space:]/]+:[0-9]{1,5}$ ]]; } # host:port
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# UI-слой: тонкие обёртки над gum + plain-fallback.
|
||||
# Контракт: каждая ui_*-функция работает одинаково в обоих режимах.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
log_info() { if [ "$HAS_GUM" = 1 ]; then gum log --level info -- "$1"; else echo -e "${C_CYAN}[*]${C_NC} $1"; fi; }
|
||||
log_warn() { if [ "$HAS_GUM" = 1 ]; then gum log --level warn -- "$1"; else echo -e "${C_YELLOW}[!]${C_NC} $1" >&2; fi; }
|
||||
log_error() { if [ "$HAS_GUM" = 1 ]; then gum log --level error -- "$1"; else echo -e "${C_RED}[x]${C_NC} $1" >&2; fi; }
|
||||
log_success() { if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_SUCCESS" "✔ $1"; else echo -e "${C_GREEN}[+]${C_NC} $1"; fi; }
|
||||
|
||||
die() { log_error "$1"; exit 1; }
|
||||
ui_abort() { log_info "Отменено."; exit 0; }
|
||||
|
||||
# Градиент-баннер из ASCII-арта.
|
||||
ui_banner() {
|
||||
local art=(
|
||||
'███████╗██████╗ ███████╗███████╗████████╗██╗ ██╗██████╗ ███╗ ██╗'
|
||||
'██╔════╝██╔══██╗██╔════╝██╔════╝╚══██╔══╝██║ ██║██╔══██╗████╗ ██║'
|
||||
'█████╗ ██████╔╝█████╗ █████╗ ██║ ██║ ██║██████╔╝██╔██╗ ██║'
|
||||
'██╔══╝ ██╔══██╗██╔══╝ ██╔══╝ ██║ ██║ ██║██╔══██╗██║╚██╗██║'
|
||||
'██║ ██║ ██║███████╗███████╗ ██║ ╚██████╔╝██║ ██║██║ ╚████║'
|
||||
'╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝')
|
||||
printf '\n'
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
local i lines=()
|
||||
for i in "${!art[@]}"; do
|
||||
lines+=("$(gum style --foreground "${BANNER_GRADIENT[$i]}" "${art[$i]}")")
|
||||
done
|
||||
gum join --vertical "${lines[@]}"
|
||||
gum style --foreground "$MD_SECONDARY" --italic --margin "0 0 1 1" \
|
||||
"TURN-relay туннель · установщик сервера"
|
||||
else
|
||||
echo -e "${C_CYAN}"; printf '%s\n' "${art[@]}"; echo -e "${C_NC}"
|
||||
echo " TURN-relay туннель · установщик сервера"; echo
|
||||
fi
|
||||
}
|
||||
|
||||
# Информационный бокс. ui_note TITLE TEXT
|
||||
ui_note() {
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
gum style --border rounded --border-foreground "$MD_PRIMARY" --padding "0 1" --margin "1 0" \
|
||||
"$(gum style --foreground "$MD_PRIMARY" --bold "$1")" "$2"
|
||||
else
|
||||
echo; log_warn "$1: $2"
|
||||
fi
|
||||
}
|
||||
|
||||
# Текстовый ввод с дефолтом. ui_input VAR PROMPT [DEFAULT]
|
||||
ui_input() {
|
||||
local __var="$1" __prompt="$2" __def="${3:-}" __ans
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
__ans=$(gum input --prompt "$__prompt: " --prompt.foreground "$MD_PRIMARY" \
|
||||
--cursor.foreground "$MD_TERTIARY" --value "$__def" </dev/tty) || ui_abort
|
||||
else
|
||||
if [ -n "$__def" ]; then read -r -p "$__prompt [$__def]: " __ans </dev/tty
|
||||
else read -r -p "$__prompt: " __ans </dev/tty; fi
|
||||
fi
|
||||
printf -v "$__var" '%s' "${__ans:-$__def}"
|
||||
}
|
||||
|
||||
# Да/нет. ui_yesno PROMPT [DEFAULT(Y|N)] -> код 0(да)/1(нет)
|
||||
ui_yesno() {
|
||||
local __prompt="$1" __def="${2:-Y}" __ans
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
local flags=(--selected.background "$MD_PRIMARY" --selected.foreground "#1C1B1F")
|
||||
[ "$__def" = "N" ] && flags+=(--default=false)
|
||||
gum confirm "${flags[@]}" "$__prompt" </dev/tty
|
||||
return $?
|
||||
fi
|
||||
local hint; [ "$__def" = "Y" ] && hint="Y/n" || hint="y/N"
|
||||
read -r -p "$__prompt [$hint]: " __ans </dev/tty
|
||||
[[ "${__ans:-$__def}" =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
# Меню. ui_menu VAR PROMPT DEFAULT_TAG tag label [tag label ...]
|
||||
# Показывает label'ы, возвращает выбранный tag.
|
||||
ui_menu() {
|
||||
local __var="$1" __prompt="$2" __def_tag="$3"; shift 3
|
||||
local tags=() labels=()
|
||||
while [ $# -gt 0 ]; do tags+=("$1"); labels+=("$2"); shift 2; done
|
||||
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
local i def_label="" sel
|
||||
for i in "${!tags[@]}"; do [ "${tags[$i]}" = "$__def_tag" ] && def_label="${labels[$i]}"; done
|
||||
sel=$(gum choose --header "$__prompt" --header.foreground "$MD_PRIMARY" \
|
||||
--cursor "❯ " --cursor.foreground "$MD_TERTIARY" \
|
||||
--selected.foreground "$MD_PRIMARY" --selected "$def_label" \
|
||||
"${labels[@]}" </dev/tty) || ui_abort
|
||||
for i in "${!labels[@]}"; do
|
||||
[ "${labels[$i]}" = "$sel" ] && { printf -v "$__var" '%s' "${tags[$i]}"; return; }
|
||||
done
|
||||
ui_abort
|
||||
else
|
||||
local i sel
|
||||
echo; log_info "$__prompt"
|
||||
for i in "${!tags[@]}"; do
|
||||
if [ "${tags[$i]}" = "$__def_tag" ]; then echo -e " ${C_GREEN}${tags[$i]}${C_NC}) ${labels[$i]}"
|
||||
else echo " ${tags[$i]}) ${labels[$i]}"; fi
|
||||
done
|
||||
while :; do
|
||||
read -r -p "Выбор [${__def_tag}]: " sel </dev/tty
|
||||
sel="${sel:-$__def_tag}"
|
||||
for i in "${!tags[@]}"; do
|
||||
[ "$sel" = "${tags[$i]}" ] && { printf -v "$__var" '%s' "$sel"; return; }
|
||||
done
|
||||
log_warn "Неверный выбор: $sel"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Порт с повтором при ошибке. ask_port VAR PROMPT DEFAULT
|
||||
ask_port() {
|
||||
local __var="$1" __prompt="$2" __def="$3"
|
||||
while :; do
|
||||
ui_input "$__var" "$__prompt" "$__def"
|
||||
valid_port "${!__var}" && break
|
||||
ui_note "Ошибка" "Порт — число 1–65535. Получено: '${!__var}'"
|
||||
done
|
||||
}
|
||||
|
||||
# Спиннер вокруг долгой команды. ui_spin TITLE CMD ARGS...
|
||||
# Возвращает код команды; на ошибке печатает хвост вывода в боксе.
|
||||
ui_spin() {
|
||||
local title="$1"; shift
|
||||
local rc=0 log; log="$(mktemp)"
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
# Запускаем команду через bash -c, чтобы перенаправить её вывод в лог,
|
||||
# а спиннер gum оставить чистым ($0=лог, $@=команда).
|
||||
# if/else, а не `cmd; rc=$?` — иначе set -e убьёт скрипт до захвата кода.
|
||||
# shellcheck disable=SC2016 # $@/$0 должны раскрыться внутри bash -c, не здесь
|
||||
if gum spin --spinner dot --spinner.foreground "$MD_PRIMARY" --title "$title" \
|
||||
-- bash -c 'exec "$@" >"$0" 2>&1' "$log" "$@"; then rc=0; else rc=$?; fi
|
||||
if [ "$rc" -eq 0 ]; then log_success "$title"
|
||||
else
|
||||
log_error "$title — ошибка (код $rc)"
|
||||
tail -n 40 "$log" | gum style --border rounded --border-foreground "$MD_ERROR" --padding "0 1"
|
||||
fi
|
||||
elif [ -t 1 ]; then
|
||||
( "$@" >"$log" 2>&1 ) &
|
||||
local pid=$! i=0
|
||||
local ch='|/-'$'\\' # кадры спиннера: | / - \
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
i=$(((i + 1) % 4)); printf "\r${C_CYAN}[*]${C_NC} %s %s" "$title" "${ch:$i:1}"; sleep 0.2
|
||||
done
|
||||
wait "$pid" && rc=0 || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then printf "\r${C_GREEN}[+]${C_NC} %s\033[K\n" "$title"
|
||||
else printf "\r${C_RED}[x]${C_NC} %s\033[K\n" "$title"; tail -n 40 "$log" >&2; fi
|
||||
else
|
||||
log_info "$title..."
|
||||
"$@" >"$log" 2>&1 && rc=0 || rc=$?
|
||||
[ "$rc" -ne 0 ] && tail -n 40 "$log" >&2
|
||||
fi
|
||||
rm -f "$log"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Система / зависимости
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) GOARCH="amd64" ;;
|
||||
aarch64 | arm64) GOARCH="arm64" ;;
|
||||
*) die "Неподдерживаемая архитектура: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pkg_install() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -y -qq >/dev/null 2>&1 || true
|
||||
apt-get install -y -qq "$@" >/dev/null 2>&1 || true
|
||||
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" >/dev/null 2>&1 || true
|
||||
elif command -v yum >/dev/null 2>&1; then yum install -y -q "$@" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_base_deps() {
|
||||
local missing=() b
|
||||
for b in curl jq openssl tar; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done
|
||||
[ "${#missing[@]}" -eq 0 ] && return 0
|
||||
ui_spin "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}"
|
||||
missing=()
|
||||
for b in curl jq openssl tar; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done
|
||||
[ "${#missing[@]}" -ne 0 ] && die "Не удалось установить: ${missing[*]}. Поставьте вручную."
|
||||
return 0
|
||||
}
|
||||
|
||||
# Скачать конкретную версию gum в /usr/local/bin. gum_download VERSION -> 0/1
|
||||
gum_download() {
|
||||
local ver="$1" arch tmp bin
|
||||
case "$GOARCH" in amd64) arch="x86_64" ;; arm64) arch="arm64" ;; *) return 1 ;; esac
|
||||
local url="https://github.com/charmbracelet/gum/releases/download/v${ver}/gum_${ver}_Linux_${arch}.tar.gz"
|
||||
tmp="$(mktemp -d)"
|
||||
if curl -fsSL --max-time 30 "$url" | tar -xz -C "$tmp" 2>/dev/null; then
|
||||
bin="$(find "$tmp" -name gum -type f 2>/dev/null | head -n1 || true)"
|
||||
[ -n "$bin" ] && install -m 0755 "$bin" /usr/local/bin/gum 2>/dev/null
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
command -v gum >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_gum() {
|
||||
command -v gum >/dev/null 2>&1 && { HAS_GUM=1; return; }
|
||||
log_info "Установка gum ${GUM_VERSION}..."
|
||||
if gum_download "$GUM_VERSION"; then HAS_GUM=1; return; fi
|
||||
# Fallback: последний релиз (если пин недоступен/битый).
|
||||
local latest
|
||||
latest="$(curl -s --max-time 10 'https://api.github.com/repos/charmbracelet/gum/releases/latest' \
|
||||
| jq -r '.tag_name // empty' | sed 's/^v//' || true)"
|
||||
if [ -n "$latest" ] && gum_download "$latest"; then HAS_GUM=1; return; fi
|
||||
HAS_GUM=0
|
||||
log_warn "gum недоступен — текстовый режим."
|
||||
}
|
||||
|
||||
detect_wg_port() {
|
||||
WG_PORT=""
|
||||
command -v wg >/dev/null 2>&1 || return 0
|
||||
if wg show all listen-port >/dev/null 2>&1; then
|
||||
WG_PORT="$(wg show all listen-port 2>/dev/null | head -n1 | awk '{print $2}' || true)"
|
||||
fi
|
||||
if [ -z "$WG_PORT" ] && ls /etc/wireguard/*.conf >/dev/null 2>&1; then
|
||||
WG_PORT="$(grep -i ListenPort /etc/wireguard/*.conf 2>/dev/null | head -n1 | awk -F= '{print $2}' | tr -d ' ' || true)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# GitHub API
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
gh_latest_version() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name // empty'; }
|
||||
gh_recent_versions() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases?per_page=6" | jq -r '.[].tag_name'; }
|
||||
|
||||
resolve_asset_url() { # VERSION ASSET_NAME
|
||||
local api
|
||||
[ "$1" = "latest" ] && api="https://api.github.com/repos/${REPO}/releases/latest" \
|
||||
|| api="https://api.github.com/repos/${REPO}/releases/tags/$1"
|
||||
curl -s --max-time 15 "$api" | jq -r --arg n "$2" '.assets[] | select(.name==$n) | .browser_download_url'
|
||||
}
|
||||
|
||||
image_tag() { [ "$VERSION" = "latest" ] && echo "latest" || echo "$VERSION"; }
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Конфиг: load / save / validate / overrides
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
is_installed() { [ -f "$CONF_FILE" ] || [ -f "$COMPOSE_FILE" ] || [ -f "$UNIT_FILE" ]; }
|
||||
# shellcheck disable=SC1090 # путь к конфигу динамический, по дизайну
|
||||
load_config() { [ -f "$CONF_FILE" ] && . "$CONF_FILE" || true; }
|
||||
|
||||
save_config() {
|
||||
mkdir -p "$APP_DIR"
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
INSTALL_METHOD="$INSTALL_METHOD"
|
||||
VERSION="$VERSION"
|
||||
PROXY_MODE="$PROXY_MODE"
|
||||
BACKEND_PORT="$BACKEND_PORT"
|
||||
LISTEN_PORT="$LISTEN_PORT"
|
||||
OBF_PROFILE="$OBF_PROFILE"
|
||||
OBF_KEY="$OBF_KEY"
|
||||
CLIENTS_FILE_CONF="$CLIENTS_FILE_CONF"
|
||||
WG_SETUP="$WG_SETUP"
|
||||
WG_ENDPOINT="$WG_ENDPOINT"
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
}
|
||||
|
||||
apply_overrides() { # CLI бьёт install.conf
|
||||
local kv k v
|
||||
for kv in "${OVERRIDES[@]+"${OVERRIDES[@]}"}"; do
|
||||
k="${kv%%=*}"; v="${kv#*=}"; printf -v "$k" '%s' "$v"
|
||||
done
|
||||
}
|
||||
|
||||
connect_addr() { echo "127.0.0.1:${BACKEND_PORT}"; }
|
||||
|
||||
validate_config() {
|
||||
case "$INSTALL_METHOD" in docker | systemd) ;; *) die "method: docker|systemd, а не '$INSTALL_METHOD'" ;; esac
|
||||
case "$PROXY_MODE" in udp | tcp) ;; *) die "mode: udp|tcp, а не '$PROXY_MODE'" ;; esac
|
||||
valid_port "$LISTEN_PORT" || die "listen-port невалиден: '$LISTEN_PORT'"
|
||||
[ -z "$BACKEND_PORT" ] && { [ "$PROXY_MODE" = "udp" ] && BACKEND_PORT="51820" || BACKEND_PORT="443"; }
|
||||
valid_port "$BACKEND_PORT" || die "backend-port невалиден: '$BACKEND_PORT'"
|
||||
case "$OBF_PROFILE" in
|
||||
rtpopus)
|
||||
[ -z "$OBF_KEY" ] && { OBF_KEY="$(openssl rand -hex 32)"; log_info "Сгенерирован ключ обфускации."; }
|
||||
valid_hex64 "$OBF_KEY" || die "obf-key — ровно 64 hex-символа" ;;
|
||||
none) OBF_KEY="" ;;
|
||||
*) die "obf: rtpopus|none, а не '$OBF_PROFILE'" ;;
|
||||
esac
|
||||
if [ "$WG_SETUP" = "1" ]; then
|
||||
[ "$PROXY_MODE" = "udp" ] || die "WireGuard bootstrap доступен только при -mode udp."
|
||||
valid_endpoint "$WG_ENDPOINT" || die "wg-endpoint невалиден (host:port): '$WG_ENDPOINT'"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Интерактивный мастер
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
wizard_method() {
|
||||
ui_menu INSTALL_METHOD "Метод установки сервера:" "$INSTALL_METHOD" \
|
||||
docker "Docker Compose (рекомендуется)" \
|
||||
systemd "Systemd (бинарь напрямую)"
|
||||
}
|
||||
|
||||
wizard_mode() {
|
||||
ui_menu PROXY_MODE "Режим туннеля:" "$PROXY_MODE" \
|
||||
udp "UDP-relay · WireGuard / AmneziaWG" \
|
||||
tcp "TCP-forward · Xray / sing-box"
|
||||
}
|
||||
|
||||
wizard_ports() {
|
||||
detect_wg_port
|
||||
local def_backend="$BACKEND_PORT" label="Порт вашего VPN / бэкенда"
|
||||
if [ -z "$def_backend" ]; then
|
||||
if [ "$PROXY_MODE" = "udp" ] && [ -n "$WG_PORT" ]; then
|
||||
def_backend="$WG_PORT"; log_info "Найден WireGuard на порту $WG_PORT."
|
||||
elif [ "$PROXY_MODE" = "udp" ]; then def_backend="51820"
|
||||
else def_backend="443"; fi
|
||||
fi
|
||||
if [ "$PROXY_MODE" = "tcp" ]; then
|
||||
label="Порт TCP-бэкенда (Xray / sing-box inbound)"
|
||||
ui_note "TCP-режим" "Backend — ваш Xray/sing-box inbound на 127.0.0.1. Сервис поднимаете отдельно; инсталлятор его не ставит."
|
||||
fi
|
||||
ask_port BACKEND_PORT "$label" "$def_backend"
|
||||
ask_port LISTEN_PORT "Внешний порт (приём Free Turn Proxy)" "${LISTEN_PORT:-56000}"
|
||||
}
|
||||
|
||||
wizard_wireguard() {
|
||||
[ "$PROXY_MODE" = "udp" ] || { WG_SETUP=0; return; }
|
||||
if [ -n "$WG_PORT" ]; then
|
||||
log_info "Обнаружен WireGuard (порт $WG_PORT) — использую существующий."
|
||||
WG_SETUP=0; return
|
||||
fi
|
||||
if ui_yesno "WireGuard не найден. Установить и настроить WG-сервер на порту ${BACKEND_PORT}?" "N"; then
|
||||
WG_SETUP=1
|
||||
ui_note "Endpoint WG-клиента" \
|
||||
"Адрес, куда WG-приложение шлёт трафик = ваш локальный free-turn-proxy client (-listen). На том же ПК: 127.0.0.1:9000. Если WG-app на телефоне, а client на ПК — LAN-IP ПК:порт. Подставлю в конфиг и QR."
|
||||
while :; do
|
||||
ui_input WG_ENDPOINT "Endpoint для WG-клиента (host:port)" "${WG_ENDPOINT:-127.0.0.1:9000}"
|
||||
valid_endpoint "$WG_ENDPOINT" && break
|
||||
ui_note "Ошибка" "Формат host:port, напр. 127.0.0.1:9000"
|
||||
done
|
||||
else
|
||||
WG_SETUP=0
|
||||
fi
|
||||
}
|
||||
|
||||
wizard_obfuscation() {
|
||||
local def="Y"; [ "$OBF_PROFILE" = "none" ] && def="N"
|
||||
if ui_yesno "Включить маскировку rtpopus? (рекомендуется)" "$def"; then
|
||||
OBF_PROFILE="rtpopus"
|
||||
if [ -n "$OBF_KEY" ]; then
|
||||
ui_yesno "Сгенерировать НОВЫЙ ключ? (нет — оставить текущий)" "N" && OBF_KEY="$(openssl rand -hex 32)"
|
||||
elif ui_yesno "Сгенерировать случайный ключ?" "Y"; then
|
||||
OBF_KEY="$(openssl rand -hex 32)"
|
||||
else
|
||||
while :; do
|
||||
ui_input OBF_KEY "64-hex ключ обфускации" ""
|
||||
valid_hex64 "$OBF_KEY" && break
|
||||
ui_note "Ошибка" "Ключ — ровно 64 hex-символа."
|
||||
done
|
||||
fi
|
||||
else
|
||||
OBF_PROFILE="none"; OBF_KEY=""
|
||||
fi
|
||||
}
|
||||
|
||||
wizard_auth() {
|
||||
local def="N"; [ -n "$CLIENTS_FILE_CONF" ] && def="Y"
|
||||
if ui_yesno "Включить авторизацию по Client ID?" "$def"; then
|
||||
CLIENTS_FILE_CONF="${APP_DIR}/clients.json"
|
||||
else
|
||||
CLIENTS_FILE_CONF=""
|
||||
fi
|
||||
}
|
||||
|
||||
wizard_version() {
|
||||
log_info "Получение списка версий с GitHub..."
|
||||
local latest others tag def="${VERSION:-latest}"
|
||||
latest="$(gh_latest_version || true)"
|
||||
others="$([ -n "$latest" ] && gh_recent_versions 2>/dev/null | grep -vx "$latest" | head -4 || true)"
|
||||
|
||||
if [ "$HAS_GUM" = 1 ] && [ -n "$latest" ]; then
|
||||
local labels=("latest (= $latest)") tags=("latest") sel
|
||||
while IFS= read -r tag; do [ -n "$tag" ] && { labels+=("$tag"); tags+=("$tag"); }; done <<< "$others"
|
||||
labels+=("ввести тег вручную"); tags+=("custom")
|
||||
sel=$(gum choose --header "Версия:" --header.foreground "$MD_PRIMARY" \
|
||||
--cursor "❯ " --cursor.foreground "$MD_TERTIARY" "${labels[@]}" </dev/tty) || ui_abort
|
||||
local i picked="latest"
|
||||
for i in "${!labels[@]}"; do [ "${labels[$i]}" = "$sel" ] && picked="${tags[$i]}"; done
|
||||
if [ "$picked" = "custom" ]; then ui_input VERSION "Тег (latest или vX.Y.Z)" "$def"; else VERSION="$picked"; fi
|
||||
else
|
||||
[ -n "$latest" ] && log_info "Последний релиз: $latest"
|
||||
ui_input VERSION "Версия (latest или vX.Y.Z)" "$def"
|
||||
fi
|
||||
}
|
||||
|
||||
wizard_firewall() {
|
||||
ui_yesno "Открыть порт ${LISTEN_PORT}/udp в UFW/iptables?" "Y" && OPEN_FIREWALL=1 || OPEN_FIREWALL=0
|
||||
}
|
||||
|
||||
# Сводка выбранного перед применением.
|
||||
review_config() {
|
||||
local obf_line="выключена"
|
||||
[ "$OBF_PROFILE" != "none" ] && obf_line="rtpopus"
|
||||
local auth_line="выключена"; [ -n "$CLIENTS_FILE_CONF" ] && auth_line="Client ID"
|
||||
local wg_line="нет"; [ "$WG_SETUP" = "1" ] && wg_line="установить + настроить"
|
||||
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
gum format <<EOF | gum style --border double --border-foreground "$MD_PRIMARY" --padding "1 2" --margin "1 0"
|
||||
# Проверьте настройки
|
||||
|
||||
| Параметр | Значение |
|
||||
| -------------- | -------- |
|
||||
| Метод | $INSTALL_METHOD |
|
||||
| Версия | $VERSION |
|
||||
| Режим | $PROXY_MODE |
|
||||
| Порт бэкенда | 127.0.0.1:$BACKEND_PORT |
|
||||
| Внешний порт | 0.0.0.0:$LISTEN_PORT |
|
||||
| Обфускация | $obf_line |
|
||||
| Авторизация | $auth_line |
|
||||
| WireGuard | $wg_line |
|
||||
EOF
|
||||
else
|
||||
echo; log_info "Настройки: method=$INSTALL_METHOD version=$VERSION mode=$PROXY_MODE backend=$BACKEND_PORT listen=$LISTEN_PORT obf=$obf_line auth=$auth_line wireguard=$wg_line"
|
||||
fi
|
||||
ui_yesno "Применить эти настройки?" "Y" || ui_abort
|
||||
}
|
||||
|
||||
wizard() {
|
||||
wizard_method
|
||||
wizard_mode
|
||||
wizard_ports
|
||||
wizard_wireguard
|
||||
wizard_obfuscation
|
||||
wizard_auth
|
||||
wizard_version
|
||||
wizard_firewall
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Применение конфигурации
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
init_clients_file() {
|
||||
[ -n "$CLIENTS_FILE_CONF" ] && [ ! -f "$CLIENTS_FILE_CONF" ] && echo "[]" > "$CLIENTS_FILE_CONF" || true
|
||||
}
|
||||
|
||||
# ── WireGuard bootstrap (опциональный) ──────────────────────────────────────
|
||||
# Поднимает WG-сервер на 127.0.0.1+0.0.0.0:BACKEND_PORT — бэкенд, в который
|
||||
# free-turn-proxy форвардит расшифрованный UDP. Публичный порт WG в файрвол НЕ
|
||||
# открываем: трафик приходит только через free-turn-proxy.
|
||||
|
||||
wg_install() {
|
||||
command -v wg >/dev/null 2>&1 && command -v wg-quick >/dev/null 2>&1 && return 0
|
||||
ui_spin "Установка WireGuard" pkg_install wireguard-tools
|
||||
command -v wg >/dev/null 2>&1 || { ui_spin "Установка WireGuard (wireguard)" pkg_install wireguard; }
|
||||
command -v wg >/dev/null 2>&1 || die "Не удалось установить WireGuard (wg/wg-quick)."
|
||||
}
|
||||
|
||||
wg_detect_wan() { # имя WAN-интерфейса для NAT
|
||||
ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true
|
||||
}
|
||||
|
||||
wg_enable_forwarding() {
|
||||
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-free-turn-proxy-wg.conf
|
||||
sysctl -q -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Генерирует server+client ключи и пишет wg0.conf. Идемпотентно: если конфиг
|
||||
# уже есть — не перетирает (ключи стабильны).
|
||||
wireguard_bootstrap() {
|
||||
[ "$WG_SETUP" = "1" ] || return 0
|
||||
wg_install
|
||||
|
||||
if [ -f "$WG_CONF" ]; then
|
||||
log_info "WireGuard уже настроен ($WG_CONF) — переустанавливаю только службу."
|
||||
systemctl enable --now "wg-quick@${WG_IFACE}" >/dev/null 2>&1 || \
|
||||
ui_spin "Запуск wg-quick@${WG_IFACE}" wg-quick up "$WG_IFACE" || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
wg_enable_forwarding
|
||||
local wan; wan="$(wg_detect_wan)"
|
||||
[ -z "$wan" ] && { log_warn "WAN-интерфейс не определён — NAT пропущен, настройте вручную."; }
|
||||
|
||||
( umask 077; wg genkey > "${WG_DIR}/server.key"; wg genkey > "${WG_DIR}/client.key" )
|
||||
local srv_priv srv_pub cli_priv cli_pub
|
||||
srv_priv="$(cat "${WG_DIR}/server.key")"; srv_pub="$(wg pubkey < "${WG_DIR}/server.key")"
|
||||
cli_priv="$(cat "${WG_DIR}/client.key")"; cli_pub="$(wg pubkey < "${WG_DIR}/client.key")"
|
||||
|
||||
local postup="" postdown=""
|
||||
if [ -n "$wan" ]; then
|
||||
postup="PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ${wan} -j MASQUERADE"
|
||||
postdown="PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ${wan} -j MASQUERADE"
|
||||
fi
|
||||
|
||||
( umask 077
|
||||
cat > "$WG_CONF" <<EOF
|
||||
[Interface]
|
||||
Address = ${WG_NET}.1/24
|
||||
ListenPort = ${BACKEND_PORT}
|
||||
PrivateKey = ${srv_priv}
|
||||
${postup}
|
||||
${postdown}
|
||||
|
||||
[Peer]
|
||||
# первый клиент (ключи в ${WG_CLIENT_CONF})
|
||||
PublicKey = ${cli_pub}
|
||||
AllowedIPs = ${WG_NET}.2/32
|
||||
EOF
|
||||
)
|
||||
|
||||
systemctl enable --now "wg-quick@${WG_IFACE}" >/dev/null 2>&1 \
|
||||
|| ui_spin "Запуск wg-quick@${WG_IFACE}" wg-quick up "$WG_IFACE" \
|
||||
|| die "Не удалось поднять ${WG_IFACE}."
|
||||
log_success "WireGuard ${WG_IFACE} поднят (ListenPort ${BACKEND_PORT})."
|
||||
log_warn "WG слушает 0.0.0.0:${BACKEND_PORT}. Порт в firewall НЕ открывается (трафик идёт через free-turn-proxy). Если firewall отсутствует — WG доступен напрямую; закройте ${BACKEND_PORT}/udp вручную."
|
||||
|
||||
# Клиентский конфиг. Endpoint = ЛОКАЛЬНЫЙ free-turn-proxy client (не IP VPS),
|
||||
# т.к. WG-приложение подключается к туннелю, а не напрямую к серверу.
|
||||
( umask 077
|
||||
cat > "$WG_CLIENT_CONF" <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${cli_priv}
|
||||
Address = ${WG_NET}.2/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${srv_pub}
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
# Endpoint = локальный free-turn-proxy client (-listen), задан при установке.
|
||||
Endpoint = ${WG_ENDPOINT}
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
)
|
||||
log_info "Клиентский WG-конфиг: ${WG_CLIENT_CONF} (Endpoint ${WG_ENDPOINT}, готов к импорту)."
|
||||
}
|
||||
|
||||
# Рендер QR клиентского WG-конфига в терминал (qrencode).
|
||||
wg_render_qr() {
|
||||
[ "$WG_SETUP" = "1" ] && [ -f "$WG_CLIENT_CONF" ] || return 0
|
||||
command -v qrencode >/dev/null 2>&1 || ui_spin "Установка qrencode" pkg_install qrencode
|
||||
command -v qrencode >/dev/null 2>&1 || { log_warn "qrencode недоступен — QR пропущен."; return 0; }
|
||||
echo
|
||||
if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_PRIMARY" --bold "QR клиентского WireGuard-конфига (Endpoint ${WG_ENDPOINT}):"
|
||||
else echo "QR клиентского WireGuard-конфига (Endpoint ${WG_ENDPOINT}):"; fi
|
||||
qrencode -t ansiutf8 < "$WG_CLIENT_CONF"
|
||||
log_info "Сканируйте в приложении WireGuard — конфиг готов к работе."
|
||||
}
|
||||
|
||||
firewall_open() {
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
ufw allow "${LISTEN_PORT}/udp" >/dev/null 2>&1 || log_warn "ufw allow не удался."
|
||||
log_success "Порт ${LISTEN_PORT}/udp открыт (UFW)."
|
||||
elif command -v iptables >/dev/null 2>&1; then
|
||||
if ! iptables -C INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null; then
|
||||
iptables -I INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null || log_warn "iptables -I не удался."
|
||||
command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1 || true
|
||||
fi
|
||||
log_success "Порт ${LISTEN_PORT}/udp открыт (iptables)."
|
||||
else
|
||||
log_warn "Брандмауэр не найден — откройте ${LISTEN_PORT}/udp вручную."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_docker() {
|
||||
command -v docker >/dev/null 2>&1 && return 0
|
||||
if [ "$NONINTERACTIVE" != 1 ] && ! ui_yesno "Docker не найден. Установить?" "Y"; then
|
||||
die "Для метода docker нужен Docker."
|
||||
fi
|
||||
ui_spin "Установка Docker" sh -c 'curl -fsSL https://get.docker.com | sh' || die "Установка Docker не удалась."
|
||||
command -v docker >/dev/null 2>&1 || die "Docker не появился в PATH."
|
||||
}
|
||||
|
||||
healthcheck_docker() {
|
||||
sleep 2
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ] \
|
||||
&& { log_success "Контейнер работает."; return; }
|
||||
log_warn "Контейнер не запущен. Логи:"
|
||||
docker logs --tail 40 "$CONTAINER" 2>&1 || true
|
||||
die "$CONTAINER не поднялся."
|
||||
}
|
||||
|
||||
apply_docker() {
|
||||
ensure_docker
|
||||
{
|
||||
echo "services:"
|
||||
echo " free-turn-proxy:"
|
||||
echo " image: ${IMAGE}:$(image_tag)"
|
||||
echo " container_name: ${CONTAINER}"
|
||||
echo " network_mode: \"host\""
|
||||
echo " restart: unless-stopped"
|
||||
echo " environment:"
|
||||
echo " - CONNECT_ADDR=$(connect_addr)"
|
||||
echo " - LISTEN_ADDR=0.0.0.0:${LISTEN_PORT}"
|
||||
echo " - MODE=${PROXY_MODE}"
|
||||
echo " - OBF_PROFILE=${OBF_PROFILE}"
|
||||
[ "$OBF_PROFILE" != "none" ] && echo " - OBF_KEY=${OBF_KEY}"
|
||||
if [ -n "$CLIENTS_FILE_CONF" ]; then
|
||||
echo " - CLIENTS_FILE=${CLIENTS_FILE_CONF}"
|
||||
echo " volumes:"
|
||||
echo " - ${CLIENTS_FILE_CONF}:${CLIENTS_FILE_CONF}"
|
||||
fi
|
||||
} > "$COMPOSE_FILE"
|
||||
chmod 600 "$COMPOSE_FILE" # содержит OBF_KEY: не отдавать non-root
|
||||
|
||||
( cd "$APP_DIR" && ui_spin "Загрузка образа ${IMAGE}:$(image_tag)" docker compose pull ) || die "docker compose pull не удался."
|
||||
( cd "$APP_DIR" && ui_spin "Запуск контейнера" docker compose up -d ) || die "docker compose up не удался."
|
||||
healthcheck_docker
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local url
|
||||
url="$(resolve_asset_url "$VERSION" "server-linux-${GOARCH}" || true)"
|
||||
[ -z "$url" ] && die "Не найден server-linux-${GOARCH} в релизе ${VERSION}."
|
||||
ui_spin "Скачивание server-linux-${GOARCH} (${VERSION})" \
|
||||
curl -fL -o "${APP_DIR}/server.new" "$url" || die "Не удалось скачать $url"
|
||||
chmod +x "${APP_DIR}/server.new"
|
||||
mv -f "${APP_DIR}/server.new" "${APP_DIR}/server"
|
||||
}
|
||||
|
||||
healthcheck_systemd() {
|
||||
sleep 1
|
||||
systemctl is-active --quiet "$SERVICE" && { log_success "Служба активна."; return; }
|
||||
log_warn "Служба не активна. Логи:"
|
||||
journalctl -u "$SERVICE" --no-pager -n 40 2>&1 || true
|
||||
die "$SERVICE не запустилась."
|
||||
}
|
||||
|
||||
apply_systemd() {
|
||||
download_binary
|
||||
local args; args="-listen 0.0.0.0:${LISTEN_PORT} -connect $(connect_addr) -mode ${PROXY_MODE}"
|
||||
[ "$OBF_PROFILE" != "none" ] && args="$args -obf-profile ${OBF_PROFILE} -obf-key ${OBF_KEY}"
|
||||
[ -n "$CLIENTS_FILE_CONF" ] && args="$args -clients-file ${CLIENTS_FILE_CONF}"
|
||||
cat > "$UNIT_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Free TURN Proxy Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${APP_DIR}/server ${args}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
chmod 640 "$UNIT_FILE" # ExecStart содержит -obf-key: не отдавать non-root
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE" >/dev/null 2>&1 || true
|
||||
systemctl restart "$SERVICE" || die "systemctl restart не удался."
|
||||
healthcheck_systemd
|
||||
}
|
||||
|
||||
apply() {
|
||||
mkdir -p "$APP_DIR"
|
||||
init_clients_file
|
||||
wireguard_bootstrap
|
||||
if [ "$INSTALL_METHOD" = "docker" ]; then apply_docker; else apply_systemd; fi
|
||||
[ "$OPEN_FIREWALL" = "1" ] && firewall_open
|
||||
save_config
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local ext_ip; ext_ip="$(curl -s --max-time 5 ifconfig.me || echo "IP_СЕРВЕРА")"
|
||||
local add_cmd
|
||||
if [ "$INSTALL_METHOD" = "docker" ]; then
|
||||
add_cmd="docker exec -it ${CONTAINER} /app/server clients add <id>"
|
||||
else
|
||||
add_cmd="${APP_DIR}/server -clients-file ${CLIENTS_FILE_CONF} clients add <id>"
|
||||
fi
|
||||
|
||||
if [ "$HAS_GUM" = 1 ]; then
|
||||
{
|
||||
echo "# Готово ✓"
|
||||
echo
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| **Сервер (peer)** | \`${ext_ip}:${LISTEN_PORT}\` |"
|
||||
echo "| **Режим** | ${PROXY_MODE} |"
|
||||
echo "| **Версия** | ${VERSION} |"
|
||||
echo "| **Метод** | ${INSTALL_METHOD} |"
|
||||
[ "$OBF_PROFILE" != "none" ] && echo "| **Обфускация** | ${OBF_PROFILE} |"
|
||||
[ "$OBF_PROFILE" != "none" ] && echo "| **Ключ** | \`${OBF_KEY}\` |"
|
||||
echo
|
||||
if [ -n "$CLIENTS_FILE_CONF" ]; then
|
||||
echo "Client ID auth включён. Добавить клиента:"
|
||||
echo
|
||||
echo '```'
|
||||
echo "$add_cmd"
|
||||
echo '```'
|
||||
fi
|
||||
if [ "$WG_SETUP" = "1" ]; then
|
||||
echo "WireGuard поднят. Клиентский конфиг (\`Endpoint ${WG_ENDPOINT}\`, готов к импорту):"
|
||||
echo
|
||||
echo '```'
|
||||
echo "$WG_CLIENT_CONF"
|
||||
echo '```'
|
||||
fi
|
||||
echo "Документация: https://github.com/${REPO}"
|
||||
} | gum format | gum style --border rounded --border-foreground "$MD_SUCCESS" --padding "1 2" --margin "1 0"
|
||||
else
|
||||
echo; log_success "Готово!"
|
||||
echo "--------------------------------------------------------"
|
||||
echo "Сервер (peer): ${ext_ip}:${LISTEN_PORT}"
|
||||
echo "Режим: ${PROXY_MODE} | Версия: ${VERSION} | Метод: ${INSTALL_METHOD}"
|
||||
[ "$OBF_PROFILE" != "none" ] && echo "Обфускация: ${OBF_PROFILE} | Ключ: ${OBF_KEY}"
|
||||
[ -n "$CLIENTS_FILE_CONF" ] && { echo "Client ID auth включён. Добавить клиента:"; echo " $add_cmd"; }
|
||||
[ "$WG_SETUP" = "1" ] && echo "WireGuard поднят. Клиентский конфиг: $WG_CLIENT_CONF (Endpoint $WG_ENDPOINT, готов)."
|
||||
echo "--------------------------------------------------------"
|
||||
echo "Документация: https://github.com/${REPO}"
|
||||
fi
|
||||
wg_render_qr
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Сценарии
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
flow_install() { wizard; validate_config; review_config; apply; print_summary; }
|
||||
|
||||
flow_update() {
|
||||
[ -f "$CONF_FILE" ] || { log_warn "Конфиг не найден — переконфигурация."; flow_install; return; }
|
||||
wizard_version
|
||||
[ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=0
|
||||
validate_config; apply; print_summary
|
||||
}
|
||||
|
||||
do_uninstall() { # REMOVE_DIR(0|1)
|
||||
if [ "$INSTALL_METHOD" = "docker" ]; then
|
||||
if [ -f "$COMPOSE_FILE" ]; then
|
||||
( cd "$APP_DIR" && docker compose down ) || log_warn "docker compose down — ошибка."
|
||||
fi
|
||||
else
|
||||
systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_FILE"; systemctl daemon-reload || true
|
||||
fi
|
||||
log_success "Служба остановлена и удалена."
|
||||
if [ "$1" = "1" ]; then rm -rf "$APP_DIR"; log_success "Каталог $APP_DIR удалён."
|
||||
else log_info "Каталог сохранён: $APP_DIR"; fi
|
||||
}
|
||||
|
||||
flow_uninstall() {
|
||||
ui_yesno "Удалить Free Turn Proxy Server?" "N" || ui_abort
|
||||
local rm_dir=0
|
||||
ui_yesno "Удалить каталог ${APP_DIR} (ключи, clients.json, конфиг)?" "N" && rm_dir=1
|
||||
do_uninstall "$rm_dir"
|
||||
}
|
||||
|
||||
menu_existing() {
|
||||
local choice
|
||||
ui_menu choice "Установка найдена (метод: ${INSTALL_METHOD}, версия: ${VERSION:-?}). Действие:" "update" \
|
||||
update "Обновить версию (конфиг сохранён)" \
|
||||
reconfigure "Переконфигурировать" \
|
||||
uninstall "Удалить" \
|
||||
exit "Выход"
|
||||
case "$choice" in
|
||||
update) flow_update ;;
|
||||
reconfigure) flow_install ;;
|
||||
uninstall) flow_uninstall ;;
|
||||
exit) ui_abort ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_noninteractive() {
|
||||
is_installed && load_config || true
|
||||
apply_overrides
|
||||
if [ "${ACTION:-install}" = "uninstall" ]; then do_uninstall "$PURGE"; return; fi
|
||||
if [ "${ACTION:-}" = "update" ] && ! is_installed; then
|
||||
die "--update: установка не найдена. Запустите без --update."
|
||||
fi
|
||||
validate_config
|
||||
[ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=1
|
||||
log_info "Применяю конфигурацию (метод: ${INSTALL_METHOD})..."
|
||||
apply; print_summary
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Free Turn Proxy — установщик сервера.
|
||||
|
||||
sudo bash install.sh интерактивный мастер (gum)
|
||||
sudo bash install.sh -y [опции] non-interactive
|
||||
|
||||
Опции:
|
||||
-y, --yes, --non-interactive без вопросов
|
||||
--method docker|systemd метод (default docker)
|
||||
--mode udp|tcp режим (default udp)
|
||||
--backend-port N порт бэкенда (default udp→51820 / tcp→443)
|
||||
--listen-port N внешний порт (default 56000)
|
||||
--obf rtpopus|none обфускация (default rtpopus)
|
||||
--obf-key HEX64 ключ (нет → сгенерируется)
|
||||
--clients-auth | --no-clients-auth авторизация Client ID (default off)
|
||||
--wireguard | --no-wireguard поднять WG-сервер на backend-порту (udp; default off)
|
||||
--wg-endpoint HOST:PORT Endpoint для WG-клиента (адрес локального client; default 127.0.0.1:9000)
|
||||
--version latest|vX.Y.Z версия (default latest)
|
||||
--firewall | --no-firewall открывать порт (NI default: да)
|
||||
--update обновить версию (конфиг сохранён)
|
||||
--uninstall [--purge] удалить (--purge — снести каталог)
|
||||
-h, --help
|
||||
|
||||
Примеры:
|
||||
sudo bash install.sh -y --method docker --mode udp --backend-port 51820
|
||||
sudo bash install.sh -y --update --version v1.2.3
|
||||
sudo bash install.sh -y --uninstall --purge
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y | --yes | --non-interactive) NONINTERACTIVE=1 ;;
|
||||
--method)
|
||||
case "${2:-}" in
|
||||
docker | systemd) OVERRIDES+=("INSTALL_METHOD=${2}") ;;
|
||||
*) die "--method: docker|systemd" ;;
|
||||
esac; shift ;;
|
||||
--mode) OVERRIDES+=("PROXY_MODE=${2:-}"); shift ;;
|
||||
--backend-port) OVERRIDES+=("BACKEND_PORT=${2:-}"); shift ;;
|
||||
--listen-port) OVERRIDES+=("LISTEN_PORT=${2:-}"); shift ;;
|
||||
--obf) OVERRIDES+=("OBF_PROFILE=${2:-}"); shift ;;
|
||||
--obf-key) OVERRIDES+=("OBF_KEY=${2:-}"); shift ;;
|
||||
--clients-auth) OVERRIDES+=("CLIENTS_FILE_CONF=${APP_DIR}/clients.json") ;;
|
||||
--no-clients-auth) OVERRIDES+=("CLIENTS_FILE_CONF=") ;;
|
||||
--wireguard) OVERRIDES+=("WG_SETUP=1") ;;
|
||||
--no-wireguard) OVERRIDES+=("WG_SETUP=0") ;;
|
||||
--wg-endpoint) OVERRIDES+=("WG_ENDPOINT=${2:-}"); shift ;;
|
||||
--version) OVERRIDES+=("VERSION=${2:-}"); shift ;;
|
||||
--firewall) OPEN_FIREWALL=1 ;;
|
||||
--no-firewall) OPEN_FIREWALL=0 ;;
|
||||
--update) ACTION="update"; NONINTERACTIVE=1 ;;
|
||||
--uninstall) ACTION="uninstall"; NONINTERACTIVE=1 ;;
|
||||
--purge) PURGE=1 ;;
|
||||
-h | --help) usage; exit 0 ;;
|
||||
*) die "Неизвестный аргумент: $1 (см. --help)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# main
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
main() {
|
||||
parse_args "$@"
|
||||
[ "$(id -u)" -ne 0 ] && die "Запустите от root (sudo)."
|
||||
ensure_base_deps
|
||||
detect_arch
|
||||
|
||||
if [ "$NONINTERACTIVE" = 1 ]; then
|
||||
run_noninteractive
|
||||
return
|
||||
fi
|
||||
|
||||
ensure_gum
|
||||
ui_banner
|
||||
if is_installed; then load_config; menu_existing; else flow_install; fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user