The «Перенос» tab moves a panel + VPN server to a panel-only server without users noticing: the new panel issues a one-time awgmove:// code, the old one sends its ui.db, subscription keys and hub certificate encrypted with it, and becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is byte-identical. The old server relays its former ports (awg-relay, PROXY v1 header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep working. The same is available from the CLI: migrate export/import/cancel, rehome. Nodes now learn the panel's address: the hub sends it in ready, the agent stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The new panel shows when the old server can be turned off: devices that got the new address, nodes that confirmed it, master keys never opened. «Серверы для всех» on the master keys tab adds or removes servers on every live key in one go, with a dry run that warns about API keys that would lose sight of some keys, and sets the servers new keys get.
1438 lines
82 KiB
Bash
1438 lines
82 KiB
Bash
# 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.4.4"
|
||
CHANNEL="${CHANNEL:-Beta}"
|
||
|
||
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
||
# переопределяется окружением. Путь релиза — <repo>/releases/download/v<VERSION>/<file>
|
||
# (такой же у Gitea, так что подойдёт и своё зеркало).
|
||
# REPO_BASE — основной источник;
|
||
# REPO_FALLBACK — запасной, если в основном релиза нет (по умолчанию не задан);
|
||
# ARCHIVE_URL — полный URL архива, перекрывает оба варианта;
|
||
# BRAND — имя продукта в баннерах, systemd-юните и панели; по
|
||
# умолчанию берётся из .env в архиве, здесь только запасное
|
||
# значение для баннера самого установщика.
|
||
BRAND_OVERRIDE="${BRAND:-}"
|
||
BRAND="${BRAND:-Forgetting}"
|
||
REPO_BASE="${REPO_BASE:-https://github.com/maeneko/forgetting}"
|
||
REPO_FALLBACK="${REPO_FALLBACK:-$REPO_BASE}"
|
||
# Архив — под архитектуру: в нём готовый бинарник 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" <<UNIT
|
||
[Unit]
|
||
Description=AWG Control — ${what} (${BRAND})
|
||
After=network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
RemainAfterExit=yes
|
||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||
${pre}
|
||
ExecStart=/usr/local/bin/awg-ctrl start all
|
||
ExecStop=/usr/local/bin/awg-ctrl stop all
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
run systemctl daemon-reload
|
||
run systemctl enable awg-control.service \
|
||
|| warn "systemctl enable awg-control не удался — автозапуск не настроен"
|
||
ok "systemd-юнит awg-control установлен (автозапуск на загрузке)"
|
||
}
|
||
|
||
# Запуск/перезапуск сервисов + статус. Через systemd, если он есть (тогда
|
||
# работает автозапуск на загрузке); иначе — напрямую через CLI, без автозапуска.
|
||
start_and_status() {
|
||
if command -v systemctl >/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")
|
||
# NODE_ID 0 — сервер, с которого переехала панель (awg-ctrl migrate import).
|
||
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" && -n "$NODE_ID" ]] \
|
||
&& (( 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 2>/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" <<CONF
|
||
[Interface]
|
||
Address = ${SUBNET}.0.1/16
|
||
MTU = ${MTU}
|
||
PostUp = ${AWG_BIN} set ${IFACE} private-key ${PRIV_KEY_FILE}; iptables -A FORWARD -i ${IFACE} -j ACCEPT; iptables -t nat -A POSTROUTING -o ${NET_IFACE} -j MASQUERADE
|
||
PreDown = iptables -D FORWARD -i ${IFACE} -j ACCEPT; iptables -t nat -D POSTROUTING -o ${NET_IFACE} -j MASQUERADE
|
||
ListenPort = ${AWG_PORT}
|
||
PrivateKey = ${PRIV_KEY}
|
||
Jc = ${JC}
|
||
Jmin = ${JMIN}
|
||
Jmax = ${JMAX}
|
||
S1 = ${S1}
|
||
S2 = ${S2}
|
||
S3 = ${S3}
|
||
S4 = ${S4}
|
||
H1 = ${H1}
|
||
H2 = ${H2}
|
||
H3 = ${H3}
|
||
H4 = ${H4}
|
||
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)"
|
||
|
||
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 <<ENV
|
||
# Generated by install.sh — $(date -u '+%Y-%m-%d %H:%M UTC') · роль: ${ROLE}
|
||
ENV
|
||
|
||
if [[ "$ROLE" != "core" ]]; then
|
||
cat <<ENV
|
||
|
||
# ── awgctrl (Ring 0) ──────────────────────────────────────────────────────
|
||
AWGCTRL_PORT=${AWGCTRL_PORT}
|
||
SERVER_IP=${SERVER_IP}
|
||
SERVER_PORT=${AWG_PORT}
|
||
SERVER_NAME=${SERVER_NAME}
|
||
ENV
|
||
fi
|
||
|
||
cat <<ENV
|
||
|
||
# ── внутренняя авторизация awg-ui/awg-agent → awg-ctrl (Ed25519) ──────────
|
||
INTERNAL_AUTH_KEY_FILE=${INTERNAL_AUTH_PRIV}
|
||
INTERNAL_AUTH_PUB_FILE=${INTERNAL_AUTH_PUB}
|
||
ENV
|
||
|
||
if [[ "$ROLE" != "node" ]]; then
|
||
cat <<ENV
|
||
|
||
# ── ui (Ring 4) ───────────────────────────────────────────────────────────
|
||
UI_PORT=${UI_PORT}
|
||
UI_USER=${UI_USER}
|
||
UI_PASS=${UI_PASS}
|
||
JWT_SECRET=${JWT_SECRET}
|
||
|
||
# ── подписка sen:// для SenAWG (мастер-ключи) ──────────────────────────────
|
||
SUB_PORT=${SUB_PORT}
|
||
SUB_TLS=${SUB_TLS}
|
||
SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV}
|
||
SUB_TLS_KEY_FILE=${SUB_TLS_KEY}
|
||
SUB_TLS_CERT_FILE=${SUB_TLS_CRT}
|
||
SUB_HOST=${SERVER_IP}
|
||
|
||
# ── приём нод (несколько серверов) ────────────────────────────────────────
|
||
NODE_PORT=${NODE_PORT}
|
||
NODE_TLS_KEY_FILE=${NODE_TLS_KEY}
|
||
NODE_TLS_CERT_FILE=${NODE_TLS_CRT}
|
||
LOCAL_NODE=$([[ "$ROLE" == "core" ]] && echo off || echo on)
|
||
ENV
|
||
fi
|
||
|
||
# JOIN_SECRET одноразовый: агент сам сотрёт его отсюда после первого подключения.
|
||
if [[ "$ROLE" == "node" ]]; then
|
||
cat <<ENV
|
||
|
||
# ── нода: подключение к панели (из строки awgjoin://…) ─────────────────────
|
||
CORE_HOST=${CORE_HOST}
|
||
CORE_PORT=${CORE_PORT}
|
||
CORE_PIN=${CORE_PIN}
|
||
NODE_ID=${NODE_ID}
|
||
NODE_KEY_FILE=${NODE_KEY_FILE}
|
||
JOIN_SECRET=${JOIN_SECRET}
|
||
ENV
|
||
fi
|
||
} > "$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
|