amneziawg-installer/manage_amneziawg.sh
Ivan Bondarev 7f9e6b17db
Some checks failed
Docs consistency / docs-consistency (push) Waiting to run
ShellCheck / Lint and syntax check (push) Waiting to run
Tests / unit-tests (push) Has been cancelled
v5.27.1: regen no longer collapses the AllowedIPs and DNS lists (#225)
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.
2026-08-23 17:59:38 +03:00

2385 lines
125 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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