fix: prevent split-brain in load_awg_params + atomic export + optimizations (#38)

Post-audit improvements on top of the v5.7.13 fix commit (20ba58d).
The audit identified one HIGH severity bug in the post-#38 code path,
plus self-review found two further improvements.

CRITICAL — split-brain prevention in load_awg_params

The post-#38 load_awg_params silently fell back to safe_load_config
when load_awg_params_from_server_conf returned 1. With a partially
corrupt awg0.conf (e.g. user accidentally removed H4 during a manual
edit) the validation loop saw AWG_* still populated from the init
file and reported success — while the live server actually ran the
new (broken) config. regen / render_client_config would then issue
clients stale J*/S*/H* from the cached init file, producing exactly
the kind of mismatch elvaleto and Klavishnik reported in #38, only
quietly.

Fix: live awg0.conf is now the SOLE source of truth for AWG protocol
parameters whenever the file exists. Atomic-parser failure on a live
config is now an explicit error with multi-line log_error and
return 1 — no fallback. The init file is used for AWG params ONLY
when awg0.conf is missing entirely (bootstrap path of first install).

This is the most important change in this commit — it closes a
regression introduced by the original #38 fix.

Atomic export in load_awg_params_from_server_conf

Self-review: previously the parser exported each AWG_* variable as
soon as it parsed the matching key. On a partially corrupt awg0.conf
that meant AWG_Jc could be updated to a new value while AWG_H4
remained at the stale init value — mixed state in the environment.
The function now accumulates values into local _Jc/_Jmin/.../_H4 and
exports them only after verifying that all 11 required fields were
found. Either everything is exported, or nothing is touched.

Optimization: single /dev/urandom read in generate_awg_h_ranges

Previously the function called rand_range 8 times (8 separate od
subprocess invocations). It now does a single
od -An -N32 -tu4 /dev/urandom read that yields all 8 uint32 values
at once, then parses them with read. Falls back to the per-value
rand_range loop if /dev/urandom is unavailable. ~8x fewer subprocess
invocations on the hot path. Mirrored in install_amneziawg.sh and
both EN scripts.

Explicit return 0 in apply_config success path

The syncconf success path ended with exec apply_fd close and returned
that command exit code implicitly. Added an explicit return 0 so
callers (manage add/remove, check_expired_clients) get a guaranteed
exit code on success. Mirrored in EN.

Tests

Three new bats cases in test_load_awg_params.bats (66 total in the
suite, up from 63):
- split-brain prevention: corrupt server_conf with init present must
  return 1 (regression for the audit finding)
- bootstrap path: server_conf missing must fall back to init and
  return 0 (sanity check that bootstrap still works)
- atomic export: corrupt server_conf must not pollute env with the
  partially parsed values
This commit is contained in:
Ivan Bondarev 2026-04-07 23:29:19 +03:00
parent 30116904cf
commit ece59174c3
5 changed files with 340 additions and 84 deletions

View file

@ -101,17 +101,38 @@ rand_range() {
# Минимальная ширина каждого диапазона = 1000.
# Печатает 4 строки "low-high" в stdout. Возвращает 1 при неудаче.
# Защита от ТСПУ-фингерпринта по статическим H-значениям (#38).
#
# Оптимизация: один вызов `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 p1 p2 p3 p4 p5 p6 p7 p8 sorted
p1=$(rand_range 0 4294967295); p2=$(rand_range 0 4294967295)
p3=$(rand_range 0 4294967295); p4=$(rand_range 0 4294967295)
p5=$(rand_range 0 4294967295); p6=$(rand_range 0 4294967295)
p7=$(rand_range 0 4294967295); p8=$(rand_range 0 4294967295)
sorted=$(printf '%s\n' "$p1" "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" | sort -n)
local arr=()
while IFS= read -r _line; do arr+=("$_line"); done <<< "$sorted"
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
arr+=("$_v"); 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 4294967295)")
done
fi
# Сортировка
local sorted
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
arr=()
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
# Проверка минимальной ширины каждой пары
if (( ${arr[1]} - ${arr[0]} >= 1000 )) && \
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \
@ -165,8 +186,11 @@ safe_load_config() {
}
# Парсер живого серверного конфига AmneziaWG (источник истины для AWG_*).
# Читает секцию [Interface] из awg0.conf и экспортирует AWG_* переменные.
# Возвращает 0 если нашёл хотя бы Jc и H4, иначе 1.
# Читает секцию [Interface] из awg0.conf и экспортирует AWG_* переменные
# АТОМАРНО: либо все 11 обязательных параметров (Jc/Jmin/Jmax/S1-S4/H1-H4)
# найдены и экспортированы, либо ничего не меняется в окружении и возврат 1.
# Это защищает от mixed-state при частично corrupt awg0.conf.
# I1, ListenPort — опциональные, экспортируются если нашлись.
# Решает баг #38: regen использовал устаревшие значения из init-файла,
# а не актуальные из awg0.conf после ручной правки.
# shellcheck disable=SC2120 # Опциональный аргумент используется только в тестах
@ -174,6 +198,12 @@ 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="" _Port=""
local in_iface=0 line key value
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^\[Interface\] ]]; then in_iface=1; continue; fi
@ -188,45 +218,90 @@ load_awg_params_from_server_conf() {
# Trim trailing whitespace
value="${value%"${value##*[![:space:]]}"}"
case "$key" in
Jc) export AWG_Jc="$value" ;;
Jmin) export AWG_Jmin="$value" ;;
Jmax) export AWG_Jmax="$value" ;;
S1) export AWG_S1="$value" ;;
S2) export AWG_S2="$value" ;;
S3) export AWG_S3="$value" ;;
S4) export AWG_S4="$value" ;;
H1) export AWG_H1="$value" ;;
H2) export AWG_H2="$value" ;;
H3) export AWG_H3="$value" ;;
H4) export AWG_H4="$value" ;;
I1) export AWG_I1="$value" ;;
ListenPort) export AWG_PORT="$value" ;;
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" ;;
ListenPort) _Port="$value" ;;
esac
fi
done < "$conf"
[[ -n "${AWG_Jc:-}" && -n "${AWG_H4:-}" ]]
# 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 "$_Port" ]] && export AWG_PORT="$_Port"
return 0
}
# Загрузка AWG параметров.
# Приоритет: live серверный конфиг (awg0.conf) > init-файл (awgsetup_cfg.init).
# Это гарантирует что regen после ручной правки awg0.conf работает корректно (#38).
#
# Семантика источников (важно для предотвращения 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) когда файл существует.
#
# Если 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-файла (OS_ID, ALLOWED_IPS, AWG_PORT, AWG_ENDPOINT и т.п.)
# 1. Базовые настройки из init (всегда, для не-AWG ключей)
if [[ -f "$CONFIG_FILE" ]]; then
safe_load_config "$CONFIG_FILE" || log_warn "Не удалось загрузить $CONFIG_FILE"
fi
# 2. AWG протокольные параметры — приоритет live awg0.conf (источник истины)
if load_awg_params_from_server_conf; then
# 2. AWG протокольные параметры
if [[ -f "$SERVER_CONF_FILE" ]]; then
# Live config существует — он единственный источник истины.
# Никакого fallback на init: иначе получим split-brain.
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
log_debug "AWG параметры загружены из $CONFIG_FILE (init fallback)"
# Bootstrap: server config ещё не существует (первая установка).
# AWG_* должны быть в env через safe_load_config выше.
log_debug "$SERVER_CONF_FILE не существует — использую AWG params из $CONFIG_FILE (bootstrap)"
fi
# Проверка обязательных AWG 2.0 параметров
# 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 не найден ни в $SERVER_CONF_FILE, ни в $CONFIG_FILE"
log_error "Параметр $param не найден"
missing=1
fi
done
@ -501,6 +576,7 @@ apply_config() {
}
log_debug "Конфигурация применена (syncconf)."
exec {apply_fd}>&-
return 0
}
# ==============================================================================

View file

@ -101,17 +101,38 @@ rand_range() {
# Minimum width per range = 1000.
# Prints 4 "low-high" lines to stdout. Returns 1 on failure.
# Mitigates Russian DPI fingerprinting of static H values (#38).
#
# Optimization: a single `od -N32 -tu4` call reads 32 bytes = 8 uint32
# values in one operation, instead of 8 separate subprocess calls via
# rand_range. Falls back to rand_range if /dev/urandom is unavailable.
generate_awg_h_ranges() {
local attempt=0 max_attempts=20
while (( attempt < max_attempts )); do
local p1 p2 p3 p4 p5 p6 p7 p8 sorted
p1=$(rand_range 0 4294967295); p2=$(rand_range 0 4294967295)
p3=$(rand_range 0 4294967295); p4=$(rand_range 0 4294967295)
p5=$(rand_range 0 4294967295); p6=$(rand_range 0 4294967295)
p7=$(rand_range 0 4294967295); p8=$(rand_range 0 4294967295)
sorted=$(printf '%s\n' "$p1" "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" | sort -n)
local arr=()
while IFS= read -r _line; do arr+=("$_line"); done <<< "$sorted"
local raw arr=() _v
# One 32-byte read from /dev/urandom = 8 uint32 values
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
arr+=("$_v"); count=$((count + 1))
(( count == 8 )) && break
done <<< "$raw"
fi
# Fallback: 8 separate rand_range calls (if urandom unavailable)
if (( ${#arr[@]} != 8 )); then
arr=()
local _i
for _i in 1 2 3 4 5 6 7 8; do
arr+=("$(rand_range 0 4294967295)")
done
fi
# Sort
local sorted
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
arr=()
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
# Minimum width per pair
if (( ${arr[1]} - ${arr[0]} >= 1000 )) && \
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \
@ -165,8 +186,11 @@ safe_load_config() {
}
# Parser for the live AmneziaWG server config (source of truth for AWG_*).
# Reads the [Interface] section of awg0.conf and exports AWG_* variables.
# Returns 0 if at least Jc and H4 were found, otherwise 1.
# Reads the [Interface] section of awg0.conf and exports AWG_* variables
# ATOMICALLY: either all 11 required parameters (Jc/Jmin/Jmax/S1-S4/H1-H4)
# are found and exported, or nothing changes in the environment and 1
# is returned. Protects against mixed state when awg0.conf is partially
# corrupt. I1, ListenPort are optional — exported only if found.
# Fixes #38: regen used stale values from the init file instead of the
# actual awg0.conf after manual edits.
# shellcheck disable=SC2120 # Optional argument is only used in tests
@ -174,6 +198,12 @@ load_awg_params_from_server_conf() {
local conf="${1:-$SERVER_CONF_FILE}"
[[ -f "$conf" ]] || return 1
# Local accumulation — all-or-nothing export at the end
local _Jc="" _Jmin="" _Jmax=""
local _S1="" _S2="" _S3="" _S4=""
local _H1="" _H2="" _H3="" _H4=""
local _I1="" _Port=""
local in_iface=0 line key value
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^\[Interface\] ]]; then in_iface=1; continue; fi
@ -187,45 +217,91 @@ load_awg_params_from_server_conf() {
value="${BASH_REMATCH[2]}"
value="${value%"${value##*[![:space:]]}"}"
case "$key" in
Jc) export AWG_Jc="$value" ;;
Jmin) export AWG_Jmin="$value" ;;
Jmax) export AWG_Jmax="$value" ;;
S1) export AWG_S1="$value" ;;
S2) export AWG_S2="$value" ;;
S3) export AWG_S3="$value" ;;
S4) export AWG_S4="$value" ;;
H1) export AWG_H1="$value" ;;
H2) export AWG_H2="$value" ;;
H3) export AWG_H3="$value" ;;
H4) export AWG_H4="$value" ;;
I1) export AWG_I1="$value" ;;
ListenPort) export AWG_PORT="$value" ;;
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" ;;
ListenPort) _Port="$value" ;;
esac
fi
done < "$conf"
[[ -n "${AWG_Jc:-}" && -n "${AWG_H4:-}" ]]
# Atomic check: are all 11 required fields present?
[[ -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 — environment is modified only on full success
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 "$_Port" ]] && export AWG_PORT="$_Port"
return 0
}
# Load AWG parameters.
# Priority: live server config (awg0.conf) > init file (awgsetup_cfg.init).
# This ensures regen after manual awg0.conf edits works correctly (#38).
#
# Source semantics (important for preventing split-brain between server
# and client configs, see #38):
#
# * init file ($CONFIG_FILE = awgsetup_cfg.init) — for NON-AWG settings
# (OS_ID, ALLOWED_IPS, AWG_PORT, AWG_ENDPOINT etc.). Always loaded
# when present.
# * Live server config ($SERVER_CONF_FILE = /etc/amnezia/amneziawg/awg0.conf)
# — the SOLE source of truth for AWG protocol parameters
# (Jc/Jmin/Jmax/S1-S4/H1-H4/I1) when the file exists.
#
# If the live server config exists but does NOT contain a complete set of
# AWG parameters (corruption / incomplete manual edit) — the function
# returns 1 with an explicit error. Silently falling back to stale values
# from the init file would create split-brain: the server runs the new
# awg0.conf while regen would issue clients old J*/S*/H*. This is exactly
# the class of issue reported by elvaleto and Klavishnik in Discussion #38.
#
# The init file is used for AWG parameters ONLY when the live server
# config is missing entirely — that is the bootstrap path of the first
# install when awg0.conf has not been written yet but generate_awg_params
# has already stored values in the init file.
load_awg_params() {
# 1. Base parameters from init file (OS_ID, ALLOWED_IPS, AWG_PORT, AWG_ENDPOINT etc.)
# 1. Base settings from init (always, for non-AWG keys)
if [[ -f "$CONFIG_FILE" ]]; then
safe_load_config "$CONFIG_FILE" || log_warn "Failed to load $CONFIG_FILE"
fi
# 2. AWG protocol parameters — live awg0.conf takes priority (source of truth)
if load_awg_params_from_server_conf; then
# 2. AWG protocol parameters
if [[ -f "$SERVER_CONF_FILE" ]]; then
# Live config exists — it is the sole source of truth.
# No fallback to init: that would create split-brain.
if ! load_awg_params_from_server_conf; then
log_error "$SERVER_CONF_FILE is missing required AWG parameters"
log_error "(Jc/Jmin/Jmax/S1-S4/H1-H4). Refusing to use stale values from"
log_error "$CONFIG_FILE, that would create a split-brain between server"
log_error "and client configs. Restore the [Interface] section in"
log_error "$SERVER_CONF_FILE or restore awg0.conf from a backup."
return 1
fi
log_debug "AWG parameters loaded from $SERVER_CONF_FILE (live config)"
else
log_debug "AWG parameters loaded from $CONFIG_FILE (init fallback)"
# Bootstrap: server config does not exist yet (first install).
# AWG_* must be in env via safe_load_config above.
log_debug "$SERVER_CONF_FILE missing — using AWG params from $CONFIG_FILE (bootstrap)"
fi
# Check required AWG 2.0 parameters
# 3. Check required AWG 2.0 parameters
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 "Parameter $param not found in $SERVER_CONF_FILE or $CONFIG_FILE"
log_error "Parameter $param not found"
missing=1
fi
done
@ -500,6 +576,7 @@ apply_config() {
}
log_debug "Config applied (syncconf)."
exec {apply_fd}>&-
return 0
}
# ==============================================================================

View file

@ -485,19 +485,35 @@ rand_range() {
# Сортировка гарантирует low ≤ high и непересечение между парами.
# Минимальная ширина каждого диапазона = 1000 (для нормальной обфускации).
# Печатает 4 строки формата "low-high" в stdout.
# Возвращает 1 если за 20 попыток не удалось получить корректные диапазоны
# (вероятность близка к нулю при равномерном uint32 распределении).
# Возвращает 1 если за 20 попыток не удалось получить корректные диапазоны.
#
# Оптимизация: один вызов `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 p1 p2 p3 p4 p5 p6 p7 p8 sorted
p1=$(rand_range 0 4294967295); p2=$(rand_range 0 4294967295)
p3=$(rand_range 0 4294967295); p4=$(rand_range 0 4294967295)
p5=$(rand_range 0 4294967295); p6=$(rand_range 0 4294967295)
p7=$(rand_range 0 4294967295); p8=$(rand_range 0 4294967295)
sorted=$(printf '%s\n' "$p1" "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" | sort -n)
local arr=()
while IFS= read -r _line; do arr+=("$_line"); done <<< "$sorted"
local raw arr=() _v
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
arr+=("$_v"); count=$((count + 1))
(( count == 8 )) && break
done <<< "$raw"
fi
if (( ${#arr[@]} != 8 )); then
arr=()
local _i
for _i in 1 2 3 4 5 6 7 8; do
arr+=("$(rand_range 0 4294967295)")
done
fi
local sorted
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
arr=()
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
if (( ${arr[1]} - ${arr[0]} >= 1000 )) && \
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \

View file

@ -484,20 +484,36 @@ rand_range() {
# Algorithm: 8 random uint32 → sort → 4 (low, high) pairs.
# Sorting guarantees low ≤ high and non-overlap between pairs.
# Minimum width per range = 1000 (for proper obfuscation).
# Prints 4 "low-high" lines to stdout. Returns 1 on failure
# (probability is essentially zero with uniform uint32 distribution).
# Prints 4 "low-high" lines to stdout. Returns 1 on failure.
# Mitigates Russian DPI fingerprinting of static H values (#38).
#
# Optimization: a single `od -N32 -tu4` call reads 32 bytes = 8 uint32
# values in one operation, instead of 8 separate subprocess calls via
# rand_range. Falls back to rand_range if /dev/urandom is unavailable.
generate_awg_h_ranges() {
local attempt=0 max_attempts=20
while (( attempt < max_attempts )); do
local p1 p2 p3 p4 p5 p6 p7 p8 sorted
p1=$(rand_range 0 4294967295); p2=$(rand_range 0 4294967295)
p3=$(rand_range 0 4294967295); p4=$(rand_range 0 4294967295)
p5=$(rand_range 0 4294967295); p6=$(rand_range 0 4294967295)
p7=$(rand_range 0 4294967295); p8=$(rand_range 0 4294967295)
sorted=$(printf '%s\n' "$p1" "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" | sort -n)
local arr=()
while IFS= read -r _line; do arr+=("$_line"); done <<< "$sorted"
local raw arr=() _v
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
arr+=("$_v"); count=$((count + 1))
(( count == 8 )) && break
done <<< "$raw"
fi
if (( ${#arr[@]} != 8 )); then
arr=()
local _i
for _i in 1 2 3 4 5 6 7 8; do
arr+=("$(rand_range 0 4294967295)")
done
fi
local sorted
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
arr=()
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
if (( ${arr[1]} - ${arr[0]} >= 1000 )) && \
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \

View file

@ -122,3 +122,74 @@ CONF
run load_awg_params
[ "$status" -eq 0 ]
}
@test "load_awg_params: split-brain prevention — corrupt server_conf returns 1 even if init has values" {
# The exact scenario from the audit Findings.md #1:
# init file has GOOD (stale) values, server_conf exists but is missing
# required H4. Old behavior would silently fall back to init and pretend
# success — server runs new config, regen would emit clients old values.
# New behavior: error, return 1, no split-brain possible.
create_init_config
cat > "$SERVER_CONF_FILE" << 'CONF'
[Interface]
PrivateKey = TESTKEY
Address = 10.9.9.1/24
ListenPort = 39743
Jc = 99
Jmin = 200
Jmax = 999
S1 = 1
S2 = 2
S3 = 3
S4 = 4
H1 = 500-1500
H2 = 2000-3000
H3 = 4000-5000
CONF
# H4 deliberately missing
run load_awg_params
[ "$status" -eq 1 ]
}
@test "load_awg_params: split-brain prevention — server_conf missing falls back to init (bootstrap)" {
# Counterpart to the previous test: when server_conf is missing entirely,
# init fallback IS allowed — this is the bootstrap path of first install.
create_init_config
rm -f "$SERVER_CONF_FILE"
run load_awg_params
[ "$status" -eq 0 ]
}
@test "load_awg_params_from_server_conf: atomic — partial corrupt config does not pollute env" {
# Init file has GOOD values
create_init_config
safe_load_config "$CONFIG_FILE"
# Sanity: init values loaded
[ "$AWG_Jc" = "6" ]
[ "$AWG_H4" = "100000000-800000000" ]
# Now write a CORRUPT server config — missing H2, H3, H4
cat > "$SERVER_CONF_FILE" << 'CONF'
[Interface]
PrivateKey = TESTKEY
Address = 10.9.9.1/24
ListenPort = 39743
Jc = 99
Jmin = 200
Jmax = 999
S1 = 1
S2 = 2
S3 = 3
S4 = 4
H1 = 500-1500
CONF
# Atomic: should return 1 because H2-H4 missing
run load_awg_params_from_server_conf "$SERVER_CONF_FILE"
[ "$status" -eq 1 ]
# CRITICAL: env must NOT be partially polluted with new Jc/H1
# The init values must remain intact
[ "$AWG_Jc" = "6" ]
[ "$AWG_H1" = "100000-800000" ]
[ "$AWG_H4" = "100000000-800000000" ]
}