amneziawg-installer/awg_common.sh
Ivan Bondarev 702d3aff5b
Some checks failed
Docs consistency / docs-consistency (push) Has been cancelled
ShellCheck / Lint and syntax check (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
Release v5.18.1: --force port change, full-tunnel iOS ::/0, dual DNS, low ports
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).
2026-06-28 09:20:07 +03:00

2578 lines
127 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
# ==============================================================================
# Общая библиотека функций для AmneziaWG 2.0
# Автор: @bivlked
# Версия: 5.18.1
# Дата: 2026-06-27
# Репозиторий: https://github.com/bivlked/amneziawg-installer
# ==============================================================================
#
# Этот файл содержит общие функции для генерации ключей, конфигураций,
# управления пирами и работы с AWG 2.0 параметрами.
# Предназначен для подключения через source из install и manage скриптов.
# ==============================================================================
# --- Константы (могут быть переопределены до source) ---
AWG_DIR="${AWG_DIR:-/root/awg}"
CONFIG_FILE="${CONFIG_FILE:-$AWG_DIR/awgsetup_cfg.init}"
SERVER_CONF_FILE="${SERVER_CONF_FILE:-/etc/amnezia/amneziawg/awg0.conf}"
KEYS_DIR="${KEYS_DIR:-$AWG_DIR/keys}"
# --- Автоочистка временных файлов ---
# ВАЖНО: trap НЕ устанавливается здесь, чтобы не перезаписать trap вызывающего скрипта.
# Вызывающий скрипт должен вызвать _awg_cleanup() в своём обработчике EXIT.
_AWG_TEMP_FILES=()
# Файл-реестр temp-файлов: awg_mktemp часто вызывается через $(...) (subshell),
# где правка массива _AWG_TEMP_FILES теряется в родителе. Файл переживает
# subshell, поэтому _awg_cleanup надёжно удалит даже temp, созданный в
# подстановке команды (например прерванная запись конфига между mktemp и mv).
# $$ = PID вызывающего скрипта, стабилен для всех его subshell.
# Реестр лежит в $AWG_DIR (root-only 0700), а НЕ в общедоступном /tmp:
# предсказуемое имя в /tmp позволяло бы локальному пользователю заранее
# подложить файл со списком чужих путей, которые _awg_cleanup удалил бы от root.
_AWG_TEMP_REGISTRY="${AWG_DIR}/.awg_temp_registry.$$"
_awg_cleanup() {
local f
for f in "${_AWG_TEMP_FILES[@]}"; do
[[ -f "$f" ]] && rm -f "$f"
done
# Файловый кэш public IP (см. get_server_public_ip) - per-PID, подчищаем.
rm -f "${AWG_DIR}/.public_ip.cache.$$" 2>/dev/null
# Guard от symlink-подмены реестра: читаем только обычный файл.
if [[ -n "${_AWG_TEMP_REGISTRY:-}" && -f "$_AWG_TEMP_REGISTRY" && ! -L "$_AWG_TEMP_REGISTRY" ]]; then
while IFS= read -r f; do
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
done < "$_AWG_TEMP_REGISTRY"
rm -f "$_AWG_TEMP_REGISTRY"
fi
}
# Обёртка mktemp с автоочисткой.
# Опциональный 1-й аргумент - целевой каталог: temp создаётся в нём же, где
# окажется итоговый файл, чтобы последующий mv был атомарным rename в пределах
# одной ФС, а не cross-fs copy+unlink (важно, когда /tmp смонтирован как tmpfs).
# Без аргумента поведение прежнее (/tmp или $TMPDIR) - обратная совместимость.
awg_mktemp() {
local dir="${1:-}" f
if [[ -n "$dir" ]]; then
mkdir -p "$dir" 2>/dev/null
f=$(mktemp -p "$dir") || return 1
else
f=$(mktemp) || return 1
fi
_AWG_TEMP_FILES+=("$f")
# Дублируем путь в файл-реестр - он переживает subshell ($(awg_mktemp ...)),
# в отличие от массива выше.
[[ -n "${_AWG_TEMP_REGISTRY:-}" ]] && printf '%s\n' "$f" >> "$_AWG_TEMP_REGISTRY" 2>/dev/null
echo "$f"
}
# --- Заглушки для логирования (переопределяются вызывающим скриптом) ---
if ! declare -f log >/dev/null 2>&1; then
log() { echo "[INFO] $1"; }
log_warn() { echo "[WARN] $1" >&2; }
log_error() { echo "[ERROR] $1" >&2; }
log_debug() { echo "[DEBUG] $1"; }
fi
# ==============================================================================
# Утилиты
# ==============================================================================
# --- Валидаторы IP / CIDR (общие для install и manage) ---
# Проверяют не только форму, но и числовые диапазоны: октеты IPv4 0-255,
# префикс IPv4 0-32, IPv6 0-128. Без префикса адрес валиден (wireguard-tools
# трактует голый IPv4 как /32, IPv6 как /128 - host-route).
# _valid_ipv4 <addr> : ровно 4 октета, каждый 0-255 (10# защищает от трактовки
# ведущего нуля как восьмеричного числа в (( )) ).
_valid_ipv4() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
local o
for o in "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"; do
(( 10#$o <= 255 )) || return 1
done
return 0
}
# _valid_ipv6 <addr> : структурная проверка (не только charset). Допускает одну
# компрессию "::"; без неё требует ровно 8 групп по 1-4 hex; с ней - не более 7.
# Встроенный IPv4 (::ffff:1.2.3.4) намеренно не поддержан - в AllowedIPs туннеля
# не встречается, а точки уже отсекаются charset-проверкой.
_valid_ipv6() {
local ip="$1"
[[ "$ip" =~ ^[0-9A-Fa-f:]+$ ]] || return 1
case "$ip" in
*:::*) return 1 ;; # три и более ":" подряд
*::*::*) return 1 ;; # более одной "::"
esac
[[ "$ip" == :* && "$ip" != ::* ]] && return 1 # одиночное ведущее ":"
[[ "$ip" == *: && "$ip" != *:: ]] && return 1 # одиночное хвостовое ":"
local has_dcolon=0
[[ "$ip" == *::* ]] && has_dcolon=1
local IFS=':' parts=() p ngroups=0
read -ra parts <<< "$ip"
for p in "${parts[@]}"; do
[[ -z "$p" ]] && continue # пустые поля от "::"
[[ "$p" =~ ^[0-9A-Fa-f]{1,4}$ ]] || return 1
(( ngroups++ ))
done
if [[ $has_dcolon -eq 1 ]]; then
(( ngroups <= 7 )) || return 1 # "::" заменяет >=1 группу
else
(( ngroups == 8 )) || return 1
fi
return 0
}
# _valid_cidr <token> : IPv4/IPv6 адрес с опциональным префиксом. Префикс, если
# задан, обязан быть числом в допустимом диапазоне (IPv4 0-32, IPv6 0-128).
# Пустой префикс после "/" (например "1.2.3.4/") отвергается.
_valid_cidr() {
local tok="$1" addr prefix
if [[ "$tok" == */* ]]; then
addr="${tok%/*}"; prefix="${tok##*/}"
[[ "$prefix" =~ ^[0-9]+$ ]] || return 1
else
addr="$tok"; prefix=""
fi
if _valid_ipv4 "$addr"; then
[[ -z "$prefix" ]] && return 0
(( 10#$prefix <= 32 )) || return 1
return 0
elif _valid_ipv6 "$addr"; then
[[ -z "$prefix" ]] && return 0
(( 10#$prefix <= 128 )) || return 1
return 0
fi
return 1
}
# _valid_host_or_ipv4 <host> : для Endpoint - корректный IPv4 ИЛИ FQDN.
_valid_host_or_ipv4() {
local host="$1"
_valid_ipv4 "$host" && return 0
[[ "$host" =~ ^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$ ]] || return 1
# Полностью числовая последняя метка = не настоящий TLD (RFC 3696), а скорее
# битый IPv4 (например "999.1.1.1"); отвергаем, чтобы не принять опечатку в IP.
local last="${host##*.}"
[[ "$last" =~ ^[0-9]+$ ]] && return 1
return 0
}
# Определение основного сетевого интерфейса
get_main_nic() {
ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}'
}
# Определение внешнего IP-адреса сервера (с кэшированием).
#
# Список 6 сервисов покрывает основные NAT и cloud-сценарии без
# жёсткого ранжирования по uptime: ifconfig.me исторически стабилен
# на обычных VPS (Hetzner, Vultr, OVH), checkip.amazonaws.com -
# доступен даже из AWS / GCP / OCI private subnet за NAT Gateway,
# ipinfo.io / icanhazip / ifconfig.io - дополнительные fallback'и
# на случай rate-limit одного из endpoint'ов. Порядок alphabetical
# (детерминирован для тестов и diff'ов). First-wins: при первом
# валидном ответе остальные не запрашиваются.
_CACHED_PUBLIC_IP=""
# Файловый дубль кэша: get_server_public_ip практически всегда вызывается как
# $(...) (subshell), где присваивание _CACHED_PUBLIC_IP теряется в родителе и
# кэш-переменная никогда не срабатывает. Файл с PID-суффиксом переживает
# subshell (тот же приём, что _AWG_TEMP_REGISTRY) и удаляется в _awg_cleanup.
# Без него `manage regen` по N клиентам делал бы N curl-раундов (до 6 сервисов
# по 5 сек каждый) при пустом AWG_ENDPOINT.
_PUBLIC_IP_CACHE="${AWG_DIR}/.public_ip.cache.$$"
get_server_public_ip() {
if [[ -n "$_CACHED_PUBLIC_IP" ]]; then
echo "$_CACHED_PUBLIC_IP"
return 0
fi
if [[ -f "$_PUBLIC_IP_CACHE" && ! -L "$_PUBLIC_IP_CACHE" ]]; then
local cached
cached=$(<"$_PUBLIC_IP_CACHE")
if [[ -n "$cached" ]] && _valid_ipv4 "$cached"; then
_CACHED_PUBLIC_IP="$cached"
echo "$cached"
return 0
fi
fi
local ip="" svc
for svc in \
https://api.ipify.org \
https://checkip.amazonaws.com \
https://icanhazip.com \
https://ifconfig.io \
https://ifconfig.me \
https://ipinfo.io/ip
do
ip=$(curl -4 -sf --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]')
if [[ -n "$ip" ]] && _valid_ipv4 "$ip"; then
_CACHED_PUBLIC_IP="$ip"
printf '%s\n' "$ip" > "$_PUBLIC_IP_CACHE" 2>/dev/null || true
# Observability: write trace to LOG_FILE directly. Never to stdout
# (the function's stdout IS the IP; any extra bytes corrupt the
# caller's $(get_server_public_ip) capture and the generated
# client Endpoint line).
if [[ -n "${LOG_FILE:-}" && -w "$(dirname "${LOG_FILE}")" ]]; then
printf '[%s] DEBUG: public IP detected: %s (via %s)\n' \
"$(date +'%F %T')" "$ip" "$svc" >>"$LOG_FILE" 2>/dev/null || true
fi
echo "$ip"
return 0
fi
done
if [[ -n "${LOG_FILE:-}" && -w "$(dirname "${LOG_FILE}")" ]]; then
printf '[%s] DEBUG: public IP detection failed (all 6 services unreachable or invalid)\n' \
"$(date +'%F %T')" >>"$LOG_FILE" 2>/dev/null || true
fi
echo ""
return 1
}
# Fallback: первый non-loopback IPv4 с сетевого интерфейса.
# Нужен когда curl до ifconfig.me / ipify / ... не проходит (LXC без egress,
# fail2ban на outbound, firewall, и т.п.). На bare metal / обычных VPS
# обычно совпадает с public IP; на NAT'нутом хосте даёт private IP — в
# этом случае вызывающий код должен написать log_warn чтобы пользователь
# сам исправил Endpoint в клиентских .conf.
_try_local_ip() {
local ip
ip=$(ip -4 -o addr show scope global 2>/dev/null \
| awk '{print $4}' \
| cut -d/ -f1 \
| grep -v '^127\.' \
| head -1)
{ [[ -n "$ip" ]] && _valid_ipv4 "$ip"; } || return 1
echo "$ip"
return 0
}
# Note: apt_update_tolerant() определена inline в install_amneziawg.sh
# (нужна в шагах 1-2 до скачивания этого файла). Здесь её нет — мёртвый код.
# ==============================================================================
# Генерация AWG 2.0 параметров (используется в тестах + manage)
# ==============================================================================
# Случайное число [min, max] через /dev/urandom (поддержка uint32).
# Дублирует install_amneziawg.sh:rand_range — нужно здесь для тестов и regen.
rand_range() {
local min=$1 max=$2
local range=$((max - min + 1))
local random_val
random_val=$(od -An -tu4 -N4 /dev/urandom 2>/dev/null | tr -d ' ')
if [[ -z "$random_val" || ! "$random_val" =~ ^[0-9]+$ ]]; then
# Fallback: три $RANDOM (15 бит) с XOR-перекрытием = полные 31 бит.
random_val=$(( (RANDOM << 16) ^ (RANDOM << 8) ^ RANDOM ))
fi
echo $(( (random_val % range) + min ))
}
# Генерация 4 непересекающихся диапазонов для AWG H1-H4.
# Алгоритм: 8 случайных значений → sort → 4 пары (low, high).
# Сортировка даёт low <= high; строгие проверки ниже гарантируют зазор между
# парами (касание границ = пересечение в одной точке) и нижнюю границу >= 5
# (значения 1-4 зарезервированы под типы сообщений vanilla WireGuard).
# Минимальная ширина каждого диапазона = 1000.
# Печатает 4 строки "low-high" в stdout. Возвращает 1 при неудаче.
# Защита от ТСПУ-фингерпринта по статическим H-значениям (#38).
#
# Диапазон: [0, 2^31-1] = [0, 2147483647]. Спецификация AmneziaWG
# допускает полный uint32 (0-4294967295), но standalone Windows-клиент
# `amneziawg-windows-client` имеет UI-валидатор ограниченный 2^31-1 в
# `ui/syntax/highlighter.go:isValidHField()` (upstream bug
# amnezia-vpn/amneziawg-windows-client#85, не исправлен). Значения
# выше 2^31-1 на сервере работают, но клиентский редактор подчёркивает
# их красным и не даёт сохранять правки. Для совместимости генерируем
# в безопасной половине диапазона (#40).
#
# Оптимизация: один вызов `od -N32 -tu4` читает 32 байта = 8 uint32 значений
# одной операцией, вместо 8 отдельных subprocess через rand_range.
# Fallback на rand_range если /dev/urandom недоступен.
generate_awg_h_ranges() {
local attempt=0 max_attempts=20
while (( attempt < max_attempts )); do
local raw arr=() _v
# Один read 32 байт из /dev/urandom = 8 uint32 значений
raw=$(od -An -N32 -tu4 /dev/urandom 2>/dev/null | tr -s ' \n' '\n' | sed '/^$/d')
if [[ -n "$raw" ]]; then
local count=0
while IFS= read -r _v; do
[[ "$_v" =~ ^[0-9]+$ ]] || continue
# Маска 0x7FFFFFFF: очищает старший бит, значение в [0, 2^31-1]
# без bias (каждый младший бит независим).
arr+=("$(( _v & 2147483647 ))")
count=$((count + 1))
(( count == 8 )) && break
done <<< "$raw"
fi
# Fallback: 8 отдельных вызовов rand_range (если urandom недоступен)
if (( ${#arr[@]} != 8 )); then
arr=()
local _i
for _i in 1 2 3 4 5 6 7 8; do
arr+=("$(rand_range 0 2147483647)")
done
fi
# Сортировка
local sorted
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
arr=()
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
# Проверка: минимальная ширина каждой пары, строгий зазор между
# парами (без касания границ) и нижняя граница вне зарезервированных
# значений 1-4 (типы сообщений vanilla WireGuard).
if (( ${arr[0]} >= 5 )) && \
(( ${arr[1]} - ${arr[0]} >= 1000 )) && \
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \
(( ${arr[7]} - ${arr[6]} >= 1000 )) && \
(( ${arr[2]} > ${arr[1]} )) && \
(( ${arr[4]} > ${arr[3]} )) && \
(( ${arr[6]} > ${arr[5]} )); then
printf '%s-%s\n' "${arr[0]}" "${arr[1]}"
printf '%s-%s\n' "${arr[2]}" "${arr[3]}"
printf '%s-%s\n' "${arr[4]}" "${arr[5]}"
printf '%s-%s\n' "${arr[6]}" "${arr[7]}"
return 0
fi
attempt=$((attempt + 1))
done
return 1
}
# ==============================================================================
# DKMS / Автовосстановление модуля ядра amneziawg
# ==============================================================================
#
# После apt upgrade ядра DKMS-модуль должен пересобраться для нового kernel.
# Если это не произошло (или модуль был отвязан), 4 функции ниже выполняют
# idempotent восстановление:
#
# _sanitize_awg_dkms_conf — убрать deprecated REMAKE_INITRD= из dkms.conf
# _install_kernel_headers — distro-aware fallback chain (Ubuntu/Debian)
# _ensure_awg_quick_running — стартовать awg-quick@awg0 если неактивен
# ensure_amneziawg_kernel_module — master, публичная точка входа
#
# === Контекст использования и safety contract ===
#
# Master ensure_amneziawg_kernel_module() исходит из того, что running kernel
# (uname -r) и есть target kernel — то есть подходит только для post-reboot
# контекстов: manage repair-module, manage add/remove (после reboot user'а),
# systemd unit (стартует на boot когда ядро уже новое). Из DPkg::Post-Invoke
# хука uname -r всё ещё возвращает СТАРОЕ ядро — для этого случая Phase 3
# Apt hook helper будет использовать отдельную обёртку, итерирующую target
# ядра через /lib/modules/*/build.
#
# Master НЕ вызывает apt-get install по умолчанию (это deadlock в любом
# контексте где parent держит /var/lib/dpkg/lock-frontend). Вызов apt
# гейтится переменной окружения AWG_ALLOW_APT_IN_ENSURE=1 — её устанавливает
# только install_amneziawg step 2 / manage repair-module. Apt hook helper
# и systemd unit её НЕ устанавливают, master skip'ит шаг с headers.
#
# Headers нужно ставить отдельно — на этапе install через мета-пакет
# (linux-headers-$(arch) для Debian, linux-headers-generic для Ubuntu) —
# apt сам подтянет matching headers при apt upgrade ядра.
# Удаление deprecated директивы REMAKE_INITRD= из dkms.conf модуля amneziawg.
# Современные версии DKMS считают её deprecated и печатают noisy warnings.
_sanitize_awg_dkms_conf() {
local conf
for conf in /var/lib/dkms/amneziawg/*/source/dkms.conf; do
[[ -f "$conf" ]] && sed -i '/^REMAKE_INITRD=/d' "$conf"
done
}
# Установка пакета kernel headers через distro-aware fallback chain.
# Аргумент: версия ядра (по умолчанию $(uname -r)).
# Возвращает: 0 если хотя бы один кандидат установлен успешно, 1 если все провалились.
#
# ВАЖНО: вызывается только из контекстов где apt lock доступен (install_amneziawg
# step 2 или manage repair-module). НЕ должна вызываться из DPkg::Post-Invoke хука.
#
# Поддерживается распознавание Raspberry Pi Foundation kernel (+rpt/-rpi suffix):
# linux-headers-rpi-2712 (Pi 5 / Cortex-A76) или linux-headers-rpi-v8 (Pi 3/4 arm64).
_install_kernel_headers() {
# Defense-in-depth: эта функция вызывает apt-get install и не должна
# запускаться из hook-context (deadlock на dpkg lock). Master уже гейтит
# её через AWG_ALLOW_APT_IN_ENSURE, но _ префикс не enforced — добавляем
# тот же гард сюда чтобы случайный direct call из чужого скрипта не
# обошёл защиту.
if [[ "${AWG_ALLOW_APT_IN_ENSURE:-0}" != "1" ]]; then
log_error "_install_kernel_headers: AWG_ALLOW_APT_IN_ENSURE не выставлен — apt-вызов запрещён в этом контексте."
return 1
fi
local kernel_ver="${1:-$(uname -r)}"
local candidates=()
# RPi Foundation kernel (suffix +rpt или -rpi) — отдельный мета-пакет
# независимо от distro. Pattern check order: 2712 → v7l → v7 → v8 (default).
if [[ "$kernel_ver" == *+rpt* || "$kernel_ver" == *-rpi* ]]; then
if [[ "$kernel_ver" == *2712* ]]; then
candidates+=("linux-headers-rpi-2712") # Pi 5 / Cortex-A76
elif [[ "$kernel_ver" == *-rpi-v7l* ]]; then
candidates+=("linux-headers-rpi-v7l") # armhf 32-bit (LPAE)
elif [[ "$kernel_ver" == *-rpi-v7* ]]; then
candidates+=("linux-headers-rpi-v7") # armhf 32-bit older
else
candidates+=("linux-headers-rpi-v8") # Pi 3/4 arm64 default
fi
fi
case "${OS_ID:-}" in
ubuntu)
candidates+=(
"linux-headers-${kernel_ver}"
"linux-headers-generic"
"raspberrypi-kernel-headers"
)
;;
debian)
local arch
arch=$(dpkg --print-architecture 2>/dev/null)
candidates+=("linux-headers-${kernel_ver}")
if [[ -n "$arch" ]]; then
# Cloud-images Debian используют отдельный мета-пакет
# linux-headers-cloud-${arch} вместо обычного linux-headers-${arch}
# (kernel ABI в них другая — sched/IRQ-таймеры урезаны под VM).
# Prefer cloud-meta когда running kernel явно cloud — иначе
# repair-module падает на AWS/Azure/GCP/cloud-Hetzner после
# kernel upgrade, хотя headers доступны через cloud-meta.
if [[ "$kernel_ver" == *-cloud-* ]]; then
candidates+=("linux-headers-cloud-${arch}")
fi
candidates+=("linux-headers-${arch}")
fi
;;
*)
log_error "Установка kernel headers: неизвестный OS_ID='${OS_ID:-}' (поддерживаются только ubuntu/debian)."
return 1
;;
esac
local pkg
for pkg in "${candidates[@]}"; do
if apt-get install -y "$pkg" >/dev/null 2>&1; then
log "Установлены kernel headers: $pkg"
return 0
fi
log_warn "Не удалось установить $pkg, пробую следующий кандидат..."
done
log_error "Не удалось установить ни один из пакетов kernel headers (${candidates[*]})."
return 1
}
# Запуск awg-quick@<iface>, если сервис не активен.
# Аргумент: имя интерфейса (по умолчанию awg0).
# Возвращает: 0 при успешном старте или если сервис уже активен, 1 при сбое.
_ensure_awg_quick_running() {
local iface="${1:-awg0}"
local svc="awg-quick@${iface}.service"
if systemctl is-active --quiet "$svc"; then
return 0
fi
log "Запуск $svc (был неактивен)..."
if systemctl start "$svc"; then
log "$svc запущен."
return 0
fi
log_error "Не удалось запустить $svc. Подробности: systemctl status $svc"
return 1
}
# Master: гарантирует что модуль ядра amneziawg собран и загружен для running kernel.
# Idempotent: fast-path возвращает 0 если модуль уже loaded.
#
# Аргумент: режим — "full" (по умолчанию: модуль + старт awg-quick) или
# "module-only" (только модуль, без старта сервиса).
#
# ВАЖНО: master рассчитан на post-reboot контексты (manage repair-module,
# manage add/remove после reboot, systemd unit на boot). Apt/dpkg хук код
# НЕ должен звать master — uname -r в Post-Invoke возвращает старое ядро,
# поэтому хук должен использовать отдельную обёртку, итерирующую target
# kernels через /lib/modules/*/build (Phase 3 helper).
#
# Окружение: AWG_ALLOW_APT_IN_ENSURE=1 разрешает шаг установки kernel headers
# через apt-get install (опасно в hook context — deadlock на dpkg lock).
# Не установлено → шаг с headers пропускается с warn (предполагается что
# headers уже на диске через мета-пакет linux-headers-$(arch)).
#
# При необходимости запускает 5-шаговое восстановление:
# headers → sanitize → dkms autoinstall → depmod → modprobe.
#
# Возвращает:
# 0 — модуль успешно загружен (и в "full" режиме awg-quick активен).
# 1 — финальный modprobe провалился, либо невалидный режим
# (с печатью 4-шагового manual recovery).
ensure_amneziawg_kernel_module() {
local mode="${1:-full}"
case "$mode" in
full|module-only) ;;
*)
log_error "ensure_amneziawg_kernel_module: невалидный режим '$mode' (ожидается 'full' или 'module-only')."
return 1
;;
esac
local kernel_ver
kernel_ver="$(uname -r)"
# Fast-path: модуль уже загружен.
if lsmod 2>/dev/null | awk '{print $1}' | grep -qx 'amneziawg'; then
if [[ "$mode" == "full" ]]; then
_ensure_awg_quick_running awg0 || \
log_warn "Модуль активен, но awg-quick@awg0 не стартовал (модуль OK, это сервис-проблема)."
fi
return 0
fi
# Модуль на диске для running kernel — пробуем modprobe до full repair.
if find "/lib/modules/${kernel_ver}" -name 'amneziawg.ko*' -print -quit 2>/dev/null | grep -q .; then
if modprobe amneziawg 2>/dev/null && \
lsmod 2>/dev/null | awk '{print $1}' | grep -qx 'amneziawg'; then
log "amneziawg-модуль найден на диске и успешно загружен."
if [[ "$mode" == "full" ]]; then
_ensure_awg_quick_running awg0 || \
log_warn "Модуль загружен, но awg-quick@awg0 не стартовал (модуль OK, это сервис-проблема)."
fi
return 0
fi
fi
log_warn "amneziawg-модуль не загружен и не собран для ядра ${kernel_ver}."
log_warn "Запускаю автоматическое восстановление..."
# Step 1: kernel headers — только если apt разрешён вызвавшим контекстом.
if [[ "${AWG_ALLOW_APT_IN_ENSURE:-0}" == "1" ]]; then
case "${OS_ID:-}" in
ubuntu|debian)
local headers_pkg="linux-headers-${kernel_ver}"
if ! dpkg-query -W -f='${Status}' "$headers_pkg" 2>/dev/null | grep -q 'install ok installed'; then
log "Kernel headers ($headers_pkg) не установлены. Устанавливаю..."
_install_kernel_headers "$kernel_ver" || \
log_warn "Не удалось установить kernel headers. Сборка DKMS-модуля может провалиться."
fi
;;
esac
elif [[ ! -d "/lib/modules/${kernel_ver}/build" ]]; then
log_warn "/lib/modules/${kernel_ver}/build отсутствует, headers не установлены."
log_warn "Apt-установка пропущена (контекст не разрешает apt). Сборка DKMS-модуля скорее всего провалится."
fi
# Step 2: убрать deprecated REMAKE_INITRD из dkms.conf
_sanitize_awg_dkms_conf
# Step 3: dkms autoinstall для running kernel.
# Если шаг ошибётся, всё равно пробуем modprobe ниже — он окончательный indicator.
if command -v dkms >/dev/null 2>&1; then
log "Запуск: dkms autoinstall -k ${kernel_ver}"
if ! dkms autoinstall -k "${kernel_ver}" >/dev/null 2>&1; then
log_warn "dkms autoinstall завершился с ошибкой для ядра ${kernel_ver}."
local dkms_log
dkms_log=$(find /var/lib/dkms/amneziawg -name 'make.log' -path "*${kernel_ver}*" 2>/dev/null | head -n 1)
if [[ -n "$dkms_log" ]]; then
log_warn "Последние 20 строк лога сборки DKMS (${dkms_log}):"
tail -20 "$dkms_log" | while IFS= read -r line; do log_warn " $line"; done
else
log_warn "Лог сборки не найден. Подробности в /var/lib/dkms/amneziawg/."
fi
fi
else
log_warn "Пакет dkms не установлен. Пересборка модуля ядра невозможна."
fi
# Step 4: обновить module dependency cache для конкретного ядра.
if command -v depmod >/dev/null 2>&1; then
depmod -a "$kernel_ver" 2>/dev/null || \
log_warn "depmod -a $kernel_ver завершился с ошибкой; modprobe ниже даст финальный диагноз."
fi
# Step 5: финальная попытка modprobe.
if ! modprobe amneziawg 2>/dev/null; then
log_error "Модуль ядра amneziawg не удалось загрузить для ядра ${kernel_ver}."
log_error "Модуль отсутствует в /lib/modules/${kernel_ver}/."
log_error "Ручное восстановление:"
log_error " 1. apt install -y \"linux-headers-${kernel_ver}\""
log_error " 2. dkms autoinstall -k \"${kernel_ver}\" && depmod -a"
log_error " 3. modprobe amneziawg"
log_error " 4. systemctl start \"awg-quick@awg0\""
return 1
fi
log "Модуль amneziawg успешно загружен для ядра ${kernel_ver}."
if [[ "$mode" == "full" ]]; then
_ensure_awg_quick_running awg0 || \
log_warn "Модуль загружен, но awg-quick@awg0 не стартовал (модуль OK, это сервис-проблема)."
fi
return 0
}
# ==============================================================================
# Загрузка / сохранение параметров
# ==============================================================================
# Безопасная загрузка конфигурации (whitelist-парсер, без source/eval)
# Парсит только разрешённые ключи формата KEY=VALUE или export KEY=VALUE
safe_load_config() {
local config_file="${1:-$CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then return 1; fi
local line key value first_line=1
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$first_line" -eq 1 ]]; then
line="${line#$'\xEF\xBB\xBF'}"
first_line=0
fi
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// /}" ]] && continue
line="${line#export }"
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
if [[ "$value" == \'*\' ]]; then
value="${value#\'}"
value="${value%\'}"
elif [[ "$value" == \"*\" ]]; then
value="${value#\"}"
value="${value%\"}"
fi
case "$key" in
OS_ID|OS_VERSION|OS_CODENAME|AWG_PORT|AWG_TUNNEL_SUBNET|\
DISABLE_IPV6|ALLOWED_IPS_MODE|ALLOWED_IPS|AWG_ENDPOINT|AWG_MTU|\
AWG_Jc|AWG_Jmin|AWG_Jmax|AWG_S1|AWG_S2|AWG_S3|AWG_S4|\
AWG_H1|AWG_H2|AWG_H3|AWG_H4|AWG_I1|AWG_I2|AWG_I3|AWG_I4|AWG_I5|AWG_PRESET|NO_TWEAKS|\
AWG_APPLY_MODE|ALLOW_IPV6_TUNNEL|IPV6_SUBNET|SERVER_HAS_NATIVE_IPV6)
export "$key=$value"
;;
esac
fi
done < "$config_file"
}
# Парсер живого серверного конфига AmneziaWG (источник истины для AWG_*).
# Читает секцию [Interface] из awg0.conf и экспортирует AWG_* переменные
# АТОМАРНО: либо все 11 обязательных параметров (Jc/Jmin/Jmax/S1-S4/H1-H4)
# найдены и экспортированы, либо ничего не меняется в окружении и возврат 1.
# Это защищает от mixed-state при частично corrupt awg0.conf.
# I1-I5, ListenPort - опциональные, экспортируются если нашлись.
# Решает баг #38: regen использовал устаревшие значения из init-файла,
# а не актуальные из awg0.conf после ручной правки.
# shellcheck disable=SC2120 # Опциональный аргумент используется только в тестах
load_awg_params_from_server_conf() {
local conf="${1:-$SERVER_CONF_FILE}"
[[ -f "$conf" ]] || return 1
# Локальное накопление — экспортируем всё-или-ничего в конце
local _Jc="" _Jmin="" _Jmax=""
local _S1="" _S2="" _S3="" _S4=""
local _H1="" _H2="" _H3="" _H4=""
local _I1="" _I2="" _I3="" _I4="" _I5="" _Port="" _MTU=""
local in_iface=0 line key value
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^\[Interface\] ]]; then in_iface=1; continue; fi
if [[ "$line" =~ ^\[ ]]; then in_iface=0; continue; fi
(( in_iface )) || continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// /}" ]] && continue
if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
# Trim trailing whitespace
value="${value%"${value##*[![:space:]]}"}"
case "$key" in
Jc) _Jc="$value" ;;
Jmin) _Jmin="$value" ;;
Jmax) _Jmax="$value" ;;
S1) _S1="$value" ;;
S2) _S2="$value" ;;
S3) _S3="$value" ;;
S4) _S4="$value" ;;
H1) _H1="$value" ;;
H2) _H2="$value" ;;
H3) _H3="$value" ;;
H4) _H4="$value" ;;
I1) _I1="$value" ;;
I2) _I2="$value" ;;
I3) _I3="$value" ;;
I4) _I4="$value" ;;
I5) _I5="$value" ;;
ListenPort) _Port="$value" ;;
MTU) _MTU="$value" ;;
esac
fi
done < "$conf"
# Atomic check: все 11 обязательных полей найдены?
[[ -n "$_Jc" && -n "$_Jmin" && -n "$_Jmax" && \
-n "$_S1" && -n "$_S2" && -n "$_S3" && -n "$_S4" && \
-n "$_H1" && -n "$_H2" && -n "$_H3" && -n "$_H4" ]] || return 1
# Atomic export — окружение модифицируется только при полном успехе
export AWG_Jc="$_Jc" AWG_Jmin="$_Jmin" AWG_Jmax="$_Jmax"
export AWG_S1="$_S1" AWG_S2="$_S2" AWG_S3="$_S3" AWG_S4="$_S4"
export AWG_H1="$_H1" AWG_H2="$_H2" AWG_H3="$_H3" AWG_H4="$_H4"
[[ -n "$_I1" ]] && export AWG_I1="$_I1"
[[ -n "$_I2" ]] && export AWG_I2="$_I2"
[[ -n "$_I3" ]] && export AWG_I3="$_I3"
[[ -n "$_I4" ]] && export AWG_I4="$_I4"
[[ -n "$_I5" ]] && export AWG_I5="$_I5"
[[ -n "$_Port" ]] && export AWG_PORT="$_Port"
if _validate_mtu "${_MTU:-}"; then
export AWG_MTU="$_MTU"
fi
return 0
}
# Загрузка AWG параметров.
#
# Семантика источников (важно для предотвращения split-brain между сервером
# и клиентскими конфигами, см. #38):
#
# * init-файл ($CONFIG_FILE = awgsetup_cfg.init) — для НЕ-AWG настроек
# (OS_ID, ALLOWED_IPS, AWG_PORT, AWG_ENDPOINT и т.п.). Загружается всегда
# если существует.
# * Live server config ($SERVER_CONF_FILE = /etc/amnezia/amneziawg/awg0.conf)
# — ЕДИНСТВЕННЫЙ источник истины для AWG протокольных параметров
# (Jc/Jmin/Jmax/S1-S4/H1-H4/I1-I5) когда файл существует.
#
# Если live server config существует но НЕ содержит полного набора AWG
# параметров (повреждение / неполная ручная правка) — функция возвращает 1
# с явной ошибкой. Молчаливый fallback на устаревшие значения из init-файла
# создал бы split-brain: сервер живёт по новому awg0.conf, а regen выпускал
# бы клиентам старые J*/S*/H*. Это именно тот класс проблем, который
# elvaleto и Klavishnik сообщили в Discussion #38.
#
# Init-файл используется для AWG параметров ТОЛЬКО когда live server config
# вообще отсутствует — это путь bootstrap первой установки, когда awg0.conf
# ещё не записан, а generate_awg_params уже сохранил значения в init.
load_awg_params() {
# 1. Базовые настройки из init (всегда, для не-AWG ключей)
if [[ -f "$CONFIG_FILE" ]]; then
safe_load_config "$CONFIG_FILE" || log_warn "Не удалось загрузить $CONFIG_FILE"
fi
# 2. AWG протокольные параметры
# Если CLI задал --preset/--jc/--jmin/--jmax, параметры уже set через generate_awg_params.
# Пропускаем перезагрузку из awg0.conf чтобы не перезатереть свежие значения.
if [[ -n "${CLI_PRESET:-}" || -n "${CLI_JC:-}" || -n "${CLI_JMIN:-}" || -n "${CLI_JMAX:-}" ]]; then
log_debug "CLI overrides заданы — AWG params из generate_awg_params, не из $SERVER_CONF_FILE"
elif [[ -f "$SERVER_CONF_FILE" ]]; then
# Live config существует — он единственный источник истины.
# Никакого fallback на init: иначе получим split-brain.
# Unset I1-I5 перед парсингом: они опциональны, если их нет в live conf -
# не должны утечь stale из init-файла.
unset AWG_I1 AWG_I2 AWG_I3 AWG_I4 AWG_I5
if ! load_awg_params_from_server_conf; then
log_error "В $SERVER_CONF_FILE отсутствуют обязательные AWG-параметры"
log_error "(Jc/Jmin/Jmax/S1-S4/H1-H4). Не использую устаревшие значения"
log_error "из $CONFIG_FILE, чтобы не создавать split-brain между сервером"
log_error "и клиентскими конфигами. Восстановите [Interface] секцию в"
log_error "$SERVER_CONF_FILE или восстановите awg0.conf из бэкапа."
return 1
fi
log_debug "AWG параметры загружены из $SERVER_CONF_FILE (live config)"
else
# Bootstrap: server config ещё не существует (первая установка).
# AWG_* должны быть в env через safe_load_config выше.
log_debug "$SERVER_CONF_FILE не существует — использую AWG params из $CONFIG_FILE (bootstrap)"
fi
# 3. Проверка обязательных AWG 2.0 параметров
local missing=0
local param
for param in AWG_Jc AWG_Jmin AWG_Jmax AWG_S1 AWG_S2 AWG_S3 AWG_S4 AWG_H1 AWG_H2 AWG_H3 AWG_H4; do
if [[ -z "${!param:-}" ]]; then
log_error "Параметр $param не найден"
missing=1
fi
done
if [[ $missing -eq 1 ]]; then
return 1
fi
return 0
}
# ==============================================================================
# Генерация ключей
# ==============================================================================
# Генерация пары ключей (приватный + публичный)
# generate_keypair <name>
# Результат: keys/<name>.private, keys/<name>.public
generate_keypair() {
local name="$1"
if [[ -z "$name" ]]; then
log_error "generate_keypair: не указано имя"
return 1
fi
mkdir -p "$KEYS_DIR" || {
log_error "Ошибка создания $KEYS_DIR"
return 1
}
# 700 сразу при создании: mkdir -p с дефолтным umask дал бы 755, и до
# secure_files инсталлера каталог ключей был бы доступен на чтение всем.
chmod 700 "$KEYS_DIR"
local privkey pubkey
privkey=$(awg genkey) || {
log_error "Ошибка генерации приватного ключа для '$name'"
return 1
}
pubkey=$(echo "$privkey" | awg pubkey) || {
log_error "Ошибка генерации публичного ключа для '$name'"
return 1
}
# umask 077 в subshell: файл рождается сразу 600, без окна world-readable
# между записью и chmod (при дефолтном umask 022 ключ был бы 644 на миг).
( umask 077; echo "$privkey" > "$KEYS_DIR/${name}.private" ) || {
log_error "Ошибка записи приватного ключа для '$name'"
return 1
}
( umask 077; echo "$pubkey" > "$KEYS_DIR/${name}.public" ) || {
log_error "Ошибка записи публичного ключа для '$name'"
return 1
}
chmod 600 "$KEYS_DIR/${name}.private" "$KEYS_DIR/${name}.public" || {
log_error "Ошибка установки прав на ключи '$name'"
return 1
}
log_debug "Ключи для '$name' сгенерированы."
return 0
}
# Генерация серверных ключей
# Результат: server_private.key, server_public.key в AWG_DIR
generate_server_keys() {
local privkey pubkey
privkey=$(awg genkey) || {
log_error "Ошибка генерации приватного ключа сервера"
return 1
}
pubkey=$(echo "$privkey" | awg pubkey) || {
log_error "Ошибка генерации публичного ключа сервера"
return 1
}
# umask 077: без окна world-readable между записью и chmod (см. generate_keypair).
( umask 077; echo "$privkey" > "$AWG_DIR/server_private.key" ) || return 1
( umask 077; echo "$pubkey" > "$AWG_DIR/server_public.key" ) || return 1
chmod 600 "$AWG_DIR/server_private.key" "$AWG_DIR/server_public.key" || {
log_error "Ошибка установки прав на серверные ключи"
return 1
}
log "Серверные ключи сгенерированы."
return 0
}
# Гарантирует наличие $AWG_DIR/server_public.key.
# Если файла нет — пытается восстановить его из PrivateKey в awg0.conf
# (полезно для ручных установок вне нашего installer, где кеш серверного
# pubkey не создаётся на шаге 6). Возвращает 0 если ключ уже есть или
# успешно восстановлен, 1 если ни того ни другого.
_ensure_server_public_key() {
[[ -f "$AWG_DIR/server_public.key" ]] && return 0
[[ -f "$SERVER_CONF_FILE" ]] || {
log_error "Не могу восстановить server_public.key — отсутствует $SERVER_CONF_FILE"
return 1
}
local _srv_priv
_srv_priv=$(awk '
/^\[Interface\]/ {in_iface=1; next}
in_iface && /^[ \t]*PrivateKey[ \t]*=/ {
sub(/^[ \t]*PrivateKey[ \t]*=[ \t]*/, "")
gsub(/[[:space:]]/, "")
print
exit
}
/^\[/ && !/^\[Interface\]/ {in_iface=0}
' "$SERVER_CONF_FILE")
if [[ -z "$_srv_priv" ]]; then
log_error "Не найден PrivateKey в $SERVER_CONF_FILE — восстановить server_public.key невозможно"
return 1
fi
mkdir -p "$AWG_DIR"
local _tmp
_tmp=$(awg_mktemp "$AWG_DIR") || return 1
if ! echo "$_srv_priv" | awg pubkey > "$_tmp"; then
rm -f "$_tmp"
log_error "Не удалось вычислить публичный ключ через awg pubkey"
return 1
fi
if ! mv -f "$_tmp" "$AWG_DIR/server_public.key"; then
rm -f "$_tmp"
log_error "Ошибка перемещения в $AWG_DIR/server_public.key"
return 1
fi
chmod 600 "$AWG_DIR/server_public.key" 2>/dev/null || true
log "server_public.key восстановлен из awg0.conf PrivateKey."
return 0
}
# ==============================================================================
# Рендеринг конфигураций
# ==============================================================================
# Вычисление IPv6-адреса сервера (хост ::1) из туннельной подсети.
# Вход: PREFIX::/MASK (например fddd:2c4:2c4:2c4::/64).
# Выход: PREFIX::1/MASK (например fddd:2c4:2c4:2c4::1/64).
# Допущение: подсеть всегда оканчивается на ::/MASK (так формирует install-скрипт).
# Если завершающего ::/ нет - возвращаю вход без изменений (defensive fallback).
_derive_ipv6_server_addr() {
local subnet="$1"
if [[ "$subnet" == *"::/"* ]]; then
echo "${subnet/::\//::1\/}"
else
echo "$subnet"
fi
}
# Рендер серверного конфига AWG 2.0
# render_server_config [peers_source_file]
# Использует глобальные переменные из load_awg_params()
# peers_source_file (необязательный): файл, чьи [Peer]-блоки переносятся в
# новый конфиг ДО атомарного mv (обычно бэкап живого awg0.conf). Благодаря
# этому живой конфиг ни на мгновение не остаётся без пиров - сбой между
# render и отдельным append оставлял бы безпировый файл, а повторный запуск
# шага 6 уже бэкапил бы его (потеря всех пиров при --force reinstall).
# shellcheck disable=SC2154 # AWG_* vars loaded via load_awg_params -> source
render_server_config() {
local peers_source="${1:-}"
load_awg_params || return 1
# Порт для НОВОГО awg0.conf берём из init-файла (намерение пользователя:
# флаг --port или сохранённый прежний порт), а НЕ из перезаписываемого
# старого awg0.conf. load_awg_params перечитывает ListenPort из живого
# конфига, поэтому без этого --port при --force молча игнорировался бы.
# render_server_config вызывается только из install, regen клиентов
# (regenerate_client) идёт своим путём и не затрагивается.
local _init_port
_init_port=$(grep -oP '^\s*export AWG_PORT=\K[0-9]+' "$CONFIG_FILE" 2>/dev/null | head -n1)
[[ -n "$_init_port" ]] && AWG_PORT="$_init_port"
local server_privkey
if [[ -f "$AWG_DIR/server_private.key" ]]; then
server_privkey=$(cat "$AWG_DIR/server_private.key")
else
log_error "Приватный ключ сервера не найден: $AWG_DIR/server_private.key"
return 1
fi
local nic
nic=$(get_main_nic)
if [[ -z "$nic" ]]; then
log_error "Не удалось определить сетевой интерфейс."
return 1
fi
local server_ip subnet_mask
server_ip=$(echo "$AWG_TUNNEL_SUBNET" | cut -d'/' -f1)
subnet_mask=$(echo "$AWG_TUNNEL_SUBNET" | cut -d'/' -f2)
# Адрес [Interface]: IPv4 всегда, IPv6 только при включённом туннеле.
# Сервер берёт хост ::1 в туннельной IPv6-подсети.
# IPV6_SUBNET имеет форму PREFIX::/MASK (по умолчанию fddd:2c4:2c4:2c4::/64),
# поэтому адрес сервера получаю заменой завершающего ::/MASK на ::1/MASK.
local address_line="${server_ip}/${subnet_mask}"
if [[ "${ALLOW_IPV6_TUNNEL:-0}" -eq 1 ]]; then
local ipv6_subnet="${IPV6_SUBNET:-fddd:2c4:2c4:2c4::/64}"
local ipv6_server_addr
ipv6_server_addr=$(_derive_ipv6_server_addr "$ipv6_subnet")
address_line="${address_line}, ${ipv6_server_addr}"
fi
local conf_dir
conf_dir=$(dirname "$SERVER_CONF_FILE")
mkdir -p "$conf_dir" || {
log_error "Ошибка создания $conf_dir"
return 1
}
# PostUp/PostDown правила для маршрутизации
local postup="iptables -I FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ${nic} -j MASQUERADE"
local postdown="iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ${nic} -j MASQUERADE"
# MSS/PMTU-clamp: фиксируем TCP MSS под туннельный MTU, чтобы крупные сегменты
# не упирались в 1280-туннель при фильтрованном ICMP "frag needed" (PMTUD-блэкхол:
# VPN подключается, но крупные страницы/закачки виснут на мобильных/double-NAT/
# каскадных путях). Фикс из AWG_MTU детерминирован при жёстко заданном MTU и
# авто-синхронен с ним; clamp-to-pmtu зависел бы от egress-маршрута. Би-directional
# (-o %i и -i %i) кэпит MSS в обе стороны. IPv4: MTU-40, IPv6: MTU-60. Только SYN,
# таблица mangle (отдельная от UFW/filter). Стиль -A/-D зеркалит MASQUERADE выше.
local awg_mtu="${AWG_MTU:-1280}"
local mss4=$(( awg_mtu - 40 ))
local mss6=$(( awg_mtu - 60 ))
postup="${postup}; iptables -t mangle -A FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss4}; iptables -t mangle -A FORWARD -i %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss4}"
postdown="${postdown}; iptables -t mangle -D FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss4}; iptables -t mangle -D FORWARD -i %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss4}"
# IPv6 правила: при включённом IPv6-туннеле (FORWARD внутри туннеля + MASQUERADE
# на публичный интерфейс). MASQUERADE безвреден если у VPS нет native IPv6 -
# это no-op, пока нет IPv6 default route, зато peer-to-peer внутри туннеля работает.
# Использую тот же nic, что и IPv4 MASQUERADE (не хардкожу интерфейс).
# Условие DISABLE_IPV6=0 сохранено для байт-в-байт совместимости с v5.14.x:
# установка с --allow-ipv6 (без туннеля) получает те же IPv6-правила фильтра, что и раньше.
if [[ "${ALLOW_IPV6_TUNNEL:-0}" -eq 1 || "${DISABLE_IPV6:-1}" -eq 0 ]]; then
postup="${postup}; ip6tables -I FORWARD -i %i -j ACCEPT; ip6tables -t nat -A POSTROUTING -o ${nic} -j MASQUERADE; ip6tables -t mangle -A FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss6}; ip6tables -t mangle -A FORWARD -i %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss6}"
postdown="${postdown}; ip6tables -D FORWARD -i %i -j ACCEPT; ip6tables -t nat -D POSTROUTING -o ${nic} -j MASQUERADE; ip6tables -t mangle -D FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss6}; ip6tables -t mangle -D FORWARD -i %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss ${mss6}"
fi
# Формируем конфиг через временный файл (атомарная запись).
# temp создаём в каталоге итогового конфига, чтобы mv был атомарным rename
# на той же ФС (а не cross-fs copy+unlink, если /tmp = tmpfs).
local tmpfile
tmpfile=$(awg_mktemp "$(dirname "$SERVER_CONF_FILE")") || { log_error "Ошибка mktemp"; return 1; }
cat > "$tmpfile" << EOF
[Interface]
PrivateKey = ${server_privkey}
Address = ${address_line}
MTU = ${AWG_MTU:-1280}
ListenPort = ${AWG_PORT}
PostUp = ${postup}
PostDown = ${postdown}
Jc = ${AWG_Jc}
Jmin = ${AWG_Jmin}
Jmax = ${AWG_Jmax}
S1 = ${AWG_S1}
S2 = ${AWG_S2}
S3 = ${AWG_S3}
S4 = ${AWG_S4}
H1 = ${AWG_H1}
H2 = ${AWG_H2}
H3 = ${AWG_H3}
H4 = ${AWG_H4}
EOF
# Добавляем I1-I5 только если заданы (CPS-параметры опциональны).
# I2-I5 задаются админом вручную в awg0.conf (issue #71), переносятся как есть.
[[ -n "${AWG_I1:-}" ]] && echo "I1 = ${AWG_I1}" >> "$tmpfile"
[[ -n "${AWG_I2:-}" ]] && echo "I2 = ${AWG_I2}" >> "$tmpfile"
[[ -n "${AWG_I3:-}" ]] && echo "I3 = ${AWG_I3}" >> "$tmpfile"
[[ -n "${AWG_I4:-}" ]] && echo "I4 = ${AWG_I4}" >> "$tmpfile"
[[ -n "${AWG_I5:-}" ]] && echo "I5 = ${AWG_I5}" >> "$tmpfile"
# Перенос [Peer]-блоков из peers_source в temp ДО mv (см. док-комментарий).
# Буфер сбрасывается на каждом новом [Peer]: переносятся ВСЕ блоки.
if [[ -n "$peers_source" && -f "$peers_source" ]]; then
local _peers
_peers=$(awk '
/^\[Peer\]/ { if (in_peer) printf "%s", buf; buf=$0"\n"; in_peer=1; next }
in_peer && /^\[/ { printf "%s", buf; buf=""; in_peer=0; next }
in_peer { buf=buf $0"\n"; next }
END { if (in_peer) printf "%s", buf }
' "$peers_source")
if [[ -n "$_peers" ]]; then
printf '\n%s' "$_peers" >> "$tmpfile" || {
rm -f "$tmpfile"
log_error "Ошибка переноса [Peer]-блоков в новый конфиг"
return 1
}
fi
fi
if ! mv "$tmpfile" "$SERVER_CONF_FILE"; then
rm -f "$tmpfile"
log_error "Ошибка записи серверного конфига"
return 1
fi
chmod 600 "$SERVER_CONF_FILE"
log "Серверный конфиг создан: $SERVER_CONF_FILE"
return 0
}
# Допустимый диапазон MTU для AWG / WireGuard.
# Минимум 576 (классический минимум IPv4), максимум 9100 (verge на jumbo frame).
# Значения вне диапазона трактуются как ошибочные и игнорируются (fallback к 1280).
_validate_mtu() {
local v="$1"
[[ "$v" =~ ^[0-9]+$ ]] || return 1
(( v >= 576 && v <= 9100 )) || return 1
return 0
}
# Извлечение MTU из секции [Interface] серверного awg0.conf (если файл существует).
# Печатает целое число в stdout, либо ничего если MTU не найден / файл недоступен.
# Last-wins: если в [Interface] несколько строк MTU = ..., возвращается последняя
# (так же как awg-quick применяет последнее присвоение).
# Используется render_client_config для синхронизации MTU клиента с сервером
# (баг v5.14.0: ручная правка MTU в awg0.conf не подхватывалась regen-ом).
_extract_mtu_from_server_conf() {
local conf="${SERVER_CONF_FILE:-/etc/amnezia/amneziawg/awg0.conf}"
[[ -r "$conf" ]] || return 1
local val
val=$(awk '
/^\[Interface\]/ {in_iface=1; next}
/^\[/ {in_iface=0}
in_iface && /^[[:space:]]*MTU[[:space:]]*=/ {
gsub(/^[[:space:]]*MTU[[:space:]]*=[[:space:]]*/, "")
gsub(/[[:space:]].*$/, "")
if ($0 ~ /^[0-9]+$/) { mtu=$0 }
}
END { if (mtu != "") print mtu }
' "$conf")
_validate_mtu "$val" || return 1
echo "$val"
}
# Рендер клиентского конфига AWG 2.0
# render_client_config <name> <client_ip> <client_privkey> <server_pubkey> <endpoint> <port> [client_ipv6]
#
# client_ipv6 (необязательный, 7-й аргумент): IPv6-адрес клиента без префикса
# длины (например fddd:2c4:2c4:2c4::5). Если непустой и ALLOW_IPV6_TUNNEL=1:
# - Address = <ipv4>/32, <ipv6>/128
# - AllowedIPs (зеркалю IPv4 routing mode в IPv6, intent-mirroring):
# full tunnel (ALLOWED_IPS=0.0.0.0/0): + ::/0 (native) или + <IPV6_SUBNET> (no-native)
# split tunnel (кастомный ALLOWED_IPS): IPv4-список БЕЗ изменений + ТОЛЬКО <IPV6_SUBNET>,
# НИКОГДА ::/0 - нет IPv6 split-list, нельзя угонять весь IPv6 (ломает split-tunnel).
# Если пустой (legacy-клиент): Address = <ipv4>/32, AllowedIPs без изменений.
render_client_config() {
local name="$1"
local client_ip="$2"
local client_privkey="$3"
local server_pubkey="$4"
local endpoint="$5"
local port="$6"
local client_ipv6="${7:-}"
load_awg_params || return 1
local conf_file="$AWG_DIR/${name}.conf"
local allowed_ips
if [[ -n "$client_ipv6" ]]; then
# Dual-stack: зеркалю IPv4 routing intent в IPv6.
# full tunnel (IPv4=0.0.0.0/0) -> ::/0 (native) или tunnel-ULA (no-native).
# split tunnel (кастомный ALLOWED_IPS) -> IPv4-split AS-IS + ТОЛЬКО tunnel-ULA,
# никогда ::/0 (нет IPv6 split-list, нельзя угонять весь IPv6).
local ipv4_part ipv6_part
ipv4_part="${ALLOWED_IPS:-0.0.0.0/0}"
if [[ "$ipv4_part" == "0.0.0.0/0" && "${SERVER_HAS_NATIVE_IPV6:-0}" == "1" ]]; then
ipv6_part="::/0"
else
ipv6_part="${IPV6_SUBNET:-fddd:2c4:2c4:2c4::/64}"
fi
# Защитный de-dup: ALLOWED_IPS по конструкции IPv4-only, но не дублирую
# ipv6_part если он уже присутствует токеном в списке.
case ",${ipv4_part// /}," in
*",${ipv6_part},"*) allowed_ips="$ipv4_part" ;;
*) allowed_ips="${ipv4_part}, ${ipv6_part}" ;;
esac
else
allowed_ips="${ALLOWED_IPS:-0.0.0.0/0}"
# iOS AmneziaVPN в режиме "весь трафик" требует обе семьи адресов: при
# голом 0.0.0.0/0 он считает это незавершённой раздельной маршрутизацией
# и не поднимает туннель. Для full-tunnel добавляем ::/0 - IPv6 уходит в
# туннель (и отсекается, если у сервера нет нативного IPv6), наружу мимо
# VPN не утекает. Затрагивает только mode-1: split-режим = кастомный
# список, не равен 0.0.0.0/0 и под условие не попадает.
if [[ "$allowed_ips" == "0.0.0.0/0" ]]; then
allowed_ips="0.0.0.0/0, ::/0"
fi
fi
# MTU: приоритет server awg0.conf > AWG_MTU из awgsetup_cfg.init > 1280 fallback.
# Server config - источник правды для уже работающего сервера: пользователь
# мог поправить MTU в /etc/amnezia/amneziawg/awg0.conf руками, и regen должен
# это подхватить (MyAI-sdge, Discussion #38). Невалидные значения (вне 576-9100)
# на любом этапе откатываются к 1280.
local mtu
mtu=$(_extract_mtu_from_server_conf) || mtu=""
if [[ -z "$mtu" ]]; then
if _validate_mtu "${AWG_MTU:-}"; then
mtu="$AWG_MTU"
else
mtu=1280
fi
fi
# temp в каталоге клиентского конфига ($AWG_DIR) -> mv = атомарный rename.
local tmpfile
tmpfile=$(awg_mktemp "$AWG_DIR") || { log_error "Ошибка mktemp"; return 1; }
local address_line
if [[ -n "$client_ipv6" ]]; then
address_line="${client_ip}/32, ${client_ipv6}/128"
else
address_line="${client_ip}/32"
fi
cat > "$tmpfile" << EOF
[Interface]
PrivateKey = ${client_privkey}
Address = ${address_line}
DNS = 1.1.1.1, 1.0.0.1
MTU = ${mtu}
Jc = ${AWG_Jc}
Jmin = ${AWG_Jmin}
Jmax = ${AWG_Jmax}
S1 = ${AWG_S1}
S2 = ${AWG_S2}
S3 = ${AWG_S3}
S4 = ${AWG_S4}
H1 = ${AWG_H1}
H2 = ${AWG_H2}
H3 = ${AWG_H3}
H4 = ${AWG_H4}
EOF
# I1-I5: переносим заданные CPS-параметры в клиентский конфиг (issue #71).
# Значения обязаны совпадать с серверными - regen разносит их по клиентам.
[[ -n "${AWG_I1:-}" ]] && echo "I1 = ${AWG_I1}" >> "$tmpfile"
[[ -n "${AWG_I2:-}" ]] && echo "I2 = ${AWG_I2}" >> "$tmpfile"
[[ -n "${AWG_I3:-}" ]] && echo "I3 = ${AWG_I3}" >> "$tmpfile"
[[ -n "${AWG_I4:-}" ]] && echo "I4 = ${AWG_I4}" >> "$tmpfile"
[[ -n "${AWG_I5:-}" ]] && echo "I5 = ${AWG_I5}" >> "$tmpfile"
cat >> "$tmpfile" << EOF
[Peer]
PublicKey = ${server_pubkey}
EOF
# PresharedKey — опциональный дополнительный слой поверх AWG 2.0
# обфускации (включается через `manage add --psk`). Должен совпадать
# в server peer и client [Peer].
if [[ -n "${CLIENT_PSK:-}" ]]; then
echo "PresharedKey = ${CLIENT_PSK}" >> "$tmpfile"
fi
cat >> "$tmpfile" << EOF
Endpoint = ${endpoint}:${port}
AllowedIPs = ${allowed_ips}
PersistentKeepalive = 33
EOF
if ! mv "$tmpfile" "$conf_file"; then
rm -f "$tmpfile"
log_error "Ошибка записи конфига клиента '$name'"
return 1
fi
chmod 600 "$conf_file"
log_debug "Конфиг для '$name' создан: $conf_file"
return 0
}
# ==============================================================================
# Применение конфигурации (syncconf)
# ==============================================================================
# Применение изменений конфигурации
# AWG_SKIP_APPLY=1: пропустить apply (для batch-автоматизации)
# AWG_APPLY_MODE=syncconf|restart: режим применения (конфиг или --apply-mode CLI)
# flock на .awg_apply.lock: защита от параллельных вызовов
apply_config() {
# Пропуск apply (AWG_SKIP_APPLY=1 manage add/remove ...)
if [[ "${AWG_SKIP_APPLY:-0}" == "1" ]]; then
log_debug "apply_config пропущен (AWG_SKIP_APPLY=1)."
return 0
fi
# Межпроцессная блокировка apply_config
local apply_lockfile="${AWG_DIR}/.awg_apply.lock"
local apply_fd
exec {apply_fd}>"$apply_lockfile"
if ! flock -x -w 120 "$apply_fd"; then
log_warn "Не удалось получить блокировку apply_config."
exec {apply_fd}>&-
return 1
fi
local rc=0
if [[ "${AWG_APPLY_MODE:-syncconf}" == "restart" ]]; then
log "Перезапуск сервиса (apply-mode=restart)..."
systemctl restart awg-quick@awg0 2>/dev/null; rc=$?
[[ $rc -ne 0 ]] && log_warn "Ошибка перезапуска."
exec {apply_fd}>&-
return $rc
fi
local strip_out
strip_out=$(timeout 10 awg-quick strip awg0 2>/dev/null) || {
log_warn "awg-quick strip не удался или timeout, использую полный перезапуск."
systemctl restart awg-quick@awg0 2>/dev/null; rc=$?
[[ $rc -ne 0 ]] && log_warn "Ошибка перезапуска."
exec {apply_fd}>&-
return $rc
}
echo "$strip_out" | timeout 10 awg syncconf awg0 /dev/stdin 2>/dev/null || {
log_warn "awg syncconf не удался или timeout, использую полный перезапуск."
systemctl restart awg-quick@awg0 2>/dev/null; rc=$?
[[ $rc -ne 0 ]] && log_warn "Ошибка перезапуска."
exec {apply_fd}>&-
return $rc
}
log_debug "Конфигурация применена (syncconf)."
exec {apply_fd}>&-
return 0
}
# ==============================================================================
# Управление пирами
# ==============================================================================
# Получить следующий свободный IP в подсети
get_next_client_ip() {
local subnet_base
subnet_base=$(echo "${AWG_TUNNEL_SUBNET:-10.9.9.1/24}" | cut -d'/' -f1 | cut -d'.' -f1-3)
# Ассоциативный массив для O(1) lookup
declare -A used_set
used_set["${subnet_base}.1"]=1
if [[ -f "$SERVER_CONF_FILE" ]]; then
while IFS= read -r ip; do
used_set["$ip"]=1
done < <(grep -oP 'AllowedIPs\s*=\s*\K[0-9.]+' "$SERVER_CONF_FILE")
fi
local i candidate
for i in $(seq 2 254); do
candidate="${subnet_base}.${i}"
if [[ -z "${used_set[$candidate]+x}" ]]; then
echo "$candidate"
return 0
fi
done
log_error "Нет свободных IP в подсети ${subnet_base}.0/24"
return 1
}
# Получить IPv6-адрес клиента из его IPv4 (детерминировано по последнему октету).
# Используется только при ALLOW_IPV6_TUNNEL=1. Аллокация зеркальна IPv4:
# клиент 10.9.9.N получает fddd:2c4:2c4:2c4::N (тот же индекс N, /128).
# Аргумент: IPv4-адрес клиента, например 10.9.9.5 -> fddd:2c4:2c4:2c4::5
# Возвращает строку без префикса длины (только адрес).
#
# get_next_client_ipv6 <ipv4_addr>
get_next_client_ipv6() {
local ipv4="$1"
if [[ -z "$ipv4" ]]; then
log_error "get_next_client_ipv6: не передан IPv4-адрес"
return 1
fi
local n="${ipv4##*.}"
local subnet="${IPV6_SUBNET:-fddd:2c4:2c4:2c4::/64}"
local prefix="${subnet%%::*}"
[[ "$prefix" == *:* ]] || { log_error "get_next_client_ipv6: IPV6_SUBNET не содержит :: (значение: $subnet)"; return 1; }
echo "${prefix}::${n}"
return 0
}
# Добавление [Peer] в серверный конфиг (атомарно через tmpfile + mv).
#
# КОНТРАКТ БЛОКИРОВКИ: вызывающий код ОБЯЗАН держать exclusive flock на
# ${AWG_DIR}/.awg_config.lock когда вызывает эту функцию. Эту блокировку
# берёт generate_client() — единственный текущий caller. Не вызывать
# add_peer_to_server напрямую без удержания lock'а.
#
# Почему inner flock здесь невозможен: bash flock не re-entrant между
# разными file descriptors на тот же файл. generate_client() открывает
# .awg_config.lock на свой fd и держит exclusive lock, а попытка
# открыть тот же файл на новый fd внутри add_peer_to_server и взять
# на нём exclusive lock приводит к самоблокировке (родительский lock
# виден как чужой). Контракт-based locking — единственный надёжный
# вариант для bash в этой ситуации. Re-entrant поведение возможно
# только если sub-функция использует TOТ ЖЕ fd что родитель (через
# inheritance), но это требует передачи fd как аргумента.
#
# add_peer_to_server <name> <pubkey> <client_ip> [client_ipv6]
#
# client_ipv6 (необязательный, 4-й аргумент): IPv6-адрес без префикса длины.
# Если непустой: AllowedIPs = <ipv4>/32, <ipv6>/128
# Если пустой (legacy): AllowedIPs = <ipv4>/32
add_peer_to_server() {
local name="$1"
local pubkey="$2"
local client_ip="$3"
local client_ipv6="${4:-}"
if [[ -z "$name" || -z "$pubkey" || -z "$client_ip" ]]; then
log_error "add_peer_to_server: недостаточно аргументов"
return 1
fi
# Имя уходит в heredoc конфига (#_Name = ...): перевод строки в имени
# дал бы инъекцию секции [Peer]. Defense-in-depth, см. generate_client.
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "add_peer_to_server: невалидное имя клиента '$name'"
return 1
fi
if grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Пир '$name' уже существует в конфиге"
return 1
fi
# Добавляем пир через временный файл (атомарно).
# temp в каталоге серверного конфига -> mv = атомарный rename на той же ФС.
local tmpfile
tmpfile=$(awg_mktemp "$(dirname "$SERVER_CONF_FILE")") || { log_error "Ошибка mktemp"; return 1; }
cp "$SERVER_CONF_FILE" "$tmpfile" || {
rm -f "$tmpfile"
log_error "Ошибка копирования серверного конфига"
return 1
}
cat >> "$tmpfile" << EOF
[Peer]
#_Name = ${name}
PublicKey = ${pubkey}
EOF
# PresharedKey — опционально, пишется если передан через CLIENT_PSK env.
# Должен совпадать у server peer и client [Peer].
if [[ -n "${CLIENT_PSK:-}" ]]; then
echo "PresharedKey = ${CLIENT_PSK}" >> "$tmpfile"
fi
if [[ -n "$client_ipv6" ]]; then
echo "AllowedIPs = ${client_ip}/32, ${client_ipv6}/128" >> "$tmpfile"
else
echo "AllowedIPs = ${client_ip}/32" >> "$tmpfile"
fi
if ! mv "$tmpfile" "$SERVER_CONF_FILE"; then
rm -f "$tmpfile"
log_error "Ошибка обновления серверного конфига"
return 1
fi
chmod 600 "$SERVER_CONF_FILE"
log "Пир '$name' добавлен в серверный конфиг."
return 0
}
# Удаление [Peer] из серверного конфига по имени (с блокировкой)
# remove_peer_from_server <name>
remove_peer_from_server() {
local name="$1"
if [[ -z "$name" ]]; then
log_error "remove_peer_from_server: не указано имя"
return 1
fi
# Defense-in-depth: тот же контракт, что в add_peer_to_server.
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "remove_peer_from_server: невалидное имя клиента '$name'"
return 1
fi
# Межпроцессная блокировка
local lockfile="${AWG_DIR}/.awg_config.lock"
local lock_fd
exec {lock_fd}>"$lockfile"
if ! flock -x -w 10 "$lock_fd"; then
log_error "Не удалось получить блокировку конфига"
exec {lock_fd}>&-
return 1
fi
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Пир '$name' не найден в конфиге"
exec {lock_fd}>&-
return 1
fi
# temp в каталоге серверного конфига -> финальный mv = атомарный rename.
local tmpfile
tmpfile=$(awg_mktemp "$(dirname "$SERVER_CONF_FILE")") || { log_error "Ошибка mktemp"; exec {lock_fd}>&-; return 1; }
# Удаляем блок [Peer] содержащий #_Name = name
# Логика: буферизуем каждый [Peer] блок, проверяем имя, выводим только если не совпадает
awk -v target="$name" '
BEGIN { buf=""; is_target=0 }
/^\[Peer\]/ {
# Вывести предыдущий буфер если он не target
if (buf != "" && !is_target) printf "%s", buf
buf = $0 "\n"
is_target = 0
next
}
/^\[/ && !/^\[Peer\]/ {
# Любая другая секция — сбросить буфер
if (buf != "" && !is_target) printf "%s", buf
buf = ""
is_target = 0
print
next
}
{
if (buf != "") {
buf = buf $0 "\n"
if ($0 == "#_Name = " target) is_target = 1
} else {
print
}
}
END {
if (buf != "" && !is_target) printf "%s", buf
}
' "$SERVER_CONF_FILE" > "$tmpfile" || {
log_error "Ошибка фильтрации серверного конфига (awk)"
rm -f "$tmpfile"
exec {lock_fd}>&-
return 1
}
# Sanity-check ДО mv: при ENOSPC/I/O-сбое awk оставил бы пустой/обрезанный
# tmpfile, и атомарный mv заменил бы рабочий конфиг битым (потеря
# PrivateKey сервера и всех пиров). [Interface] обязан сохраниться.
if ! grep -q '^\[Interface\]' "$tmpfile"; then
log_error "Результат удаления пира выглядит битым ([Interface] отсутствует) - конфиг не изменён"
rm -f "$tmpfile"
exec {lock_fd}>&-
return 1
fi
# Нормализация: сжать множественные пустые строки в одну.
# tmpclean - на той же ФС, что и tmpfile (mv tmpclean->tmpfile атомарен).
local tmpclean
tmpclean=$(awg_mktemp "$(dirname "$SERVER_CONF_FILE")") || { log_error "Ошибка mktemp"; exec {lock_fd}>&-; return 1; }
if cat -s "$tmpfile" > "$tmpclean" 2>/dev/null; then
mv "$tmpclean" "$tmpfile"
else
rm -f "$tmpclean"
fi
if ! mv "$tmpfile" "$SERVER_CONF_FILE"; then
rm -f "$tmpfile"
log_error "Ошибка обновления серверного конфига"
exec {lock_fd}>&-
return 1
fi
chmod 600 "$SERVER_CONF_FILE"
exec {lock_fd}>&-
log "Пир '$name' удалён из серверного конфига."
return 0
}
# ==============================================================================
# Полный цикл работы с клиентом
# ==============================================================================
# Генерация QR-кода для клиента
# generate_qr <name>
generate_qr() {
local name="$1"
local conf_file="$AWG_DIR/${name}.conf"
local png_file="$AWG_DIR/${name}.png"
if [[ ! -f "$conf_file" ]]; then
log_error "Конфиг клиента '$name' не найден: $conf_file"
return 1
fi
if ! command -v qrencode &>/dev/null; then
log_warn "qrencode не установлен, QR-код не создан для '$name'."
return 1
fi
# C4: генерируем во временный файл и атомарно переносим (mv) - чтобы
# прерывание qrencode не оставило частичный/битый PNG поверх рабочего.
# awg_mktemp "$AWG_DIR" кладёт tmp в ту же папку (mv = атомарный rename на
# одной ФС) И регистрирует его в общем cleanup-реестре, поэтому SIGKILL
# между qrencode и mv не оставит осиротевший tmp.
local tmp_png
tmp_png=$(awg_mktemp "$AWG_DIR") || { log_error "Ошибка mktemp для QR '$name'"; return 1; }
if ! qrencode -t png -o "$tmp_png" < "$conf_file"; then
log_error "Ошибка генерации QR-кода для '$name'"
rm -f "$tmp_png"
return 1
fi
chmod 600 "$tmp_png" 2>/dev/null
if ! mv -f "$tmp_png" "$png_file"; then
log_error "Ошибка сохранения QR-кода для '$name'"
rm -f "$tmp_png"
return 1
fi
log_debug "QR-код для '$name' создан: $png_file"
return 0
}
# Генерация vpn:// URI для импорта в Amnezia Client
# generate_vpn_uri <name>
generate_vpn_uri() {
local name="$1"
local conf_file="$AWG_DIR/${name}.conf"
local uri_file="$AWG_DIR/${name}.vpnuri"
if [[ ! -f "$conf_file" ]]; then
log_error "Конфиг клиента '$name' не найден: $conf_file"
return 1
fi
if ! command -v perl &>/dev/null; then
log_warn "perl не найден, vpn:// URI не создан для '$name'."
return 1
fi
if ! perl -MCompress::Zlib -MMIME::Base64 -e '1' 2>/dev/null; then
log_warn "Perl модули Compress::Zlib/MIME::Base64 не найдены, vpn:// URI не создан."
return 1
fi
load_awg_params || return 1
# AWG_PORT - единственное НЕкавыченное числовое поле inner JSON ("port":N).
# Пустое/нечисловое значение дало бы "port":, - синтаксически битый JSON,
# который Amnezia Client молча не импортирует.
if ! [[ "${AWG_PORT:-}" =~ ^[0-9]+$ ]]; then
log_warn "AWG_PORT не определён или не число ('${AWG_PORT:-}') - vpn:// URI не создан для '$name'."
return 1
fi
local client_privkey client_ip client_ipv6 server_pubkey endpoint allowed_ips client_psk
client_privkey=$(grep -oP 'PrivateKey\s*=\s*\K\S+' "$conf_file") || return 1
# Извлекаем IPv4 из Address (первое поле до запятой, без /prefix).
# Regex останавливается на цифрах и точках - не захватывает IPv6 при dual-stack.
client_ip=$(awk '/^Address[[:space:]]*=/{
sub(/^Address[[:space:]]*=[[:space:]]*/, "")
sub(/\r$/, "")
n = split($0, parts, /[[:space:]]*,[[:space:]]*/)
sub(/\/[0-9]+$/, "", parts[1])
print parts[1]; exit
}' "$conf_file") || return 1
# Извлекаем IPv6 из Address (второе поле, если присутствует), без /prefix.
client_ipv6=$(awk '/^Address[[:space:]]*=/{
sub(/^Address[[:space:]]*=[[:space:]]*/, "")
sub(/\r$/, "")
n = split($0, parts, /[[:space:]]*,[[:space:]]*/)
if (n >= 2) {
sub(/\/[0-9]+$/, "", parts[2])
gsub(/[[:space:]]/, "", parts[2])
print parts[2]
}
exit
}' "$conf_file" 2>/dev/null)
client_ipv6="${client_ipv6:-}"
_ensure_server_public_key || return 1
server_pubkey=$(cat "$AWG_DIR/server_public.key" 2>/dev/null) || return 1
# PresharedKey — опциональный. awk вместо grep чтобы пустой результат
# не считался ошибкой (grep -P без match → rc=1, нам это здесь не нужно).
# Дополнительно срезаем CR (CRLF от Windows-редакторов) и хвостовые
# пробелы — иначе они улетят в JSON psk_key и сломают handshake так же,
# как полное отсутствие поля. Без psk_key в inner JSON AmneziaVPN импорт
# vpn:// теряет PSK и handshake падает (issue #67, fix v5.11.4).
client_psk=$(awk '/^[[:space:]]*PresharedKey[[:space:]]*=/{sub(/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*/, ""); sub(/\r$/, ""); sub(/[ \t]+$/, ""); print; exit}' "$conf_file" 2>/dev/null)
local raw_endpoint
raw_endpoint=$(grep -oP 'Endpoint\s*=\s*\K\S+' "$conf_file") || return 1
if [[ "$raw_endpoint" == \[* ]]; then
# IPv6: [addr]:port
endpoint="${raw_endpoint%%]:*}"
endpoint="${endpoint#\[}"
else
# IPv4/hostname: addr:port
endpoint="${raw_endpoint%:*}"
fi
# tr -d ' \r' - стирает пробелы И CR (на CRLF-конфигах '.+' жадно
# затягивает \r в значение, что ломает JSON.allowed_ips).
allowed_ips=$(grep -oP 'AllowedIPs\s*=\s*\K.+' "$conf_file" | tr -d ' \r') || allowed_ips="0.0.0.0/0"
# MTU/PersistentKeepalive/DNS из .conf - могли быть изменены через manage modify.
# Клиент Amnezia при импорте vpn:// использует структурные поля inner JSON
# (awgConfigurator берёт mtu именно из структурного поля, не из embedded config),
# поэтому хардкод рассинхронизировал бы их с .conf - тот же класс, что issue #67
# (structured-поле psk_key было авторитетным).
local mtu keepalive dns_line dns1 dns2
mtu=$(grep -oP '^MTU\s*=\s*\K[0-9]+' "$conf_file" | head -n1); mtu="${mtu:-1280}"
keepalive=$(grep -oP '^PersistentKeepalive\s*=\s*\K[0-9]+' "$conf_file" | head -n1); keepalive="${keepalive:-33}"
dns_line=$(grep -oP '^DNS\s*=\s*\K.+' "$conf_file" | head -n1 | tr -d ' \r')
dns1="${dns_line%%,*}"; dns1="${dns1:-1.1.1.1}"
if [[ "$dns_line" == *,* ]]; then dns2="${dns_line#*,}"; dns2="${dns2%%,*}"; else dns2="$dns1"; fi
local vpn_uri perl_err
perl_err=$(awg_mktemp "$AWG_DIR") || { log_warn "Ошибка mktemp - vpn:// URI не создан для '$name'."; return 1; }
# Секреты (privkey клиента, PSK) передаются в perl через env, НЕ через argv:
# командная строка процесса видна всем пользователям в /proc/<pid>/cmdline
# на время работы perl. server_pubkey не секрет, но идёт той же группой.
# shellcheck disable=SC2016
vpn_uri=$(AWG_URI_CPK="$client_privkey" AWG_URI_PSK="$client_psk" AWG_URI_SPK="$server_pubkey" \
perl -MCompress::Zlib -MMIME::Base64 -e '
my ($conf_path, $h1,$h2,$h3,$h4, $jc,$jmin,$jmax,
$s1,$s2,$s3,$s4, $i1,$i2,$i3,$i4,$i5, $port, $ep, $cip, $cipv6, $aips,
$mtu, $keepalive, $dns1, $dns2) = @ARGV;
my $cpk = $ENV{AWG_URI_CPK} // "";
my $psk = $ENV{AWG_URI_PSK} // "";
my $spk = $ENV{AWG_URI_SPK} // "";
open my $fh, "<", $conf_path or die;
local $/; my $raw = <$fh>; close $fh;
chomp $raw;
sub je {
my $s = shift;
$s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
$s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g;
$s =~ s/\t/\\t/g; return $s;
}
my $inner = "{";
$inner .= qq("H1":"$h1","H2":"$h2","H3":"$h3","H4":"$h4",);
$inner .= qq("Jc":"$jc","Jmin":"$jmin","Jmax":"$jmax",);
$inner .= qq("S1":"$s1","S2":"$s2","S3":"$s3","S4":"$s4",);
if ($i1 ne "" || $i2 ne "" || $i3 ne "" || $i4 ne "" || $i5 ne "") {
my $ei1 = je($i1); my $ei2 = je($i2); my $ei3 = je($i3);
my $ei4 = je($i4); my $ei5 = je($i5);
$inner .= qq("I1":"$ei1","I2":"$ei2","I3":"$ei3","I4":"$ei4","I5":"$ei5",);
}
my $eraw = je($raw);
my @ips = split(/,/, $aips);
my $ips_json = join(",", map { qq("$_") } @ips);
$inner .= qq("allowed_ips":[$ips_json],);
$inner .= qq("client_ip":"$cip",);
$cipv6 //= "";
$inner .= qq("client_ipv6":"$cipv6",);
$inner .= qq("client_priv_key":"$cpk",);
if (defined $psk && $psk ne "") {
my $epsk = je($psk);
$inner .= qq("psk_key":"$epsk",);
}
$inner .= qq("config":"$eraw",);
$inner .= qq("hostName":"$ep","mtu":"$mtu",);
$inner .= qq("persistent_keep_alive":"$keepalive","port":$port,);
$inner .= qq("server_pub_key":"$spk"});
my $einner = je($inner);
my $outer = "{";
$outer .= qq("containers":[{"awg":{"isThirdPartyConfig":true,);
$outer .= qq("last_config":"$einner",);
$outer .= qq("port":"$port","protocol_version":"2",);
$outer .= qq("transport_proto":"udp"\},"container":"amnezia-awg"\}],);
$outer .= qq("defaultContainer":"amnezia-awg",);
$outer .= qq("description":"AWG Server",);
my $ed1 = je($dns1); my $ed2 = je($dns2);
$outer .= qq("dns1":"$ed1","dns2":"$ed2",);
$outer .= qq("hostName":"$ep"});
my $compressed = compress($outer);
my $payload = pack("N", length($outer)) . $compressed;
my $b64 = encode_base64($payload, "");
$b64 =~ tr|+/|-_|;
$b64 =~ s/=+$//;
print "vpn://" . $b64;
' "$conf_file" \
"$AWG_H1" "$AWG_H2" "$AWG_H3" "$AWG_H4" \
"$AWG_Jc" "$AWG_Jmin" "$AWG_Jmax" \
"$AWG_S1" "$AWG_S2" "$AWG_S3" "$AWG_S4" \
"$AWG_I1" "${AWG_I2:-}" "${AWG_I3:-}" "${AWG_I4:-}" "${AWG_I5:-}" "$AWG_PORT" "$endpoint" \
"$client_ip" "$client_ipv6" "$allowed_ips" \
"$mtu" "$keepalive" "$dns1" "$dns2" 2>"$perl_err"
)
if [[ -z "$vpn_uri" ]]; then
log_warn "Ошибка генерации vpn:// URI для '$name'."
[[ -s "$perl_err" ]] && log_warn "Perl: $(cat "$perl_err")"
rm -f "$perl_err"
return 1
fi
rm -f "$perl_err"
# Пишем через tmp + atomic mv (как .conf/.png), чтобы обрыв записи не оставил
# пустой/обрезанный .vpnuri поверх рабочего.
local _uri_tmp
_uri_tmp=$(awg_mktemp "$AWG_DIR") || { log_error "Ошибка mktemp для vpn:// URI '$name'"; return 1; }
printf '%s\n' "$vpn_uri" > "$_uri_tmp" || { rm -f "$_uri_tmp"; log_error "Ошибка записи vpn:// URI для '$name'"; return 1; }
chmod 600 "$_uri_tmp"
if ! mv -f "$_uri_tmp" "$uri_file"; then
rm -f "$_uri_tmp"
log_error "Ошибка сохранения vpn:// URI для '$name'"
return 1
fi
log_debug "vpn:// URI для '$name' создан: $uri_file"
return 0
}
# Генерация QR-кода из vpn:// URI (для импорта в Amnezia VPN app Android/iOS/Desktop)
# generate_qr_vpnuri <name>
#
# Пишет через tmp в той же директории + atomic mv, чтобы при сбое qrencode
# или chmod пользователь никогда не увидел обрезанный `.vpnuri.png`:
# старая версия файла остаётся на месте, новая появляется только целиком.
generate_qr_vpnuri() {
local name="$1"
local uri_file="$AWG_DIR/${name}.vpnuri"
local png_file="$AWG_DIR/${name}.vpnuri.png"
local tmp_png
if [[ ! -f "$uri_file" ]]; then
log_error "vpn:// URI для '$name' не найден: $uri_file"
return 1
fi
if ! command -v qrencode &>/dev/null; then
log_warn "qrencode не установлен, QR vpn:// не создан для '$name'."
return 1
fi
# tmp через awg_mktemp (общий cleanup-реестр + atomic mv в той же ФС).
tmp_png=$(awg_mktemp "$AWG_DIR") || { log_error "Ошибка mktemp для QR vpn:// '$name'"; return 1; }
# Флаги qrencode для длинных vpn:// URI с PSK (issue #72):
# -s 6 размер модуля 6 пикселей вместо дефолтных 3 - это и есть основной фикс.
# На дефолтном масштабе модули были слишком мелкими, чтобы камера iPhone
# различала их при сканировании PNG с экрана компьютера - отсюда ошибка 900
# ImportInvalidConfigError в AmneziaVPN iOS у @haritos90 в issue #72.
# -l L низший уровень коррекции ошибок - это уже дефолт qrencode, фиксируем явно
# для защиты от смены дефолта в будущих версиях библиотеки.
# -m 4 стандартная тихая зона из 4 модулей - тоже дефолт, фиксируем явно.
if ! qrencode -t png -l L -s 6 -m 4 -o "$tmp_png" < "$uri_file"; then
log_error "Ошибка генерации QR vpn:// для '$name'"
rm -f "$tmp_png"
return 1
fi
if ! chmod 600 "$tmp_png"; then
log_error "Не удалось выставить права 600 на $tmp_png"
rm -f "$tmp_png"
return 1
fi
if ! mv -f "$tmp_png" "$png_file"; then
log_error "Ошибка сохранения QR vpn:// для '$name'"
rm -f "$tmp_png"
return 1
fi
log_debug "QR vpn:// для '$name' создан: $png_file"
return 0
}
# Удаляет частично созданные артефакты клиента (ключи + .conf). Используется
# в early-error путях generate_client - C10: не оставлять orphan-ключи при сбое
# до коммита пира в серверный конфиг.
_rollback_client_artifacts() {
rm -f "$KEYS_DIR/$1.private" "$KEYS_DIR/$1.public" "$AWG_DIR/$1.conf"
}
# Полный набор клиентских артефактов (conf/png/vpnuri/vpnuri.png + ключи).
# Единый список для `manage remove` и автоудаления истёкших, чтобы пути не
# расходились (раньше expiry-cleanup забывал .vpnuri.png). НЕ трогает expiry-метку
# и cron - это делает вызывающий (remove_client_expiry / rm "$efile").
_remove_client_files() {
local name="$1"
rm -f "$AWG_DIR/${name}.conf" "$AWG_DIR/${name}.png" \
"$AWG_DIR/${name}.vpnuri" "$AWG_DIR/${name}.vpnuri.png" \
"$KEYS_DIR/${name}.private" "$KEYS_DIR/${name}.public"
}
# Полный цикл создания клиента:
# keypair → next IP → client config → add peer → QR
# generate_client <name> [endpoint]
#
# Env var contract:
# CLIENT_PSK — необязательный. Если установлен в "auto", генерирует
# свежий PSK через `awg genpsk` и прописывает его и в серверный
# [Peer], и в клиентский [Peer]. Если установлен в конкретное
# значение (32-байт base64) — использует его без генерации. Если
# пуст/не установлен — PSK не добавляется (default behaviour).
generate_client() {
local name="$1"
local endpoint="${2:-}"
if [[ -z "$name" ]]; then
log_error "generate_client: не указано имя"
return 1
fi
# Контракт библиотеки (defense-in-depth): имя с метасимволами/переводами
# строк дало бы инъекцию в пути и heredoc серверного конфига. Тот же
# regex, что validate_client_name в manage и set_client_expiry здесь.
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "generate_client: невалидное имя клиента '$name'"
return 1
fi
# Загружаем параметры
load_awg_params || return 1
# Опциональный PresharedKey: "auto" → `awg genpsk`, иначе используем
# переданное значение как есть. Пустое/unset → без PSK.
if [[ "${CLIENT_PSK:-}" == "auto" ]]; then
# --psk запрошен явно: при сбое awg genpsk НЕ деградируем молча в клиента
# без PSK (это ослабило бы запрошенную безопасность). Fail-closed; здесь
# ещё нет созданных артефактов (ключи/конфиг создаются ниже), откат не нужен.
CLIENT_PSK=$(awg genpsk) || {
log_error "awg genpsk не сработал - клиент с PresharedKey (--psk) НЕ создан. Повторите."
return 1
}
fi
# Межпроцессная блокировка: атомарность IP-аллокации + добавления пира
local lockfile="${AWG_DIR}/.awg_config.lock"
local lock_fd
exec {lock_fd}>"$lockfile"
if ! flock -x -w 30 "$lock_fd"; then
log_error "Не удалось получить блокировку конфига"
exec {lock_fd}>&-
return 1
fi
# C6: клиент не должен уже существовать. Проверяю ПОД локом, ДО генерации
# ключей - иначе `add <существующее_имя>` молча перезатёр бы ключи живого
# клиента (generate_keypair перезаписывает безусловно), а параллельный add
# того же имени гонялся бы за перезапись.
if [[ -e "$KEYS_DIR/${name}.private" || -e "$KEYS_DIR/${name}.public" || -e "$AWG_DIR/${name}.conf" ]]; then
log_error "Клиент '$name' уже существует. Используйте 'remove' или другое имя."
exec {lock_fd}>&-
return 1
fi
# Генерация ключей. С этого момента любой ранний сбой обязан удалить уже
# созданные ключи/conf (C10) через _rollback_client_artifacts.
generate_keypair "$name" || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# Следующий свободный IP
local client_ip
client_ip=$(get_next_client_ip) || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# IPv6-адрес клиента (при ALLOW_IPV6_TUNNEL=1)
local client_ipv6=""
if [[ "${ALLOW_IPV6_TUNNEL:-0}" == "1" ]]; then
client_ipv6=$(get_next_client_ipv6 "$client_ip") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
log_debug "Выделен IPv6-адрес ${client_ipv6} для клиента ${name}"
fi
# Читаем ключи
local client_privkey client_pubkey server_pubkey
client_privkey=$(cat "$KEYS_DIR/${name}.private") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
client_pubkey=$(cat "$KEYS_DIR/${name}.public") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# Пытаемся восстановить server_public.key из awg0.conf если кеша нет
# (поддержка ручных установок без installer-шага 6).
_ensure_server_public_key || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
server_pubkey=$(cat "$AWG_DIR/server_public.key") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# Endpoint: из аргумента → AWG_ENDPOINT (awgsetup_cfg.init) → curl до
# внешних сервисов → локальный IP с сетевого интерфейса.
# Последний fallback для LXC / сред без egress: может быть NAT-адресом,
# поэтому предупреждаем пользователя в лог.
if [[ -z "$endpoint" ]]; then
endpoint="${AWG_ENDPOINT:-}"
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(get_server_public_ip)
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(_try_local_ip) && log_warn "Используется локальный IP сервера как Endpoint ('$endpoint') — curl до внешних сервисов не прошёл. Если сервер за NAT, поправьте Endpoint в клиентских .conf вручную."
fi
if [[ -z "$endpoint" ]]; then
log_error "Не удалось определить внешний IP сервера. Задайте AWG_ENDPOINT в awgsetup_cfg.init (или переустановите с --endpoint=IP)."
_rollback_client_artifacts "$name"
exec {lock_fd}>&-
return 1
fi
# Конфиг клиента
render_client_config "$name" "$client_ip" "$client_privkey" "$server_pubkey" "$endpoint" "${AWG_PORT}" "$client_ipv6" || {
log_error "Откат: удаление артефактов '$name'"
_rollback_client_artifacts "$name"
exec {lock_fd}>&-
return 1
}
# Добавляем пир в серверный конфиг
if ! add_peer_to_server "$name" "$client_pubkey" "$client_ip" "$client_ipv6"; then
log_error "Откат: удаление артефактов '$name'"
_rollback_client_artifacts "$name"
exec {lock_fd}>&-
return 1
fi
# Освобождаем блокировку — пир записан, дальше некритичные операции
exec {lock_fd}>&-
# QR-код (необязательный, ошибка не фатальна)
if ! generate_qr "$name"; then
log_warn "QR-код не создан. Конфиг: $AWG_DIR/${name}.conf"
fi
# vpn:// URI и QR для Amnezia VPN app (необязательные).
# QR vpn:// пробуем только если URI создан успешно — иначе читать нечего.
if ! generate_vpn_uri "$name"; then
log_warn "vpn:// URI не создан для '$name'."
elif ! generate_qr_vpnuri "$name"; then
log_warn "QR vpn:// не создан для '$name'."
fi
log "Клиент '$name' создан (IP: $client_ip)."
return 0
}
# Перегенерация конфига и QR для существующего клиента
# regenerate_client <name> [endpoint]
#
# v5.11.0 A5.3: защищается блокировкой .awg_config.lock (сериализация
# с modify_client / remove и параллельными regen на том же имени) и
# проверяет возврат каждого sed -i при восстановлении пользовательских
# настроек — прежде молча игнорировались ошибки sed.
#
# Lock scope: держится только пока мутируется $AWG_DIR/${name}.conf.
# generate_qr / generate_vpn_uri / generate_qr_vpnuri вызываются ВНЕ lock
# как best-effort derived artifacts — если между sed-ом и QR-генерацией
# concurrent modify успеет изменить conf, QR может устареть на один такт.
# Также concurrent `manage remove <name>` может удалить клиента после
# release lock, и regen «воскресит» `.conf` / `.png` / `.vpnuri` /
# `.vpnuri.png` для уже удалённого peer-а (stale artefacts в $AWG_DIR).
# Это приемлемо: пользователь получит актуальное состояние на следующей
# операции (повторный `remove` или `regen`), и peer уже удалён из server-
# конфига — трафик через него не идёт. Включать QR/URI в lock дороже
# (lock на несколько секунд — блокирует другие клиенты) без выигрыша
# по целостности server-state.
regenerate_client() {
local name="$1"
local endpoint="${2:-}"
if [[ -z "$name" ]]; then
log_error "regenerate_client: не указано имя"
return 1
fi
# Контракт библиотеки (defense-in-depth): имя интерполируется в пути и
# конфиг, поэтому валидируем здесь же, не полагаясь на вызывающего
# (manage делает свой validate_client_name, но cron/чужой скрипт - нет).
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "regenerate_client: невалидное имя клиента '$name'"
return 1
fi
# Межпроцессная блокировка: защита от race с modify_client/remove и
# параллельных regen на одном имени клиента.
local lockfile="${AWG_DIR}/.awg_config.lock"
local lock_fd
exec {lock_fd}>"$lockfile"
if ! flock -x -w 10 "$lock_fd"; then
log_error "Не удалось получить блокировку конфига (другая операция выполняется)"
exec {lock_fd}>&-
return 1
fi
load_awg_params || { exec {lock_fd}>&-; return 1; }
# Проверяем, что клиент существует в серверном конфиге
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Клиент '$name' не найден в серверном конфиге"
exec {lock_fd}>&-
return 1
fi
# Читаем приватный ключ клиента
local client_privkey client_ip server_pubkey
if [[ -f "$KEYS_DIR/${name}.private" ]]; then
client_privkey=$(cat "$KEYS_DIR/${name}.private")
elif [[ -f "$AWG_DIR/${name}.conf" ]]; then
# Пробуем извлечь из существующего конфига
client_privkey=$(sed -n 's/^PrivateKey[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
fi
if [[ -z "$client_privkey" ]]; then
log_error "Приватный ключ клиента '$name' не найден"
exec {lock_fd}>&-
return 1
fi
# IP клиента из серверного конфига
# Ищем блок [Peer] с #_Name = name, затем AllowedIPs
# Для dual-stack: ips[1] = IPv4/32, ips[2] = IPv6/128 (если есть)
local _regen_awk_out
_regen_awk_out=$(awk -v target="$name" '
/^\[Peer\]/ { in_peer=1; found=0; next }
in_peer && $0 == "#_Name = " target { found=1; next }
in_peer && found && /^AllowedIPs/ {
sub(/^AllowedIPs[ \t]*=[ \t]*/, "")
n = split($0, ips, /[ \t]*,[ \t]*/)
sub(/\/[0-9]+$/, "", ips[1])
gsub(/^[ \t]+|[ \t]+$/, "", ips[1])
ipv4 = ips[1]
ipv6 = ""
if (n >= 2) {
sub(/\/[0-9]+$/, "", ips[2])
gsub(/^[ \t]+|[ \t]+$/, "", ips[2])
ipv6 = ips[2]
}
print ipv4 " " ipv6
exit
}
/^\[/ && !/^\[Peer\]/ { in_peer=0; found=0 }
' "$SERVER_CONF_FILE")
client_ip="${_regen_awk_out%% *}"
local client_ipv6="${_regen_awk_out#* }"
# Defensive guard: awk always prints trailing space, so client_ipv6 is "" for IPv4-only.
# This guard fires only if awk produces no trailing space (not expected in practice).
if [[ "$client_ipv6" == "$client_ip" ]]; then
client_ipv6=""
fi
# Only carry IPv6 forward if ALLOW_IPV6_TUNNEL is enabled
if [[ "${ALLOW_IPV6_TUNNEL:-0}" != "1" ]]; then
client_ipv6=""
fi
if [[ -z "$client_ip" ]]; then
log_error "IP клиента '$name' не найден в серверном конфиге"
exec {lock_fd}>&-
return 1
fi
# Auto-gen из awg0.conf если кеша нет (ручная установка)
_ensure_server_public_key || { exec {lock_fd}>&-; return 1; }
server_pubkey=$(cat "$AWG_DIR/server_public.key" 2>/dev/null) || {
log_error "Публичный ключ сервера не найден"
exec {lock_fd}>&-
return 1
}
# Endpoint chain: arg → AWG_ENDPOINT → curl → local IP (best-effort).
if [[ -z "$endpoint" ]]; then
endpoint="${AWG_ENDPOINT:-}"
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(get_server_public_ip)
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(_try_local_ip) && log_warn "Используется локальный IP сервера как Endpoint ('$endpoint') — curl до внешних сервисов не прошёл."
fi
if [[ -z "$endpoint" ]]; then
log_error "Не удалось определить внешний IP сервера."
exec {lock_fd}>&-
return 1
fi
# Сохраняем пользовательские настройки из текущего .conf (modify)
local current_dns="1.1.1.1, 1.0.0.1" current_keepalive="33" current_allowed_ips="${ALLOWED_IPS:-0.0.0.0/0}"
if [[ -f "$AWG_DIR/${name}.conf" ]]; then
local _v
_v=$(sed -n 's/^DNS[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
[[ -n "$_v" ]] && current_dns="$_v"
_v=$(sed -n 's/^PersistentKeepalive[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
[[ -n "$_v" ]] && current_keepalive="$_v"
_v=$(sed -n '/^\[Peer\]/,$ s/^AllowedIPs[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
[[ -n "$_v" ]] && current_allowed_ips="$_v"
# v5.11.1: preserve PresharedKey через regen — если у клиента
# был PSK (создан с manage add --psk), regen без этого сохранения
# выбросил бы его и сломал handshake (server peer всё ещё с PSK,
# client conf уже без). CLIENT_PSK передаётся в render_client_config.
local _psk
_psk=$(sed -n '/^\[Peer\]/,$ s/^PresharedKey[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
if [[ -n "$_psk" ]]; then
export CLIENT_PSK="$_psk"
else
unset CLIENT_PSK
fi
else
# Клиентский .conf утерян (regen как восстановление): PresharedKey
# восстанавливаем из server [Peer]-блока, иначе пересозданный конфиг
# вышел бы без PSK при живом PSK на сервере - handshake молча ломается.
# Порядок полей в блоке контролируем мы (add_peer_to_server пишет
# #_Name первым), поэтому found-then-PSK достаточно.
local _psk
_psk=$(awk -v target="$name" '
/^\[Peer\]/ { in_peer=1; found=0; next }
in_peer && $0 == "#_Name = " target { found=1; next }
in_peer && found && /^PresharedKey[ \t]*=/ {
sub(/^PresharedKey[ \t]*=[ \t]*/, ""); sub(/\r$/, ""); print; exit
}
/^\[/ && !/^\[Peer\]/ { in_peer=0; found=0 }
' "$SERVER_CONF_FILE" 2>/dev/null | tr -d '[:space:]')
if [[ -n "$_psk" ]]; then
export CLIENT_PSK="$_psk"
else
unset CLIENT_PSK
fi
fi
# Перегенерация конфига (передаём client_ipv6 если dual-stack)
render_client_config "$name" "$client_ip" "$client_privkey" "$server_pubkey" "$endpoint" "${AWG_PORT}" "$client_ipv6" || {
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
}
# При regen подтягиваем новые дефолты для НЕ-кастомизированных клиентов:
# полнотуннельный 0.0.0.0/0 получает ::/0 (нужно iOS AmneziaVPN), одиночный
# DNS 1.1.1.1 становится парой с резервом. Значения, заданные пользователем
# через modify, не равны старым дефолтам и потому сохраняются как есть.
[[ "$current_allowed_ips" == "0.0.0.0/0" ]] && current_allowed_ips="0.0.0.0/0, ::/0"
[[ "$current_dns" == "1.1.1.1" ]] && current_dns="1.1.1.1, 1.0.0.1"
# Восстанавливаем пользовательские настройки (экранируем & и \ для sed replacement)
local _dns _ka _aip
_dns=$(printf '%s' "$current_dns" | sed 's/[&\\/]/\\&/g')
_ka=$(printf '%s' "$current_keepalive" | sed 's/[&\\/]/\\&/g')
_aip=$(printf '%s' "$current_allowed_ips" | sed 's/[&\\/]/\\&/g')
local _client_conf="$AWG_DIR/${name}.conf"
if ! sed -i "s/^DNS = .*/DNS = ${_dns}/" "$_client_conf"; then
log_error "Ошибка sed при записи DNS в $_client_conf"
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
fi
if ! sed -i "s/^PersistentKeepalive = .*/PersistentKeepalive = ${_ka}/" "$_client_conf"; then
log_error "Ошибка sed при записи PersistentKeepalive в $_client_conf"
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
fi
# Делимитер '/' (а не '|'): класс экранирования выше покрывает & \ / -
# символ '|' в значении сломал бы sed-выражение с '|'-делимитером.
if ! sed -i "s/^AllowedIPs = .*/AllowedIPs = ${_aip}/" "$_client_conf"; then
log_error "Ошибка sed при записи AllowedIPs в $_client_conf"
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
fi
# Освобождаем блокировку — конфиг записан, дальше некритичные операции
exec {lock_fd}>&-
# QR-код
generate_qr "$name"
# vpn:// URI и QR для Amnezia VPN app (best-effort).
# QR vpn:// пробуем только если URI пересоздан успешно.
if generate_vpn_uri "$name"; then
generate_qr_vpnuri "$name" || log_warn "QR vpn:// не обновлён для '$name'."
else
log_warn "vpn:// URI не обновлён для '$name'."
fi
# Hygiene: PSK не должен протекать в следующие операции в том же shell
unset CLIENT_PSK
log "Конфиг клиента '$name' перегенерирован."
return 0
}
# ==============================================================================
# Валидация
# ==============================================================================
# Проверка AWG 2.0 конфигурации серверного конфига
validate_awg_config() {
if [[ ! -f "$SERVER_CONF_FILE" ]]; then
log_error "Серверный конфиг не найден: $SERVER_CONF_FILE"
return 1
fi
local ok=1
local param val
local int_params=("Jc" "Jmin" "Jmax" "S1" "S2" "S3" "S4")
local range_params=("H1" "H2" "H3" "H4")
# Парсинг выровнен с load_awg_params_from_server_conf: произвольные пробелы
# вокруг '=', last-wins при дублях строк (валидируем то значение, которое
# реально загрузится), trim пробелов/CR. Раньше валидатор требовал ровно
# один пробел и брал first-wins - вручную поправленный 'Jc=4' успешно
# загружался, но проваливал валидацию с ложным "параметр не найден".
for param in "${int_params[@]}"; do
val=$(sed -n "s/^[[:space:]]*${param}[[:space:]]*=[[:space:]]*//p" "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
if [[ -z "$val" ]]; then
log_error "Параметр '$param' не найден в серверном конфиге"
ok=0
elif ! [[ "$val" =~ ^[0-9]+$ ]]; then
log_error "Параметр '$param' содержит невалидное значение: '$val' (ожидается целое число)"
ok=0
fi
done
# Протокольные границы (defense-in-depth для восстановленных бэкапов)
local jc jmin jmax s3 s4
jc=$(sed -n 's/^[[:space:]]*Jc[[:space:]]*=[[:space:]]*//p' "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
jmin=$(sed -n 's/^[[:space:]]*Jmin[[:space:]]*=[[:space:]]*//p' "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
jmax=$(sed -n 's/^[[:space:]]*Jmax[[:space:]]*=[[:space:]]*//p' "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
s3=$(sed -n 's/^[[:space:]]*S3[[:space:]]*=[[:space:]]*//p' "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
s4=$(sed -n 's/^[[:space:]]*S4[[:space:]]*=[[:space:]]*//p' "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
if [[ "$jc" =~ ^[0-9]+$ ]]; then
if [[ "$jc" -lt 1 || "$jc" -gt 128 ]]; then
log_error "Jc=$jc вне допустимого диапазона (1-128)"
ok=0
fi
fi
if [[ "$jmin" =~ ^[0-9]+$ && "$jmax" =~ ^[0-9]+$ ]]; then
if [[ "$jmin" -gt 1280 ]]; then
log_error "Jmin=$jmin превышает 1280"
ok=0
fi
if [[ "$jmax" -gt 1280 ]]; then
log_error "Jmax=$jmax превышает 1280"
ok=0
fi
if [[ "$jmax" -lt "$jmin" ]]; then
log_error "Jmax ($jmax) меньше Jmin ($jmin)"
ok=0
fi
fi
if [[ "$s3" =~ ^[0-9]+$ && "$s3" -gt 64 ]]; then
log_error "S3=$s3 превышает максимум (64)"
ok=0
fi
if [[ "$s4" =~ ^[0-9]+$ && "$s4" -gt 32 ]]; then
log_error "S4=$s4 превышает максимум (32)"
ok=0
fi
local _h_ranges=()
for param in "${range_params[@]}"; do
val=$(sed -n "s/^[[:space:]]*${param}[[:space:]]*=[[:space:]]*//p" "$SERVER_CONF_FILE" | tail -1 | tr -d '[:space:]')
if [[ -z "$val" ]]; then
log_error "Параметр '$param' не найден в серверном конфиге"
ok=0
elif ! [[ "$val" =~ ^[0-9]+-[0-9]+$ ]]; then
log_error "Параметр '$param' содержит невалидное значение: '$val' (ожидается формат MIN-MAX)"
ok=0
else
local range_lo="${val%-*}" range_hi="${val#*-}"
if [[ "$range_lo" -ge "$range_hi" ]]; then
log_error "Параметр '$param': нижняя граница ($range_lo) >= верхней ($range_hi)"
ok=0
else
_h_ranges+=("$range_lo $range_hi $param")
fi
fi
done
# Попарное непересечение H1-H4 - ключевой инвариант AWG 2.0. Без этой
# проверки конфиг из чужого бэкапа с пересекающимися диапазонами
# проходил валидацию, хотя протокол его не допускает.
if [[ ${#_h_ranges[@]} -eq 4 ]]; then
local _i _j _lo1 _hi1 _n1 _lo2 _hi2 _n2
for ((_i = 0; _i < 4; _i++)); do
for ((_j = _i + 1; _j < 4; _j++)); do
read -r _lo1 _hi1 _n1 <<< "${_h_ranges[$_i]}"
read -r _lo2 _hi2 _n2 <<< "${_h_ranges[$_j]}"
if (( _lo1 <= _hi2 && _lo2 <= _hi1 )); then
log_error "Диапазоны ${_n1} (${_lo1}-${_hi1}) и ${_n2} (${_lo2}-${_hi2}) пересекаются"
ok=0
fi
done
done
fi
# I1 опционален, но рекомендован для AWG 2.0
if ! grep -q "^I1 = " "$SERVER_CONF_FILE"; then
log_warn "Параметр I1 (CPS) не найден — CPS concealment не активен"
fi
if [[ $ok -eq 1 ]]; then
log "Валидация AWG 2.0 конфига: OK"
return 0
else
return 1
fi
}
# ==============================================================================
# Срок действия клиентов (expiry)
# ==============================================================================
EXPIRY_DIR="${AWG_DIR}/expiry"
EXPIRY_CRON="${EXPIRY_CRON:-/etc/cron.d/awg-expiry}"
# Парсинг длительности в секунды: 1h, 12h, 1d, 7d, 30d
# parse_duration <duration_string>
parse_duration() {
local input="$1"
local num unit
if [[ "$input" =~ ^([0-9]+)([hdw])$ ]]; then
num="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
else
log_error "Некорректный формат длительности: '$input'. Используйте: 1h, 12h, 1d, 7d, 4w"
return 1
fi
case "$unit" in
h) echo $((num * 3600)) ;;
d) echo $((num * 86400)) ;;
w) echo $((num * 604800)) ;; # 7 дней
*) return 1 ;;
esac
}
# Установка срока действия клиента
# set_client_expiry <name> <duration>
set_client_expiry() {
local name="$1"
local duration="$2"
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "Невалидное имя клиента: '$name'"
return 1
fi
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Клиент '$name' не найден."
return 1
fi
local seconds
seconds=$(parse_duration "$duration") || return 1
local now
now=$(date +%s)
local expires_at=$((now + seconds))
mkdir -p "$EXPIRY_DIR" || {
log_error "Ошибка создания $EXPIRY_DIR"
return 1
}
echo "$expires_at" > "$EXPIRY_DIR/$name" || {
log_error "Ошибка записи expiry для '$name'"
return 1
}
chmod 600 "$EXPIRY_DIR/$name"
local expires_date
expires_date=$(date -d "@$expires_at" '+%F %T' 2>/dev/null || echo "$expires_at")
log "Срок действия '$name': $expires_date ($duration)"
return 0
}
# Получение срока действия клиента (unix timestamp или пустая строка)
# get_client_expiry <name>
get_client_expiry() {
local name="$1"
local efile="$EXPIRY_DIR/$name"
if [[ -f "$efile" ]]; then
cat "$efile"
fi
}
# Форматирование оставшегося времени
# format_remaining <expires_at_timestamp>
format_remaining() {
local expires_at="$1"
local now
now=$(date +%s)
local diff=$((expires_at - now))
if [[ $diff -le 0 ]]; then
local ago=$(( (-diff) / 3600 ))
if [[ $ago -ge 24 ]]; then
echo "истёк $(( ago / 24 ))д назад"
elif [[ $ago -ge 1 ]]; then
echo "истёк ${ago}ч назад"
else
local ago_mins=$(( (-diff) / 60 ))
if [[ $ago_mins -ge 1 ]]; then
echo "истёк ${ago_mins}м назад"
else
echo "только что истёк"
fi
fi
return 0
fi
local days=$((diff / 86400))
local hours=$(( (diff % 86400) / 3600 ))
if [[ $days -gt 0 ]]; then
echo "${days}д ${hours}ч"
else
local mins=$(( (diff % 3600) / 60 ))
echo "${hours}ч ${mins}м"
fi
}
# Проверка и удаление истёкших клиентов
check_expired_clients() {
if [[ ! -d "$EXPIRY_DIR" ]]; then return 0; fi
local removed=0
local efile
for efile in "$EXPIRY_DIR"/*; do
[[ -f "$efile" ]] || continue
local name
name=$(basename "$efile")
# Валидация имени: тот же regex что validate_client_name в manage_amneziawg.sh.
# Defense-in-depth — EXPIRY_DIR доступен только root, но защита от
# случайно попавшего невалидного файла (или symlink attack если expiry_dir
# когда-то станет shared) нужна перед использованием $name в путях
# и передачей в remove_peer_from_server (self-audit).
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_warn "Пропуск невалидного expiry файла: '$name'"
continue
fi
local expires_at
expires_at=$(cat "$efile" 2>/dev/null)
if [[ -z "$expires_at" || ! "$expires_at" =~ ^[0-9]+$ ]]; then
log_warn "Некорректные данные expiry для '$name': '$(head -c 50 "$efile" 2>/dev/null)'"
continue
fi
local now
now=$(date +%s)
if [[ $now -ge $expires_at ]]; then
log "Клиент '$name' истёк. Удаление..."
if [[ -r "$SERVER_CONF_FILE" ]] && ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE"; then
# Orphan-метка: peer уже удалён из конфига (вручную, через awg
# или restore старого бэкапа). Без этой ветки cron каждые 5
# минут вечно ретраил бы remove_peer_from_server и копил warn
# в expiry.log, а артефакты клиента никогда не зачищались.
# Гард [[ -r ]]: временно отсутствующий/нечитаемый конфиг
# (mid-restore, сбой ФС) НЕ повод стирать артефакты клиента -
# такой случай уходит в обычную ветку с warn и повтором позже.
_remove_client_files "$name"
remove_client_expiry "$name"
log "Клиент '$name': peer отсутствует в конфиге - зачищены осиротевшие артефакты и expiry-метка."
elif remove_peer_from_server "$name" 2>/dev/null; then
_remove_client_files "$name"
remove_client_expiry "$name"
log "Клиент '$name' удалён (истёк)."
((removed++))
else
log_warn "Не удалось удалить истёкшего клиента '$name'."
fi
fi
done
if [[ $removed -gt 0 ]]; then
log "Удалено истёкших клиентов: $removed. Применение конфигурации..."
if ! apply_config; then
log_error "apply_config упал после удаления истёкших клиентов. Peer-ы убраны из конфига и expiry/, но могут оставаться на live интерфейсе. Требуется ручной перезапуск: systemctl restart awg-quick@awg0"
return 1
fi
fi
return 0
}
# Установка cron-задачи для автоудаления
install_expiry_cron() {
# Идемпотентность по СОДЕРЖИМОМУ, не по факту существования файла. Раньше
# ранний выход «файл есть» оставлял stale-пути после restore/переноса/
# --conf-dir: cron продолжал смотреть в старый AWG_DIR. Генерируем ожидаемый
# текст и заменяем файл, только если он отличается.
local _cron_tmp
_cron_tmp=$(awg_mktemp "$(dirname "$EXPIRY_CRON")") || { log_error "Ошибка mktemp для cron expiry"; return 1; }
# Проверяем успех записи ДО cmp/mv: иначе сбой (диск/права) мог бы атомарно
# заменить рабочий cron пустым/частичным tmp.
if ! cat > "$_cron_tmp" << CRONEOF
# AmneziaWG client expiry check - every 5 minutes
AWG_DIR="${AWG_DIR}"
CONFIG_FILE="${CONFIG_FILE}"
SERVER_CONF_FILE="${SERVER_CONF_FILE}"
*/5 * * * * root /bin/bash -c 'source "${AWG_DIR}/awg_common.sh" || exit 1; trap _awg_cleanup EXIT; check_expired_clients' >> "${AWG_DIR}/expiry.log" 2>&1
CRONEOF
then
rm -f "$_cron_tmp"
log_error "Ошибка записи cron-задачи expiry"
return 1
fi
if [[ -f "$EXPIRY_CRON" ]] && cmp -s "$_cron_tmp" "$EXPIRY_CRON"; then
rm -f "$_cron_tmp"
log_debug "Cron-задача expiry уже актуальна."
return 0
fi
chmod 644 "$_cron_tmp"
if ! mv -f "$_cron_tmp" "$EXPIRY_CRON"; then
rm -f "$_cron_tmp"
log_error "Ошибка установки cron-задачи expiry: $EXPIRY_CRON"
return 1
fi
log "Cron-задача expiry установлена/обновлена: $EXPIRY_CRON"
}
# Удаление expiry-данных клиента
remove_client_expiry() {
local name="$1"
rm -f "$EXPIRY_DIR/$name" 2>/dev/null
# Удаляем cron если больше нет клиентов с expiry
if [[ -d "$EXPIRY_DIR" ]] && [[ -z "$(ls -A "$EXPIRY_DIR" 2>/dev/null)" ]]; then
rm -f "$EXPIRY_CRON" 2>/dev/null
log_debug "Cron-задача expiry удалена (нет клиентов с expiry)."
fi
}