diff --git a/install.sh b/install.sh index a0d21f3..4006d56 100644 --- a/install.sh +++ b/install.sh @@ -5,20 +5,87 @@ set -euo pipefail -GRN='\033[0;32m'; YLW='\033[1;33m'; RED='\033[0;31m'; BLU='\033[0;34m' -BLD='\033[1m'; NC='\033[0m' - +# Цвета — настоящие 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; } -step() { echo -e "\n${BLD}${BLU}── $* ──${NC}"; } +info() { echo -e " ${DIM}→ $*${NC}"; } +skip() { echo -e " ${DIM}– $*${NC}"; } +rule() { echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"; } +# Заголовок шага: step "1/7 AmneziaWG" → «▍ AmneziaWG 1 из 7»; без номера — просто заголовок. +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]}" +} + +# Вопрос: 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/npm), плюс то, что установщик раньше глушил. В файле — без цветов +# и без перерисовок строк (ESC-последовательности и всё до последнего \r вырезаются). +# Права 600: в итоге печатается пароль панели. LOGFILE — глобал, на него ссылается ERR-трап. +LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log" +( umask 077; : > "$LOGFILE" ) +exec > >(tee >(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 скачан, поэтому взять их оттуда не может. # При бампе версии правь оба места. @@ -58,6 +125,12 @@ INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" 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" @@ -68,13 +141,85 @@ MTU="1376" TSX="$PROJECT/cli/node_modules/.bin/tsx" CLI="$PROJECT/cli/src/index.ts" -echo -e "${BLD}${BRAND} ${CHANNEL} ${VERSION}${NC}" echo +echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" +echo -e " ${DIM}Панель управления VPN на AmneziaWG — установка${NC}" +echo -e " ${DIM}Лог: $LOGFILE${NC}" +rule -# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой +# Поверх существующей установки: обновить (настройки, пользователи и роль остаются) +# или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. +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 + echo +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) здесь НЕ проверяем — это отдельно (фаервол/облако). -echo -e "${BLD}Проверка совместимости:${NC}" +step "Проверка совместимости" KERNEL=$(uname -r) VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") @@ -110,7 +255,7 @@ else HDR_FIX="apt-get install -y linux-generic && reboot" fi -echo " Ядро: $KERNEL · виртуализация: $VIRT · ${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)}" +echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} · ядро $KERNEL · виртуализация $VIRT · роль: $ROLE_LABEL${NC}" # Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист # со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим @@ -124,25 +269,29 @@ if [[ -z "$OS_FAMILY" ]]; then OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian" fi -# 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. -case "$VIRT" in - openvz|lxc|lxc-libvirt|docker|podman|wsl) - VIRT_OK=0 - VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" - ;; -esac +# Виртуализация и заголовки нужны только для модуля ядра — панели без 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 заново" + # 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) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой @@ -160,10 +309,12 @@ done [[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS" # Чеклист: ✓ — пройдено, ✗ — нет. -mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; } +mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; } mark "$OS_OK" "Дистрибутив" -mark "$VIRT_OK" "Виртуализация" -mark "$HDR_OK" "Заголовки ядра" +if [[ "$ROLE" != "core" ]]; then + mark "$VIRT_OK" "Виртуализация" + mark "$HDR_OK" "Заголовки ядра" +fi mark "$NET_OK" "Доступ в интернет" if [[ "$OS_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then @@ -249,15 +400,7 @@ kill_related() { [[ -n "$_pids" ]] && kill -KILL $_pids 2>/dev/null || true fi - rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid 2>/dev/null || true -} - -# Логирование: дублируем stdout/stderr в файл лога через tee. LOGFILE — глобал, -# на него ссылается ERR-трап. -start_logging() { - LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log" - exec > >(tee -a "$LOGFILE") 2>&1 - echo -e " ${BLD}Лог установки:${NC} $LOGFILE" + rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid /tmp/awg-agent.pid 2>/dev/null || true } # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые @@ -274,7 +417,7 @@ download_extract() { || fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL" tmp="$AWG_LOCAL_TARBALL" keep_tarball=1 # чужой файл — после распаковки не удаляем - echo " → локальный архив: $tmp" + info "локальный архив: $tmp" ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)" else local urls=() @@ -286,7 +429,7 @@ download_extract() { && urls+=("$REPO_FALLBACK/releases/download/v${VERSION}/${ARCHIVE_NAME}") fi - echo " → версия: ${VERSION}" + info "версия: ${VERSION}" local url got="" for url in "${urls[@]}"; do if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi @@ -299,7 +442,7 @@ $(printf ' %s\n' "${urls[@]}") fi mkdir -p "$PROJECT" - tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ + run tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ || fail "Не удалось распаковать архив" [[ "$keep_tarball" == 1 ]] || rm -f "$tmp" ok "Распакован → $PROJECT" @@ -311,6 +454,8 @@ $(printf ' %s\n' "${urls[@]}") "$PROJECT/awg-ui/public/index.html" \ "$PROJECT/awg-ui/server.ts" \ "$PROJECT/awg-ui/package.json" \ + "$PROJECT/awg-agent/index.ts" \ + "$PROJECT/awg-agent/package.json" \ "$PROJECT/cli/src/index.ts" \ "$PROJECT/cli/package.json" do @@ -319,15 +464,14 @@ $(printf ' %s\n' "${urls[@]}") ok "Архив проверен" } -# npm install во всех трёх сервисах. +# npm install во всех сервисах. npm_install_all() { local SVC - for SVC in awg-ctrl awg-ui cli; do + for SVC in awg-ctrl awg-ui awg-agent cli; do if [[ -f "$PROJECT/$SVC/package.json" ]]; then - echo -n " $SVC ... " - (cd "$PROJECT/$SVC" && npm install --silent) \ + info "npm install · $SVC" + (cd "$PROJECT/$SVC" && run npm install) \ || fail "npm install в $SVC завершился ошибкой" - echo -e "${GRN}ok${NC}" else warn "$PROJECT/$SVC/package.json не найден — пропуск" fi @@ -345,11 +489,18 @@ setup_service() { # запускается через shebang `#!/usr/bin/env node` — иначе ExecStart падает с # кодом 127 «node not found». Прописываем реальный каталог node в PATH юнита; # sbin тоже включаем (awg-quick зовёт iptables/ip/sysctl). - local node_dir + local node_dir pre="" what node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)") + # Панели без 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 || awg-quick up ${IFACE}' +${pre} ExecStart=/usr/local/bin/awg-ctrl start all ExecStop=/usr/local/bin/awg-ctrl stop all [Install] WantedBy=multi-user.target UNIT - systemctl daemon-reload - systemctl enable awg-control.service >/dev/null 2>&1 \ + run systemctl daemon-reload + run systemctl enable awg-control.service \ || warn "systemctl enable awg-control не удался — автозапуск не настроен" ok "systemd-юнит awg-control установлен (автозапуск на загрузке)" } @@ -375,37 +526,21 @@ UNIT start_and_status() { if command -v systemctl >/dev/null 2>&1; then setup_service - systemctl restart awg-control.service \ + run systemctl restart awg-control.service \ || fail "systemctl restart awg-control завершился ошибкой" else warn "systemd не найден — запускаю напрямую через CLI (без автозапуска)" - "$TSX" "$CLI" start all + run "$TSX" "$CLI" start all fi sleep 2 echo "$TSX" "$CLI" status } -INSTALL_MODE="fresh" -if [[ -d "$PROJECT" ]]; then - echo - warn "Найден каталог $PROJECT" - echo " 1) Обновить до версии ${VERSION} — сохранить настройки и пользователей" - echo " 2) Полностью переустановить — удалить каталог и начать заново" - read -rp " Выбери [1/2]: " INST_CHOICE - case "${INST_CHOICE:-1}" in - 1) INSTALL_MODE="update" ;; - 2) INSTALL_MODE="fresh" ;; - *) fail "Неверный выбор: введи 1 или 2" ;; - esac - echo -fi - if [[ "$INSTALL_MODE" == "update" ]]; then - start_logging - step "Обновление до версии ${VERSION}" + step "Обновление до версии ${VERSION} ($ROLE_LABEL)" - echo " → останавливаем и убиваем все процессы awg-control" + info "останавливаем и убиваем все процессы awg-control" kill_related download_extract @@ -413,7 +548,9 @@ if [[ "$INSTALL_MODE" == "update" ]]; then start_and_status echo - echo -e "${BLD}${GRN}✓ Обновление завершено — версия ${VERSION}${NC}" + rule + echo -e " ${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${ROLE_LABEL}${NC}" + rule echo exit 0 fi @@ -442,30 +579,41 @@ ok "Server IP: $SERVER_IP" # в шаге 4 (Ключи). Здесь секрет не нужен. AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 )) -read -rp " UI port (Enter — случайный): " UI_PORT -UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" +# Панель (awg-ui) есть везде, кроме ноды. +if [[ "$ROLE" != "node" ]]; then + ask UI_PORT "UI port (Enter — случайный): " + UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" -read -rp " UI логин [admin]: " UI_USER -UI_USER="${UI_USER:-admin}" + ask UI_USER "UI логин [admin]: " + UI_USER="${UI_USER:-admin}" -SUGGESTED_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12 2>/dev/null || openssl rand -hex 6) -read -rsp " UI пароль [$SUGGESTED_PASS]: " UI_PASS; echo -UI_PASS="${UI_PASS:-$SUGGESTED_PASS}" + SUGGESTED_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12 2>/dev/null || openssl rand -hex 6) + ask UI_PASS "UI пароль [$SUGGESTED_PASS]: " secret + UI_PASS="${UI_PASS:-$SUGGESTED_PASS}" -JWT_SECRET=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32 2>/dev/null || openssl rand -hex 16) + JWT_SECRET=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32 2>/dev/null || openssl rand -hex 16) +fi -# Если найдены база пользователей и ключи сервера — предлагаем их сохранить. -# Важно: vpn:// ключи пользователей привязаны к ключу сервера, поэтому -# сохранять базу имеет смысл только вместе со старым ключом сервера. +# Что сохранять при переустановке. +# С VPN (standalone, node): база пользователей и ключ сервера — только вместе: +# vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. +# Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). KEEP_DATA="n" -if [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then +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 - read -rp " Сохранить пользователей и ключ сервера? [Y/n]: " KD + ask KD "Сохранить пользователей и ключ сервера? [Y/n]: " [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" else warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." @@ -473,285 +621,398 @@ if [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then fi fi -# sen://-подписка (мастер-ключи для SenAWG). Порт и режим TLS зашиты в уже выданные ссылки, -# поэтому при KEEP_DATA=y берём прежние значения из существующего cli.env. -PREV_ENV="$PROJECT/cli/cli.env" -prev_val() { grep -s "^$1=" "$PREV_ENV" | tail -1 | cut -d= -f2- || true; } +# sen://-подписка и приём нод живут в панели — у ноды их нет. +# Порты и режим TLS зашиты в уже выданные ссылки, поэтому при KEEP_DATA=y берём прежние +# значения из cli.env прежней установки (PREV_CLI_ENV). SUB_PORT="${SUB_PORT:-}" SUB_TLS="${SUB_TLS:-}" -if [[ "$KEEP_DATA" == "y" ]]; then - [[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)" - [[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)" -fi -if [[ -z "$SUB_PORT" ]]; then - read -rp " Sub port для sen:// (Enter — случайный): " SUB_PORT - 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 - read -rp " Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " ST - [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" -fi -[[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" +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" -# Имя сервера: если база уже существует (DB_EXISTS, проверено заранее в -# preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. -if [[ "$DB_EXISTS" == "y" ]]; then - SERVER_NAME="VPN" - echo " Server name: берётся из существующей базы (пропускаем)" -else - read -rp " Server name [VPN]: " SERVER_NAME - SERVER_NAME="${SERVER_NAME:-VPN}" + # Приём нод: ноды сами подключаются к панели по 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 -echo " Project dir: $PROJECT" -echo " Server IP: $SERVER_IP" -echo " Server name: $SERVER_NAME" -echo " AWG port: $AWG_PORT (udp)" -echo " awgctrl port: $AWGCTRL_PORT" -echo " UI port: $UI_PORT" -echo " Sub port: $SUB_PORT (tcp, sen://, TLS: $SUB_TLS)" -echo " UI логин: $UI_USER" -echo " Net interface: $NET_IFACE" -if [[ "$KEEP_DATA" == "y" ]]; then - echo -e " Данные: ${GRN}сохранить существующих пользователей и ключ${NC}" -elif [[ -f "$DB_FILE" ]]; then - echo -e " Данные: ${YLW}новая установка (старая база → бэкап)${NC}" +# Нода: к какой панели подключаться. Строку 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 -read -rp " Продолжить? [Y/n]: " YN +ask YN "Продолжить? [Y/n]: " [[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } -start_logging - # Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в # секции «Проверка совместимости»; $KERNEL задан там же. step "1/7 AmneziaWG" -# 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 - echo " → подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)" +if [[ "$ROLE" == "core" ]]; then + skip "пропущено — у панели без VPN AmneziaWG не нужен" +else - # Ключ берём по полному отпечатку и сверяем отпечаток после загрузки — - # 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 + # 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" - 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" -} + 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 - echo " → apt-get update" - 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) + 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 - DEPS=(software-properties-common python3-launchpadlib gnupg2 dkms build-essential iptables) - fi - echo " → установка зависимостей сборки" - DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ - apt-get install -y "${DEPS[@]}" \ - || fail "Не удалось установить зависимости сборки: ${DEPS[*]}" + info "apt-get update" + run apt-get update || fail "apt-get update упал — проверь /etc/apt/sources.list*" - # Заголовки — отдельным шагом: их нехватка — самая частая причина сбоя, и на - # неё нужен точный совет, а не общее «не удалось установить». Ошибку apt - # здесь не валим: решает наличие /lib/modules/$KERNEL/build ниже. - HDR_PKGS=("linux-headers-$KERNEL") - [[ "$OS_FAMILY" == "ubuntu" ]] && HDR_PKGS+=(linux-headers-generic) - echo " → установка заголовков ядра: ${HDR_PKGS[*]}" - DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ - apt-get install -y "${HDR_PKGS[@]}" \ - || warn "apt не смог поставить ${HDR_PKGS[*]}" - - if [[ ! -d "/lib/modules/$KERNEL/build" ]]; then - warn "Нет /lib/modules/$KERNEL/build — заголовки под текущее ядро отсутствуют." + # iptables — в обоих списках: его зовёт PostUp в awg1.conf, а в Debian 13 + # (nftables по умолчанию) он в минимальной системе не установлен. if [[ "$OS_FAMILY" == "debian" ]]; then - # Debian держит в архиве только последнюю сборку ядра: если сервер - # давно не перезагружали, пакета под запущенное ядро там уже нет. - warn "Debian хранит в архиве только свежую сборку ядра — запущенное $KERNEL" - warn "уже устарело. Поставь актуальное ядро вместе с заголовками:" + DEPS=(gnupg ca-certificates curl dkms build-essential iptables) else - warn "Часто бывает на кастомном ядре провайдера. Решение:" + DEPS=(software-properties-common python3-launchpadlib gnupg2 dkms build-essential iptables) 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 - echo " → add-apt-repository ppa:amnezia/ppa" - add-apt-repository -y ppa:amnezia/ppa \ - || fail "Не удалось добавить PPA ppa:amnezia/ppa" - fi - - echo " → apt-get update (после PPA)" - apt-get update \ - || fail "apt-get update после PPA упал — проверь источники amnezia в /etc/apt" - - echo " → установка amneziawg (сборка DKMS-модуля, может занять до минуты)" - if ! DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ - 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 - echo " ───── $MKLOG (последние 40 строк) ─────" - tail -n 40 "$MKLOG" | sed 's/^/ /' - echo " ───────────────────────────────────────────────" - else - warn "make.log не найден — ошибка, вероятно, на этапе apt/репозиториев." - fi - fail "Не удалось установить amneziawg (детали выше и в $LOGFILE)" - fi - - echo " → проверка собранного модуля" - dkms status amneziawg 2>/dev/null | sed 's/^/ /' || true - if ! modprobe amneziawg 2>/dev/null; then - MKLOG=$(ls -1t /var/lib/dkms/amneziawg/*/build/make.log 2>/dev/null | head -1 || true) - [[ -n "${MKLOG:-}" && -f "$MKLOG" ]] && { echo " ── make.log (tail) ──"; tail -n 40 "$MKLOG" | sed 's/^/ /'; } - 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-версию. - apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true - apt-get update >/dev/null 2>&1 || true + info "установка зависимостей сборки" DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ - apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true - MOD_VER=$(disk_mod_ver) - fi + run apt-get install -y "${DEPS[@]}" \ + || fail "Не удалось установить зависимости сборки: ${DEPS[*]}" - # Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль, - # поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен. - LOADED_VER=$(loaded_mod_ver) - if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then - warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль." - awg-quick down "$IFACE" 2>/dev/null || true - ip link delete dev "$IFACE" 2>/dev/null || true - if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then - LOADED_VER=$(loaded_mod_ver) - ok "Модуль перезагружен: в ядре теперь $LOADED_VER" + # Заголовки — отдельным шагом: их нехватка — самая частая причина сбоя, и на + # неё нужен точный совет, а не общее «не удалось установить». Ошибку 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 - warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера." + 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 - # Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс. - [[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER" + # Поколение протокола: 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 можно потом из панели. - if [[ "${MOD_VER%%.*}" != "3" ]]; then + 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" - if [[ "$AWG_GEN" == "3.1" ]]; then - fail "Нужен модуль amneziawg 3.x, а в ядре версия '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)). + 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). + 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 - 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» в панели, переустановка не нужна." + 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/7 Node.js" @@ -759,9 +1020,11 @@ step "2/7 Node.js" if command -v node &>/dev/null; then ok "Node.js уже установлен: $(node --version)" else - echo " → установка Node.js 20.x (nodesource)" - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y nodejs || fail "Не удалось установить Node.js" + info "установка Node.js 20.x (nodesource)" + run bash -c 'set -o pipefail; curl -fsSL https://deb.nodesource.com/setup_20.x | bash -' \ + || fail "Не удалось подключить репозиторий Node.js (deb.nodesource.com)" + DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ + run apt-get install -y nodejs || fail "Не удалось установить Node.js" ok "Node.js установлен: $(node --version)" fi @@ -773,40 +1036,44 @@ step "4/7 Ключи и конфиг AWG" mkdir -p "$AWG_DIR" "$AMNEZIA_DIR" -AWG_BIN=$(which awg || echo /usr/bin/awg) +# 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 -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" +# Ключ сервера и база пользователей — только там, где есть 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 - # ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД. - if [[ -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 "Старая база API-ключей сохранена: $UI_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 # Внутренняя авторизация awg-ui → awg-ctrl: Ed25519-пара. Приватный → awg-ui, @@ -818,72 +1085,76 @@ fi chmod 600 "$INTERNAL_AUTH_PRIV" "$INTERNAL_AUTH_PUB" ok "Ключи внутренней авторизации awg-ui ↔ awg-ctrl" -# Подписка 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:// сохранён" +# Подписка 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 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 лет)" + ( 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 -# Свой файл в /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' +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)" + 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 взят из существующего конфига" + # Параметры 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 - [[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk) - AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY} + 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 + fi -cat > "$AWG_CONF" < "$AWG_CONF" <> "$AWG_CONF" + [[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF" -chmod 600 "$AWG_CONF" -NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0) -ok "$AWG_CONF (AmneziaWG $NEW_GEN)" + 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 "«Перевыпустить ключи», затем раздай пользователям новые ключи." + if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then + warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN." + warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах" + warn "и работать перестанут. После запуска открой панель и нажми" + warn "«Перевыпустить ключи», затем раздай пользователям новые ключи." + fi fi step "5/7 Запуск AWG" -if awg show "$IFACE" &>/dev/null; then - warn "Интерфейс $IFACE уже существует — перезапускаем" - awg-quick down "$IFACE" 2>/dev/null || 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) *=' - -if ! awg-quick up "$IFACE"; then - ip link delete dev "$IFACE" 2>/dev/null || true - - if [[ "$AWG3" != "y" ]]; then - fail "awg-quick up $IFACE завершился с ошибкой" +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 - - warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину." - echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true - - if awg-quick up "$IFACE"; then - ok "Со второй попытки интерфейс поднялся" - else - ip link delete dev "$IFACE" 2>/dev/null || true - echo " ── dmesg (последние 15 строк) ─────────────────" - dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true - echo " ───────────────────────────────────────────────" - - 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» в панели." - - 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" + + 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) *=' + + 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 + 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 -TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}') - -# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под -# живым сервером (issue #215: между двумя dkms-сборками словили регрессию — -# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше; -# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся. -if [[ "$AWG_PIN" == "y" ]]; then - apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || 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 " · версия закреплена")" - # 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 - echo " → UFW активен, открываем маршрут и порты" - ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \ - || warn "ufw route allow не сработал — трафик клиентов может резаться" - ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp" - ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp" - ufw allow "${SUB_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${SUB_PORT}/tcp" - ufw reload >/dev/null 2>&1 || true - ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp, ${UI_PORT}/tcp и ${SUB_PORT}/tcp" + info "UFW активен, открываем маршрут и порты" + 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 - echo " → UFW не активен, правила фаервола не трогаем" + info "UFW не активен, правила фаервола не трогаем" fi step "6/7 npm install" @@ -1027,18 +1314,33 @@ ok "awg-ctrl → /usr/local/bin/awg-ctrl" step "7/7 Запуск сервисов" -cat > "$PROJECT/cli/cli.env" < "$PROJECT/cli/cli.env" # Имя продукта попадает в cli.env, только если оператор задал его явно # (BRAND=… перед запуском). Иначе оно берётся из .env, приехавшего в архиве, — @@ -1062,20 +1386,48 @@ 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 "Нода пока не подключилась к панели. Последние строки $AGENT_LOG:" + tail -n "+$AGENT_LOG_FROM" "$AGENT_LOG" 2>/dev/null | tail -n 5 | logblock || true + fi +fi + echo -echo -e "${BLD}${GRN}✓ Установка завершена${NC}" -echo -echo -e " ${BLD}Сервисы:${NC}" -echo " awg-ctrl → http://localhost:${AWGCTRL_PORT} (внутренний)" -echo " awg-ui → http://${SERVER_IP}:${UI_PORT}" -echo " sen:// → $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} (подписка SenAWG)" -echo -echo -e " ${BLD}${YLW}UI логин:${NC} ${UI_USER}" -echo -e " ${BLD}${YLW}UI пароль:${NC} ${UI_PASS}" -echo -echo -e " ${BLD}Управление:${NC}" -echo " awg-ctrl — CLI: start/stop/status/credentials" -echo " systemctl start|stop awg-control — сервис (автозапуск на загрузке включён)" +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