mirror of
https://github.com/bivlked/amneziawg-installer.git
synced 2026-07-09 17:29:19 +00:00
* docs(cascade): bundle ru.zone snapshot as offline fallback awg-routing.sh now tries ipdeny -> repo snapshot (cascade/ru.zone via raw.githubusercontent) -> local last-good, so a fresh server where ipdeny is blocked still brings the cascade up instead of hard-failing. Add cascade/ru.zone (ipdeny ru-aggregated snapshot, 2026-07-06, 8626 networks) + cascade/README.md with attribution and refresh steps. Also clean the temp list file via a trap on exit. * Release v5.18.4: cascade offline fallback + onlink route retry Bump version to 5.18.4 across the 6 scripts, refresh the 4 helper SHA pins, bump the pinned install URLs and the version badge, and add the changelog entry (RU + EN). Cumulative since v5.18.3: bundled cascade/ru.zone snapshot as an offline fallback for awg-routing.sh, onlink route retry for out-of-subnet-gateway VPS (#158), DeepWiki wiki.json + badge (#161), CI setup-qemu 4.1 -> 4.2 (#157).
2578 lines
127 KiB
Bash
2578 lines
127 KiB
Bash
#!/bin/bash
|
||
|
||
# ==============================================================================
|
||
# Общая библиотека функций для AmneziaWG 2.0
|
||
# Автор: @bivlked
|
||
# Версия: 5.18.4
|
||
# Дата: 2026-07-06
|
||
# Репозиторий: 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
|
||
}
|