refactor: install.sh

This commit is contained in:
samosvalishe
2026-05-26 02:32:41 +03:00
parent 281dd3e87a
commit 0f74c238c5
4 changed files with 947 additions and 621 deletions
+5 -5
View File
@@ -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
View File
@@ -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).
-615
View File
@@ -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 "$@"
+941
View File
@@ -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 "$@"