mirror of
https://github.com/bivlked/amneziawg-installer.git
synced 2026-07-09 17:29:19 +00:00
Bug-fix release. Four fixes plus documentation. - install --force --port=N now changes the server port (was silently ignored: the render re-read the old ListenPort). - full-tunnel clients get 0.0.0.0/0, ::/0 so AmneziaVPN on iOS accepts the all-traffic mode; manage regen upgrades non-customized clients too. - default client DNS is now the 1.1.1.1, 1.0.0.1 pair. - --port accepts ports 1-65535 (was 1024-65535) so 443/80/53 work for DPI evasion on mobile carriers. Docs: CHANGELOG, S3/S4 range fix, stale issue link cleanup, Ubuntu 26.04 in the subtitle, --force re-run FAQ, a mobile-port note. Live-tested on Debian 13 (x86_64) and Ubuntu 26.04 (ARM64, native IPv6).
1884 lines
89 KiB
Bash
1884 lines
89 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.18.1
|
||
# Дата: 2026-06-27
|
||
# Репозиторий: https://github.com/bivlked/amneziawg-installer
|
||
# ==============================================================================
|
||
|
||
# --- Безопасный режим и Константы ---
|
||
# shellcheck disable=SC2034
|
||
SCRIPT_VERSION="5.18.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=()
|
||
|
||
manage_mktempdir() {
|
||
local d
|
||
d=$(mktemp -d) || return 1
|
||
_manage_temp_dirs+=("$d")
|
||
echo "$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"
|
||
}
|
||
trap _manage_cleanup 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=*)
|
||
_CLI_APPLY_MODE="${1#*=}"
|
||
# Валидация сразу при парсинге: опечатка (--apply-mode=restrat)
|
||
# молча работала бы как syncconf - пользователь, обходящий проблему
|
||
# режимом restart, не узнал бы, что режим не применился.
|
||
case "$_CLI_APPLY_MODE" in
|
||
syncconf|restart) ;;
|
||
*) echo "Недопустимое значение --apply-mode: '$_CLI_APPLY_MODE' (ожидается: syncconf или restart)" >&2; exit 1 ;;
|
||
esac
|
||
export AWG_APPLY_MODE="$_CLI_APPLY_MODE"
|
||
shift ;;
|
||
--psk) CLI_ADD_PSK=1; shift ;;
|
||
--yes) CLI_YES=1; shift ;;
|
||
--carrier=*) CLI_CARRIER="${1#*=}"; shift ;;
|
||
--*) echo "Неизвестная опция: $1" >&2; 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]}"
|
||
|
||
# Обновляем пути после возможного переопределения --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() { 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 return 0; fi
|
||
local action="$1" subject="$2"
|
||
read -rp "Вы действительно хотите $action $subject? [y/N]: " confirm < /dev/tty
|
||
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||
return 0
|
||
else
|
||
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
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Проверка зависимостей
|
||
# ==============================================================================
|
||
|
||
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
|
||
|
||
# Подключаем общую библиотеку
|
||
# shellcheck source=/dev/null
|
||
source "$COMMON_SCRIPT_PATH" || die "Ошибка загрузки $COMMON_SCRIPT_PATH"
|
||
|
||
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"
|
||
td=$(manage_mktempdir) || 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
|
||
_rtd=$(manage_mktempdir) || {
|
||
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"
|
||
|
||
log "Откат завершён — пытаюсь запустить сервис..."
|
||
if systemctl start awg-quick@awg0; then
|
||
log "Сервис запущен после отката."
|
||
return 0
|
||
else
|
||
log_error "Сервис не стартовал после отката — проверьте: systemctl status awg-quick@awg0"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
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
|
||
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:-}"
|
||
|
||
td=$(manage_mktempdir) || {
|
||
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 [[ "$_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
|
||
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"
|
||
|
||
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
|
||
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
|
||
|
||
log "Статус сервиса:"
|
||
if ! systemctl status awg-quick@awg0 --no-pager; then ok=0; fi
|
||
|
||
log "Интерфейс awg0:"
|
||
if ! ip addr show awg0 &>/dev/null; then
|
||
log_error " - Интерфейс не найден!"
|
||
ok=0
|
||
else
|
||
while IFS= read -r line; do log " $line"; done < <(ip addr show awg0)
|
||
fi
|
||
|
||
log "Прослушивание порта:"
|
||
safe_load_config "$CONFIG_FILE" 2>/dev/null
|
||
local port=${AWG_PORT:-0}
|
||
if [[ "$port" -eq 0 ]]; then
|
||
log_warn " - Не удалось определить порт."
|
||
else
|
||
if ! ss -lunp | grep -q ":${port} "; then
|
||
log_error " - Порт ${port}/udp НЕ прослушивается!"
|
||
ok=0
|
||
else
|
||
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 "Правила UFW:"
|
||
if command -v ufw &>/dev/null; then
|
||
if [[ "$port" -eq 0 ]]; then
|
||
# Порт не определился выше - grep по "0/udp" дал бы ложный warning.
|
||
log_warn " - Порт не определён, проверка правила UFW пропущена."
|
||
elif ! ufw status | grep -qw "${port}/udp"; then
|
||
log_warn " - Правило UFW для ${port}/udp не найдено!"
|
||
else
|
||
log " - Правило 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 [[ "$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
|
||
_diag_line OK "Модуль ядра amneziawg загружен"; 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
|
||
local awg_port="${AWG_PORT:-39743}"
|
||
if command -v ufw &>/dev/null; then
|
||
local ufw_st
|
||
ufw_st=$(ufw status 2>/dev/null | head -1)
|
||
if [[ "$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
|
||
echo "[]"
|
||
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
|
||
( IFS=","; echo "[${json_entries[*]}]" )
|
||
else
|
||
echo ""
|
||
log "Всего клиентов: $tot, Активных/Недавно: $act"
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Статистика трафика
|
||
# ==============================================================================
|
||
|
||
# Экранирование строки для безопасного включения в JSON
|
||
json_escape() {
|
||
local s="$1"
|
||
s="${s//\\/\\\\}"
|
||
s="${s//\"/\\\"}"
|
||
s="${s//$'\n'/\\n}"
|
||
s="${s//$'\r'/\\r}"
|
||
s="${s//$'\t'/\\t}"
|
||
printf '%s' "$s"
|
||
}
|
||
|
||
# Форматирование размера в человекочитаемый формат
|
||
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
|
||
echo "[]"
|
||
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
|
||
|
||
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
|
||
( IFS=","; echo "[${json_entries[*]}]" )
|
||
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}"
|
||
[[ "$_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-вывод (для команд list / stats)"
|
||
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 " --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 [имя ...] Перегенерировать файлы клиента(ов), можно несколько имён"
|
||
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 || 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
|
||
ensure_amneziawg_kernel_module \
|
||
|| die "Модуль ядра amneziawg недоступен. Запустите 'manage repair-module' и повторите."
|
||
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
|
||
for _cname in "${ARGS[@]}"; do
|
||
validate_client_name "$_cname" || { _cmd_rc=1; continue; }
|
||
|
||
if grep -qxF "#_Name = ${_cname}" "$SERVER_CONF_FILE"; then
|
||
log_warn "Клиент '$_cname' уже существует, пропуск."
|
||
continue
|
||
fi
|
||
|
||
# В batch-режиме каждому клиенту — свой PSK: сбрасываем на "auto"
|
||
# чтобы generate_client сгенерировал новый.
|
||
if [[ "${CLI_ADD_PSK:-0}" == "1" ]]; then
|
||
export CLIENT_PSK="auto"
|
||
fi
|
||
|
||
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++))
|
||
else
|
||
log_error "Ошибка добавления клиента '$_cname'."
|
||
_cmd_rc=1
|
||
fi
|
||
done
|
||
|
||
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
|
||
log "Добавлено клиентов: $_added. Конфигурация применена."
|
||
else
|
||
log_error "Добавлено клиентов: $_added, но apply_config упал. Конфиг записан, но НЕ применён к live интерфейсу. Проверьте: systemctl status awg-quick@awg0"
|
||
_cmd_rc=1
|
||
fi
|
||
fi
|
||
# Hygiene: CLIENT_PSK не должен протекать в будущие операции
|
||
unset CLIENT_PSK
|
||
;;
|
||
|
||
remove)
|
||
[[ ${#ARGS[@]} -eq 0 ]] && die "Не указано имя клиента."
|
||
|
||
# Валидация всех имён перед удалением
|
||
_valid_names=()
|
||
for _rname in "${ARGS[@]}"; do
|
||
validate_client_name "$_rname" || { _cmd_rc=1; continue; }
|
||
if ! grep -qxF "#_Name = ${_rname}" "$SERVER_CONF_FILE"; then
|
||
log_warn "Клиент '$_rname' не найден, пропуск."
|
||
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
|
||
ensure_amneziawg_kernel_module \
|
||
|| die "Модуль ядра amneziawg недоступен. Запустите 'manage repair-module' и повторите."
|
||
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++))
|
||
else
|
||
log_error "Ошибка удаления '$_rname'."
|
||
_cmd_rc=1
|
||
fi
|
||
done
|
||
|
||
if [[ $_removed -gt 0 ]]; then
|
||
if [[ "${AWG_SKIP_APPLY:-0}" == "1" ]]; then
|
||
apply_config
|
||
log "Удалено клиентов: $_removed. Применение отложено (AWG_SKIP_APPLY=1)."
|
||
elif apply_config; then
|
||
log "Удалено клиентов: $_removed. Конфигурация применена."
|
||
else
|
||
log_error "Удалено клиентов: $_removed, но apply_config упал. Peer-ы убраны из конфига, но могут оставаться на live интерфейсе. Проверьте: systemctl status awg-quick@awg0"
|
||
_cmd_rc=1
|
||
fi
|
||
fi
|
||
fi
|
||
;;
|
||
|
||
list)
|
||
list_clients || _cmd_rc=1
|
||
;;
|
||
|
||
stats)
|
||
stats_clients || _cmd_rc=1
|
||
;;
|
||
|
||
regen)
|
||
log "Перегенерация файлов конфигурации и QR..."
|
||
if [[ ${#ARGS[@]} -eq 0 ]]; then
|
||
# Без аргументов — все клиенты (сохраняет прежнее поведение).
|
||
all_clients=$(grep '^#_Name = ' "$SERVER_CONF_FILE" | sed 's/^#_Name = //')
|
||
if [[ -z "$all_clients" ]]; then
|
||
log "Клиенты не найдены."
|
||
else
|
||
while IFS= read -r cname; do
|
||
cname="${cname## }"; cname="${cname%% }"
|
||
[[ -z "$cname" ]] && continue
|
||
log "Перегенерация '$cname'..."
|
||
regenerate_client "$cname" || { log_warn "Ошибка перегенерации '$cname'"; _cmd_rc=1; }
|
||
done <<< "$all_clients"
|
||
log "Перегенерация завершена."
|
||
fi
|
||
else
|
||
# С аргументами — обрабатываем каждое имя отдельно (паритет с add/remove).
|
||
# До v5.11.5 здесь читался только $CLIENT_NAME (=ARGS[0]), остальные имена
|
||
# молча терялись (Issue #70).
|
||
_regen_count=0
|
||
for _cname in "${ARGS[@]}"; do
|
||
validate_client_name "$_cname" || { _cmd_rc=1; continue; }
|
||
if ! grep -qxF "#_Name = ${_cname}" "$SERVER_CONF_FILE"; then
|
||
log_warn "Клиент '$_cname' не найден, пропуск."
|
||
_cmd_rc=1
|
||
continue
|
||
fi
|
||
log "Перегенерация '$_cname'..."
|
||
if regenerate_client "$_cname"; then
|
||
_regen_count=$((_regen_count + 1))
|
||
else
|
||
log_error "Ошибка перегенерации '$_cname'."
|
||
_cmd_rc=1
|
||
fi
|
||
done
|
||
if [[ $_regen_count -gt 0 ]]; then
|
||
log "Перегенерация завершена. Обработано: $_regen_count из ${#ARGS[@]}."
|
||
fi
|
||
fi
|
||
;;
|
||
|
||
modify)
|
||
[[ -z "$CLIENT_NAME" ]] && die "Не указано имя клиента."
|
||
validate_client_name "$CLIENT_NAME" || exit 1
|
||
modify_client "$CLIENT_NAME" "$PARAM" "$VALUE" || _cmd_rc=1
|
||
;;
|
||
|
||
backup)
|
||
backup_configs || _cmd_rc=1
|
||
;;
|
||
|
||
restore)
|
||
restore_backup "$CLIENT_NAME" || _cmd_rc=1 # CLIENT_NAME используется как [файл]
|
||
;;
|
||
|
||
check|status)
|
||
check_server || _cmd_rc=1
|
||
;;
|
||
|
||
show)
|
||
log "Статус AmneziaWG 2.0..."
|
||
if ! awg show; then log_error "Ошибка awg show."; _cmd_rc=1; fi
|
||
;;
|
||
|
||
restart)
|
||
log "Перезапуск сервиса..."
|
||
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
|
||
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
|
||
log "Сервис перезапущен."
|
||
fi
|
||
;;
|
||
|
||
repair-module|repair)
|
||
# Явная пользовательская команда: после kernel upgrade модуль может
|
||
# требовать пересборки DKMS. Здесь разрешаем apt-установку headers
|
||
# (AWG_ALLOW_APT_IN_ENSURE=1) — пользователь явно запросил восстановление.
|
||
log "Восстановление модуля ядра amneziawg (может занять до 5 минут — DKMS rebuild)..."
|
||
if AWG_ALLOW_APT_IN_ENSURE=1 ensure_amneziawg_kernel_module full; then
|
||
log "Модуль ядра amneziawg восстановлен, сервис awg-quick@awg0 активен."
|
||
else
|
||
log_error "Не удалось восстановить модуль ядра. См. лог выше; при необходимости выполните ручное восстановление."
|
||
_cmd_rc=1
|
||
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
|