mirror of
https://github.com/bivlked/amneziawg-installer.git
synced 2026-10-19 05:03:18 +00:00
Reported by @humowns in discussion #38. The installer writes AllowedIPs and DNS as `a, b, c`. regenerate_client read those values out of the client .conf through tr -d '[:space:]', which strips the spaces along with the carriage return, and wrote back what it had read. The first regen collapsed the list to `a,b,c` and every later reissue kept it that way. The difference is invisible to the eye and covers 34 entries in the default routing mode. manage modify did the same from the other side: the value was validated element by element and then written verbatim. Reading now normalises to the canonical form through a shared awg_normalize_csv helper, which has one property worth calling out: a repeated regen also repairs configs that earlier versions had already collapsed, so nobody has to edit files by hand. The vpn:// builder deliberately keeps stripping spaces before it builds the structured allowed_ips array, and a test guards that now. The first pass of this branch normalised there too, which was wrong: the value feeds split(/,/), so the canonical form put a leading space inside every element but the first, and the Amnezia GUI client reads that array without trimming. Measured on a live server, 33 of 34 entries came out with a stray space. A collapsed link always traces back to a .conf that regen had already collapsed, because the embedded config is inlined from the file as it is. A review of the branch found that the first pass had traded two loud failures for quiet ones. Repeated AllowedIPs or DNS lines, which wg allows and adds up, were truncated to the first line, handing back a valid config with part of the networks gone; they are joined into one list now and the join itself is reported. And modify could blank a setting and delete the backup, because the post-write check accepted a bare `Key = ` prefix while awg_normalize_csv arrived in a patch release and _check_common_compat deliberately tolerates a patch-level drift between manage and the library. It verifies the helper is present and requires a non-empty value now. Verified on a live server rather than in unit tests alone: 5.27.0 collapsed a healthy 34-entry list, this branch restored that damaged config byte for byte, and the allowed_ips array inside the regenerated link stayed clean. Suite: 1319 passing, 0 failing, 0 skipped on Linux.
2385 lines
125 KiB
Bash
2385 lines
125 KiB
Bash
#!/bin/bash
|
||
|
||
# Проверка минимальной версии Bash
|
||
if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
|
||
echo "ОШИБКА: Требуется Bash >= 4.0 (текущая: ${BASH_VERSION})" >&2; exit 1
|
||
fi
|
||
|
||
# ==============================================================================
|
||
# Скрипт для управления пользователями (пирами) AmneziaWG 2.0
|
||
# Автор: @bivlked
|
||
# Версия: 5.27.1
|
||
# Дата: 2026-08-22
|
||
# Репозиторий: https://github.com/bivlked/amneziawg-installer
|
||
# ==============================================================================
|
||
|
||
# --- Безопасный режим и Константы ---
|
||
# shellcheck disable=SC2034
|
||
SCRIPT_VERSION="5.27.1"
|
||
set -o pipefail
|
||
AWG_DIR="/root/awg"
|
||
SERVER_CONF_FILE="/etc/amnezia/amneziawg/awg0.conf"
|
||
CONFIG_FILE="$AWG_DIR/awgsetup_cfg.init"
|
||
KEYS_DIR="$AWG_DIR/keys"
|
||
COMMON_SCRIPT_PATH="$AWG_DIR/awg_common.sh"
|
||
LOG_FILE="$AWG_DIR/manage_amneziawg.log"
|
||
NO_COLOR=0
|
||
VERBOSE_LIST=0
|
||
JSON_OUTPUT=0
|
||
EXPIRES_DURATION=""
|
||
CLI_CARRIER=""
|
||
|
||
# --- Автоочистка временных файлов и директорий ---
|
||
# _manage_temp_dirs хранит mktemp -d пути для backup/restore.
|
||
# _awg_cleanup из awg_common.sh удаляет файлы (awg_mktemp), но не директории —
|
||
# поэтому здесь chained cleanup: сначала наши директории, потом библиотечный.
|
||
# Гарантирует что SIGINT во время backup_configs/restore_backup не оставит
|
||
# orphan /tmp/tmp.XXXX (audit).
|
||
_manage_temp_dirs=()
|
||
|
||
# Путь пишем в переменную по имени (printf -v), БЕЗ command substitution: при
|
||
# td=$(manage_mktempdir) append в _manage_temp_dirs уходил в сабшелл и терялся,
|
||
# а cleanup на INT/TERM/EXIT эти папки не удалял (audit 5kag). Вызов:
|
||
# manage_mktempdir_var td || die ...
|
||
manage_mktempdir_var() {
|
||
local __rv="$1" __d
|
||
__d=$(mktemp -d) || return 1
|
||
_manage_temp_dirs+=("$__d")
|
||
printf -v "$__rv" '%s' "$__d"
|
||
}
|
||
|
||
_manage_cleaned=0
|
||
_manage_cleanup() {
|
||
# Идемпотентно: на INT/TERM зовётся из сигнального обработчика, затем ещё раз
|
||
# на EXIT - повтор должен быть no-op.
|
||
[[ "$_manage_cleaned" -eq 1 ]] && return 0
|
||
_manage_cleaned=1
|
||
local d
|
||
for d in "${_manage_temp_dirs[@]}"; do
|
||
[[ -d "$d" ]] && rm -rf "$d"
|
||
done
|
||
type _awg_cleanup &>/dev/null && _awg_cleanup
|
||
}
|
||
# На INT/TERM раньше cleanup срабатывал, но скрипт НЕ завершался - выполнение шло
|
||
# дальше после прерванной команды, и cleanup повторялся на EXIT. Теперь сигнал =
|
||
# cleanup + явный выход 130/143. restore_backup на время destructive-фазы ставит
|
||
# СВОЙ INT/TERM-обработчик (с откатом), затем снимает его в _restore_cleanup.
|
||
_manage_on_signal() {
|
||
_manage_cleanup
|
||
exit "$1"
|
||
}
|
||
|
||
# ==============================================================================
|
||
# JSON-хелперы (v5.21.0)
|
||
# ==============================================================================
|
||
# Определены ДО установки EXIT-trap: _manage_on_exit зовёт _json_exit_guard,
|
||
# а тот - json_escape. Ранний выход (ошибка опций) без этих определений дал бы
|
||
# "command not found" вместо аварийного JSON.
|
||
|
||
# Побайтовая замена невалидного UTF-8 на U+FFFD. Именно замена, не iconv -c:
|
||
# молчаливая потеря байтов делает текст ошибки бессмысленным ровно тогда,
|
||
# когда он нужнее всего. iconv печатает валидный префикс до первого битого
|
||
# байта: префикс забираем, битый байт заменяем, хвост прогоняем снова.
|
||
# Вызывается только когда валидация уже провалилась - на валидном входе цена 0.
|
||
_json_utf8_sanitize() {
|
||
local s="$1" out="" prefix
|
||
local LC_ALL=C # ${#} и ${:offset} должны считать БАЙТЫ, не символы
|
||
while [[ -n "$s" ]]; do
|
||
if printf '%s' "$s" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then
|
||
out+="$s"
|
||
break
|
||
fi
|
||
# Сентинель X сохраняет хвостовые \n префикса ($() их режет).
|
||
prefix=$(printf '%s' "$s" | iconv -f UTF-8 -t UTF-8 2>/dev/null; printf X)
|
||
prefix="${prefix%X}"
|
||
out+="${prefix}"$'\xEF\xBF\xBD'
|
||
s="${s:$(( ${#prefix} + 1 ))}"
|
||
done
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
# Экранирование строки для безопасного включения в JSON. Помимо базовых
|
||
# \\ \" \n \r \t экранирует ВСЕ управляющие C0 (0x01-0x1F) как \u00XX - jq
|
||
# отвергает сырой ESC/BEL (тот же класс, что ESC-баг vpn:// в v5.20.0), а
|
||
# аварийный путь кормит сюда произвольный текст ошибок и пути из --conf-dir.
|
||
# Битый UTF-8 -> U+FFFD. NUL не обрабатываем: bash-переменная его не донесёт.
|
||
json_escape() {
|
||
local s="$1"
|
||
# Fork-free fast-path через printf %q: чистые строки (имена, IP, наши
|
||
# статус-литералы, включая валидную кириллицу) %q возвращает как есть -
|
||
# iconv-спавн не нужен (важно для list/stats: сотни вызовов за прогон).
|
||
# Битые байты и C0 %q ВСЕГДА квотит ($'...') -> уходят на iconv-проверку.
|
||
# Ложное срабатывание (пробелы/скобки) стоит одной дешёвой валидации.
|
||
# НЕ подходит как детектор сам по себе: сравнение длин символы==байты
|
||
# битый UTF-8 пропускает (каждый битый байт считается «символом»).
|
||
local _q
|
||
printf -v _q '%q' "$s"
|
||
if [[ "$_q" != "$s" ]] && command -v iconv >/dev/null 2>&1; then
|
||
if ! printf '%s' "$s" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then
|
||
s=$(_json_utf8_sanitize "$s"; printf X)
|
||
s="${s%X}"
|
||
fi
|
||
fi
|
||
s="${s//\\/\\\\}"
|
||
s="${s//\"/\\\"}"
|
||
s="${s//$'\n'/\\n}"
|
||
s="${s//$'\r'/\\r}"
|
||
s="${s//$'\t'/\\t}"
|
||
# Редкий путь: остальные C0 (после замен выше \n\r\t уже двухсимвольные).
|
||
if [[ "$s" =~ [[:cntrl:]] ]]; then
|
||
local _i _ch _u
|
||
for _i in 1 2 3 4 5 6 7 8 11 12 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31; do
|
||
printf -v _ch "\\$(printf '%03o' "$_i")"
|
||
[[ "$s" == *"$_ch"* ]] || continue
|
||
printf -v _u '\\u%04x' "$_i"
|
||
s="${s//$_ch/$_u}"
|
||
done
|
||
fi
|
||
printf '%s' "$s"
|
||
}
|
||
|
||
# Единственная точка печати JSON в stdout. Правило контракта: при --json
|
||
# stdout содержит РОВНО ОДИН JSON-документ, включая любой провал.
|
||
_JSON_EMITTED=0
|
||
_JSON_ERR=""
|
||
json_out() {
|
||
[[ "${JSON_OUTPUT:-0}" -eq 1 ]] || return 0
|
||
[[ "$_JSON_EMITTED" -eq 1 ]] && return 0 # защита от двойной эмиссии
|
||
_JSON_EMITTED=1
|
||
printf '%s\n' "$1"
|
||
}
|
||
|
||
# Аварийная эмиссия на EXIT: любой путь выхода с rc!=0, не напечатавший свой
|
||
# конверт (die, голый exit, отказ strict-confirm, сигнал, ошибка usage),
|
||
# оставляет боту {"command","ok":false,"error","rc"} вместо пустого stdout.
|
||
# rc приходит АРГУМЕНТОМ: guard зовётся из _manage_on_exit, и читать $? здесь
|
||
# уже поздно. Поле error - человекочитаемый текст (может быть локализован),
|
||
# машинные решения бот принимает по ok/rc/status.
|
||
_json_exit_guard() {
|
||
local rc="$1"
|
||
[[ "${JSON_OUTPUT:-0}" -eq 1 && "$_JSON_EMITTED" -eq 0 && "$rc" -ne 0 ]] || return 0
|
||
# show/diagnose вне JSON-контракта (--json задокументированно не поддержан):
|
||
# их человеческий вывод уже ушёл в stdout, аварийный объект сверху дал бы
|
||
# смешанный поток вместо "ровно одного документа".
|
||
case "${COMMAND:-}" in show|diagnose) return 0 ;; esac
|
||
json_out "{\"command\":\"$(json_escape "${COMMAND:-}")\",\"ok\":false,\"error\":\"$(json_escape "${_JSON_ERR:-command failed}")\",\"rc\":$rc}"
|
||
}
|
||
|
||
# ЕДИНСТВЕННЫЙ обработчик EXIT. Guard живёт здесь, а НЕ внутри идемпотентного
|
||
# _manage_cleanup: сигнальный путь зовёт cleanup напрямую (в тот момент $? ещё
|
||
# не 130/143 - guard соврал бы rc), а повторный вызов cleanup на EXIT упирается
|
||
# в _manage_cleaned=1 (guard после этой проверки не выполнился бы вовсе).
|
||
# Здесь же rc честный на всех путях, включая exit 130/143 из сигнальных хуков.
|
||
_manage_on_exit() {
|
||
local rc=$?
|
||
_json_exit_guard "$rc"
|
||
_manage_cleanup
|
||
}
|
||
trap _manage_on_exit EXIT
|
||
trap '_manage_on_signal 130' INT
|
||
trap '_manage_on_signal 143' TERM
|
||
|
||
# --- Обработка аргументов ---
|
||
COMMAND=""
|
||
HELP_EXIT_RC=0 # C1: 0 = явный help (exit 0); ставится в 1 для ошибок использования
|
||
ARGS=()
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-h|--help) COMMAND="help"; HELP_EXIT_RC=0; break ;;
|
||
-v|--verbose) VERBOSE_LIST=1; shift ;;
|
||
--no-color) NO_COLOR=1; shift ;;
|
||
--json) JSON_OUTPUT=1; shift ;;
|
||
--expires=*) EXPIRES_DURATION="${1#*=}"; shift ;;
|
||
--conf-dir=*) AWG_DIR="${1#*=}"; shift ;;
|
||
--server-conf=*) SERVER_CONF_FILE="${1#*=}"; shift ;;
|
||
--apply-mode=*)
|
||
# Только запоминаем; валидация - ПОСЛЕ цикла (см. ниже): внутри
|
||
# цикла --json мог быть ещё не разобран ('add x --apply-mode=bad
|
||
# --json'), и аварийный JSON-guard молчал бы при ошибке здесь.
|
||
_CLI_APPLY_MODE="${1#*=}"
|
||
shift ;;
|
||
--psk) CLI_ADD_PSK=1; shift ;;
|
||
--reset-routes) CLI_RESET_ROUTES=1; shift ;;
|
||
--yes) CLI_YES=1; shift ;;
|
||
--carrier=*) CLI_CARRIER="${1#*=}"; shift ;;
|
||
--*) echo "Неизвестная опция: $1" >&2; for _rest in "$@"; do [[ "$_rest" == "--json" ]] && JSON_OUTPUT=1; done; COMMAND="help"; HELP_EXIT_RC=1; break ;;
|
||
*)
|
||
if [[ -z "$COMMAND" ]]; then
|
||
COMMAND=$1
|
||
else
|
||
ARGS+=("$1")
|
||
fi
|
||
shift ;;
|
||
esac
|
||
done
|
||
CLIENT_NAME="${ARGS[0]}"
|
||
PARAM="${ARGS[1]}"
|
||
VALUE="${ARGS[2]}"
|
||
|
||
# Канонизация алиасов (контракт 3.2): бот не должен разбирать, как его набрали.
|
||
# Диспетчер ниже матчит оба написания, поэтому канонизация безопасна и для
|
||
# человеческого пути.
|
||
case "$COMMAND" in
|
||
status) COMMAND="check" ;;
|
||
repair) COMMAND="repair-module" ;;
|
||
esac
|
||
|
||
# Валидация --apply-mode после разбора ВСЕХ опций (--json уже известен).
|
||
# Опечатка (--apply-mode=restrat) молча работала бы как syncconf - пользователь,
|
||
# обходящий проблему режимом restart, не узнал бы, что режим не применился.
|
||
if [[ -n "${_CLI_APPLY_MODE:-}" ]]; then
|
||
case "$_CLI_APPLY_MODE" in
|
||
syncconf|restart) export AWG_APPLY_MODE="$_CLI_APPLY_MODE" ;;
|
||
*)
|
||
_JSON_ERR="Недопустимое значение --apply-mode: '$_CLI_APPLY_MODE' (ожидается: syncconf или restart)"
|
||
echo "$_JSON_ERR" >&2
|
||
exit 1 ;;
|
||
esac
|
||
fi
|
||
|
||
# Обновляем пути после возможного переопределения --conf-dir
|
||
CONFIG_FILE="$AWG_DIR/awgsetup_cfg.init"
|
||
KEYS_DIR="$AWG_DIR/keys"
|
||
COMMON_SCRIPT_PATH="$AWG_DIR/awg_common.sh"
|
||
LOG_FILE="$AWG_DIR/manage_amneziawg.log"
|
||
|
||
# ==============================================================================
|
||
# Функции логирования
|
||
# ==============================================================================
|
||
|
||
log_msg() {
|
||
local type="$1" msg="$2"
|
||
local ts
|
||
ts=$(date +'%F %T')
|
||
local entry="[$ts] $type: $msg"
|
||
local color_start="" color_end=""
|
||
|
||
if [[ "$NO_COLOR" -eq 0 ]]; then
|
||
color_end="\033[0m"
|
||
case "$type" in
|
||
INFO) color_start="\033[0;32m" ;;
|
||
WARN) color_start="\033[0;33m" ;;
|
||
ERROR) color_start="\033[1;31m" ;;
|
||
DEBUG) color_start="\033[0;36m" ;;
|
||
*) color_start=""; color_end="" ;;
|
||
esac
|
||
fi
|
||
|
||
if ! mkdir -p "$(dirname "$LOG_FILE")" || ! echo "$entry" >> "$LOG_FILE"; then
|
||
echo "[$ts] ERROR: Ошибка записи лога $LOG_FILE" >&2
|
||
fi
|
||
|
||
# WARN и ERROR в stderr (симметрия с install_amneziawg.sh:110+, важно
|
||
# для CI/automation парсинга: stdout = «данные», stderr = «диагностика»).
|
||
if [[ "$type" == "ERROR" || "$type" == "WARN" ]]; then
|
||
printf "${color_start}%s${color_end}\n" "$entry" >&2
|
||
elif [[ "${JSON_OUTPUT:-0}" -eq 1 ]]; then
|
||
# weaq P2: в режиме --json stdout обязан содержать ТОЛЬКО JSON (jq/automation).
|
||
# INFO/DEBUG уводим в stderr, иначе list/show/stats --json печатают INFO-строки
|
||
# перед JSON и ломают парсинг (подтверждено на biHetzner).
|
||
printf "${color_start}%s${color_end}\n" "$entry" >&2
|
||
else
|
||
printf "${color_start}%s${color_end}\n" "$entry"
|
||
fi
|
||
}
|
||
|
||
log() { log_msg "INFO" "$1"; }
|
||
log_warn() { log_msg "WARN" "$1"; }
|
||
log_error() { log_msg "ERROR" "$1"; }
|
||
log_debug() { if [[ "$VERBOSE_LIST" -eq 1 ]]; then log_msg "DEBUG" "$1"; fi; }
|
||
# die дублирует сообщение в _JSON_ERR: аварийный JSON guard-а несёт
|
||
# осмысленный текст вместо дефолтного "command failed".
|
||
die() { _JSON_ERR="$1"; log_error "$1"; exit 1; }
|
||
|
||
# ==============================================================================
|
||
# Утилиты
|
||
# ==============================================================================
|
||
|
||
is_interactive() { [[ -t 0 && -t 1 ]]; }
|
||
|
||
# Экранирование спецсимволов для sed (предотвращает command injection)
|
||
escape_sed() {
|
||
local s="$1"
|
||
s="${s//\\/\\\\}"
|
||
s="${s//&/\\&}"
|
||
s="${s//#/\\#}"
|
||
s="${s////\\/}"
|
||
printf '%s' "$s"
|
||
}
|
||
|
||
confirm_action() {
|
||
# CLI флаг --yes или ENV AWG_YES=1 пропускают confirm-prompt — для скриптов,
|
||
# cron, Ansible и интерактивных вызовов где явно подтвердили заранее.
|
||
if [[ "${CLI_YES:-0}" == "1" || "${AWG_YES:-0}" == "1" ]]; then
|
||
return 0
|
||
fi
|
||
if ! is_interactive; then
|
||
# AWG_STRICT_CONFIRM=1 (opt-in, v5.21.0): неинтерактивный запуск без
|
||
# явного --yes/AWG_YES=1 отклоняется вместо тихого согласия - защита
|
||
# от destructive-команд из пайплайнов, где никто не смотрит на экран.
|
||
# Дефолт 0 сохраняет прежнее поведение; строго строка "1", не "true".
|
||
# ENV действует на один запуск и НЕ персистится в awgsetup_cfg.init.
|
||
if [[ "${AWG_STRICT_CONFIRM:-0}" == "1" ]]; then
|
||
_JSON_ERR="AWG_STRICT_CONFIRM=1: non-interactive run requires --yes"
|
||
log_error "AWG_STRICT_CONFIRM=1: неинтерактивный запуск требует --yes (или AWG_YES=1). Действие отменено."
|
||
return 1
|
||
fi
|
||
return 0
|
||
fi
|
||
local action="$1" subject="$2"
|
||
read -rp "Вы действительно хотите $action $subject? [y/N]: " confirm < /dev/tty
|
||
# Принимаем y/yes (регистронезависимо) + случайные пробелы/CR по краям.
|
||
if [[ "$confirm" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then
|
||
return 0
|
||
else
|
||
_JSON_ERR="confirmation denied"
|
||
log "Действие отменено."
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
validate_client_name() {
|
||
local name="$1"
|
||
if [[ -z "$name" ]]; then log_error "Имя пустое."; return 1; fi
|
||
if [[ ${#name} -gt 63 ]]; then log_error "Имя > 63 симв."; return 1; fi
|
||
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then log_error "Имя содержит недоп. символы."; return 1; fi
|
||
return 0
|
||
}
|
||
|
||
# JSON-запись успешной перегенерации (v5.21.0). qr/vpnuri - пути, если файл
|
||
# существует на момент ответа (regenerate_client обновляет их best-effort;
|
||
# гарантий свежести контракт не даёт - см. доку про гонки).
|
||
_regen_json_entry() {
|
||
local name="$1" _jqr="null" _juri="null"
|
||
[[ -f "$AWG_DIR/${name}.png" ]] && _jqr="\"$(json_escape "$AWG_DIR/${name}.png")\""
|
||
[[ -f "$AWG_DIR/${name}.vpnuri" ]] && _juri="\"$(json_escape "$AWG_DIR/${name}.vpnuri")\""
|
||
printf '%s' "{\"name\":\"$(json_escape "$name")\",\"status\":\"regenerated\",\"conf\":\"$(json_escape "$AWG_DIR/${name}.conf")\",\"qr\":$_jqr,\"vpnuri\":$_juri}"
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Проверка зависимостей
|
||
# ==============================================================================
|
||
|
||
# Сверка совместимости awg_common.sh с этим скриптом. Файлы обновляются парой;
|
||
# если обновили только один, рассинхрон иначе всплывает как "command not found"
|
||
# в случайном месте (issue #183). Сверяем MAJOR.MINOR: расхождение в patch
|
||
# допускаем (в пределах minor ломающих изменений в библиотеку не вносим), а вот
|
||
# другой minor или библиотека без версии (старее этой проверки) = стоп.
|
||
_check_common_compat() {
|
||
local have="${AWG_COMMON_VERSION:-}"
|
||
local want="$SCRIPT_VERSION"
|
||
# Сравниваем MAJOR и MINOR по отдельности как ЧИСЛА, а не через ${v%.*}
|
||
# (тот схлопывал бы "5.20" и "5.9" в "5"). Формат X.Y.* с числовыми X.Y
|
||
# обязателен: пустая/двухкомпонентная/нечисловая версия библиотеки не
|
||
# проходит и приводит к die. Хвост после MINOR (patch, -rc1) игнорируется.
|
||
local re='^([0-9]+)\.([0-9]+)\.'
|
||
if [[ "$have" =~ $re ]]; then
|
||
local have_mj="${BASH_REMATCH[1]}" have_mn="${BASH_REMATCH[2]}"
|
||
if [[ "$want" =~ $re ]]; then
|
||
[[ "$have_mj" == "${BASH_REMATCH[1]}" && "$have_mn" == "${BASH_REMATCH[2]}" ]] && return 0
|
||
fi
|
||
fi
|
||
die "awg_common.sh (${have:-без версии}) несовместима с manage_amneziawg.sh ($want). Обнови обе половины под одну версию:
|
||
wget -O $AWG_DIR/manage_amneziawg.sh https://raw.githubusercontent.com/bivlked/amneziawg-installer/v$want/manage_amneziawg.sh
|
||
wget -O $COMMON_SCRIPT_PATH https://raw.githubusercontent.com/bivlked/amneziawg-installer/v$want/awg_common.sh
|
||
chmod 700 $AWG_DIR/manage_amneziawg.sh $COMMON_SCRIPT_PATH"
|
||
}
|
||
|
||
check_dependencies() {
|
||
log "Проверка зависимостей..."
|
||
local ok=1
|
||
|
||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||
log_error "Не найден: $CONFIG_FILE"
|
||
ok=0
|
||
fi
|
||
if [[ ! -f "$COMMON_SCRIPT_PATH" ]]; then
|
||
log_error "Не найден: $COMMON_SCRIPT_PATH"
|
||
ok=0
|
||
fi
|
||
if [[ ! -f "$SERVER_CONF_FILE" ]]; then
|
||
log_error "Не найден: $SERVER_CONF_FILE"
|
||
ok=0
|
||
fi
|
||
if [[ "$ok" -eq 0 ]]; then
|
||
die "Не найдены файлы установки. Запустите install_amneziawg.sh."
|
||
fi
|
||
|
||
if ! command -v awg &>/dev/null; then die "'awg' не найден."; fi
|
||
if ! command -v qrencode &>/dev/null; then log_warn "qrencode не найден (QR-коды не будут созданы)."; fi
|
||
|
||
# Подключаем общую библиотеку.
|
||
# Сбрасываем перед source, чтобы версию задавала ТОЛЬКО библиотека, а не
|
||
# унаследованное окружение (иначе старая библиотека без переменной могла бы
|
||
# ложно пройти проверку совместимости).
|
||
unset AWG_COMMON_VERSION
|
||
# shellcheck source=/dev/null
|
||
source "$COMMON_SCRIPT_PATH" || die "Ошибка загрузки $COMMON_SCRIPT_PATH"
|
||
_check_common_compat
|
||
|
||
log "Зависимости OK."
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Резервное копирование
|
||
# ==============================================================================
|
||
|
||
# Внутренняя функция: выполняет бэкап без захвата блокировки.
|
||
# Вызывается только из контекста, где .awg_backup.lock уже удерживается.
|
||
#
|
||
# Контракт обработки ошибок (v5.11.0 A1.1):
|
||
# - Критичные артефакты (awg0.conf, CONFIG_FILE, server_*.key, клиентские
|
||
# *.conf, $KEYS_DIR/*) — при ошибке cp возвращает 1 (не продолжает
|
||
# молча). Повреждённый backup опаснее отсутствующего.
|
||
# - Опциональные (QR *.png, *.vpnuri, expiry/, cron) — ошибка cp → log_warn,
|
||
# продолжаем. Они восстанавливаются из конфига.
|
||
# - Отсутствие глобов (клиентов нет) отличается от cp-failure через
|
||
# compgen -G pre-check.
|
||
# По успеху устанавливает LAST_BACKUP_PATH (используется restore_backup
|
||
# для rollback snapshot).
|
||
_backup_configs_nolock() {
|
||
# --no-prune: не удалять старые бэкапы после создания. Используется
|
||
# pre-restore snapshot'ом: иначе при уже накопленных 10 бэкапах prune
|
||
# обрезал бы самый старый, которым может оказаться именно выбранный для
|
||
# восстановления файл (он лежит в той же папке $AWG_DIR/backups).
|
||
local no_prune=0
|
||
if [[ "${1:-}" == "--no-prune" ]]; then
|
||
no_prune=1
|
||
shift
|
||
fi
|
||
log "Создание бэкапа..."
|
||
local bd="$AWG_DIR/backups"
|
||
mkdir -p "$bd" || die "Ошибка mkdir $bd"
|
||
chmod 700 "$bd" 2>/dev/null
|
||
local ts bf td
|
||
# Миллисекундная точность в timestamp защищает от collision при rapid-fire
|
||
# backup'ах (например, regen → backup → modify → backup в одной секунде).
|
||
ts=$(date +%F_%H-%M-%S.%3N)
|
||
bf="$bd/awg_backup_${ts}.tar.gz"
|
||
manage_mktempdir_var td || die "Ошибка создания временной директории"
|
||
|
||
mkdir -p "$td/server" "$td/clients" "$td/keys"
|
||
|
||
# Серверный конфиг (mandatory)
|
||
if [[ -f "$SERVER_CONF_FILE" ]]; then
|
||
if ! cp -a "$SERVER_CONF_FILE" "$td/server/"; then
|
||
log_error "Не удалось сохранить $SERVER_CONF_FILE в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
else
|
||
log_warn "Серверный конфиг отсутствует ($SERVER_CONF_FILE) — в бэкап не попадёт."
|
||
fi
|
||
# Опциональные файлы рядом с awg0.conf (backup'ы от modify, и т.п.)
|
||
if compgen -G "${SERVER_CONF_FILE}.*" > /dev/null; then
|
||
cp -a "${SERVER_CONF_FILE}".* "$td/server/" 2>/dev/null || \
|
||
log_warn "Не удалось сохранить ${SERVER_CONF_FILE}.* (некритично)."
|
||
fi
|
||
|
||
# Метаданные клиентов (mandatory)
|
||
if [[ -f "$CONFIG_FILE" ]]; then
|
||
if ! cp -a "$CONFIG_FILE" "$td/clients/"; then
|
||
log_error "Не удалось сохранить $CONFIG_FILE в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
# Клиентские *.conf (critical если существуют)
|
||
if compgen -G "$AWG_DIR/*.conf" > /dev/null; then
|
||
if ! cp -a "$AWG_DIR"/*.conf "$td/clients/"; then
|
||
log_error "Не удалось сохранить клиентские *.conf в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
# QR-коды *.png (optional — перегенерируются из conf)
|
||
if compgen -G "$AWG_DIR/*.png" > /dev/null; then
|
||
cp -a "$AWG_DIR"/*.png "$td/clients/" 2>/dev/null || \
|
||
log_warn "Не удалось сохранить клиентские *.png (некритично)."
|
||
fi
|
||
# vpn:// URI (optional — перегенерируются)
|
||
if compgen -G "$AWG_DIR/*.vpnuri" > /dev/null; then
|
||
cp -a "$AWG_DIR"/*.vpnuri "$td/clients/" 2>/dev/null || \
|
||
log_warn "Не удалось сохранить клиентские *.vpnuri (некритично)."
|
||
fi
|
||
|
||
# Ключи клиентов (critical если существуют)
|
||
if compgen -G "$KEYS_DIR/*" > /dev/null; then
|
||
if ! cp -a "$KEYS_DIR"/* "$td/keys/"; then
|
||
log_error "Не удалось сохранить ключи клиентов ($KEYS_DIR) в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# Ключи сервера (mandatory если существуют)
|
||
if [[ -f "$AWG_DIR/server_private.key" ]]; then
|
||
if ! cp -a "$AWG_DIR/server_private.key" "$td/"; then
|
||
log_error "Не удалось сохранить server_private.key в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
if [[ -f "$AWG_DIR/server_public.key" ]]; then
|
||
if ! cp -a "$AWG_DIR/server_public.key" "$td/"; then
|
||
log_error "Не удалось сохранить server_public.key в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# Expiry (critical — Unix epoch метки не восстановимы из других конфигов).
|
||
# Потеря этих данных меняет поведение expiry-enforcement после restore.
|
||
if [[ -d "${EXPIRY_DIR:-$AWG_DIR/expiry}" ]]; then
|
||
if ! cp -a "${EXPIRY_DIR:-$AWG_DIR/expiry}" "$td/expiry"; then
|
||
log_error "Не удалось сохранить expiry/ в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
# Cron awg-expiry (critical — без него expiry-enforcement перестаёт работать).
|
||
if [[ -f /etc/cron.d/awg-expiry ]]; then
|
||
if ! cp -a /etc/cron.d/awg-expiry "$td/"; then
|
||
log_error "Не удалось сохранить /etc/cron.d/awg-expiry в бэкап."
|
||
rm -rf "$td"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
tar -czf "$bf" -C "$td" . || { rm -rf "$td"; die "Ошибка tar $bf"; }
|
||
log_debug "tar: архив создан $bf"
|
||
rm -rf "$td"
|
||
chmod 600 "$bf" || log_warn "Ошибка chmod бэкапа"
|
||
|
||
# Оставляем максимум 10 бэкапов (кроме режима --no-prune)
|
||
if [[ "$no_prune" -eq 0 ]]; then
|
||
find "$bd" -maxdepth 1 -name "awg_backup_*.tar.gz" -printf '%T@ %p\n' | \
|
||
sort -nr | tail -n +11 | cut -d' ' -f2- | xargs -r rm -f || \
|
||
log_warn "Ошибка удаления старых бэкапов"
|
||
fi
|
||
|
||
LAST_BACKUP_PATH="$bf"
|
||
log "Бэкап создан: $bf"
|
||
}
|
||
|
||
backup_configs() {
|
||
local backup_lockfile="${AWG_DIR}/.awg_backup.lock"
|
||
local backup_lock_fd
|
||
exec {backup_lock_fd}>"$backup_lockfile"
|
||
if ! flock -x -w 30 "$backup_lock_fd"; then
|
||
log_error "Таймаут ожидания блокировки backup (30 сек). Другая операция backup/restore уже запущена."
|
||
exec {backup_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
# Дополнительно берём конфиг-лок: параллельный `manage add/remove` мог
|
||
# изменить awg0.conf/keys МЕЖДУ копированием server/ и clients/ в tmpdir -
|
||
# каждый файл в бэкапе цел (atomic mv), но набор рассинхронизирован
|
||
# (peer-mismatch при restore). restore_backup держит оба лока - бэкап
|
||
# должен делать так же. ВАЖНО: в restore _backup_configs_nolock вызывается
|
||
# под уже взятым конфиг-локом - здесь лок берётся только для прямой
|
||
# команды backup (flock non-reentrant, см. контракт в awg_common.sh).
|
||
local config_lockfile="${AWG_DIR}/.awg_config.lock"
|
||
local config_lock_fd
|
||
exec {config_lock_fd}>"$config_lockfile"
|
||
if ! flock -x -w 30 "$config_lock_fd"; then
|
||
log_error "Таймаут ожидания блокировки конфига (30 сек)."
|
||
exec {config_lock_fd}>&-
|
||
exec {backup_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
_backup_configs_nolock
|
||
local _rc=$?
|
||
exec {config_lock_fd}>&-
|
||
exec {backup_lock_fd}>&-
|
||
return "$_rc"
|
||
}
|
||
|
||
# Откат к pre-restore snapshot (v5.11.0 A5.1).
|
||
# Вызывается из restore_backup при любой ошибке после начала destructive ops.
|
||
# Извлекает snapshot из $1 и копирует файлы обратно в исходные пути, затем
|
||
# пытается запустить сервис. Не критично, если cp какого-то файла провалится:
|
||
# цель — вернуть систему в рабочее состояние best-effort, чтобы пользователь
|
||
# не остался без VPN.
|
||
_restore_do_rollback() {
|
||
local _snap="$1"
|
||
if [[ -z "$_snap" || ! -f "$_snap" ]]; then
|
||
log_error "Rollback snapshot недоступен ($_snap) — требуется ручное восстановление."
|
||
return 1
|
||
fi
|
||
log_warn "Откат к состоянию до restore ($(basename "$_snap"))..."
|
||
local _rtd
|
||
manage_mktempdir_var _rtd || {
|
||
log_error "Не удалось создать tmpdir для отката. Ручное: tar -xzf $_snap -C /"
|
||
return 1
|
||
}
|
||
if ! tar -xzf "$_snap" --no-same-owner --no-same-permissions -C "$_rtd" 2>/dev/null; then
|
||
rm -rf "$_rtd"
|
||
log_error "Не удалось распаковать rollback snapshot ($_snap). Ручное восстановление: tar -xzf $_snap -C <нужная папка>"
|
||
return 1
|
||
fi
|
||
local _scdir
|
||
_scdir=$(dirname "$SERVER_CONF_FILE")
|
||
[[ -d "$_rtd/server" ]] && cp -a "$_rtd/server/"* "$_scdir/" 2>/dev/null
|
||
[[ -d "$_rtd/clients" ]] && cp -a "$_rtd/clients/"* "$AWG_DIR/" 2>/dev/null
|
||
[[ -d "$_rtd/keys" ]] && cp -a "$_rtd/keys/"* "$KEYS_DIR/" 2>/dev/null
|
||
[[ -f "$_rtd/server_private.key" ]] && cp -a "$_rtd/server_private.key" "$AWG_DIR/" 2>/dev/null
|
||
[[ -f "$_rtd/server_public.key" ]] && cp -a "$_rtd/server_public.key" "$AWG_DIR/" 2>/dev/null
|
||
[[ -d "$_rtd/expiry" ]] && { mkdir -p "${EXPIRY_DIR:-$AWG_DIR/expiry}"; cp -a "$_rtd/expiry"/* "${EXPIRY_DIR:-$AWG_DIR/expiry}/" 2>/dev/null; }
|
||
[[ -f "$_rtd/awg-expiry" ]] && cp -a "$_rtd/awg-expiry" /etc/cron.d/awg-expiry 2>/dev/null
|
||
rm -rf "$_rtd"
|
||
# Файлы отката скопированы - для JSON-конверта rolled_back=true даже если
|
||
# сервис ниже не стартует (состояние ФС уже возвращено к pre-restore).
|
||
_RESTORE_ROLLED_BACK=1
|
||
|
||
log "Откат завершён — пытаюсь запустить сервис..."
|
||
if systemctl start awg-quick@awg0; then
|
||
log "Сервис запущен после отката."
|
||
return 0
|
||
else
|
||
log_error "Сервис не стартовал после отката — проверьте: systemctl status awg-quick@awg0"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Возвращает 0, если в пути есть '..' как ЦЕЛЫЙ компонент (parent traversal):
|
||
# ровно "..", префикс "../", "/../" в середине или "/.." в конце. Подстрока
|
||
# ".." внутри имени (my..backup.conf, v1..2) легитимна - прежний substring-чек
|
||
# ложно отклонял такие файлы при restore чужих/модифицированных архивов.
|
||
_path_has_parent_component() {
|
||
local p="$1"
|
||
[[ "$p" == ".." || "$p" == "../"* || "$p" == *"/../"* || "$p" == *"/.." ]]
|
||
}
|
||
|
||
restore_backup() {
|
||
local bf="$1"
|
||
local bd="$AWG_DIR/backups"
|
||
|
||
if [[ -z "$bf" ]]; then
|
||
if ! is_interactive; then
|
||
die "Путь к бэкапу обязателен в неинтерактивном режиме: restore <файл>"
|
||
fi
|
||
if [[ ! -d "$bd" ]] || [[ -z "$(ls -A "$bd" 2>/dev/null)" ]]; then
|
||
die "Бэкапы не найдены в $bd."
|
||
fi
|
||
local backups
|
||
backups=$(find "$bd" -maxdepth 1 -name "awg_backup_*.tar.gz" | sort -r)
|
||
if [[ -z "$backups" ]]; then die "Бэкапы не найдены."; fi
|
||
|
||
echo "Доступные бэкапы:"
|
||
local i=1
|
||
local bl=()
|
||
while IFS= read -r f; do
|
||
echo " $i) $(basename "$f")"
|
||
bl[$i]="$f"
|
||
((i++))
|
||
done <<< "$backups"
|
||
|
||
read -rp "Номер для восстановления (0-отмена): " choice < /dev/tty
|
||
if ! [[ "$choice" =~ ^[0-9]+$ ]] || [[ "$choice" -eq 0 ]] || [[ "$choice" -ge "$i" ]]; then
|
||
log "Отмена."
|
||
return 1
|
||
fi
|
||
bf="${bl[$choice]}"
|
||
fi
|
||
|
||
if [[ ! -f "$bf" ]]; then die "Файл бэкапа '$bf' не найден."; fi
|
||
_RESTORE_SOURCE="$bf" # для JSON-конверта restore (v5.21.0)
|
||
log "Восстановление из $bf"
|
||
if ! confirm_action "восстановить" "конфигурацию из '$bf'"; then return 1; fi
|
||
|
||
# v5.11.0 A5.1: rollback infrastructure.
|
||
# _rollback_snap заполнится после _backup_configs_nolock — до этого
|
||
# момента destructive ops не выполняются, откат не нужен.
|
||
# _destructive_ops_started=1 ставится перед первой деструктивной
|
||
# операцией (после systemctl stop) — rollback делаем только когда
|
||
# система реально изменена, иначе cp тех же байт это no-op overhead.
|
||
# _restore_ok=1 выставляется только на финальном успехе.
|
||
local _rollback_snap=""
|
||
local _restore_ok=0
|
||
local _destructive_ops_started=0
|
||
local td=""
|
||
|
||
# Захват блокировки backup (внешняя) — предотвращает параллельные backup/restore
|
||
local backup_lockfile="${AWG_DIR}/.awg_backup.lock"
|
||
local backup_lock_fd
|
||
exec {backup_lock_fd}>"$backup_lockfile"
|
||
if ! flock -x -w 30 "$backup_lock_fd"; then
|
||
log_error "Таймаут ожидания блокировки backup (30 сек). Другая операция backup/restore уже запущена."
|
||
exec {backup_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
|
||
# Захват блокировки конфига (внутренняя) — предотвращает изменение конфига во время restore
|
||
local config_lockfile="${AWG_DIR}/.awg_config.lock"
|
||
local config_lock_fd
|
||
exec {config_lock_fd}>"$config_lockfile"
|
||
if ! flock -x -w 30 "$config_lock_fd"; then
|
||
log_error "Таймаут ожидания блокировки конфига (30 сек)."
|
||
exec {config_lock_fd}>&-
|
||
exec {backup_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
|
||
# Cleanup-хук: вызывается на любом return (через trap RETURN).
|
||
# При _restore_ok=0 И _destructive_ops_started=1 → rollback к
|
||
# _rollback_snap. Всегда → удаление временной директории и снятие
|
||
# блокировок. Первым делом сбрасываем RETURN trap — bash `trap ...
|
||
# RETURN` имеет global lifetime и без очистки срабатывал бы на
|
||
# любом последующем return в этом shell.
|
||
_restore_cleanup() {
|
||
# Порядок важен: сначала захватываем $? (return-code функции
|
||
# restore_backup), потом снимаем RETURN trap. Swap сломал бы
|
||
# захват, т.к. `trap - RETURN` — builtin, затирает $? в 0.
|
||
# Реентранс невозможен: `local` и `trap -` не вызывают функций,
|
||
# а после `trap - RETURN` наш trap уже снят.
|
||
local _rc=$?
|
||
# Снимаем RETURN и ВОССТАНАВЛИВАЕМ глобальные INT/TERM (локальные хуки
|
||
# restore выставлены ниже). Просто `trap -` сбросил бы их в default и
|
||
# менеджер после restore потерял бы B1-поведение signal -> cleanup+exit.
|
||
trap - RETURN
|
||
trap '_manage_on_signal 130' INT
|
||
trap '_manage_on_signal 143' TERM
|
||
if [[ $_restore_ok -eq 0 && $_destructive_ops_started -eq 1 && -n "$_rollback_snap" ]]; then
|
||
_restore_do_rollback "$_rollback_snap" || true
|
||
fi
|
||
[[ -n "$td" && -d "$td" ]] && rm -rf "$td"
|
||
[[ -n "${config_lock_fd:-}" ]] && exec {config_lock_fd}>&- 2>/dev/null
|
||
[[ -n "${backup_lock_fd:-}" ]] && exec {backup_lock_fd}>&- 2>/dev/null
|
||
return $_rc
|
||
}
|
||
trap _restore_cleanup RETURN
|
||
# INT/TERM в ходе restore: тот же rollback+cleanup, что и на обычном return
|
||
# (_restore_cleanup видит локальные _restore_ok/_rollback_snap/td), затем выход
|
||
# с сигнальным кодом. Перекрывает глобальный _manage_on_signal, чтобы прерывание
|
||
# destructive-фазы не оставило систему без отката. _restore_cleanup сам снимет
|
||
# эти хуки (trap - INT TERM выше).
|
||
trap '_restore_cleanup; exit 130' INT
|
||
trap '_restore_cleanup; exit 143' TERM
|
||
|
||
log "Создание бэкапа текущей..."
|
||
# --no-prune: выбранный для восстановления $bf лежит в той же папке бэкапов;
|
||
# prune после создания pre-restore снапшота мог бы удалить именно его.
|
||
if ! _backup_configs_nolock --no-prune; then
|
||
log_error "Не удалось создать бэкап текущей конфигурации."
|
||
return 1
|
||
fi
|
||
# Фиксируем rollback snapshot (устанавливается _backup_configs_nolock)
|
||
_rollback_snap="${LAST_BACKUP_PATH:-}"
|
||
|
||
manage_mktempdir_var td || {
|
||
log_error "Ошибка создания временной директории"
|
||
return 1
|
||
}
|
||
|
||
# Pre-extraction валидация: проверяем содержимое tar до распаковки.
|
||
# Defense-in-depth: наш threat model (root-only локальные бэкапы) делает
|
||
# эксплуатацию маловероятной, но crafted или подменённый архив мог бы
|
||
# использовать path traversal (../), абсолютные пути, symlinks или device
|
||
# файлы для перезаписи произвольных системных файлов при распаковке от root.
|
||
|
||
# Проверка типов через verbose listing: отклоняем block/char/FIFO/symlink ('l')
|
||
# и hardlink ('h') - оба класса ссылок небезопасны при распаковке.
|
||
local _tar_verbose _vline _tc
|
||
_tar_verbose=$(tar -tvzf "$bf" 2>/dev/null) || {
|
||
log_error "Не удалось прочитать содержимое архива $bf"
|
||
return 1
|
||
}
|
||
while IFS= read -r _vline; do
|
||
[[ -z "$_vline" ]] && continue
|
||
_tc="${_vline:0:1}"
|
||
case "$_tc" in
|
||
b|c|p|h|l)
|
||
log_error "Архив содержит опасный тип файла ('${_tc}'): '${_vline}' — восстановление отменено."
|
||
return 1
|
||
;;
|
||
esac
|
||
done <<< "$_tar_verbose"
|
||
|
||
# Проверка путей: абсолютные пути и path traversal
|
||
local _tar_list _bad_entry
|
||
_tar_list=$(tar -tzf "$bf" 2>/dev/null) || {
|
||
log_error "Не удалось прочитать содержимое архива $bf"
|
||
return 1
|
||
}
|
||
while IFS= read -r _bad_entry; do
|
||
[[ -z "$_bad_entry" ]] && continue
|
||
# Абсолютные пути
|
||
if [[ "$_bad_entry" == /* ]]; then
|
||
log_error "Архив содержит абсолютный путь: '$_bad_entry' — восстановление отменено."
|
||
return 1
|
||
fi
|
||
# Parent directory traversal ('..' только как компонент пути)
|
||
if _path_has_parent_component "$_bad_entry"; then
|
||
log_error "Архив содержит path traversal (..): '$_bad_entry' — восстановление отменено."
|
||
return 1
|
||
fi
|
||
done <<< "$_tar_list"
|
||
log_debug "Pre-extraction проверка пройдена: $(echo "$_tar_list" | wc -l) файлов в архиве."
|
||
|
||
if ! tar -xzf "$bf" --no-same-owner --no-same-permissions -C "$td"; then
|
||
log_error "Ошибка tar $bf"
|
||
return 1
|
||
fi
|
||
|
||
# Post-extraction проверка: нет symlinks в распакованном дереве
|
||
local _symlinks
|
||
_symlinks=$(find "$td" -type l 2>/dev/null)
|
||
if [[ -n "$_symlinks" ]]; then
|
||
log_error "Архив содержит symlinks (возможная symlink attack):"
|
||
while IFS= read -r _sl; do log_error " $_sl → $(readlink "$_sl")"; done <<< "$_symlinks"
|
||
return 1
|
||
fi
|
||
|
||
# Проверка полноты бэкапа ДО остановки сервиса. Бэкап без серверного конфига
|
||
# бесполезен (VPN без него не поднять), а пустой server/ ронял `cp "$td/server/"*`
|
||
# уже ПОСЛЕ stop и форсил откат рабочей системы. Проверяем до destructive-фазы:
|
||
# сервис не трогаем, откат не нужен.
|
||
local _srv_base
|
||
_srv_base=$(basename "$SERVER_CONF_FILE")
|
||
if [[ ! -f "$td/server/$_srv_base" ]]; then
|
||
log_error "Бэкап неполный: отсутствует серверный конфиг ($_srv_base) - восстановление отменено."
|
||
return 1
|
||
fi
|
||
|
||
log "Остановка сервиса..."
|
||
systemctl stop awg-quick@awg0 || log_warn "Сервис не остановлен."
|
||
|
||
# С этого момента destructive ops. Все error paths → trap _restore_cleanup → rollback.
|
||
_destructive_ops_started=1
|
||
if [[ -d "$td/server" ]]; then
|
||
log "Восстановление конфига сервера..."
|
||
local server_conf_dir
|
||
server_conf_dir=$(dirname "$SERVER_CONF_FILE")
|
||
mkdir -p "$server_conf_dir"
|
||
if ! cp -a "$td/server/"* "$server_conf_dir/"; then
|
||
log_error "Ошибка копирования server — восстановление прервано (запуск отката)."
|
||
return 1
|
||
fi
|
||
chmod 600 "$server_conf_dir"/*.conf 2>/dev/null
|
||
chmod 700 "$server_conf_dir"
|
||
log_debug "Конфиг сервера восстановлен в $server_conf_dir"
|
||
fi
|
||
|
||
if [[ -d "$td/clients" ]]; then
|
||
log "Восстановление файлов клиентов..."
|
||
# C11: чистая замена, не merge. Удаляю stale client-артефакты, которых
|
||
# нет в бэкапе (иначе клиент, удалённый после снятия бэкапа, остаётся
|
||
# orphan .conf/.png/.vpnuri). Scope строго managed client-globs - НЕ
|
||
# трогаю скрипты, server-ключи, backups/, логи, .lock, awgsetup_cfg.init.
|
||
rm -f "$AWG_DIR"/*.conf "$AWG_DIR"/*.png "$AWG_DIR"/*.vpnuri 2>/dev/null || true
|
||
# Пустой clients/ - валидный случай (сервер без клиентских конфигов):
|
||
# prune выше уже дал чистую замену, copy просто пропускаем (без compgen
|
||
# голый glob "$td/clients/"* остался бы литералом и уронил cp -> откат).
|
||
if compgen -G "$td/clients/*" > /dev/null; then
|
||
if ! cp -a "$td/clients/"* "$AWG_DIR/"; then
|
||
log_error "Ошибка копирования clients — восстановление прервано (запуск отката)."
|
||
return 1
|
||
fi
|
||
chmod 600 "$AWG_DIR"/*.conf 2>/dev/null
|
||
chmod 600 "$AWG_DIR"/*.png 2>/dev/null
|
||
chmod 600 "$AWG_DIR"/*.vpnuri 2>/dev/null
|
||
chmod 600 "$CONFIG_FILE" 2>/dev/null
|
||
log_debug "Файлы клиентов восстановлены в $AWG_DIR"
|
||
else
|
||
log_debug "Бэкап без клиентских файлов (clients/ пуст) - пропуск копирования."
|
||
fi
|
||
fi
|
||
|
||
if [[ -d "$td/keys" ]]; then
|
||
log "Восстановление ключей..."
|
||
mkdir -p "$KEYS_DIR"
|
||
# C11: удаляю stale client-ключи, которых нет в бэкапе (server-ключи
|
||
# лежат в AWG_DIR, не в KEYS_DIR, поэтому не затрагиваются).
|
||
rm -f "$KEYS_DIR"/* 2>/dev/null || true
|
||
# C2: keys/ в бэкапе может быть пустым (сервер без клиентских ключей).
|
||
# Без compgen-guard голый glob "$td/keys/*" остался бы литералом, cp упал
|
||
# бы, и весь restore ушёл бы в откат. Пустой keys/ - не ошибка.
|
||
if ! compgen -G "$td/keys/*" > /dev/null; then
|
||
log_debug "Бэкап без клиентских ключей (keys/ пуст) - пропуск, не ошибка."
|
||
elif ! cp -a "$td/keys/"* "$KEYS_DIR/"; then
|
||
log_error "Ошибка копирования keys — восстановление прервано (запуск отката)."
|
||
return 1
|
||
else
|
||
chmod 600 "$KEYS_DIR"/* 2>/dev/null
|
||
log_debug "Ключи восстановлены в $KEYS_DIR"
|
||
fi
|
||
fi
|
||
|
||
# Серверные ключи: cp -a сохраняет mode из архива, поэтому форсируем 600
|
||
# независимо от того с какими правами они лежали в backup-е (audit fix).
|
||
if [[ -f "$td/server_private.key" ]]; then
|
||
if ! cp -a "$td/server_private.key" "$AWG_DIR/"; then
|
||
log_error "Ошибка копирования server_private.key — восстановление прервано (запуск отката)."
|
||
return 1
|
||
fi
|
||
chmod 600 "$AWG_DIR/server_private.key" 2>/dev/null || true
|
||
fi
|
||
if [[ -f "$td/server_public.key" ]]; then
|
||
if ! cp -a "$td/server_public.key" "$AWG_DIR/"; then
|
||
log_error "Ошибка копирования server_public.key — восстановление прервано (запуск отката)."
|
||
return 1
|
||
fi
|
||
chmod 600 "$AWG_DIR/server_public.key" 2>/dev/null || true
|
||
fi
|
||
|
||
if [[ -d "$td/expiry" ]]; then
|
||
log "Восстановление данных expiry..."
|
||
mkdir -p "${EXPIRY_DIR:-$AWG_DIR/expiry}"
|
||
# C11: expiry НЕ пруним намеренно. Orphan-метки для несуществующих клиентов
|
||
# безвредны: check_expired_clients при истечении распознаёт отсутствие peer
|
||
# в конфиге и зачищает метку с артефактами сам. Prune здесь был бы небезопасен:
|
||
# и rm, и последующий cp - best-effort (|| true), так что сбой copy после
|
||
# prune молча оставил бы expiry пустым. Сами client-артефакты пруним выше.
|
||
cp -a "$td/expiry/"* "${EXPIRY_DIR:-$AWG_DIR/expiry}/" 2>/dev/null || true
|
||
chmod 600 "${EXPIRY_DIR:-$AWG_DIR/expiry}"/* 2>/dev/null
|
||
fi
|
||
if [[ -f "$td/awg-expiry" ]]; then
|
||
cp -a "$td/awg-expiry" /etc/cron.d/awg-expiry
|
||
chmod 644 /etc/cron.d/awg-expiry
|
||
fi
|
||
|
||
# Pre-flight: валидация восстановленного конфига ДО старта сервиса.
|
||
# Если конфиг invalid — сервис гарантированно упадёт, лучше откатиться
|
||
# сейчас и объяснить причину, чем стартовать сломанный awg-quick@awg0.
|
||
if ! validate_awg_config >/dev/null 2>&1; then
|
||
log_error "Восстановленный серверный конфиг не прошёл валидацию — запуск отката."
|
||
return 1
|
||
fi
|
||
|
||
log "Запуск сервиса..."
|
||
if ! systemctl start awg-quick@awg0; then
|
||
log_error "Ошибка запуска сервиса — запуск отката."
|
||
local status_out
|
||
status_out=$(systemctl status awg-quick@awg0 --no-pager 2>&1) || true
|
||
while IFS= read -r line; do log_error " $line"; done <<< "$status_out"
|
||
return 1
|
||
fi
|
||
|
||
# Успех — rollback не нужен, trap выполнит только cleanup
|
||
_restore_ok=1
|
||
# Восстановление подменило awg0.conf и пересоздало интерфейс, поэтому снимок
|
||
# набора device-параметров надо взять заново: он должен описывать то, что
|
||
# стоит на живом интерфейсе СЕЙЧАС, а не до восстановления.
|
||
awg_record_device_params
|
||
log "Восстановление завершено."
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Изменение параметра клиента
|
||
# ==============================================================================
|
||
|
||
modify_client() {
|
||
local name="$1" param="$2" value="$3"
|
||
|
||
if [[ -z "$name" || -z "$param" || -z "$value" ]]; then
|
||
log_error "Использование: modify <имя> <параметр> <значение>"
|
||
return 1
|
||
fi
|
||
|
||
# Валидация ДО взятия блокировки (ранние return не требуют fd cleanup)
|
||
local allowed_params="DNS|Endpoint|AllowedIPs|PersistentKeepalive"
|
||
if ! [[ "$param" =~ ^($allowed_params)$ ]]; then
|
||
log_error "Параметр '$param' нельзя изменить через modify."
|
||
log_error "Допустимые параметры: ${allowed_params//|/, }"
|
||
return 1
|
||
fi
|
||
|
||
case "$param" in
|
||
DNS)
|
||
# Структурная проверка списка DNS. Старый charset-only regex
|
||
# ^[0-9a-fA-F.:,\ ]+$ пропускал мусор ('abc' - буквы a-f; '999.999.999.999' -
|
||
# вне диапазона). DNS по контракту - только IP через запятую (без FQDN),
|
||
# поэтому каждый элемент = bare IPv4 или IPv6, как у Endpoint/AllowedIPs.
|
||
case "$value" in
|
||
*$'\n'*|*$'\r'*|*\\*|*\"*|*\'*|"")
|
||
log_error "Невалидный DNS: '$value'"
|
||
return 1 ;;
|
||
esac
|
||
case "$value" in
|
||
,*|*,|*,,*)
|
||
log_error "Невалидный DNS '$value': пустой элемент списка (лишняя запятая)"
|
||
return 1 ;;
|
||
esac
|
||
local _dns_tok _dns_ifs="$IFS"
|
||
IFS=','
|
||
for _dns_tok in $value; do
|
||
_dns_tok="${_dns_tok//[[:space:]]/}"
|
||
if [[ -z "$_dns_tok" ]]; then
|
||
IFS="$_dns_ifs"
|
||
log_error "Невалидный DNS '$value': пустой элемент списка (лишняя запятая)"
|
||
return 1
|
||
fi
|
||
if ! _valid_ipv4 "$_dns_tok" && ! _valid_ipv6 "$_dns_tok"; then
|
||
IFS="$_dns_ifs"
|
||
log_error "Невалидный DNS '$value': '$_dns_tok' не похож на IPv4/IPv6-адрес"
|
||
return 1
|
||
fi
|
||
done
|
||
IFS="$_dns_ifs"
|
||
;;
|
||
PersistentKeepalive)
|
||
if ! [[ "$value" =~ ^[0-9]+$ ]] || [[ "$value" -gt 65535 ]]; then
|
||
log_error "Невалидный PersistentKeepalive: '$value' (допустимо: 0-65535)"
|
||
return 1
|
||
fi ;;
|
||
Endpoint)
|
||
# C5: помимо отсечения опасных символов - позитивная проверка host:port.
|
||
case "$value" in
|
||
*$'\n'*|*$'\r'*|*\\*|*\"*|*\'*|*' '*|*$'\t'*|"")
|
||
log_error "Невалидный Endpoint: '$value'"
|
||
return 1 ;;
|
||
esac
|
||
local _eh _ept
|
||
if [[ "$value" == \[*\]:* ]]; then
|
||
_eh="${value%]:*}"; _eh="${_eh#\[}" # IPv6 без скобок
|
||
_ept="${value##*]:}"
|
||
_valid_ipv6 "$_eh" || { log_error "Невалидный Endpoint '$value': некорректный IPv6-хост"; return 1; }
|
||
else
|
||
_eh="${value%:*}"; _ept="${value##*:}"
|
||
_valid_host_or_ipv4 "$_eh" || { log_error "Невалидный Endpoint '$value': ожидается host:port (FQDN / IPv4 / [IPv6])"; return 1; }
|
||
fi
|
||
{ [[ "$_ept" =~ ^[0-9]+$ ]] && [[ "$_ept" -ge 1 && "$_ept" -le 65535 ]]; } || { log_error "Невалидный Endpoint '$value': порт должен быть 1-65535"; return 1; }
|
||
;;
|
||
AllowedIPs)
|
||
# C5: помимо отсечения опасных символов - позитивная проверка CIDR-списка.
|
||
case "$value" in
|
||
*$'\n'*|*$'\r'*|*\\*|*\"*|*\'*|"")
|
||
log_error "Невалидный AllowedIPs: '$value'"
|
||
return 1 ;;
|
||
esac
|
||
# Лишние запятые: word-splitting по IFS=',' молча отбрасывает
|
||
# ХВОСТОВОЙ пустой элемент (например "10.0.0.0/24,"), поэтому проверяем
|
||
# структуру списка отдельно: ведущая/хвостовая/двойная запятая.
|
||
case "$value" in
|
||
,*|*,|*,,*)
|
||
log_error "Невалидный AllowedIPs '$value': пустой элемент списка (лишняя запятая)"
|
||
return 1 ;;
|
||
esac
|
||
local _aip_tok _aip_ifs="$IFS"
|
||
IFS=','
|
||
for _aip_tok in $value; do
|
||
_aip_tok="${_aip_tok//[[:space:]]/}"
|
||
if [[ -z "$_aip_tok" ]]; then
|
||
IFS="$_aip_ifs"
|
||
log_error "Невалидный AllowedIPs '$value': пустой элемент списка (лишняя запятая)"
|
||
return 1
|
||
fi
|
||
if ! _valid_cidr "$_aip_tok"; then
|
||
IFS="$_aip_ifs"
|
||
log_error "Невалидный AllowedIPs '$value': '$_aip_tok' не похож на CIDR (IPv4/IPv6 с опциональным префиксом /n)"
|
||
return 1
|
||
fi
|
||
done
|
||
IFS="$_aip_ifs"
|
||
;;
|
||
esac
|
||
|
||
# Блокировка перед state-проверками (защита от TOCTOU с concurrent remove)
|
||
local modify_lockfile="${AWG_DIR}/.awg_config.lock"
|
||
local modify_lock_fd
|
||
exec {modify_lock_fd}>"$modify_lockfile"
|
||
if ! flock -x -w 10 "$modify_lock_fd"; then
|
||
log_error "Не удалось получить блокировку конфигурации (другая операция выполняется)"
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
|
||
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE"; then
|
||
exec {modify_lock_fd}>&-
|
||
die "Клиент '$name' не найден."
|
||
fi
|
||
|
||
local cf="$AWG_DIR/$name.conf"
|
||
if [[ ! -f "$cf" ]]; then exec {modify_lock_fd}>&-; die "Файл $cf не найден."; fi
|
||
|
||
if ! grep -q -E "^${param}[[:space:]]*=" "$cf"; then
|
||
log_error "Параметр '$param' не найден в $cf."
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
|
||
log "Изменение '$param' на '$value' для '$name'..."
|
||
local bak
|
||
bak="${cf}.bak-$(date +%F_%H-%M-%S)"
|
||
# v5.11.0 A5.2: бэкап критически важен — если cp провалился, без бэкапа
|
||
# destructive sed может повредить конфиг без возможности отката. Выходим.
|
||
if ! cp "$cf" "$bak"; then
|
||
log_error "Не удалось создать бэкап '$bak' — destructive sed отменён."
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
log "Бэкап: $bak"
|
||
|
||
# Списочные параметры приводим к каноническому "a, b, c" (D#38): установщик
|
||
# пишет их с пробелом после запятой, и modify не должен оставлять в конфиге
|
||
# второй, слипшийся вариант того же значения.
|
||
#
|
||
# 🔴 Проверка наличия функции обязательна. _check_common_compat сверяет
|
||
# только MAJOR.MINOR и осознанно пропускает расхождение в patch, а
|
||
# awg_normalize_csv появилась в патче 5.27.1. На полуобновлённом сервере
|
||
# (свежий manage рядом со старой библиотекой) вызов дал бы пустую строку,
|
||
# и она молча уехала бы в конфиг вместо списка маршрутов.
|
||
case "$param" in
|
||
AllowedIPs|DNS)
|
||
command -v awg_normalize_csv >/dev/null 2>&1 || {
|
||
log_error "awg_common.sh устарела: нет awg_normalize_csv. Обнови обе половины под одну версию."
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
}
|
||
local _norm
|
||
_norm=$(awg_normalize_csv "$value")
|
||
[[ -n "$_norm" ]] || {
|
||
log_error "Нормализация '$param' дала пустое значение - правка отменена."
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
}
|
||
value="$_norm"
|
||
log "Значение приведено к виду: $value"
|
||
;;
|
||
esac
|
||
|
||
local escaped_value
|
||
escaped_value=$(escape_sed "$value")
|
||
if ! sed -i "s#^${param}[[:space:]]*=[[:space:]]*.*#${param} = ${escaped_value}#" "$cf"; then
|
||
log_error "Ошибка sed. Восстановление..."
|
||
# После успешного отката .bak идентичен конфигу - удаляем, чтобы
|
||
# повторные неудачные modify не копили .bak-файлы в $AWG_DIR.
|
||
if cp "$bak" "$cf"; then rm -f "$bak"; else log_warn "Ошибка восстановления."; fi
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
# Требуем НЕПУСТОЕ значение: префиксная проверка пропускала строку вида
|
||
# "AllowedIPs = ", то есть обнуление настройки рапортовалось как успех, а
|
||
# бэкап при этом удалялся.
|
||
if ! grep -q -E "^${param} = .+" "$cf"; then
|
||
log_error "Замена не выполнена для '$param'. Восстановление..."
|
||
if cp "$bak" "$cf"; then rm -f "$bak"; else log_warn "Ошибка восстановления."; fi
|
||
exec {modify_lock_fd}>&-
|
||
return 1
|
||
fi
|
||
log_debug "sed: ${param} = ${value} в $cf"
|
||
|
||
log "Параметр '$param' изменен."
|
||
rm -f "$bak"
|
||
|
||
log "Перегенерация QR-кода и vpn:// URI..."
|
||
generate_qr "$name" || log_warn "Не удалось обновить QR-код."
|
||
if generate_vpn_uri "$name"; then
|
||
generate_qr_vpnuri "$name" || log_warn "Не удалось обновить QR vpn://."
|
||
else
|
||
log_warn "Не удалось обновить vpn:// URI."
|
||
fi
|
||
|
||
exec {modify_lock_fd}>&-
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Проверка состояния сервера
|
||
# ==============================================================================
|
||
|
||
check_server() {
|
||
log "Проверка состояния сервера AmneziaWG 2.0..."
|
||
local ok=1
|
||
# Снимок для JSON-конверта (v5.21.0): собирается по ходу человеческих
|
||
# проверок, чтобы данные и вердикт шли из одного прогона.
|
||
local _c_svc_active=false _c_present=false _c_mtu=null _c_addrs=""
|
||
local _c_listen=false _c_mod=false _c_ufw_active=false _c_allowed=false
|
||
local _c_mod_ver=""
|
||
|
||
log "Статус сервиса:"
|
||
# В --json сырой вывод systemctl уходит в stderr: stdout занят контрактом.
|
||
if [[ "${JSON_OUTPUT:-0}" -eq 1 ]]; then
|
||
if ! systemctl status awg-quick@awg0 --no-pager >&2; then ok=0; fi
|
||
else
|
||
if ! systemctl status awg-quick@awg0 --no-pager; then ok=0; fi
|
||
fi
|
||
systemctl is-active --quiet awg-quick@awg0 2>/dev/null && _c_svc_active=true
|
||
|
||
log "Интерфейс awg0:"
|
||
local _ip_out
|
||
if ! _ip_out=$(ip addr show awg0 2>/dev/null); then
|
||
log_error " - Интерфейс не найден!"
|
||
ok=0
|
||
else
|
||
_c_present=true
|
||
while IFS= read -r line; do log " $line"; done <<< "$_ip_out"
|
||
_c_mtu=$(sed -n 's/.*mtu \([0-9][0-9]*\).*/\1/p' <<< "$_ip_out" | head -n1)
|
||
[[ "$_c_mtu" =~ ^[0-9]+$ ]] || _c_mtu=null
|
||
local _a
|
||
while IFS= read -r _a; do
|
||
[[ -z "$_a" ]] && continue
|
||
_c_addrs+="${_c_addrs:+,}\"$(json_escape "$_a")\""
|
||
done < <(awk '/^[[:space:]]*inet6? /{print $2}' <<< "$_ip_out")
|
||
fi
|
||
|
||
log "Прослушивание порта:"
|
||
safe_load_config "$CONFIG_FILE" 2>/dev/null
|
||
local port
|
||
port=$(_sanitize_port "${AWG_PORT:-}")
|
||
if [[ "$port" -eq 0 ]]; then
|
||
if [[ -n "${AWG_PORT:-}" ]]; then
|
||
# В конфиге что-то лежит, но портом это не является: файл повреждён,
|
||
# а не «настройка не задана». Молчать нельзя - для мониторинга это
|
||
# такая же поломка, как незапущенный сервис. Значение показываю
|
||
# обрезанным и без управляющих символов: строка там произвольная и
|
||
# может утащить с собой перевод строки или ESC-последовательность.
|
||
local _bad_port="${AWG_PORT:0:32}"
|
||
_bad_port="${_bad_port//[^[:print:]]/?}"
|
||
log_error " - Порт в конфиге некорректный: '${_bad_port}'."
|
||
ok=0
|
||
else
|
||
log_warn " - Не удалось определить порт."
|
||
fi
|
||
else
|
||
if ! ss -lunp | grep -q ":${port} "; then
|
||
log_error " - Порт ${port}/udp НЕ прослушивается!"
|
||
ok=0
|
||
else
|
||
_c_listen=true
|
||
log " - Порт ${port}/udp прослушивается."
|
||
fi
|
||
fi
|
||
|
||
log "Настройки ядра:"
|
||
local fwd
|
||
fwd=$(sysctl -n net.ipv4.ip_forward)
|
||
if [[ "$fwd" != "1" ]]; then
|
||
log_error " - IP Forwarding выключен ($fwd)!"
|
||
ok=0
|
||
else
|
||
log " - IP Forwarding включен."
|
||
fi
|
||
|
||
log "Модуль ядра:"
|
||
# Паттерн из diagnose: точное имя модуля в первом столбце lsmod.
|
||
if lsmod 2>/dev/null | awk '$1 == "amneziawg" {f=1} END {exit !f}'; then
|
||
_c_mod=true
|
||
# Версия модуля: у линии 3.0 строка начинается с 3., у 2.0 - с 1.
|
||
# (имена тегов upstream исторически не совпадают с версией протокола,
|
||
# поэтому печатаем сырое значение и не выводим протокол по догадке).
|
||
# awg_module_version спрашивает ЗАГРУЖЕННЫЙ модуль, а modinfo (файл на
|
||
# диске) оставляет вторым путём - см. пояснение у функции в awg_common.sh.
|
||
_c_mod_ver=$(awg_module_version)
|
||
if [[ -n "$_c_mod_ver" ]]; then
|
||
log " - Модуль amneziawg загружен (версия $_c_mod_ver)."
|
||
else
|
||
log " - Модуль amneziawg загружен."
|
||
fi
|
||
else
|
||
# WARN, не ok=0: на userspace-инсталляциях (amneziawg-go, LXC) модуля
|
||
# нет и не будет, а сломанный kernel-путь и так валит сервис/интерфейс.
|
||
log_warn " - Модуль amneziawg не загружен (для userspace-режима это норма)."
|
||
fi
|
||
|
||
log "Правила UFW:"
|
||
if command -v ufw &>/dev/null; then
|
||
local _ufw_st
|
||
_ufw_st=$(ufw status 2>/dev/null | head -1)
|
||
[[ "$_ufw_st" == "Status: active" ]] && _c_ufw_active=true
|
||
if [[ "$port" -eq 0 ]]; then
|
||
# Порт не определился выше - grep по "0/udp" дал бы ложный warning.
|
||
log_warn " - Порт не определён, проверка правила UFW пропущена."
|
||
elif [[ "$_c_ufw_active" != true ]]; then
|
||
# Раньше inactive UFW маскировался под "правило не найдено":
|
||
# grep по выводу inactive-статуса не находил порт и ругался не на то.
|
||
log_warn " - UFW не активен (${_ufw_st:-нет статуса})."
|
||
elif ufw status 2>/dev/null | grep -qE "^${port}/udp[[:space:]]+ALLOW"; then
|
||
# Строгий паттерн из diagnose: именно ALLOW, а не любое упоминание
|
||
# порта (прежний grep -qw не отличал ALLOW от DENY).
|
||
_c_allowed=true
|
||
log " - Правило UFW для ${port}/udp есть."
|
||
else
|
||
log_warn " - Правило UFW для ${port}/udp не найдено!"
|
||
fi
|
||
else
|
||
log_warn " - UFW не установлен."
|
||
fi
|
||
|
||
log "Статус AmneziaWG 2.0:"
|
||
# Раньше awg show вызывался через process substitution без проверки exit code,
|
||
# из-за чего check мог отрапортовать "Состояние OK" даже когда awg упал.
|
||
# Теперь захватываем вывод и проверяем exit code (audit).
|
||
local _awg_out
|
||
if ! _awg_out=$(awg show awg0 2>&1); then
|
||
log_error " - awg show awg0 завершился с ошибкой:"
|
||
while IFS= read -r _l; do log_error " $_l"; done <<< "$_awg_out"
|
||
ok=0
|
||
else
|
||
while IFS= read -r _l; do log " $_l"; done <<< "$_awg_out"
|
||
if grep -q "jc:" <<< "$_awg_out"; then
|
||
log " - AWG 2.0 параметры обфускации: активны"
|
||
else
|
||
log_warn " - AWG 2.0 параметры обфускации не обнаружены"
|
||
fi
|
||
fi
|
||
|
||
if [[ "${JSON_OUTPUT:-0}" -eq 1 ]]; then
|
||
local _c_clients _jok=false
|
||
_c_clients=$(grep -c '^\[Peer\]' "$SERVER_CONF_FILE" 2>/dev/null) || _c_clients=0
|
||
[[ "$ok" -eq 1 ]] && _jok=true
|
||
json_out "{\"command\":\"check\",\"ok\":$_jok,\"service\":{\"unit\":\"awg-quick@awg0\",\"active\":$_c_svc_active},\"interface\":{\"name\":\"awg0\",\"present\":$_c_present,\"mtu\":$_c_mtu,\"addresses\":[$_c_addrs]},\"port\":{\"number\":$port,\"proto\":\"udp\",\"listening\":$_c_listen},\"module\":{\"loaded\":$_c_mod,\"version\":$([[ -n "$_c_mod_ver" ]] && printf '"%s"' "$(json_escape "$_c_mod_ver")" || printf 'null')},\"clients\":{\"total\":$_c_clients},\"firewall\":{\"ufw_active\":$_c_ufw_active,\"port_allowed\":$_c_allowed}}"
|
||
fi
|
||
|
||
if [[ "$ok" -eq 1 ]]; then
|
||
log "Проверка завершена: Состояние OK."
|
||
return 0
|
||
else
|
||
log_error "Проверка завершена: ОБНАРУЖЕНЫ ПРОБЛЕМЫ!"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Diagnose: self-troubleshooting с опциональным сравнением по оператору
|
||
# ==============================================================================
|
||
|
||
# Известные операторы и рекомендуемые AWG-параметры.
|
||
# Формат: jc_min jc_max jmin_lo jmin_hi jmax_offset_lo jmax_offset_hi i1_mode
|
||
# i1_mode: random (формат "<r N>"), absent (I1 не должно быть), binary ("<r N><b 0xHEX>")
|
||
# Источник: ADVANCED.md operator matrix (только подтверждённые ✅ строки).
|
||
# Megafon Москва из таблицы пока 🔄 тестируется (Jc=3, Jmin=80, Jmax=268) -
|
||
# параметры широкие и не вписываются в mobile preset; добавим когда оператор
|
||
# подтвердят и зафиксируют диапазоны. T-Mobile MO US - Discussion #45 (o2me).
|
||
_diagnose_carrier_known() {
|
||
case "$1" in
|
||
beeline_msk) echo "3 6 40 89 50 250 random" ;;
|
||
yota_msk|tele2_msk|tattelecom) echo "3 3 30 50 20 80 random" ;;
|
||
tele2_krasnoyarsk|megafon_regions) echo "3 3 30 50 20 80 absent" ;;
|
||
tmobile_us) echo "6 6 10 10 40 40 binary" ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
_diagnose_carrier_list() {
|
||
echo "beeline_msk yota_msk tele2_msk tele2_krasnoyarsk tattelecom megafon_regions tmobile_us"
|
||
}
|
||
|
||
# Вывод одной строки результата с цветом
|
||
_diag_line() {
|
||
local status="$1" msg="$2"
|
||
local color_start="" color_end=""
|
||
if [[ "$NO_COLOR" -eq 0 ]]; then
|
||
color_end="\033[0m"
|
||
case "$status" in
|
||
OK) color_start="\033[0;32m" ;;
|
||
WARN) color_start="\033[0;33m" ;;
|
||
FAIL) color_start="\033[0;31m" ;;
|
||
INFO) color_start="\033[0;36m" ;;
|
||
esac
|
||
fi
|
||
printf "%b[%-4s]%b %s\n" "$color_start" "$status" "$color_end" "$msg"
|
||
}
|
||
|
||
# Главная функция: пробегается по health-checks + опционально сравнивает с оператором
|
||
diagnose_server() {
|
||
local carrier="${CLI_CARRIER}"
|
||
local ok=0 warn=0 fail=0
|
||
|
||
log "Диагностика AmneziaWG 2.0 сервера..."
|
||
if [[ -n "$carrier" ]] && ! _diagnose_carrier_known "$carrier" >/dev/null; then
|
||
log_error "Неизвестный оператор: '$carrier'"
|
||
log_error "Поддерживаемые: $(_diagnose_carrier_list)"
|
||
return 1
|
||
fi
|
||
|
||
# 1. Kernel module
|
||
if lsmod 2>/dev/null | awk '$1 == "amneziawg" {f=1} END {exit !f}'; then
|
||
local _d_mod_ver
|
||
_d_mod_ver=$(awg_module_version)
|
||
if [[ "$_d_mod_ver" == 3.* ]]; then
|
||
_diag_line OK "Модуль ядра amneziawg загружен (AmneziaWG 3.0, $_d_mod_ver)"
|
||
elif [[ -n "$_d_mod_ver" ]]; then
|
||
_diag_line OK "Модуль ядра amneziawg загружен ($_d_mod_ver)"
|
||
else
|
||
_diag_line OK "Модуль ядра amneziawg загружен"
|
||
fi
|
||
ok=$((ok+1))
|
||
else
|
||
_diag_line FAIL "Модуль ядра amneziawg НЕ загружен"
|
||
echo " Fix: sudo bash $0 repair-module"
|
||
fail=$((fail+1))
|
||
fi
|
||
|
||
# 2. Service active
|
||
if systemctl is-active --quiet awg-quick@awg0 2>/dev/null; then
|
||
_diag_line OK "Сервис awg-quick@awg0 активен"; ok=$((ok+1))
|
||
else
|
||
_diag_line FAIL "Сервис awg-quick@awg0 НЕактивен"
|
||
echo " Fix: sudo systemctl start awg-quick@awg0"
|
||
fail=$((fail+1))
|
||
fi
|
||
|
||
# 3. Interface awg0 UP
|
||
if ip link show awg0 2>/dev/null | grep -qE "state (UP|UNKNOWN)"; then
|
||
local awg_ip
|
||
awg_ip=$(ip -4 -o addr show awg0 2>/dev/null | awk '{print $4; exit}')
|
||
_diag_line OK "Интерфейс awg0 UP (${awg_ip:-?})"; ok=$((ok+1))
|
||
else
|
||
_diag_line FAIL "Интерфейс awg0 не UP (или не существует)"
|
||
fail=$((fail+1))
|
||
fi
|
||
|
||
# 4. sysctl ip_forward
|
||
local fwd
|
||
fwd=$(sysctl -n net.ipv4.ip_forward 2>/dev/null || echo "?")
|
||
if [[ "$fwd" == "1" ]]; then
|
||
_diag_line OK "sysctl net.ipv4.ip_forward=1"; ok=$((ok+1))
|
||
else
|
||
_diag_line FAIL "sysctl net.ipv4.ip_forward=$fwd (требуется 1)"
|
||
echo " Fix: echo 'net.ipv4.ip_forward=1' | sudo tee -a /etc/sysctl.d/99-awg.conf && sudo sysctl --system"
|
||
fail=$((fail+1))
|
||
fi
|
||
|
||
# 5. BBR congestion control (recommended, not required)
|
||
local cc
|
||
cc=$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null || echo "?")
|
||
if [[ "$cc" == "bbr" ]]; then
|
||
_diag_line OK "sysctl tcp_congestion_control=bbr"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "sysctl tcp_congestion_control=$cc (рекомендуется bbr)"
|
||
warn=$((warn+1))
|
||
fi
|
||
|
||
# 6. UFW state + AWG port
|
||
safe_load_config "$CONFIG_FILE" 2>/dev/null
|
||
# Порт берётся без дефолта: подставить 39743 и отчитаться по нему значило бы
|
||
# утверждать про порт, которого в конфиге нет. Заодно значение больше не
|
||
# попадает сырым в regex ниже, где '.*' совпадал с любым правилом.
|
||
local awg_port
|
||
awg_port=$(_sanitize_port "${AWG_PORT:-}")
|
||
if command -v ufw &>/dev/null; then
|
||
local ufw_st
|
||
ufw_st=$(ufw status 2>/dev/null | head -1)
|
||
if [[ "$awg_port" -eq 0 ]]; then
|
||
# Состояние фаервола называю и здесь: это отдельная находка, и
|
||
# терять её из-за неисправного порта нельзя.
|
||
local _ufw_state_txt="UFW active"
|
||
[[ "$ufw_st" == "Status: active" ]] || _ufw_state_txt="UFW не active ($ufw_st)"
|
||
if [[ -n "${AWG_PORT:-}" ]]; then
|
||
local _bad_port="${AWG_PORT:0:32}"
|
||
_bad_port="${_bad_port//[^[:print:]]/?}"
|
||
_diag_line FAIL "${_ufw_state_txt}; порт в конфиге некорректный ('${_bad_port}'), правило не проверено"
|
||
fail=$((fail+1))
|
||
else
|
||
_diag_line WARN "${_ufw_state_txt}; порт в конфиге не найден, правило не проверено"
|
||
warn=$((warn+1))
|
||
fi
|
||
elif [[ "$ufw_st" == "Status: active" ]]; then
|
||
if ufw status 2>/dev/null | grep -qE "^${awg_port}/udp[[:space:]]+ALLOW"; then
|
||
_diag_line OK "UFW active, ${awg_port}/udp ALLOW"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "UFW active, но ${awg_port}/udp не в ALLOW (трафик может не приходить)"
|
||
warn=$((warn+1))
|
||
fi
|
||
else
|
||
_diag_line WARN "UFW не active ($ufw_st)"; warn=$((warn+1))
|
||
fi
|
||
else
|
||
_diag_line WARN "ufw не установлен"; warn=$((warn+1))
|
||
fi
|
||
|
||
# 7. Peer count
|
||
local peer_count
|
||
peer_count=$(awg show awg0 peers 2>/dev/null | wc -l)
|
||
_diag_line INFO "Peers сконфигурировано: $peer_count"
|
||
|
||
# 8. AWG params snapshot (один вызов awg show вместо четырёх)
|
||
local _awg_show jc jmin jmax i1
|
||
_awg_show=$(awg show awg0 2>/dev/null)
|
||
jc=$(awk '/^[[:space:]]*jc:/ {print $2; exit}' <<< "$_awg_show")
|
||
jmin=$(awk '/^[[:space:]]*jmin:/ {print $2; exit}' <<< "$_awg_show")
|
||
jmax=$(awk '/^[[:space:]]*jmax:/ {print $2; exit}' <<< "$_awg_show")
|
||
i1=$(awk -F': ' '/^[[:space:]]*i1:/ {print $2; exit}' <<< "$_awg_show")
|
||
_diag_line INFO "AWG params: Jc=${jc:-?} Jmin=${jmin:-?} Jmax=${jmax:-?} I1=${i1:-absent}"
|
||
|
||
# 9. Carrier comparison
|
||
if [[ -n "$carrier" ]]; then
|
||
echo ""
|
||
log "Сравнение с профилем оператора '$carrier'..."
|
||
local row
|
||
row=$(_diagnose_carrier_known "$carrier")
|
||
# row: jc_min jc_max jmin_lo jmin_hi jmax_off_lo jmax_off_hi i1_mode
|
||
local rc_jc_min rc_jc_max rc_jmin_lo rc_jmin_hi rc_jmax_off_lo rc_jmax_off_hi rc_i1
|
||
read -r rc_jc_min rc_jc_max rc_jmin_lo rc_jmin_hi rc_jmax_off_lo rc_jmax_off_hi rc_i1 <<<"$row"
|
||
|
||
# Jc range check
|
||
if [[ -n "$jc" && "$jc" =~ ^[0-9]+$ && "$jc" -ge "$rc_jc_min" && "$jc" -le "$rc_jc_max" ]]; then
|
||
_diag_line OK "Jc=$jc в диапазоне [$rc_jc_min..$rc_jc_max] для $carrier"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "Jc=${jc:-?} вне рекомендуемого [$rc_jc_min..$rc_jc_max] для $carrier"
|
||
warn=$((warn+1))
|
||
fi
|
||
|
||
# Jmin range check
|
||
if [[ -n "$jmin" && "$jmin" =~ ^[0-9]+$ && "$jmin" -ge "$rc_jmin_lo" && "$jmin" -le "$rc_jmin_hi" ]]; then
|
||
_diag_line OK "Jmin=$jmin в диапазоне [$rc_jmin_lo..$rc_jmin_hi] для $carrier"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "Jmin=${jmin:-?} вне рекомендуемого [$rc_jmin_lo..$rc_jmin_hi] для $carrier"
|
||
warn=$((warn+1))
|
||
fi
|
||
|
||
# Jmax offset check (Jmax should be in [Jmin+off_lo, Jmin+off_hi])
|
||
if [[ -n "$jmax" && -n "$jmin" && "$jmax" =~ ^[0-9]+$ && "$jmin" =~ ^[0-9]+$ ]]; then
|
||
local jmax_off=$((jmax - jmin))
|
||
if [[ "$jmax_off" -ge "$rc_jmax_off_lo" && "$jmax_off" -le "$rc_jmax_off_hi" ]]; then
|
||
_diag_line OK "Jmax-Jmin=$jmax_off в диапазоне [$rc_jmax_off_lo..$rc_jmax_off_hi]"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "Jmax-Jmin=$jmax_off вне [$rc_jmax_off_lo..$rc_jmax_off_hi] (для $carrier меньше Jmax часто стабильнее)"
|
||
warn=$((warn+1))
|
||
fi
|
||
else
|
||
_diag_line WARN "Jmax-Jmin не удалось вычислить (Jmax=${jmax:-?}, Jmin=${jmin:-?})"
|
||
warn=$((warn+1))
|
||
fi
|
||
|
||
# I1 mode check
|
||
case "$rc_i1" in
|
||
absent)
|
||
if [[ -z "$i1" || "$i1" == "absent" ]]; then
|
||
_diag_line OK "I1 отсутствует (требуется для $carrier)"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "I1=$i1, но $carrier требует I1=absent"
|
||
echo " Fix: отредактировать /etc/amnezia/amneziawg/awg0.conf, удалить строку 'I1 = ...', sudo systemctl restart awg-quick@awg0"
|
||
warn=$((warn+1))
|
||
fi
|
||
;;
|
||
random)
|
||
if [[ -n "$i1" && "$i1" =~ ^\<r\ [0-9]+\>$ ]]; then
|
||
_diag_line OK "I1 random ($i1) - подходит для $carrier"; ok=$((ok+1))
|
||
elif [[ -z "$i1" ]]; then
|
||
_diag_line WARN "I1 отсутствует, $carrier обычно работает с I1 random (<r N>)"
|
||
warn=$((warn+1))
|
||
else
|
||
_diag_line WARN "I1=$i1 нестандартный формат (для $carrier обычно <r N>)"
|
||
warn=$((warn+1))
|
||
fi
|
||
;;
|
||
binary)
|
||
if [[ -n "$i1" && "$i1" =~ ^\<r\ [0-9]+\>\<b\ 0x[0-9A-Fa-f]+\> ]]; then
|
||
_diag_line OK "I1 binary ($i1) - подходит для $carrier"; ok=$((ok+1))
|
||
else
|
||
_diag_line WARN "I1=${i1:-absent}, $carrier (T-Mobile MO) требует binary I1 (<r N><b 0xHEX>)"
|
||
warn=$((warn+1))
|
||
fi
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# Summary
|
||
echo ""
|
||
log "Итого: OK=$ok WARN=$warn FAIL=$fail"
|
||
if [[ "$fail" -gt 0 ]]; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Список клиентов
|
||
# ==============================================================================
|
||
|
||
list_clients() {
|
||
log "Получение списка клиентов..."
|
||
local clients
|
||
clients=$(grep '^#_Name = ' "$SERVER_CONF_FILE" | sed 's/^#_Name = //' | sort) || clients=""
|
||
if [[ -z "$clients" ]]; then
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
json_out "[]"
|
||
else
|
||
log "Клиенты не найдены."
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
local verbose=$VERBOSE_LIST
|
||
local act=0 tot=0
|
||
|
||
# Однопроходный парсинг серверного конфига: name → pubkey
|
||
local -A _name_to_pk
|
||
local _cn=""
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
if [[ "$line" == "#_Name = "* ]]; then
|
||
_cn="${line#\#_Name = }"
|
||
_cn="${_cn## }"; _cn="${_cn%% }"
|
||
elif [[ -n "$_cn" && "$line" == "PublicKey = "* ]]; then
|
||
local _pk="${line#PublicKey = }"
|
||
_pk="${_pk## }"; _pk="${_pk%% }"
|
||
[[ -n "$_pk" ]] && _name_to_pk["$_cn"]="$_pk"
|
||
_cn=""
|
||
fi
|
||
done < "$SERVER_CONF_FILE"
|
||
|
||
# Однопроходный парсинг awg show dump: pubkey → handshake timestamp
|
||
local -A _pk_to_hs
|
||
local awg_dump
|
||
awg_dump=$(awg show awg0 dump 2>/dev/null) || awg_dump=""
|
||
if [[ -n "$awg_dump" ]]; then
|
||
# shellcheck disable=SC2034
|
||
while IFS=$'\t' read -r _dpk _dpsk _dep _daips _dhs _drx _dtx _dka; do
|
||
_pk_to_hs["$_dpk"]="$_dhs"
|
||
done < <(echo "$awg_dump" | tail -n +2)
|
||
fi
|
||
|
||
if [[ "$JSON_OUTPUT" -ne 1 ]]; then
|
||
if [[ $verbose -eq 1 ]]; then
|
||
printf "%-20s | %-7s | %-7s | %-36s | %-15s | %s\n" "Имя клиента" "Conf" "QR" "IP-адрес" "Ключ (нач.)" "Статус"
|
||
printf -- "-%.0s" {1..114}
|
||
echo
|
||
else
|
||
printf "%-20s | %-7s | %-7s | %s\n" "Имя клиента" "Conf" "QR" "Статус"
|
||
printf -- "-%.0s" {1..50}
|
||
echo
|
||
fi
|
||
fi
|
||
|
||
local now
|
||
now=$(date +%s)
|
||
|
||
local json_entries=()
|
||
|
||
while IFS= read -r name; do
|
||
name="${name#"${name%%[![:space:]]*}"}"; name="${name%"${name##*[![:space:]]}"}"
|
||
if [[ -z "$name" ]]; then continue; fi
|
||
((tot++))
|
||
|
||
local cf="?" png="?" pk="-" ip="-" ip6="-" st="Нет данных" st_code="no_data"
|
||
local color_start="" color_end=""
|
||
if [[ "$NO_COLOR" -eq 0 ]]; then
|
||
color_end="\033[0m"
|
||
color_start="\033[0;37m"
|
||
fi
|
||
|
||
[[ -f "$AWG_DIR/${name}.conf" ]] && cf="+"
|
||
[[ -f "$AWG_DIR/${name}.png" ]] && png="+"
|
||
|
||
if [[ "$cf" == "+" ]]; then
|
||
# Extract IPv4 and optional IPv6 from Address line (dual-stack aware)
|
||
local _addr_line
|
||
_addr_line=$(awk '/^Address[ \t]*=/ { sub(/^Address[ \t]*=[ \t]*/, ""); print; exit }' "$AWG_DIR/${name}.conf" 2>/dev/null)
|
||
if [[ -n "$_addr_line" ]]; then
|
||
local _a1 _a2
|
||
_a1="${_addr_line%%,*}"
|
||
_a1="${_a1// /}"
|
||
_a1="${_a1%%/*}"
|
||
ip="${_a1:-?}"
|
||
if [[ "$_addr_line" == *,* ]]; then
|
||
_a2="${_addr_line#*,}"
|
||
_a2="${_a2// /}"
|
||
_a2="${_a2%%/*}"
|
||
ip6="${_a2:-?}"
|
||
else
|
||
ip6="-"
|
||
fi
|
||
else
|
||
ip="?"
|
||
ip6="-"
|
||
fi
|
||
|
||
local current_pk="${_name_to_pk[$name]:-}"
|
||
|
||
if [[ -n "$current_pk" ]]; then
|
||
pk="${current_pk:0:10}..."
|
||
local handshake="${_pk_to_hs[$current_pk]:-0}"
|
||
if [[ "$handshake" =~ ^[0-9]+$ && "$handshake" -gt 0 ]]; then
|
||
local diff=$((now - handshake))
|
||
if [[ $diff -lt 180 ]]; then
|
||
st="Активен"; st_code="active"
|
||
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;32m"
|
||
((act++))
|
||
elif [[ $diff -lt 86400 ]]; then
|
||
st="Недавно"; st_code="recent"
|
||
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;33m"
|
||
((act++))
|
||
else
|
||
st="Нет handshake"; st_code="no_handshake"
|
||
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;37m"
|
||
fi
|
||
else
|
||
st="Нет handshake"; st_code="no_handshake"
|
||
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;37m"
|
||
fi
|
||
else
|
||
pk="?"
|
||
st="Ошибка ключа"; st_code="key_error"
|
||
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;31m"
|
||
fi
|
||
fi
|
||
|
||
# Expiry info: только для табличного вывода (JSON его не печатает -
|
||
# лишнее чтение файла на каждого клиента). Принимаем только числовой
|
||
# timestamp: повреждённый expiry-файл дал бы ошибку bash-арифметики
|
||
# из format_remaining прямо в таблице.
|
||
local exp_str=""
|
||
if [[ "$JSON_OUTPUT" -ne 1 ]]; then
|
||
local exp_ts
|
||
exp_ts=$(get_client_expiry "$name" 2>/dev/null)
|
||
if [[ "$exp_ts" =~ ^[0-9]+$ ]]; then
|
||
exp_str=" [$(format_remaining "$exp_ts")]"
|
||
elif [[ -n "$exp_ts" ]]; then
|
||
exp_str=" [expiry повреждён]"
|
||
fi
|
||
fi
|
||
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
local _ip6_val="${ip6}"
|
||
[[ "$_ip6_val" == "-" ]] && _ip6_val=""
|
||
json_entries+=("{\"name\":\"$(json_escape "$name")\",\"ip\":\"$(json_escape "$ip")\",\"client_ipv6\":\"$(json_escape "$_ip6_val")\",\"status\":\"$(json_escape "$st")\",\"status_code\":\"${st_code}\"}")
|
||
elif [[ $verbose -eq 1 ]]; then
|
||
local ip_display
|
||
if [[ "$ip6" != "-" ]]; then
|
||
ip_display="${ip} / ${ip6}"
|
||
else
|
||
ip_display="${ip} / -"
|
||
fi
|
||
printf "%-20s | %-7s | %-7s | %-36s | %-15s | ${color_start}%s${color_end}%s\n" "$name" "$cf" "$png" "$ip_display" "$pk" "$st" "$exp_str"
|
||
else
|
||
printf "%-20s | %-7s | %-7s | ${color_start}%s${color_end}%s\n" "$name" "$cf" "$png" "$st" "$exp_str"
|
||
fi
|
||
done <<< "$clients"
|
||
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jarr=$(IFS=","; echo "[${json_entries[*]}]")
|
||
json_out "$_jarr"
|
||
else
|
||
echo ""
|
||
log "Всего клиентов: $tot, Активных/Недавно: $act"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Статистика трафика
|
||
# ==============================================================================
|
||
|
||
# json_escape определён в блоке JSON-хелперов в начале файла (перенесён в
|
||
# v5.21.0: EXIT-guard зовёт его на любом раннем выходе, значит определение
|
||
# обязано стоять выше установки trap).
|
||
|
||
# Форматирование размера в человекочитаемый формат
|
||
format_bytes() {
|
||
local bytes="${1:-0}"
|
||
if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then printf "0 B"; return; fi
|
||
if [[ "$bytes" -ge 1073741824 ]]; then
|
||
awk "BEGIN{printf \"%.2f GiB\", $bytes/1073741824}"
|
||
elif [[ "$bytes" -ge 1048576 ]]; then
|
||
awk "BEGIN{printf \"%.2f MiB\", $bytes/1048576}"
|
||
elif [[ "$bytes" -ge 1024 ]]; then
|
||
awk "BEGIN{printf \"%.1f KiB\", $bytes/1024}"
|
||
else
|
||
printf "%d B" "$bytes"
|
||
fi
|
||
}
|
||
|
||
stats_clients() {
|
||
local clients
|
||
clients=$(grep '^#_Name = ' "$SERVER_CONF_FILE" | sed 's/^#_Name = //' | sort) || clients=""
|
||
if [[ -z "$clients" ]]; then
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
json_out "[]"
|
||
else
|
||
log "Клиенты не найдены."
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# Получаем данные awg show awg0
|
||
local awg_dump
|
||
awg_dump=$(awg show awg0 dump 2>/dev/null) || {
|
||
log_error "Ошибка получения данных awg show."
|
||
return 1
|
||
}
|
||
|
||
# Маппинг: публичный ключ → имя клиента (single-pass)
|
||
local -A pk_to_name
|
||
local _current_name=""
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
if [[ "$line" == "#_Name = "* ]]; then
|
||
_current_name="${line#\#_Name = }"
|
||
_current_name="${_current_name## }"; _current_name="${_current_name%% }"
|
||
elif [[ -n "$_current_name" && "$line" == "PublicKey = "* ]]; then
|
||
local _pk="${line#PublicKey = }"
|
||
_pk="${_pk## }"; _pk="${_pk%% }"
|
||
[[ -n "$_pk" ]] && pk_to_name["$_pk"]="$_current_name"
|
||
_current_name=""
|
||
fi
|
||
done < "$SERVER_CONF_FILE"
|
||
|
||
local json_entries=()
|
||
local table_rows=()
|
||
local total_rx=0 total_tx=0
|
||
# date +%s один раз до цикла (а не subprocess на каждого пира);
|
||
# точности секундного среза для статусов active/recent достаточно.
|
||
local _stats_now
|
||
_stats_now=$(date +%s)
|
||
|
||
# awg show dump: каждая строка пира = pubkey psk endpoint allowed-ips latest-handshake rx tx keepalive
|
||
# shellcheck disable=SC2034
|
||
while IFS=$'\t' read -r pk psk ep aips handshake rx tx keepalive; do
|
||
local cname="${pk_to_name[$pk]:-unknown}"
|
||
if [[ "$cname" == "unknown" ]]; then continue; fi
|
||
|
||
# awg show dump - вывод ядерной утилиты, но в JSON и арифметику значения
|
||
# должны идти каноничными десятичными числами. Регекс требует именно
|
||
# такой вид (0 либо без ведущего нуля): "08" прошло бы ^[0-9]+$, но дало
|
||
# бы невалидный JSON ("rx":08) и восьмеричную ошибку в $((...)). Всё
|
||
# прочее (нечисловое, битая строка dump вида a[$(...)]) тоже обнуляется.
|
||
[[ "$rx" =~ ^(0|[1-9][0-9]*)$ ]] || rx=0
|
||
[[ "$tx" =~ ^(0|[1-9][0-9]*)$ ]] || tx=0
|
||
[[ "$handshake" =~ ^(0|[1-9][0-9]*)$ ]] || handshake=0
|
||
|
||
local ip="-"
|
||
if [[ -f "$AWG_DIR/${cname}.conf" ]]; then
|
||
ip=$(grep -oP 'Address = \K[0-9.]+' "$AWG_DIR/${cname}.conf" 2>/dev/null) || ip="?"
|
||
fi
|
||
|
||
local hs_str="никогда"
|
||
local status="Неактивен" status_code="inactive"
|
||
if [[ "$handshake" =~ ^[0-9]+$ && "$handshake" -gt 0 ]]; then
|
||
local diff=$((_stats_now - handshake))
|
||
if [[ $diff -lt 180 ]]; then
|
||
status="Активен"; status_code="active"
|
||
elif [[ $diff -lt 86400 ]]; then
|
||
status="Недавно"; status_code="recent"
|
||
fi
|
||
hs_str=$(date -d "@$handshake" '+%F %T' 2>/dev/null || echo "$handshake")
|
||
fi
|
||
|
||
total_rx=$((total_rx + rx))
|
||
total_tx=$((total_tx + tx))
|
||
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
json_entries+=("{\"name\":\"$(json_escape "$cname")\",\"ip\":\"$(json_escape "$ip")\",\"rx\":$rx,\"tx\":$tx,\"last_handshake\":$handshake,\"status\":\"$(json_escape "$status")\",\"status_code\":\"${status_code}\"}")
|
||
else
|
||
local rx_h tx_h
|
||
rx_h=$(format_bytes "$rx")
|
||
tx_h=$(format_bytes "$tx")
|
||
table_rows+=("$(printf "%-15s | %-15s | %-12s | %-12s | %-19s | %s" "$cname" "$ip" "$rx_h" "$tx_h" "$hs_str" "$status")")
|
||
fi
|
||
done < <(echo "$awg_dump" | tail -n +2)
|
||
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jarr=$(IFS=","; echo "[${json_entries[*]}]")
|
||
json_out "$_jarr"
|
||
else
|
||
log "Статистика трафика клиентов:"
|
||
echo ""
|
||
printf "%-15s | %-15s | %-12s | %-12s | %-19s | %s\n" "Имя" "IP" "Получено" "Отправлено" "Последний handshake" "Статус"
|
||
printf -- "-%.0s" {1..95}
|
||
echo
|
||
for row in "${table_rows[@]}"; do
|
||
echo "$row"
|
||
done
|
||
echo ""
|
||
log "Итого: Получено $(format_bytes "$total_rx"), Отправлено $(format_bytes "$total_tx")"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Справка
|
||
# ==============================================================================
|
||
|
||
usage() {
|
||
# C1: явный help (rc=0) -> stdout + exit 0; ошибка использования (rc!=0,
|
||
# дефолт) -> stderr + exit 1. Явные help-вызовы передают 0, error-вызовы
|
||
# опускают аргумент (получают 1).
|
||
local _rc="${1:-1}"
|
||
if [[ "$_rc" -ne 0 && "${JSON_OUTPUT:-0}" -eq 1 ]]; then
|
||
# --json + ошибка использования: текст справки боту не нужен, а exec >&2
|
||
# ниже угнал бы stdout у аварийного JSON-guard-а (exec переезжает fd
|
||
# всего процесса, guard стреляет ПОЗЖЕ, на EXIT). Причина уже в stderr.
|
||
_JSON_ERR="${_JSON_ERR:-invalid usage (unknown option or command)}"
|
||
exit "$_rc"
|
||
fi
|
||
[[ "$_rc" -ne 0 ]] && exec >&2
|
||
echo ""
|
||
echo "Скрипт управления AmneziaWG 2.0 (v${SCRIPT_VERSION})"
|
||
echo "=============================================="
|
||
echo "Использование: $0 [ОПЦИИ] <КОМАНДА> [АРГУМЕНТЫ]"
|
||
echo ""
|
||
echo "Опции:"
|
||
echo " -h, --help Показать эту справку"
|
||
echo " -v, --verbose Расширенный вывод (для команды list)"
|
||
echo " --no-color Отключить цветной вывод"
|
||
echo " --json Машиночитаемый JSON-вывод (большинство команд; детали в ADVANCED.md)"
|
||
echo " ENV AWG_STRICT_CONFIRM=1: не-TTY запуск без --yes отказывает (rc 1)"
|
||
echo " --expires=ВРЕМЯ Срок действия при add (1h, 12h, 1d, 7d, 30d, 4w)"
|
||
echo " --conf-dir=ПУТЬ Указать директорию AWG (умолч: $AWG_DIR)"
|
||
echo " --server-conf=ПУТЬ Указать файл конфига сервера"
|
||
echo " --apply-mode=РЕЖИМ syncconf (умолч.) или restart (обход kernel panic)"
|
||
echo " --psk (только для add) сгенерировать PresharedKey для клиента"
|
||
echo " --reset-routes (только для regen) сбросить AllowedIPs клиентов на текущий"
|
||
echo " глобальный режим маршрутизации (Issue #170)"
|
||
echo " --yes Не спрашивать подтверждение (эквивалент ENV AWG_YES=1)"
|
||
echo " --carrier=NAME (только для diagnose) сравнить AWG-параметры с профилем оператора"
|
||
echo " Доступные: beeline_msk yota_msk tele2_msk tele2_krasnoyarsk"
|
||
echo " tattelecom megafon_regions tmobile_us"
|
||
echo " Exit code: 1 только при FAIL или неизвестном операторе (WARN -> 0)"
|
||
echo ""
|
||
echo "Команды:"
|
||
echo " add <имя> [имя2 ...] Добавить клиента(ов). --expires применяется ко всем"
|
||
echo " remove <имя> [имя2 ...] Удалить клиента(ов)"
|
||
echo " list [-v] [--json] Показать список клиентов (--json: машиночитаемый, с client_ipv6)"
|
||
echo " stats [--json] Статистика трафика по клиентам"
|
||
echo " regen [имя ...] [--reset-routes] Перегенерировать файлы клиента(ов), можно несколько имён"
|
||
echo " modify <имя> <пар> <зн> Изменить параметр клиента"
|
||
echo " backup Создать бэкап"
|
||
echo " restore [файл] Восстановить из бэкапа"
|
||
echo " check | status Проверить состояние сервера"
|
||
echo " diagnose [--carrier=N] Self-troubleshooting: kernel/sysctl/UFW + сравнение с оператором"
|
||
echo " show Показать статус \`awg show\`"
|
||
echo " restart Перезапустить сервис AmneziaWG"
|
||
echo " repair-module Восстановить модуль ядра после kernel upgrade (alias: repair)"
|
||
echo " (dkms autoinstall + modprobe + запуск awg-quick)"
|
||
echo " help Показать эту справку"
|
||
echo ""
|
||
exit "$_rc"
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Основная логика
|
||
# ==============================================================================
|
||
|
||
if [[ -z "$COMMAND" ]]; then
|
||
usage 1
|
||
fi
|
||
if [[ "$COMMAND" == "help" ]]; then
|
||
usage "$HELP_EXIT_RC"
|
||
fi
|
||
|
||
check_dependencies || { _JSON_ERR="отсутствуют зависимости (диагностика в stderr)"; exit 1; }
|
||
cd "$AWG_DIR" || die "Ошибка перехода в $AWG_DIR"
|
||
|
||
log "Запуск команды '$COMMAND'..."
|
||
_cmd_rc=0
|
||
|
||
case $COMMAND in
|
||
add)
|
||
[[ ${#ARGS[@]} -eq 0 ]] && die "Не указано имя клиента."
|
||
|
||
# Гарантируем, что модуль ядра amneziawg загружен и awg-quick@awg0 активен.
|
||
# Без этого apply_config (awg syncconf) упадёт. См. также 'manage repair-module'.
|
||
# AWG_SKIP_APPLY=1 (offline/batch edit без apply): пропускаем проверку модуля —
|
||
# apply_config сам сделает no-op, и команда должна работать на dev-машине.
|
||
if [[ "${AWG_SKIP_APPLY:-0}" != "1" ]]; then
|
||
# rc=2 (модуль OK, сервис не поднялся) не блокирует add: конфиг
|
||
# записывается, а apply_config сам явно сообщит о неприменении.
|
||
ensure_amneziawg_kernel_module; _mod_rc=$?
|
||
if [[ "$_mod_rc" -eq 1 ]]; then
|
||
die "Модуль ядра amneziawg недоступен. Запустите 'manage repair-module' и повторите."
|
||
elif [[ "$_mod_rc" -eq 2 ]]; then
|
||
log_warn "Сервис awg-quick@awg0 не активен - конфиг будет записан, но применение может не сработать."
|
||
fi
|
||
fi
|
||
|
||
# --psk: включить опциональный PresharedKey для каждого нового клиента.
|
||
# Export CLIENT_PSK="auto" → generate_client сам сгенерирует 32-байт
|
||
# PSK через `awg genpsk` для каждого client'а в batch (разный PSK
|
||
# на каждого).
|
||
if [[ "${CLI_ADD_PSK:-0}" == "1" ]]; then
|
||
export CLIENT_PSK="auto"
|
||
log "PresharedKey будет сгенерирован для каждого нового клиента (--psk)."
|
||
fi
|
||
|
||
# --expires валидируем ОДИН раз ДО создания первого клиента. Иначе при
|
||
# неверном формате (--expires=bad) клиенты создавались permanent, а
|
||
# set_client_expiry молча падал per-client - временный клиент незаметно
|
||
# становился постоянным. Плохой формат теперь рушит команду до изменений.
|
||
if [[ -n "$EXPIRES_DURATION" ]]; then
|
||
parse_duration "$EXPIRES_DURATION" >/dev/null \
|
||
|| die "Некорректный --expires='$EXPIRES_DURATION'. Используйте: 1h, 12h, 1d, 7d, 30d, 4w."
|
||
fi
|
||
|
||
_added=0
|
||
_jr=()
|
||
for _cname in "${ARGS[@]}"; do
|
||
validate_client_name "$_cname" || { _cmd_rc=1; _jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"invalid_name\"}"); continue; }
|
||
|
||
if grep -qxF "#_Name = ${_cname}" "$SERVER_CONF_FILE"; then
|
||
# _cmd_rc=1 - паритет с remove ("Нет клиентов для удаления") и
|
||
# regen ("не найден, пропуск"): no-op по этому имени должен быть
|
||
# различим по exit-коду для автоматизации (Issue #175).
|
||
log_warn "Клиент '$_cname' уже существует, пропуск."
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"exists\"}")
|
||
continue
|
||
fi
|
||
|
||
# В batch-режиме каждому клиенту — свой PSK: сбрасываем на "auto"
|
||
# чтобы generate_client сгенерировал новый.
|
||
if [[ "${CLI_ADD_PSK:-0}" == "1" ]]; then
|
||
export CLIENT_PSK="auto"
|
||
fi
|
||
|
||
# Стейл-артефакты одноимённого клиента из прошлого (QR мог не
|
||
# пересоздаться, если qrencode пропал): без зачистки проверка
|
||
# [[ -f ]] ниже рапортовала бы чужой старый файл как свежий -
|
||
# и в логе, и в JSON.
|
||
rm -f "$AWG_DIR/${_cname}.png" "$AWG_DIR/${_cname}.vpnuri" "$AWG_DIR/${_cname}.vpnuri.png"
|
||
|
||
log "Добавление '$_cname'..."
|
||
if generate_client "$_cname"; then
|
||
log "Клиент '$_cname' добавлен."
|
||
# .png упоминаем только если QR реально создан (qrencode может
|
||
# отсутствовать) - симметрично проверке .vpnuri ниже.
|
||
if [[ -f "$AWG_DIR/${_cname}.png" ]]; then
|
||
log "Файлы: $AWG_DIR/${_cname}.conf, $AWG_DIR/${_cname}.png"
|
||
else
|
||
log "Файлы: $AWG_DIR/${_cname}.conf"
|
||
fi
|
||
if [[ -f "$AWG_DIR/${_cname}.vpnuri" ]]; then
|
||
log "vpn:// URI: $AWG_DIR/${_cname}.vpnuri"
|
||
fi
|
||
if [[ -n "$EXPIRES_DURATION" ]]; then
|
||
if set_client_expiry "$_cname" "$EXPIRES_DURATION"; then
|
||
install_expiry_cron || { log_error "Клиент '$_cname' создан со сроком, но cron автоудаления НЕ установлен - истёкший клиент сам не удалится."; _cmd_rc=1; }
|
||
else
|
||
# Формат проверен выше, значит сбой записи expiry (FS/права).
|
||
# Клиент создан и рабочий, но БЕЗ авто-срока - сигналим явно,
|
||
# чтобы временный клиент не остался незаметно постоянным.
|
||
log_error "Клиент '$_cname' создан, но срок действия НЕ установлен (ошибка записи expiry). Клиент постоянный - задайте срок повторно или удалите."
|
||
_cmd_rc=1
|
||
fi
|
||
fi
|
||
((_added++))
|
||
# JSON-запись успеха: qr/vpnuri - пути, если файл реально
|
||
# существует на момент ответа (generate_client рапортует успех
|
||
# и при провале QR/URI); expires_at - epoch или null.
|
||
_jqr="null"; _juri="null"; _jexp="null"
|
||
[[ -f "$AWG_DIR/${_cname}.png" ]] && _jqr="\"$(json_escape "$AWG_DIR/${_cname}.png")\""
|
||
[[ -f "$AWG_DIR/${_cname}.vpnuri" ]] && _juri="\"$(json_escape "$AWG_DIR/${_cname}.vpnuri")\""
|
||
if [[ -n "$EXPIRES_DURATION" ]]; then
|
||
_jexp_val=$(get_client_expiry "$_cname" 2>/dev/null) || _jexp_val=""
|
||
[[ "$_jexp_val" =~ ^[0-9]+$ ]] && _jexp="$_jexp_val"
|
||
fi
|
||
_jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"created\",\"conf\":\"$(json_escape "$AWG_DIR/${_cname}.conf")\",\"qr\":$_jqr,\"vpnuri\":$_juri,\"expires_at\":$_jexp}")
|
||
else
|
||
log_error "Ошибка добавления клиента '$_cname'."
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"error\"}")
|
||
fi
|
||
done
|
||
|
||
_japplied=false
|
||
if [[ $_added -gt 0 ]]; then
|
||
if [[ "${AWG_SKIP_APPLY:-0}" == "1" ]]; then
|
||
# apply_config сам залогирует и вернёт 0
|
||
apply_config
|
||
log "Добавлено клиентов: $_added. Применение отложено (AWG_SKIP_APPLY=1)."
|
||
elif apply_config; then
|
||
_japplied=true
|
||
log "Добавлено клиентов: $_added. Конфигурация применена."
|
||
else
|
||
log_error "Добавлено клиентов: $_added, но apply_config упал. Конфиг записан, но НЕ применён к live интерфейсу. Проверьте: systemctl status awg-quick@awg0"
|
||
_cmd_rc=1
|
||
fi
|
||
fi
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jrj=""
|
||
[[ ${#_jr[@]} -gt 0 ]] && _jrj=$(IFS=,; printf '%s' "${_jr[*]}")
|
||
_jok=false; [[ "$_cmd_rc" -eq 0 ]] && _jok=true
|
||
json_out "{\"command\":\"add\",\"ok\":$_jok,\"added\":$_added,\"failed\":$(( ${#ARGS[@]} - _added )),\"applied\":$_japplied,\"results\":[$_jrj]}"
|
||
fi
|
||
# Hygiene: CLIENT_PSK не должен протекать в будущие операции
|
||
unset CLIENT_PSK
|
||
;;
|
||
|
||
remove)
|
||
[[ ${#ARGS[@]} -eq 0 ]] && die "Не указано имя клиента."
|
||
|
||
# Валидация всех имён перед удалением
|
||
_valid_names=()
|
||
_jr=()
|
||
for _rname in "${ARGS[@]}"; do
|
||
validate_client_name "$_rname" || { _cmd_rc=1; _jr+=("{\"name\":\"$(json_escape "$_rname")\",\"status\":\"invalid_name\"}"); continue; }
|
||
if ! grep -qxF "#_Name = ${_rname}" "$SERVER_CONF_FILE"; then
|
||
# _cmd_rc=1 (v5.21.0): раньше частичный not-found давал rc 0 -
|
||
# асимметрия с add (exists -> rc 1) и regen (not-found -> rc 1).
|
||
# Спека 3.4: 'remove a ghost' = частичный успех = rc 1.
|
||
log_warn "Клиент '$_rname' не найден, пропуск."
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$_rname")\",\"status\":\"not_found\"}")
|
||
continue
|
||
fi
|
||
_valid_names+=("$_rname")
|
||
done
|
||
|
||
if [[ ${#_valid_names[@]} -eq 0 ]]; then
|
||
log_error "Нет клиентов для удаления."
|
||
_cmd_rc=1
|
||
else
|
||
# Подтверждение
|
||
if [[ ${#_valid_names[@]} -eq 1 ]]; then
|
||
if ! confirm_action "удалить" "клиента '${_valid_names[0]}'"; then exit 1; fi
|
||
else
|
||
if ! confirm_action "удалить" "${#_valid_names[@]} клиентов"; then exit 1; fi
|
||
fi
|
||
|
||
# Гарантируем загруженный модуль до любых мутаций (apply_config / awg syncconf).
|
||
# AWG_SKIP_APPLY=1 (offline/batch edit без apply): пропускаем проверку модуля —
|
||
# apply_config сам сделает no-op, и команда должна работать на dev-машине.
|
||
if [[ "${AWG_SKIP_APPLY:-0}" != "1" ]]; then
|
||
# rc=2 (модуль OK, сервис не поднялся) не блокирует remove -
|
||
# симметрично add: apply_config сам явно сообщит о неприменении.
|
||
ensure_amneziawg_kernel_module; _mod_rc=$?
|
||
if [[ "$_mod_rc" -eq 1 ]]; then
|
||
die "Модуль ядра amneziawg недоступен. Запустите 'manage repair-module' и повторите."
|
||
elif [[ "$_mod_rc" -eq 2 ]]; then
|
||
log_warn "Сервис awg-quick@awg0 не активен - конфиг будет записан, но применение может не сработать."
|
||
fi
|
||
fi
|
||
|
||
_removed=0
|
||
for _rname in "${_valid_names[@]}"; do
|
||
log "Удаление '$_rname'..."
|
||
if remove_peer_from_server "$_rname"; then
|
||
_remove_client_files "$_rname"
|
||
remove_client_expiry "$_rname"
|
||
log "Клиент '$_rname' удалён."
|
||
((_removed++))
|
||
_jr+=("{\"name\":\"$(json_escape "$_rname")\",\"status\":\"removed\"}")
|
||
else
|
||
log_error "Ошибка удаления '$_rname'."
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$_rname")\",\"status\":\"error\"}")
|
||
fi
|
||
done
|
||
|
||
_japplied=false
|
||
if [[ $_removed -gt 0 ]]; then
|
||
if [[ "${AWG_SKIP_APPLY:-0}" == "1" ]]; then
|
||
apply_config
|
||
log "Удалено клиентов: $_removed. Применение отложено (AWG_SKIP_APPLY=1)."
|
||
elif apply_config; then
|
||
_japplied=true
|
||
log "Удалено клиентов: $_removed. Конфигурация применена."
|
||
else
|
||
log_error "Удалено клиентов: $_removed, но apply_config упал. Peer-ы убраны из конфига, но могут оставаться на live интерфейсе. Проверьте: systemctl status awg-quick@awg0"
|
||
_cmd_rc=1
|
||
fi
|
||
fi
|
||
fi
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jrj=""
|
||
[[ ${#_jr[@]} -gt 0 ]] && _jrj=$(IFS=,; printf '%s' "${_jr[*]}")
|
||
_jok=false; [[ "$_cmd_rc" -eq 0 ]] && _jok=true
|
||
json_out "{\"command\":\"remove\",\"ok\":$_jok,\"removed\":${_removed:-0},\"failed\":$(( ${#ARGS[@]} - ${_removed:-0} )),\"applied\":${_japplied:-false},\"results\":[$_jrj]}"
|
||
fi
|
||
;;
|
||
|
||
list)
|
||
list_clients || _cmd_rc=1
|
||
;;
|
||
|
||
stats)
|
||
stats_clients || _cmd_rc=1
|
||
;;
|
||
|
||
regen)
|
||
log "Перегенерация файлов конфигурации и QR..."
|
||
# Правка AWG_* в awgsetup_cfg.init после установки на клиентов не влияет
|
||
# (источник истины - awg0.conf). Раньше это проходило молча (#196).
|
||
warn_awg_init_drift
|
||
# --reset-routes (Issue #170): передаём флаг в regenerate_client через
|
||
# ENV - обычный regen сохраняет индивидуальные AllowedIPs клиентов, с
|
||
# флагом ставит всем глобальный режим из awgsetup_cfg.init.
|
||
if [[ "${CLI_RESET_ROUTES:-0}" == "1" ]]; then
|
||
export AWG_REGEN_RESET_ROUTES=1
|
||
log "AllowedIPs всех перегенерируемых клиентов будут сброшены на глобальный режим (--reset-routes)."
|
||
fi
|
||
_jr=()
|
||
_regen_count=0
|
||
_regen_total=0
|
||
if [[ ${#ARGS[@]} -eq 0 ]]; then
|
||
# Без аргументов — все клиенты (сохраняет прежнее поведение).
|
||
all_clients=$(grep '^#_Name = ' "$SERVER_CONF_FILE" | sed 's/^#_Name = //')
|
||
if [[ -z "$all_clients" ]]; then
|
||
# Пустой список - штатный no-op: rc 0, в JSON regenerated=0.
|
||
log "Клиенты не найдены."
|
||
else
|
||
while IFS= read -r cname; do
|
||
cname="${cname## }"; cname="${cname%% }"
|
||
[[ -z "$cname" ]] && continue
|
||
_regen_total=$((_regen_total + 1))
|
||
log "Перегенерация '$cname'..."
|
||
if regenerate_client "$cname"; then
|
||
_regen_count=$((_regen_count + 1))
|
||
_jr+=("$(_regen_json_entry "$cname")")
|
||
else
|
||
log_warn "Ошибка перегенерации '$cname'"
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$cname")\",\"status\":\"error\"}")
|
||
fi
|
||
done <<< "$all_clients"
|
||
log "Перегенерация завершена."
|
||
fi
|
||
else
|
||
# С аргументами — обрабатываем каждое имя отдельно (паритет с add/remove).
|
||
# До v5.11.5 здесь читался только $CLIENT_NAME (=ARGS[0]), остальные имена
|
||
# молча терялись (Issue #70).
|
||
_regen_total=${#ARGS[@]}
|
||
for _cname in "${ARGS[@]}"; do
|
||
validate_client_name "$_cname" || { _cmd_rc=1; _jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"invalid_name\"}"); continue; }
|
||
if ! grep -qxF "#_Name = ${_cname}" "$SERVER_CONF_FILE"; then
|
||
log_warn "Клиент '$_cname' не найден, пропуск."
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"not_found\"}")
|
||
continue
|
||
fi
|
||
log "Перегенерация '$_cname'..."
|
||
if regenerate_client "$_cname"; then
|
||
_regen_count=$((_regen_count + 1))
|
||
_jr+=("$(_regen_json_entry "$_cname")")
|
||
else
|
||
log_error "Ошибка перегенерации '$_cname'."
|
||
_cmd_rc=1
|
||
_jr+=("{\"name\":\"$(json_escape "$_cname")\",\"status\":\"error\"}")
|
||
fi
|
||
done
|
||
if [[ $_regen_count -gt 0 ]]; then
|
||
log "Перегенерация завершена. Обработано: $_regen_count из ${#ARGS[@]}."
|
||
fi
|
||
fi
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jrj=""
|
||
[[ ${#_jr[@]} -gt 0 ]] && _jrj=$(IFS=,; printf '%s' "${_jr[*]}")
|
||
_jok=false; [[ "$_cmd_rc" -eq 0 ]] && _jok=true
|
||
_jreset=false; [[ "${CLI_RESET_ROUTES:-0}" == "1" ]] && _jreset=true
|
||
# regen не меняет серверное состояние (ключи и IP переиспользуются,
|
||
# apply не требуется) - поля applied в конверте нет намеренно.
|
||
json_out "{\"command\":\"regen\",\"ok\":$_jok,\"regenerated\":$_regen_count,\"failed\":$(( _regen_total - _regen_count )),\"reset_routes\":$_jreset,\"results\":[$_jrj]}"
|
||
fi
|
||
;;
|
||
|
||
modify)
|
||
[[ -z "$CLIENT_NAME" ]] && die "Не указано имя клиента."
|
||
validate_client_name "$CLIENT_NAME" || { _JSON_ERR="невалидное имя клиента"; exit 1; }
|
||
if modify_client "$CLIENT_NAME" "$PARAM" "$VALUE"; then
|
||
# modify правит ТОЛЬКО клиентский конфиг (DNS/MTU/AllowedIPs/...):
|
||
# серверное состояние не меняется, apply не нужен - поля applied
|
||
# в конверте нет намеренно (симметрия с regen).
|
||
json_out "{\"command\":\"modify\",\"ok\":true,\"name\":\"$(json_escape "$CLIENT_NAME")\",\"param\":\"$(json_escape "$PARAM")\",\"value\":\"$(json_escape "$VALUE")\"}"
|
||
else
|
||
_cmd_rc=1
|
||
fi
|
||
;;
|
||
|
||
backup)
|
||
if backup_configs; then
|
||
_jsize=null
|
||
if [[ -n "${LAST_BACKUP_PATH:-}" && -f "$LAST_BACKUP_PATH" ]]; then
|
||
_jsize=$(stat -c%s "$LAST_BACKUP_PATH" 2>/dev/null) || _jsize=null
|
||
fi
|
||
json_out "{\"command\":\"backup\",\"ok\":true,\"path\":\"$(json_escape "${LAST_BACKUP_PATH:-}")\",\"size_bytes\":$_jsize}"
|
||
else
|
||
_cmd_rc=1
|
||
fi
|
||
;;
|
||
|
||
restore)
|
||
if restore_backup "$CLIENT_NAME"; then # CLIENT_NAME используется как [файл]
|
||
_jclients=$(grep -c '^\[Peer\]' "$SERVER_CONF_FILE" 2>/dev/null) || _jclients=0
|
||
_jkeys=false
|
||
[[ -n "$(find "$KEYS_DIR" -maxdepth 1 -name '*.private' -print -quit 2>/dev/null)" ]] && _jkeys=true
|
||
# clients = число [Peer] в ВОССТАНОВЛЕННОМ серверном конфиге
|
||
# (спека 3.3: не файлов в clients/ - они могут расходиться).
|
||
json_out "{\"command\":\"restore\",\"ok\":true,\"source\":\"$(json_escape "${_RESTORE_SOURCE:-}")\",\"applied\":true,\"rolled_back\":false,\"restored\":{\"server_conf\":true,\"clients\":$_jclients,\"keys\":$_jkeys}}"
|
||
else
|
||
_cmd_rc=1
|
||
# Конверт и на провале: боту важно знать, был ли откат. error -
|
||
# человекочитаемый текст, машинные решения по ok/rc.
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jrb=false; [[ "${_RESTORE_ROLLED_BACK:-0}" == "1" ]] && _jrb=true
|
||
json_out "{\"command\":\"restore\",\"ok\":false,\"error\":\"$(json_escape "${_JSON_ERR:-restore failed (see stderr)}")\",\"source\":\"$(json_escape "${_RESTORE_SOURCE:-}")\",\"applied\":false,\"rolled_back\":$_jrb,\"rc\":1}"
|
||
fi
|
||
fi
|
||
;;
|
||
|
||
check|status)
|
||
warn_awg_init_drift
|
||
check_server || _cmd_rc=1
|
||
;;
|
||
|
||
show)
|
||
log "Статус AmneziaWG 2.0..."
|
||
if ! awg show; then log_error "Ошибка awg show."; _cmd_rc=1; fi
|
||
;;
|
||
|
||
restart)
|
||
log "Перезапуск сервиса..."
|
||
# Предупреждение ДО confirm_action: при --yes/AWG_YES=1 подтверждения не
|
||
# будет, а отрезать себя от сервера можно и неинтерактивным запуском.
|
||
awg_warn_interface_disruption
|
||
if ! confirm_action "перезапустить" "сервис"; then exit 1; fi
|
||
# Перед systemctl restart убеждаемся, что модуль ядра загружен (mode=module-only,
|
||
# т.к. сам systemctl ниже стартует unit явно — повторный start от ensure избыточен).
|
||
ensure_amneziawg_kernel_module module-only \
|
||
|| die "Модуль ядра amneziawg недоступен. Запустите 'manage repair-module' и повторите."
|
||
if ! systemctl restart awg-quick@awg0; then
|
||
_JSON_ERR="service restart failed"
|
||
log_error "Ошибка перезапуска."
|
||
status_out=$(systemctl status awg-quick@awg0 --no-pager 2>&1) || true
|
||
while IFS= read -r line; do log_error " $line"; done <<< "$status_out"
|
||
exit 1
|
||
else
|
||
# Интерфейс пересоздан - снимок набора device-параметров обязан
|
||
# догнать конфиг. Иначе apply_config сравнивал бы с устаревшим
|
||
# набором и либо предупреждал впустую, либо ПРОПУСТИЛ бы будущее
|
||
# снятие молча (если этим перезапуском параметр как раз добавили).
|
||
awg_record_device_params
|
||
log "Сервис перезапущен."
|
||
_jactive=false
|
||
systemctl is-active --quiet awg-quick@awg0 2>/dev/null && _jactive=true
|
||
json_out "{\"command\":\"restart\",\"ok\":true,\"unit\":\"awg-quick@awg0\",\"active\":$_jactive}"
|
||
fi
|
||
;;
|
||
|
||
repair-module|repair)
|
||
# Явная пользовательская команда: после kernel upgrade модуль может
|
||
# требовать пересборки DKMS. Здесь разрешаем apt-установку headers
|
||
# (AWG_ALLOW_APT_IN_ENSURE=1) — пользователь явно запросил восстановление.
|
||
log "Восстановление модуля ядра amneziawg (может занять до 5 минут — DKMS rebuild)..."
|
||
AWG_ALLOW_APT_IN_ENSURE=1 ensure_amneziawg_kernel_module full; _mod_rc=$?
|
||
_jmod=true; _jsvc=false
|
||
case "$_mod_rc" in
|
||
0)
|
||
_jsvc=true
|
||
log "Модуль ядра amneziawg восстановлен, сервис awg-quick@awg0 активен."
|
||
;;
|
||
2)
|
||
# Раньше этот случай маскировался под успех: "сервис активен" +
|
||
# exit 0 при лежащем сервисе (Issue #175).
|
||
log_error "Модуль ядра в порядке, но сервис awg-quick@awg0 НЕ запустился."
|
||
log_error "Диагностика: systemctl status awg-quick@awg0; journalctl -u awg-quick@awg0 -n 50"
|
||
_cmd_rc=1
|
||
;;
|
||
*)
|
||
_jmod=false
|
||
log_error "Не удалось восстановить модуль ядра. См. лог выше; при необходимости выполните ручное восстановление."
|
||
_cmd_rc=1
|
||
;;
|
||
esac
|
||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||
_jok=false; [[ "$_cmd_rc" -eq 0 ]] && _jok=true
|
||
# rc здесь = код ensure_amneziawg_kernel_module (0/1/2), не exit-код.
|
||
json_out "{\"command\":\"repair-module\",\"ok\":$_jok,\"module_loaded\":$_jmod,\"service_active\":$_jsvc,\"rc\":$_mod_rc}"
|
||
fi
|
||
;;
|
||
|
||
diagnose)
|
||
diagnose_server || _cmd_rc=1
|
||
;;
|
||
|
||
# Ветки help) здесь нет намеренно: все пути, выставляющие COMMAND="help"
|
||
# (-h/--help, неизвестная опция, позиционный help), перехватываются ДО
|
||
# диспетчера ранним `usage` (он завершает процесс через exit).
|
||
|
||
*)
|
||
log_error "Неизвестная команда: '$COMMAND'"
|
||
_cmd_rc=1
|
||
usage
|
||
;;
|
||
esac
|
||
|
||
log "Скрипт управления завершил работу."
|
||
exit $_cmd_rc
|