amneziawg-installer/manage_amneziawg.sh
Ivan Bondarev 1cbee098ca
v5.15.1 - maintenance release (audit fixes, IPv6 hardening, release gate)
Maintenance release after external code and documentation audits. No new
features; hardens the v5.15.0 dual-stack IPv6 work, tightens several manage
commands, fixes correctness and robustness issues, and adds a release
preflight gate plus a docs-consistency check.

Behavior change: split tunnel + IPv6 mirrors the IPv4 intent - a custom
ALLOWED_IPS keeps that IPv4 split and adds only the tunnel ULA for IPv6
instead of forcing a full ::/0 route. A full tunnel still gets ::/0 (native
IPv6) or the ULA.

Fixed: vpn:// URI carries the server's real MTU/keepalive/DNS; stricter
native-IPv6 detection (global non-ULA address and default route) with the
host IPv6 stack re-enabled before detection; install fails fast on apt update
errors; manage modify validates Endpoint/AllowedIPs; manage add is safe
against name reuse and cleans partial artifacts; manage restore prunes stale
clients/keys and recreates the expiry dir; manage help exit codes; manage
--json keeps stdout pure JSON; log messages stop doubling percent signs.

Docs/process: subnet and OS help accuracy, list --json documented, changelog
compare-links and anchors restored, SECURITY/CONTRIBUTING refreshed, public
RELEASE_PROCESS.md, release publish gated behind a full preflight job, new
docs-consistency workflow, signing design aligned with the asset draft.

Version bumped to 5.15.1 across all six scripts; SHA pins recomputed.
2026-06-02 14:08:36 +03:00

1722 lines
77 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.15.1
# Дата: 2026-06-02
# Репозиторий: https://github.com/bivlked/amneziawg-installer
# ==============================================================================
# --- Безопасный режим и Константы ---
# shellcheck disable=SC2034
SCRIPT_VERSION="5.15.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_cleanup() {
local d
for d in "${_manage_temp_dirs[@]}"; do
[[ -d "$d" ]] && rm -rf "$d"
done
type _awg_cleanup &>/dev/null && _awg_cleanup
}
trap _manage_cleanup EXIT INT 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#*=}"; 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() {
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 бэкапов
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 "Ошибка удаления старых бэкапов"
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
_backup_configs_nolock
local _rc=$?
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=$?
trap - RETURN
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
log "Создание бэкапа текущей..."
if ! _backup_configs_nolock; 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/hardlink
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
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
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"
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
if ! cp -a "$td/keys/"* "$KEYS_DIR/"; then
log_error "Ошибка копирования keys — восстановление прервано (запуск отката)."
return 1
fi
chmod 600 "$KEYS_DIR"/* 2>/dev/null
log_debug "Ключи восстановлены в $KEYS_DIR"
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-метки для несуществующих клиентов
# безвредны (cron-чистка их игнорирует), а 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)
if ! [[ "$value" =~ ^[0-9a-fA-F.:,\ ]+$ ]]; then
log_error "Невалидный DNS: '$value' (допустимы IP-адреса через запятую)"
return 1
fi ;;
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##*]:}"
[[ "$_eh" =~ ^[0-9A-Fa-f:]+$ ]] || { log_error "Невалидный Endpoint '$value': некорректный IPv6-хост"; return 1; }
else
_eh="${value%:*}"; _ept="${value##*:}"
[[ "$_eh" =~ ^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*|[0-9]{1,3}(\.[0-9]{1,3}){3})$ ]] || { 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
local _aip_tok _aip_ifs="$IFS"
IFS=','
for _aip_tok in $value; do
_aip_tok="${_aip_tok//[[:space:]]/}"
[[ -z "$_aip_tok" ]] && continue
if ! [[ "$_aip_tok" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$ || "$_aip_tok" =~ ^[0-9A-Fa-f:]+(/[0-9]{1,3})?$ ]]; then
IFS="$_aip_ifs"
log_error "Невалидный AllowedIPs '$value': '$_aip_tok' не похож на CIDR (a.b.c.d/n или 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. Восстановление..."
cp "$bak" "$cf" || log_warn "Ошибка восстановления."
exec {modify_lock_fd}>&-
return 1
fi
if ! grep -q -E "^${param} = " "$cf"; then
log_error "Замена не выполнена для '$param'. Восстановление..."
cp "$bak" "$cf" || log_warn "Ошибка восстановления."
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 "Прослушивание порта:"
# shellcheck source=/dev/null
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 ! 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
# shellcheck source=/dev/null
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
local jc jmin jmax i1
jc=$(awg show awg0 2>/dev/null | awk '/^[[:space:]]*jc:/ {print $2; exit}')
jmin=$(awg show awg0 2>/dev/null | awk '/^[[:space:]]*jmin:/ {print $2; exit}')
jmax=$(awg show awg0 2>/dev/null | awk '/^[[:space:]]*jmax:/ {print $2; exit}')
i1=$(awg show awg0 2>/dev/null | awk -F': ' '/^[[:space:]]*i1:/ {print $2; exit}')
_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="Нет данных"
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="Активен"
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;32m"
((act++))
elif [[ $diff -lt 86400 ]]; then
st="Недавно"
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;33m"
((act++))
else
st="Нет handshake"
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;37m"
fi
else
st="Нет handshake"
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;37m"
fi
else
pk="?"
st="Ошибка ключа"
[[ "$NO_COLOR" -eq 0 ]] && color_start="\033[0;31m"
fi
fi
# Expiry info
local exp_str=""
local exp_ts
exp_ts=$(get_client_expiry "$name" 2>/dev/null)
if [[ -n "$exp_ts" ]]; then
exp_str=" [$(format_remaining "$exp_ts")]"
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")\"}")
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
# 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="Неактивен"
if [[ "$handshake" =~ ^[0-9]+$ && "$handshake" -gt 0 ]]; then
local now
now=$(date +%s)
local diff=$((now - handshake))
if [[ $diff -lt 180 ]]; then
status="Активен"
elif [[ $diff -lt 86400 ]]; then
status="Недавно"
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")\"}")
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 / show / 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"
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
_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' добавлен."
log "Файлы: $AWG_DIR/${_cname}.conf, $AWG_DIR/${_cname}.png"
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
fi
fi
((_added++))
else
log_error "Ошибка добавления клиента '$_cname'."
_cmd_rc=1
fi
done
if [[ $_added -gt 0 ]]; then
[[ -n "${_CLI_APPLY_MODE:-}" ]] && export AWG_APPLY_MODE="$_CLI_APPLY_MODE"
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
rm -f "$AWG_DIR/$_rname.conf" "$AWG_DIR/$_rname.png" \
"$AWG_DIR/$_rname.vpnuri" "$AWG_DIR/$_rname.vpnuri.png"
rm -f "$KEYS_DIR/${_rname}.private" "$KEYS_DIR/${_rname}.public"
remove_client_expiry "$_rname"
log "Клиент '$_rname' удалён."
((_removed++))
else
log_error "Ошибка удаления '$_rname'."
_cmd_rc=1
fi
done
if [[ $_removed -gt 0 ]]; then
[[ -n "${_CLI_APPLY_MODE:-}" ]] && export AWG_APPLY_MODE="$_CLI_APPLY_MODE"
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)
usage
;;
*)
log_error "Неизвестная команда: '$COMMAND'"
_cmd_rc=1
usage
;;
esac
log "Скрипт управления завершил работу."
exit $_cmd_rc