# Copyright (c) 2026 Ivan Vasilev # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. #!/usr/bin/env bash set -euo pipefail # Цвета — настоящие ESC-символы ($'…'), а не '\033' строкой: так они работают и в echo -e, # и в приглашениях read -p. ACC — фиолетовый акцент панели (design.md). Без терминала # (перенаправили в файл) или с NO_COLOR — без цветов. if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then GRN=$'\033[0;32m'; YLW=$'\033[1;33m'; RED=$'\033[0;31m'; ACC=$'\033[38;5;141m' BLD=$'\033[1m'; DIM=$'\033[2m'; NC=$'\033[0m' else GRN=""; YLW=""; RED=""; ACC=""; BLD=""; DIM=""; NC="" fi ok() { echo -e " ${GRN}✓${NC} $*"; } warn() { echo -e " ${YLW}⚠${NC} $*"; } fail() { trap - ERR; echo -e "\n ${RED}✗${NC} $*" >&2; exit 1; } info() { echo -e " ${DIM}→ $*${NC}"; } skip() { echo -e " ${DIM}– $*${NC}"; } rule() { echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"; } # Заголовок шага: step "1/6 AmneziaWG" → «▍ AmneziaWG 1 из 6»; без номера — просто заголовок. step() { if [[ "$1" =~ ^([0-9]+)/([0-9]+)\ +(.*)$ ]]; then echo -e "\n${ACC}▍${NC}${BLD} ${BASH_REMATCH[3]}${NC} ${DIM}${BASH_REMATCH[1]} из ${BASH_REMATCH[2]}${NC}" else echo -e "\n${ACC}▍${NC}${BLD} $*${NC}" fi } # Приглашение к вводу — для read -p. Q=" ${ACC}›${NC} " # Вывод команд — полностью, на экран и в лог, приглушённым блоком «│» под шагом. # run cmd … — выполнить, показать всё, что она пишет (stdout+stderr); код возврата — её. # … | logblock — то же оформление для готового текста (хвост make.log, dmesg). # Переменные перед вызовом функции bash передаёт и в неё, и в её команды: # DEBIAN_FRONTEND=noninteractive run apt-get install -y … logblock() { local line while IFS= read -r line || [[ -n "$line" ]]; do printf ' %s│ %s%s\n' "$DIM" "$line" "$NC" done } run() { "$@" 2>&1 | logblock return "${PIPESTATUS[0]}" } # Случайная строка из [a-zA-Z0-9]. Вход конечный: при `tr < /dev/urandom | head` tr получает # SIGPIPE, под pipefail вся цепочка «падала», и срабатывал запасной openssl — пароль склеивался # из двух половин. Из 1 КиБ случайных байт букв и цифр выходит ~240 — хватает с запасом. # LC_ALL=C: tr работает с байтами, а не с символами локали. rand_alnum() { head -c 1024 /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c "$1" } # Вопрос: ask VAR "текст: " [secret]. Введённое печатает сам терминал — в поток вывода, а значит # и в лог, оно не попадает. Поэтому после ответа строка «вопрос ответ» печатается ещё раз: на # экране — поверх себя же (курсор на строку вверх), в логе — целиком. Скрытый ввод — точками. ask() { local __var="$1" __prompt="$2" __secret="${3:-}" __ans __shown if [[ -n "$__secret" ]]; then read -rsp "${Q}${__prompt}" __ans else read -rp "${Q}${__prompt}" __ans fi __shown="$__ans" [[ -n "$__secret" && -n "$__ans" ]] && __shown="••••••" if [[ -t 0 && -n "$__secret" ]]; then printf '\r%s%s%s\n' "$Q" "$__prompt" "$__shown" # после read -s курсор на той же строке elif [[ -t 0 ]]; then printf '\033[1A\r%s%s%s\n' "$Q" "$__prompt" "$__shown" else printf '%s%s%s\n' "$Q" "$__prompt" "$__shown" # ввод не с терминала — приглашения не было fi printf -v "$__var" '%s' "$__ans" } trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} строка ${LINENO} код ${rc}\n команда: ${BASH_COMMAND}\n полный лог: ${LOGFILE:-<ещё не открыт>}" >&2' ERR [[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" [[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh" # Лог установки — с первой строки: всё, что видно на экране (шапка, проверки, вопросы с # ответами, вывод apt/DKMS), плюс то, что установщик раньше глушил. В файле — без цветов # и без перерисовок строк (ESC-последовательности и всё до последнего \r вырезаются). # Права 600: в итоге печатается пароль панели. LOGFILE — глобал, на него ссылается ERR-трап. LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log" ( umask 077; : > "$LOGFILE" ) # LC_ALL=C: sed режет байты, а не символы — на битом UTF-8 из чужого вывода (make.log, dmesg) он # не спотыкается. tee -p: если запись лога вдруг умрёт, tee не падает от SIGPIPE и продолжает # писать на экран — иначе вслед за ним молча умер бы и сам установщик. exec > >(tee -p >(LC_ALL=C sed -u -e $'s/\033\\[[0-9;]*[A-Za-z]//g' -e $'s/.*\r//' >> "$LOGFILE")) 2>&1 LOG_PID="${!:-}" # Дождаться, пока tee допишет хвост, — иначе последние строки вылезут уже после приглашения шелла. trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # При бампе версии правь оба места. VERSION="0.3.4" CHANNEL="${CHANNEL:-Beta}" # Откуда брать архив и как называется продукт — задаётся окружением, в коде # ничего не зашито. У Gitea и GitHub путь релиза одинаковый # (/releases/download/v/), поэтому одна схема покрывает оба. # REPO_BASE — основной источник (своё зеркало); # REPO_FALLBACK — запасной, если в основном релиза ещё нет; # ARCHIVE_URL — полный URL архива, перекрывает оба варианта; # BRAND — имя продукта в баннерах, systemd-юните и панели; по # умолчанию берётся из .env в архиве, здесь только запасное # значение для баннера самого установщика. BRAND_OVERRIDE="${BRAND:-}" BRAND="${BRAND:-Forgetting}" REPO_BASE="${REPO_BASE:-https://git.ma7neko.ru/maeneko/forgetting}" REPO_FALLBACK="${REPO_FALLBACK:-https://github.com/maeneko/forgetting}" # Архив — под архитектуру: в нём готовый бинарник awg-control (Go, статический). case "$(uname -m)" in x86_64|amd64) GOARCH="amd64" ;; aarch64|arm64) GOARCH="arm64" ;; *) GOARCH="" ;; esac ARCHIVE_NAME="awgcontrol-${VERSION}-linux-${GOARCH:-unknown}.tar.gz" ARCHIVE_URL="${ARCHIVE_URL:-}" url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; } PROJECT="/opt/awg-control" AMNEZIA_DIR="/etc/amnezia" AWG_DIR="$AMNEZIA_DIR/amneziawg" PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key" PUB_KEY_FILE="$AWG_DIR/server_public.key" AWG_CONF="$AWG_DIR/awg1.conf" DB_FILE="$AWG_DIR/users.db" UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку # Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl. INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key" INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" # Подписка sen:// (SenAWG): ключ подписи ответов (Ed25519) и, при SUB_TLS=on, самоподписанный # сертификат. В отличие от internal_auth они НЕ эфемерны — публичный ключ подписи и отпечаток # сертификата зашиты во все выданные sen:// ссылки. SUB_SIGN_PRIV="$AWG_DIR/sub_sign.key" SUB_TLS_KEY="$AWG_DIR/sub_tls.key" SUB_TLS_CRT="$AWG_DIR/sub_tls.crt" # Несколько серверов (docs/node-protocol.md). Хаб панели: самоподписанный сертификат, его # отпечаток зашит в строки подключения нод — живёт столько же, сколько панель. Нода: свой # Ed25519-ключ, которым она представляется панели. NODE_TLS_KEY="$AWG_DIR/node_tls.key" NODE_TLS_CRT="$AWG_DIR/node_tls.crt" NODE_KEY_FILE="$AWG_DIR/node.key" IFACE="awg1" AWG_PORT="47619" SUBNET="10.9" MTU="1376" # Единый бинарник: сервисы (ctrl/ui/agent) и CLI (start/stop/status/…). Команда awg-ctrl — # ссылка на него. BIN="$PROJECT/awg-control" echo echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" echo -e " ${DIM}Панель управления VPN на AmneziaWG — установка${NC}" echo -e " ${DIM}Лог: $LOGFILE${NC}" rule # Поверх существующей установки: обновить (настройки, пользователи и роль остаются) # или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. INSTALL_MODE="fresh" if [[ -d "$PROJECT" ]]; then echo warn "Уже установлено в $PROJECT" echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" ask INST_CHOICE "Выбери [1/2, Enter — 1]: " case "${INST_CHOICE:-1}" in 1) INSTALL_MODE="update" ;; 2) INSTALL_MODE="fresh" ;; *) fail "Неверный выбор: введи 1 или 2" ;; esac echo fi # cli.env прежней установки читаем сразу: при переустановке каталог проекта удаляется # раньше, чем до него доходит дело, а из него берутся роль и порты, зашитые в уже # выданные ссылки (SUB_PORT, NODE_PORT) и в подключение ноды к панели. PREV_CLI_ENV="" [[ -f "$PROJECT/cli/cli.env" ]] && PREV_CLI_ENV=$(cat "$PROJECT/cli/cli.env") prev_val() { grep "^$1=" <<< "$PREV_CLI_ENV" | tail -1 | cut -d= -f2- || true; } # Роль сервера (несколько серверов под одной панелью, docs/node-protocol.md): # standalone — панель + VPN на одной машине, как было всегда; может принимать ноды; # core — только панель, без VPN на этой машине (LOCAL_NODE=off): AmneziaWG, модуль # ядра и заголовки не ставятся, VPN-серверы подключаются к ней нодами; # node — VPN без панели: awg-ctrl + awg-agent, который сам подключается к панели # по строке awgjoin://… («Добавить сервер» в панели). Входящих портов, # кроме UDP для клиентов, нода не открывает. # Можно задать окружением: ROLE=node JOIN='awgjoin://…' bash install.sh # При обновлении роль берётся из прежней установки: смена роли — только переустановкой. PREV_ROLE="" if [[ -n "$PREV_CLI_ENV" ]]; then if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE="node" elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE="core" else PREV_ROLE="standalone" fi fi ROLE="${ROLE:-}" if [[ "$INSTALL_MODE" == "update" ]]; then if [[ -n "$ROLE" && -n "$PREV_ROLE" && "$ROLE" != "$PREV_ROLE" ]]; then fail "Смена роли ($PREV_ROLE → $ROLE) при обновлении невозможна — выбери полную переустановку" fi ROLE="${PREV_ROLE:-standalone}" elif [[ -z "$ROLE" ]]; then case "$PREV_ROLE" in node) ROLE_DEF=3 ;; core) ROLE_DEF=2 ;; *) ROLE_DEF=1 ;; esac echo echo -e " ${BLD}Роль сервера${NC}" echo -e " ${ACC}${BLD}1${NC} Панель + VPN ${DIM}самостоятельный сервер; к нему можно подключать ноды${NC}" echo -e " ${ACC}${BLD}2${NC} Только панель ${DIM}без VPN на этой машине; серверы подключаются нодами${NC}" echo -e " ${ACC}${BLD}3${NC} Нода ${DIM}VPN без панели; подключается к уже установленной${NC}" ask ROLE_CHOICE "Выбери [1–3, Enter — ${ROLE_DEF}]: " case "${ROLE_CHOICE:-$ROLE_DEF}" in 1) ROLE="standalone" ;; 2) ROLE="core" ;; 3) ROLE="node" ;; *) fail "Неверный выбор: введи 1, 2 или 3" ;; esac fi [[ "$ROLE" =~ ^(standalone|core|node)$ ]] || fail "ROLE должен быть standalone, core или node (задано: $ROLE)" case "$ROLE" in standalone) ROLE_LABEL="панель + VPN" ;; core) ROLE_LABEL="только панель" ;; node) ROLE_LABEL="нода" ;; esac # Запускается ДО вопросов конфигурации и любого деструктива (rm -rf): на несовместимой # машине лучше упасть сразу, а не после ввода данных или удаления каталога. # Порты (UDP VPN / порт UI) здесь НЕ проверяем — это отдельно (фаервол/облако). step "Проверка совместимости" KERNEL=$(uname -r) VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") # Семейство дистрибутива решает, как ставить AmneziaWG: # ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic; # debian — ни software-properties-common (в Debian 13 его нет), ни # linux-headers-generic там не существует: PPA подключаем вручную # (ключ + signed-by), заголовки — только под текущее ядро. # Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE. OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME="" if [[ -r /etc/os-release ]]; then OS_ID=$(. /etc/os-release; echo "${ID:-}") OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}") OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}") OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}") fi if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then OS_FAMILY="ubuntu" elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then OS_FAMILY="debian" else OS_FAMILY="" fi # Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64, # 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian # (linux-image-<вариант> / linux-headers-<вариант>). KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL") # Что советовать, если заголовков под текущее ядро нет. if [[ "$OS_FAMILY" == "debian" ]]; then HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot" else HDR_FIX="apt-get install -y linux-generic && reboot" fi echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} · ядро $KERNEL · виртуализация $VIRT · роль: $ROLE_LABEL${NC}" # Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист # со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим # с кодом 0 (чистый выход, без вида «упало с ошибкой»). OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1 OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY="" # Архитектура: релиз собирается под amd64 и arm64. if [[ -z "$GOARCH" ]]; then ARCH_OK=0 ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64" fi # 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian. if [[ -z "$OS_FAMILY" ]]; then OS_OK=0 OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian" fi # Виртуализация и заголовки нужны только для модуля ядра — панели без VPN (core) они не важны, # её можно ставить и в контейнер. if [[ "$ROLE" != "core" ]]; then # 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. case "$VIRT" in openvz|lxc|lxc-libvirt|docker|podman|wsl) VIRT_OK=0 VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" ;; esac # 2) Заголовки ядра: DKMS соберёт модуль только при их наличии под текущее ядро. # Уже в системе или есть кандидат в apt — ок. «(none)» — кастомное ядро, не # проходит. Пустой apt-индекс не валим: добьёт поздняя проверка на шаге AWG. HDR_POLICY=$(apt-cache policy "linux-headers-$KERNEL" 2>/dev/null || true) if [[ -d "/lib/modules/$KERNEL/build" ]]; then : elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then : elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then HDR_OK=0 HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново" fi fi # 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой # HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. # Проверяем хосты, с которых реально качаем: хватает любого из двух — # download_extract() умеет откатываться на запасной. NET_HOSTS=("$(url_host "$REPO_BASE")") [[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")") [[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \ && NET_HOSTS+=("$(url_host "$REPO_FALLBACK")") NET_OK=0 for h in "${NET_HOSTS[@]}"; do if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi done [[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS" # Чеклист: ✓ — пройдено, ✗ — нет. mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; } mark "$OS_OK" "Дистрибутив" mark "$ARCH_OK" "Архитектура" if [[ "$ROLE" != "core" ]]; then mark "$VIRT_OK" "Виртуализация" mark "$HDR_OK" "Заголовки ядра" fi mark "$NET_OK" "Доступ в интернет" if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then echo warn "Установка невозможна — не пройдены проверки:" [[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY" [[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY" [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" echo trap - ERR exit 0 fi # Наличие базы определяем заранее и молча — от него зависит, спросим ли ниже имя # сервера (если база есть, имя берётся из неё). Без вывода. KEEP_DATA — отдельно. DB_EXISTS="n" [[ -f "$DB_FILE" ]] && DB_EXISTS="y" # TODO: рандомизировать параметры обфускации при установке. # awg-ctrl уже читает их из [Interface] awg1.conf (readAwgParams), поэтому # достаточно генерировать случайные значения здесь — править awg-ctrl не нужно. # Ограничения, которые обязан соблюсти генератор: # - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU # - S1, S2: < ~150, в части версий S1 != S2 # - S1–S4: при заданном HeaderProtectionKey (AmneziaWG 3.x) НИ ОДНО из них # не может быть меньше 12 (HEADER_PROTECTION_NONCE_SIZE). Модуль на # нарушение отвечает только «Invalid argument», причина видна лишь при # `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control` # - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные # типы сообщений WireGuard), большие uint32 без пересечений # - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами # Параметры фиксируются на весь срок жизни сервера: при KEEP_DATA=y их # менять нельзя (иначе все ранее выданные vpn:// ключи станут невалидными). JC=6; JMIN=10; JMAX=50 S1=90; S2=45; S3=37; S4=14 H1="1224800044-2116730834" H2="2122053282-2133204808" H3="2133604274-2140756116" H4="2143656228-2147444225" # Поколение протокола и закрепление версии пакета — управляются переменными # окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`. # AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x # или ядро старше 5.5, install.sh падает с диагностикой. Тихий откат на 2.0 # здесь нежелателен: он молча выдал бы сервер со слабой обфускацией. # AWG_GEN=auto — старое поведение: 3.1, если условия выполняются, иначе 2.0 # без вопросов. Для установки на заведомо старое ядро/модуль. # AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0. # Сервер, установленный на 2.0, переводится на 3.1 позже из панели # (POST /awg/upgrade) — переустановка для этого не нужна. AWG_GEN="${AWG_GEN:-3.1}" [[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)" # AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools} # через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым # сервером (issue #215 — именно так словили регрессию между dkms-сборками). AWG_PIN="${AWG_PIN:-y}" NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') [[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" # Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё # держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID, # которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы # продолжат работать с уже удалёнными файлами и порт/интерфейс останутся занятыми. kill_related() { if [[ -x "$BIN" ]]; then "$BIN" stop all 2>/dev/null || true else # Установка до Go-версии: её CLI на tsx. local _tsx="$PROJECT/cli/node_modules/.bin/tsx" local _cli="$PROJECT/cli/src/index.ts" [[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true fi # cmdline всех сервисов (awg-control <роль>, у старых — tsx …) содержит путь проекта. if command -v pkill &>/dev/null; then pkill -TERM -f "$PROJECT/" 2>/dev/null || true sleep 1 pkill -KILL -f "$PROJECT/" 2>/dev/null || true else local _pids _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') [[ -n "$_pids" ]] && kill -TERM $_pids 2>/dev/null || true sleep 1 _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') [[ -n "$_pids" ]] && kill -KILL $_pids 2>/dev/null || true fi rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid /tmp/awg-agent.pid 2>/dev/null || true } # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые # файлы на месте. download_extract() { local tmp="/tmp/${ARCHIVE_NAME}" local keep_tarball=0 # AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или # встать без сети). Архив должен быть той же формы, что делает CI: # awg-control (бинарник под эту архитектуру) + awg-ui/dist/ + .env. if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then [[ -f "$AWG_LOCAL_TARBALL" ]] \ || fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL" tmp="$AWG_LOCAL_TARBALL" keep_tarball=1 # чужой файл — после распаковки не удаляем info "локальный архив: $tmp" ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)" else local urls=() if [[ -n "$ARCHIVE_URL" ]]; then urls=("$ARCHIVE_URL") else urls=("$REPO_BASE/releases/download/v${VERSION}/${ARCHIVE_NAME}") [[ "$REPO_FALLBACK" != "$REPO_BASE" ]] \ && urls+=("$REPO_FALLBACK/releases/download/v${VERSION}/${ARCHIVE_NAME}") fi info "версия: ${VERSION}" local url got="" for url in "${urls[@]}"; do if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi warn "Архив недоступен на $(url_host "$url") — пробуем следующий источник" done [[ -n "$got" ]] || fail "Не удалось скачать архив ни с одного источника: $(printf ' %s\n' "${urls[@]}") Укажи свой: ARCHIVE_URL=https://…/${ARCHIVE_NAME} bash install.sh" ok "Архив скачан с $(url_host "$got"): $(du -sh "$tmp" | cut -f1)" fi mkdir -p "$PROJECT" remove_legacy run tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ || fail "Не удалось распаковать архив" [[ "$keep_tarball" == 1 ]] || rm -f "$tmp" ok "Распакован → $PROJECT" local f for f in "$BIN" "$PROJECT/awg-ui/dist/index.html" "$PROJECT/.env"; do [[ -f "$f" ]] || fail "Файл не найден после распаковки: $f" done # Рабочие каталоги сервисов: в них живут panel.id, логи awg-ctrl, cli.env. mkdir -p "$PROJECT/awg-ctrl" "$PROJECT/awg-ui" "$PROJECT/awg-agent" "$PROJECT/cli" "$PROJECT/logs" chmod 755 "$BIN" "$BIN" version >/dev/null 2>&1 || fail "Бинарник $BIN не запускается (архив не под эту архитектуру?)" ok "Архив проверен" } # remove_legacy — при обновлении убрать код прежней версии: TS-исходники, node_modules и старую # сборку фронтенда. Данные остаются: cli/cli.env, awg-ui/panel.id, логи (базы и ключи — в /etc). remove_legacy() { local d for d in awg-ctrl awg-ui awg-agent cli; do [[ -d "$PROJECT/$d" ]] || continue rm -rf "$PROJECT/$d/node_modules" "$PROJECT/$d/src" "$PROJECT/$d/public" "$PROJECT/$d/dist" rm -f "$PROJECT/$d"/*.ts "$PROJECT/$d"/package*.json "$PROJECT/$d"/tsconfig*.json "$PROJECT/$d/index.html" done } # Команда awg-ctrl — ссылка на бинарник: CLI находит корень установки по её цели. install_command() { ln -sfn "$BIN" /usr/local/bin/awg-ctrl ok "awg-ctrl → $BIN" } # systemd-юнит: автозапуск awg-control на загрузке. Модель CLI — детач-процессы # с PID-файлами (а не один долгоживущий процесс), поэтому Type=oneshot + # RemainAfterExit: systemd держит юнит «active», процессами рулит CLI через # start/stop. awg-ctrl падает, если awg1 не поднят, а на загрузке awg-quick up # вручную не выполняется — поднимаем интерфейс в ExecStartPre (если ещё не поднят). SERVICE_UNIT="/etc/systemd/system/awg-control.service" setup_service() { # sbin в PATH юнита: awg-quick зовёт iptables/ip/sysctl. local pre="" what # Панели без VPN (core) интерфейс не нужен — ExecStartPre только там, где есть awg-ctrl. [[ "$ROLE" != "core" ]] && pre="ExecStartPre=/bin/sh -c 'awg show ${IFACE} >/dev/null 2>&1 || awg-quick up ${IFACE}'" case "$ROLE" in core) what="awg-ui" ;; node) what="awg-ctrl + awg-agent" ;; *) what="awg-ctrl + awg-ui" ;; esac cat > "$SERVICE_UNIT" </dev/null 2>&1; then setup_service run systemctl restart awg-control.service \ || fail "systemctl restart awg-control завершился ошибкой" else warn "systemd не найден — запускаю напрямую через CLI (без автозапуска)" run "$BIN" start all fi sleep 2 "$BIN" status } if [[ "$INSTALL_MODE" == "update" ]]; then step "Обновление до версии ${VERSION} ($ROLE_LABEL)" info "останавливаем и убиваем все процессы awg-control" kill_related download_extract install_command start_and_status rule echo -e " ${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${ROLE_LABEL}${NC}" rule echo exit 0 fi # При полной переустановке убиваем все процессы awg-control и очищаем старый # каталог, чтобы не оставалось ни запущенных процессов, ни старых файлов. if [[ -d "$PROJECT" ]]; then kill_related rm -rf "$PROJECT" fi step "Конфигурация" # Server IP определяем автоматически (внешний через ifconfig.me, иначе локальный # по маршруту) — не спрашиваем. Если определить не удалось — падаем. SERVER_IP=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null \ || ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \ || echo "") [[ -z "$SERVER_IP" ]] && fail "Не удалось определить Server IP автоматически" ok "Server IP: $SERVER_IP" # Имя сервера спрашиваем ниже — только если базы ещё нет (DB_EXISTS=n). # Если база есть, имя берётся из неё. # Внутренняя авторизация awg-ui ↔ awg-ctrl — асимметричная пара, генерируется # в шаге 4 (Ключи). Здесь секрет не нужен. AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 )) # Панель (awg-ui) есть везде, кроме ноды. if [[ "$ROLE" != "node" ]]; then ask UI_PORT "UI port (Enter — случайный): " UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" ask UI_USER "UI логин [admin]: " UI_USER="${UI_USER:-admin}" SUGGESTED_PASS=$(rand_alnum 12) ask UI_PASS "UI пароль [$SUGGESTED_PASS]: " secret UI_PASS="${UI_PASS:-$SUGGESTED_PASS}" JWT_SECRET=$(rand_alnum 32) fi # Что сохранять при переустановке. # С VPN (standalone, node): база пользователей и ключ сервера — только вместе: # vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. # Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). KEEP_DATA="n" if [[ "$ROLE" == "core" ]]; then if [[ -f "$UI_DB_FILE" ]]; then echo warn "Найдены данные панели: $UI_DB_FILE" ask KD "Сохранить серверы, ключи API и мастер-ключи? [Y/n]: " [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" fi elif [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then echo warn "Найдена существующая установка:" [[ -f "$DB_FILE" ]] && echo " база пользователей: $DB_FILE" [[ -f "$PRIV_KEY_FILE" ]] && echo " ключ сервера: $PRIV_KEY_FILE" if [[ -f "$DB_FILE" && -f "$PRIV_KEY_FILE" && -f "$PUB_KEY_FILE" ]]; then ask KD "Сохранить пользователей и ключ сервера? [Y/n]: " [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" else warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." warn "Пользователей не сохранить (vpn:// ключи стали бы невалидными)." fi fi # sen://-подписка и приём нод живут в панели — у ноды их нет. # Порты и режим TLS зашиты в уже выданные ссылки, поэтому при KEEP_DATA=y берём прежние # значения из cli.env прежней установки (PREV_CLI_ENV). SUB_PORT="${SUB_PORT:-}" SUB_TLS="${SUB_TLS:-}" NODE_PORT="${NODE_PORT:-}" if [[ "$ROLE" != "node" ]]; then if [[ "$KEEP_DATA" == "y" ]]; then [[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)" [[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)" [[ -z "$NODE_PORT" ]] && NODE_PORT="$(prev_val NODE_PORT)" fi if [[ -z "$SUB_PORT" ]]; then ask SUB_PORT "Sub port для sen:// (Enter — случайный): " while [[ -z "$SUB_PORT" || "$SUB_PORT" == "$UI_PORT" || "$SUB_PORT" == "$AWGCTRL_PORT" ]]; do SUB_PORT=$(( (RANDOM % 22768) + 32768 )) done fi if [[ -z "$SUB_TLS" ]]; then ask ST "Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" fi [[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" # Приём нод: ноды сами подключаются к панели по WSS на этот порт. Сертификат хаба awg-ui # создаёт при первом запуске, его отпечаток зашит в строки подключения. Панели без VPN # приём нод обязателен (иначе ей нечем управлять), у «панели + VPN» — по желанию. if [[ -z "$NODE_PORT" ]]; then if [[ "$ROLE" == "core" ]]; then ask NODE_PORT "Порт для подключения нод (Enter — случайный): " else ask NODE_PORT "Порт для подключения нод (Enter — не принимать ноды): " fi fi if [[ -n "$NODE_PORT" ]] && ! [[ "$NODE_PORT" =~ ^[0-9]+$ && "$NODE_PORT" -gt 0 && "$NODE_PORT" -lt 65536 ]]; then [[ "$ROLE" == "core" ]] && fail "«$NODE_PORT» — не номер порта" warn "NODE_PORT «$NODE_PORT» не порт — приём нод выключен" NODE_PORT="" fi if [[ "$ROLE" == "core" ]]; then while [[ -z "$NODE_PORT" || "$NODE_PORT" == "$UI_PORT" || "$NODE_PORT" == "$SUB_PORT" ]]; do NODE_PORT=$(( (RANDOM % 22768) + 32768 )) done fi fi # Нода: к какой панели подключаться. Строку awgjoin://… даёт панель («Добавить сервер»): # base64url от JSON {v, h, p, pin, n, s}. Разбираем здесь, до установки, чтобы опечатка не # обнаружилась через десять минут сборки модуля. Значения в строке ограничены # [A-Za-z0-9.:_-], поэтому JSON без экранирования и sed его разбирает надёжно. parse_join() { local b64="${1#awgjoin://}" json [[ "$1" == awgjoin://* && "$b64" =~ ^[A-Za-z0-9_-]+$ ]] || return 1 b64=$(tr '_-' '/+' <<< "$b64") while (( ${#b64} % 4 )); do b64+="="; done json=$(base64 -d <<< "$b64" 2>/dev/null) || return 1 [[ "$json" == *'"v":1,'* ]] || return 1 CORE_HOST=$(sed -nE 's/.*"h":"([A-Za-z0-9.:_-]{1,253})".*/\1/p' <<< "$json") CORE_PORT=$(sed -nE 's/.*"p":([0-9]{1,5})[,}].*/\1/p' <<< "$json") CORE_PIN=$(sed -nE 's/.*"pin":"([A-Za-z0-9_-]{43})".*/\1/p' <<< "$json") NODE_ID=$(sed -nE 's/.*"n":([0-9]+)[,}].*/\1/p' <<< "$json") JOIN_SECRET=$(sed -nE 's/.*"s":"([A-Za-z0-9_-]{22})".*/\1/p' <<< "$json") [[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" ]] \ && (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID > 0 )) } # SHA-256 от SPKI сертификата панели, base64url без «=» — в том же виде, что pin в строке. # Пусто — панель не ответила. core_pin() { local target="$1:$2" pub [[ "$1" == *:* ]] && target="[$1]:$2" # IPv6 pub=$(timeout 8 openssl s_client -connect "$target" /dev/null \ | openssl x509 -pubkey -noout 2>/dev/null) || true # Без этой проверки недоступная панель дала бы хэш пустой строки — «чужой сертификат». [[ "$pub" == *"BEGIN PUBLIC KEY"* ]] || return 0 openssl pkey -pubin -outform der <<< "$pub" 2>/dev/null \ | openssl dgst -sha256 -binary \ | base64 | tr '+/' '-_' | tr -d '=\n' || true } CORE_HOST=""; CORE_PORT=""; CORE_PIN=""; NODE_ID=""; JOIN_SECRET="" if [[ "$ROLE" == "node" ]]; then JOIN="${JOIN:-}" # Переустановка ноды: ключ ноды на месте, панель её уже знает — можно не выпускать # новую строку, а оставить прежнее подключение. if [[ -z "$JOIN" && "$PREV_ROLE" == "node" && -n "$(prev_val CORE_HOST)" && -f "$NODE_KEY_FILE" ]]; then ask KJ "Оставить подключение к панели $(prev_val CORE_HOST):$(prev_val CORE_PORT) (нода #$(prev_val NODE_ID))? [Y/n]: " if [[ "${KJ:-y}" =~ ^[Yy]$ ]]; then CORE_HOST="$(prev_val CORE_HOST)"; CORE_PORT="$(prev_val CORE_PORT)" CORE_PIN="$(prev_val CORE_PIN)"; NODE_ID="$(prev_val NODE_ID)" JOIN_SECRET="$(prev_val JOIN_SECRET)" # пусто, если нода уже подключалась fi fi if [[ -z "$CORE_HOST" ]]; then [[ -z "$JOIN" ]] && ask JOIN "Строка подключения к панели (awgjoin://…): " parse_join "$JOIN" \ || fail "Строка подключения повреждена — возьми новую в панели: «Добавить сервер»" fi ok "Панель: $CORE_HOST:$CORE_PORT · нода #$NODE_ID" # Проверяем сразу: отпечаток не совпал — это не та панель (или соединение перехвачено), # ставить дальше нет смысла. Не ответила — не страшно: агент подключится, когда будет связь. GOT_PIN=$(core_pin "$CORE_HOST" "$CORE_PORT") if [[ -z "$GOT_PIN" ]]; then warn "Панель $CORE_HOST:$CORE_PORT сейчас не отвечает — нода подключится, когда появится связь." warn "Проверь, что порт открыт на стороне панели." elif [[ "$GOT_PIN" != "$CORE_PIN" ]]; then fail "Сертификат на $CORE_HOST:$CORE_PORT не совпадает с отпечатком из строки подключения — это не та панель" else ok "Сертификат панели совпал с отпечатком" fi fi # Имя сервера — только там, где есть VPN. Если база уже существует (DB_EXISTS, проверено # заранее в preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. SERVER_NAME="VPN" if [[ "$ROLE" != "core" ]]; then if [[ "$DB_EXISTS" == "y" ]]; then echo " Server name: берётся из существующей базы (пропускаем)" else ask SERVER_NAME "Server name [VPN]: " SERVER_NAME="${SERVER_NAME:-VPN}" fi fi # Сводка. Метки выровнены пробелами прямо в строках: printf в bash считает ширину в байтах, # и кириллица поехала бы. echo echo -e " ${BLD}Будет установлено${NC}" rule echo -e " ${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}" echo -e " ${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}" if [[ "$ROLE" != "core" ]]; then echo -e " ${DIM}Имя сервера ${NC} $SERVER_NAME" echo -e " ${DIM}VPN ${NC} ${AWG_PORT}/udp" fi if [[ "$ROLE" != "node" ]]; then echo -e " ${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" echo -e " ${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}" if [[ -n "$NODE_PORT" ]]; then echo -e " ${DIM}Приём нод ${NC} ${NODE_PORT}/tcp" else echo -e " ${DIM}Приём нод ${NC} ${DIM}выключен${NC}" fi else echo -e " ${DIM}Панель ${NC} $CORE_HOST:$CORE_PORT ${DIM}· нода #$NODE_ID${NC}" fi if [[ "$KEEP_DATA" == "y" ]]; then if [[ "$ROLE" == "core" ]]; then echo -e " ${DIM}Данные ${NC} ${GRN}сохраняются: серверы и ключи панели${NC}" else echo -e " ${DIM}Данные ${NC} ${GRN}сохраняются: пользователи и ключ сервера${NC}" fi elif [[ "$ROLE" != "core" && -f "$DB_FILE" ]] || [[ "$ROLE" == "core" && -f "$UI_DB_FILE" ]]; then echo -e " ${DIM}Данные ${NC} ${YLW}с нуля, старая база уйдёт в бэкап${NC}" fi echo -e " ${DIM}Каталог ${NC} ${DIM}$PROJECT${NC}" rule ask YN "Продолжить? [Y/n]: " [[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } # Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в # секции «Проверка совместимости»; $KERNEL задан там же. step "1/6 AmneziaWG" if [[ "$ROLE" == "core" ]]; then skip "пропущено — у панели без VPN AmneziaWG не нужен" else # Debian: add-apt-repository нет (software-properties-common из Debian 13 убран), # а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию # с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии # не зависит; серия важна только для бинарника amneziawg-tools. # AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble). AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu" AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg" add_amnezia_ppa_debian() { local suite="${AMNEZIA_PPA_SUITE:-}" if [[ -z "$suite" ]]; then case "$OS_CODENAME" in bullseye) suite="focal" ;; bookworm) suite="jammy" ;; trixie) suite="noble" ;; *) suite="noble" warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;; esac fi info "подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)" # Ключ берём по полному отпечатку и сверяем отпечаток после загрузки — # apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя. local tmp gh fprs="" tmp=$(mktemp); gh=$(mktemp -d) if curl -fsSL --connect-timeout 15 \ "https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \ | gpg --dearmor > "$tmp" 2>/dev/null; then fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true) fi if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then rm -rf "$tmp" "$gh" fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com" fi install -d -m 0755 /etc/apt/keyrings install -m 0644 "$tmp" "$AMNEZIA_KEYRING" rm -rf "$tmp" "$gh" echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \ > /etc/apt/sources.list.d/amnezia-ppa.list ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING" } if command -v awg &>/dev/null && command -v awg-quick &>/dev/null && modinfo amneziawg &>/dev/null; then ok "AWG уже установлен (модуль amneziawg: $(modinfo -F version amneziawg 2>/dev/null || echo present))" else info "apt-get update" run apt-get update || fail "apt-get update упал — проверь /etc/apt/sources.list*" # iptables — в обоих списках: его зовёт PostUp в awg1.conf, а в Debian 13 # (nftables по умолчанию) он в минимальной системе не установлен. if [[ "$OS_FAMILY" == "debian" ]]; then DEPS=(gnupg ca-certificates curl dkms build-essential iptables) else DEPS=(software-properties-common python3-launchpadlib gnupg2 dkms build-essential iptables) fi info "установка зависимостей сборки" DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ run apt-get install -y "${DEPS[@]}" \ || fail "Не удалось установить зависимости сборки: ${DEPS[*]}" # Заголовки — отдельным шагом: их нехватка — самая частая причина сбоя, и на # неё нужен точный совет, а не общее «не удалось установить». Ошибку apt # здесь не валим: решает наличие /lib/modules/$KERNEL/build ниже. HDR_PKGS=("linux-headers-$KERNEL") [[ "$OS_FAMILY" == "ubuntu" ]] && HDR_PKGS+=(linux-headers-generic) info "установка заголовков ядра: ${HDR_PKGS[*]}" DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ run apt-get install -y "${HDR_PKGS[@]}" \ || warn "apt не смог поставить ${HDR_PKGS[*]}" if [[ ! -d "/lib/modules/$KERNEL/build" ]]; then warn "Нет /lib/modules/$KERNEL/build — заголовки под текущее ядро отсутствуют." if [[ "$OS_FAMILY" == "debian" ]]; then # Debian держит в архиве только последнюю сборку ядра: если сервер # давно не перезагружали, пакета под запущенное ядро там уже нет. warn "Debian хранит в архиве только свежую сборку ядра — запущенное $KERNEL" warn "уже устарело. Поставь актуальное ядро вместе с заголовками:" else warn "Часто бывает на кастомном ядре провайдера. Решение:" fi warn " $HDR_FIX" warn "и после перезагрузки запусти install.sh заново." fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль" fi ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build" # Если репозиторий amnezia уже настроен (например, ключ и источник заведены # руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически # отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно. # Проверяем именно файлы источников, а не `apt-cache policy`: сразу после # apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту. if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен" elif [[ "$OS_FAMILY" == "debian" ]]; then add_amnezia_ppa_debian else info "add-apt-repository ppa:amnezia/ppa" run add-apt-repository -y ppa:amnezia/ppa \ || fail "Не удалось добавить PPA ppa:amnezia/ppa" fi info "apt-get update (после PPA)" run apt-get update \ || fail "apt-get update после PPA упал — проверь источники amnezia в /etc/apt" info "установка amneziawg (сборка DKMS-модуля, может занять до минуты)" if ! DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ run apt-get install -y amneziawg; then warn "apt-get install amneziawg завершился с ошибкой." MKLOG=$(ls -1t /var/lib/dkms/amneziawg/*/build/make.log 2>/dev/null | head -1 || true) if [[ -n "${MKLOG:-}" && -f "$MKLOG" ]]; then info "$MKLOG — последние 40 строк" tail -n 40 "$MKLOG" | logblock else warn "make.log не найден — ошибка, вероятно, на этапе apt/репозиториев." fi fail "Не удалось установить amneziawg (детали выше и в $LOGFILE)" fi info "проверка собранного модуля" run dkms status amneziawg || true if ! run modprobe amneziawg; then MKLOG=$(ls -1t /var/lib/dkms/amneziawg/*/build/make.log 2>/dev/null | head -1 || true) [[ -n "${MKLOG:-}" && -f "$MKLOG" ]] && { info "$MKLOG — последние 40 строк"; tail -n 40 "$MKLOG" | logblock; } fail "Модуль amneziawg не загрузился — DKMS-сборка несовместима с ядром" fi ok "AWG установлен, модуль amneziawg собран и загружается" fi # Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5. # - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0, # поэтому сначала пробуем обновиться. # - ядро: header protection использует библиотечный chacha-API # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. # При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий # откат подсунул бы более слабую обфускацию незаметно для оператора. # При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение # по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно # как раньше, а перейти на 3.1 можно потом из панели. kernel_lt_5_5() { local maj min maj=${KERNEL%%.*} min=${KERNEL#*.}; min=${min%%.*} [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] } # 🛑 Версий у модуля две, и путать их нельзя: # modinfo — что лежит на диске (после apt/DKMS); # /sys/module/amneziawg/version — что реально загружено в ядро. # `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный. # Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый # загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify # interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля. disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; } loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; } if [[ "$AWG_GEN" == "2.0" ]]; then AWG3="n" MOD_VER=$(disk_mod_ver) ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0." else AWG3="y" MOD_VER=$(disk_mod_ver) if [[ "${MOD_VER%%.*}" != "3" ]]; then warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить." # Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) — # снимаем, иначе --only-upgrade молча не тронет held-версию. run apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools || true run apt-get update || true DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ run apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools || true MOD_VER=$(disk_mod_ver) fi # Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль, # поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен. LOADED_VER=$(loaded_mod_ver) if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль." run awg-quick down "$IFACE" || true ip link delete dev "$IFACE" 2>/dev/null || true if run modprobe -r amneziawg && run modprobe amneziawg; then LOADED_VER=$(loaded_mod_ver) ok "Модуль перезагружен: в ядре теперь $LOADED_VER" else warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера." fi fi # Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс. [[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER" if [[ "${MOD_VER%%.*}" != "3" ]]; then AWG3="n" if [[ "$AWG_GEN" == "3.1" ]]; then fail "Нужен модуль amneziawg 3.x, а в ядре версия '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)). Перезагрузи сервер и запусти установку снова — или поставь на 2.0: AWG_GEN=auto bash install.sh" fi warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." [[ "$(disk_mod_ver)" == 3.* ]] && \ warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1." elif kernel_lt_5_5; then AWG3="n" if [[ "$AWG_GEN" == "3.1" ]]; then fail "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210). Обнови ядро — или поставь на 2.0: AWG_GEN=auto bash install.sh" fi warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." warn "Ставим сервер на AmneziaWG 2.0." fi fi if [[ "$AWG3" == "y" ]]; then ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)" else warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль — и переходи на 3.1" warn "кнопкой «Перейти на AWG 3.1» в панели, переустановка не нужна." fi fi step "2/6 Файлы проекта" download_extract step "3/6 Ключи и конфиг AWG" mkdir -p "$AWG_DIR" "$AMNEZIA_DIR" # ui.db (серверы, API-ключи, мастер-ключи awg-ui) — в бэкап, чтобы awg-ui создал чистую БД. if [[ "$KEEP_DATA" != "y" && -f "$UI_DB_FILE" ]]; then UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" mv "$UI_DB_FILE" "$UI_DB_BAK" # WAL-сайдкары удаляем — к новой БД они неприменимы. rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm" warn "Старая база панели сохранена: $UI_DB_BAK" fi # Ключ сервера и база пользователей — только там, где есть VPN. if [[ "$ROLE" != "core" ]]; then AWG_BIN=$(which awg || echo /usr/bin/awg) if [[ "$KEEP_DATA" == "y" ]]; then # Переиспользуем существующий ключ сервера — иначе старые vpn:// ключи # пользователей в users.db станут невалидными. PRIV_KEY=$(cat "$PRIV_KEY_FILE") PUB_KEY=$(cat "$PUB_KEY_FILE") ok "Используются существующие ключи сервера (база сохранена): $PUB_KEY" else # Новая установка. Существующую базу не удаляем, а отправляем в бэкап, # чтобы awg-ctrl создал чистую users.db при старте. if [[ -f "$DB_FILE" ]]; then DB_BAK="${DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" mv "$DB_FILE" "$DB_BAK" warn "Старая база пользователей сохранена: $DB_BAK" fi PRIV_KEY=$(umask 077 && awg genkey) PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey) umask 077 printf '%s' "$PRIV_KEY" > "$PRIV_KEY_FILE" printf '%s' "$PUB_KEY" > "$PUB_KEY_FILE" chmod 600 "$PRIV_KEY_FILE" "$PUB_KEY_FILE" ok "Публичный ключ: $PUB_KEY" fi fi # Внутренняя авторизация awg-ui → awg-ctrl: Ed25519-пара. Приватный → awg-ui, # публичный → awg-ctrl. Перегенерируется при каждой установке (эфемерна: обе # стороны переписываются вместе) — даже при KEEP_DATA, на vpn:// ключи не влияет. ( umask 077 openssl genpkey -algorithm ed25519 -out "$INTERNAL_AUTH_PRIV" openssl pkey -in "$INTERNAL_AUTH_PRIV" -pubout -out "$INTERNAL_AUTH_PUB" ) chmod 600 "$INTERNAL_AUTH_PRIV" "$INTERNAL_AUTH_PUB" ok "Ключи внутренней авторизации awg-ui ↔ awg-ctrl" # Подписка sen:// живёт в панели — у ноды её нет. if [[ "$ROLE" != "node" ]]; then # Подписка sen://. ⚠️ Существующие файлы при KEEP_DATA=y сохраняем: публичный ключ подписи и # отпечаток сертификата зашиты во все выданные ссылки, новый ключ сломал бы каждую. При чистой # установке ui.db уходит в бэкап (старые мастер-ключи всё равно недействительны) — ключи новые. if [[ "$KEEP_DATA" == "y" && -f "$SUB_SIGN_PRIV" ]]; then ok "Ключ подписи sen:// сохранён (существующие ссылки продолжат работать)" else ( umask 077; openssl genpkey -algorithm ed25519 -out "$SUB_SIGN_PRIV" ) chmod 600 "$SUB_SIGN_PRIV" ok "Ключ подписи sen:// создан" fi if [[ "$SUB_TLS" == "on" ]]; then if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then ok "Сертификат подписки sen:// сохранён" else ( umask 077 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null ) chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" fi fi fi if [[ "$ROLE" != "core" ]]; then # Свой файл в /etc/sysctl.d, а не /etc/sysctl.conf: в Debian 13 systemd-sysctl # /etc/sysctl.conf при загрузке больше не читает — forwarding включился бы # только до первой перезагрузки, и клиенты остались бы без интернета. SYSCTL_FILE="/etc/sysctl.d/99-awg-control.conf" cat > "$SYSCTL_FILE" <<'SYSCTL' net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 SYSCTL sysctl -q -p "$SYSCTL_FILE" ok "IP forwarding включён ($SYSCTL_FILE)" # Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента # AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились. # RandomTrailers/DisableCookies (фичи 3.1 от 12.08.2026) пока не включаем. # 🛑 HeaderProtectionKey фиксируется на весь срок жизни сервера наравне с # J/S/H: он обязан совпадать на обоих концах, и его смена делает невалидными # все ранее выданные vpn:// ключи. При KEEP_DATA берём существующий. # Поколение предыдущей установки — нужно, чтобы предупредить о перевыпуске # ключей при KEEP_DATA. Считать обязательно ДО перезаписи конфига. PREV_GEN="none" if [[ -f "$AWG_CONF" ]]; then if grep -q '^HeaderProtectionKey *=' "$AWG_CONF"; then PREV_GEN="3.1"; else PREV_GEN="2.0"; fi fi AWG3_LINES="" if [[ "$AWG3" == "y" ]]; then HEADER_PROTECTION_KEY="" if [[ "$KEEP_DATA" == "y" && -f "$AWG_CONF" ]]; then # sed, а не awk -F'=': base64-ключ сам содержит '=' в паддинге. HEADER_PROTECTION_KEY=$(sed -n 's/^HeaderProtectionKey *= *//p' "$AWG_CONF" | head -1) [[ -n "$HEADER_PROTECTION_KEY" ]] && ok "HeaderProtectionKey взят из существующего конфига" fi [[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk) AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY} ContentPaddingAddition = 10-100 RekeyAfterTime = 100-120 RekeyTimeout = 3-7 RejectAfterTime = 150-180 KeepaliveTimeout = 5-15 MaxHandshakeAttempts = 15-20" fi cat > "$AWG_CONF" <> "$AWG_CONF" chmod 600 "$AWG_CONF" NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0) ok "$AWG_CONF (AmneziaWG $NEW_GEN)" if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN." warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах" warn "и работать перестанут. После запуска открой панель и нажми" warn "«Перевыпустить ключи», затем раздай пользователям новые ключи." fi fi step "4/6 Запуск AWG" if [[ "$ROLE" == "core" ]]; then skip "пропущено — у панели без VPN интерфейса нет" if command -v awg &>/dev/null && awg show "$IFACE" &>/dev/null; then warn "Интерфейс $IFACE от прошлой установки ещё поднят — панель его не использует." warn "Опустить: awg-quick down $IFACE" fi else if awg show "$IFACE" &>/dev/null; then warn "Интерфейс $IFACE уже существует — перезапускаем" run awg-quick down "$IFACE" || true fi # Подъём интерфейса — единственная настоящая проверка, что ядро приняло # 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину # печатает только в dmesg и только при включённом dynamic debug. Поэтому: # упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не # помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку. AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *=' info "awg-quick up $IFACE" if ! run awg-quick up "$IFACE"; then ip link delete dev "$IFACE" 2>/dev/null || true if [[ "$AWG3" != "y" ]]; then fail "awg-quick up $IFACE завершился с ошибкой" fi warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину." echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true if run awg-quick up "$IFACE"; then ok "Со второй попытки интерфейс поднялся" else ip link delete dev "$IFACE" 2>/dev/null || true info "dmesg — последние 15 строк amneziawg" dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | logblock || true cp "$AWG_CONF" "${AWG_CONF}.awg31" sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF" AWG3="n"; NEW_GEN="2.0" warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31" [[ "$AWG_GEN" == "3.1" ]] && \ warn "Модуль и ядро подходят под 3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше." warn "Разберись с причиной и повтори переход кнопкой «Перейти на AWG 3.1» в панели." run awg-quick up "$IFACE" \ || fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше" ok "Интерфейс поднят на AmneziaWG 2.0" fi fi # awg-quick не всегда применяет приватный ключ из [Interface]/PostUp # (наблюдалось: awg show public-key = none сразу после up). Если ключ не # совпал — доставляем его явно из файла и проверяем ещё раз. RUNNING_PUB=$(awg show "$IFACE" public-key 2>/dev/null || echo "") if [[ "$RUNNING_PUB" != "$PUB_KEY" ]]; then awg set "$IFACE" private-key "$PRIV_KEY_FILE" \ || fail "Не удалось применить приватный ключ к $IFACE" RUNNING_PUB=$(awg show "$IFACE" public-key 2>/dev/null || echo "") fi if [[ "$RUNNING_PUB" == "$PUB_KEY" ]]; then ok "Интерфейс $IFACE запущен, ключ применён" else fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY" fi TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}') # Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под # живым сервером (issue #215: между двумя dkms-сборками словили регрессию — # хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше; # hold ставим только теперь, когда версия проверена и интерфейс реально поднялся. if [[ "$AWG_PIN" == "y" ]]; then info "apt-mark hold amneziawg amneziawg-dkms amneziawg-tools" run apt-mark hold amneziawg amneziawg-dkms amneziawg-tools || true ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools" fi GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0") ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")" fi # UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец # цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT # дело не доходит и трафик клиентов наружу режется: в dmesg сыплется # «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет». # Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно. if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then info "UFW активен, открываем $([[ "$ROLE" != "core" ]] && echo "маршрут и ")порты" UFW_OPEN=() if [[ "$ROLE" != "core" ]]; then run ufw route allow in on "$IFACE" out on "$NET_IFACE" \ || warn "ufw route allow не сработал — трафик клиентов может резаться" UFW_OPEN+=("${AWG_PORT}/udp") fi # Нода наружу открывает только UDP для клиентов: к панели она подключается сама. [[ "$ROLE" != "node" ]] && UFW_OPEN+=("${UI_PORT}/tcp" "${SUB_PORT}/tcp") [[ "$ROLE" != "node" && -n "$NODE_PORT" ]] && UFW_OPEN+=("${NODE_PORT}/tcp") for P in "${UFW_OPEN[@]}"; do run ufw allow "$P" || warn "не удалось открыть $P" done run ufw reload || true ok "UFW:$([[ "$ROLE" != "core" ]] && echo " форвардинг $IFACE → $NET_IFACE,") порты ${UFW_OPEN[*]}" else info "UFW не активен, правила фаервола не трогаем" fi step "5/6 Команда awg-ctrl" install_command step "6/6 Запуск сервисов" # cli.env по роли. Роль CLI узнаёт отсюда же: CORE_HOST → нода (awg-ctrl + awg-agent), # LOCAL_NODE=off → панель без VPN (только awg-ui), иначе — awg-ctrl + awg-ui. { cat < "$PROJECT/cli/cli.env" # Имя продукта попадает в cli.env, только если оператор задал его явно # (BRAND=… перед запуском). Иначе оно берётся из .env, приехавшего в архиве, — # чтобы источник правды оставался один. [[ -n "$BRAND_OVERRIDE" ]] && echo "BRAND=${BRAND_OVERRIDE}" >> "$PROJECT/cli/cli.env" chmod 600 "$PROJECT/cli/cli.env" ok "$PROJECT/cli/cli.env" AGENT_LOG="$PROJECT/logs/awg-agent.log" AGENT_LOG_FROM=$(( $(wc -l 2>/dev/null < "$AGENT_LOG" || echo 0) + 1 )) start_and_status # Нода: ждём, пока агент подключится к панели — иначе оператор узнает о проблеме, # только когда не увидит сервер в панели. if [[ "$ROLE" == "node" ]]; then NODE_UP="n" for _ in $(seq 1 15); do if tail -n "+$AGENT_LOG_FROM" "$AGENT_LOG" 2>/dev/null | grep -q "подключено к core"; then NODE_UP="y"; break; fi sleep 1 done if [[ "$NODE_UP" == "y" ]]; then ok "Нода подключилась к панели $CORE_HOST:$CORE_PORT" else warn "Нода пока не подключилась к панели." if [[ -s "$AGENT_LOG" ]]; then info "$AGENT_LOG — последние строки" tail -n "+$AGENT_LOG_FROM" "$AGENT_LOG" 2>/dev/null | tail -n 5 | logblock || true else info "агент ещё ничего не записал — проверь: awg-ctrl status" fi fi fi rule echo -e " ${GRN}${BLD}✓ Готово${NC} ${DIM}${BRAND} ${VERSION} · ${ROLE_LABEL}${NC}" rule if [[ "$ROLE" != "node" ]]; then echo -e " ${DIM}Панель ${NC} ${BLD}http://${SERVER_IP}:${UI_PORT}${NC}" echo -e " ${DIM}Логин ${NC} ${UI_USER}" echo -e " ${DIM}Пароль ${NC} ${YLW}${UI_PASS}${NC} ${DIM}сменить: awg-ctrl credentials${NC}" echo echo -e " ${DIM}sen:// ${NC} $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} ${DIM}подписка SenAWG${NC}" [[ -n "$NODE_PORT" ]] && echo -e " ${DIM}Ноды ${NC} wss://${SERVER_IP}:${NODE_PORT} ${DIM}«Добавить сервер» в панели${NC}" fi if [[ "$ROLE" != "core" ]]; then echo -e " ${DIM}VPN ${NC} ${SERVER_IP}:${AWG_PORT}/udp ${DIM}· awg-ctrl на 127.0.0.1:${AWGCTRL_PORT}${NC}" fi if [[ "$ROLE" == "node" ]]; then echo -e " ${DIM}Панель ${NC} wss://${CORE_HOST}:${CORE_PORT} ${DIM}исходящее подключение${NC}" echo echo -e " Сервер «${SERVER_NAME}» появится в списке серверов панели — управляй им оттуда." fi rule echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo