Files
wireguard-scripts/wg-manager.sh
T

2705 lines
168 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
resolve_script_dir() {
local source_path=${BASH_SOURCE[0]} source_dir
if [[ $source_path == /* ]]; then
if [[ $source_path == */* ]]; then source_dir=${source_path%/*}; else source_dir=/; fi
else
if [[ $source_path == */* ]]; then source_dir=${PWD}/${source_path%/*}; else source_dir=$PWD; fi
fi
[[ -n $source_dir ]] || source_dir=/
cd -- "$source_dir" && pwd
}
SCRIPT_DIR="$(resolve_script_dir)"
readonly SCRIPT_DIR
readonly WG_MANAGER_VERSION='0.1.0'
readonly EXIT_USAGE=64
readonly EXIT_VALIDATION=65
readonly EXIT_OPERATION=70
# shellcheck disable=SC2034
readonly EXIT_SUCCESS=0
# shellcheck disable=SC2034
readonly EXIT_ROLLBACK=71
# shellcheck disable=SC2034
readonly EXIT_PERMISSION=77
readonly EXIT_DEPENDENCY=69
CONFIG_PATH=''
BACKUP_DIR=''
LOG_DIR=''
METADATA_DIR=''
ARCHIVE_DIR=''
LOCK_FILE=''
WG_INTERFACE=''
WG_CONF=''
CLIENT_DIR=''
SERVER_ENDPOINT=''
SERVER_PORT=''
SERVER_VPN_IP=''
CLIENT_DNS=''
VPN_SUBNET_PREFIX=''
START_HOST=''
END_HOST=''
USE_IPV6=''
IPV6_PREFIX=''
DEFAULT_CLIENT_MODE=''
RECENT_HANDSHAKE_SECONDS=''
BACKUP_RETENTION=''
declare -A CONFIG_VALUES=()
print_usage() {
cat <<'USAGE'
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add NAME [--mode MODE] [--split-networks CIDRS] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui}
Команды управления WireGuard будут добавлены в следующих версиях.
USAGE
}
die() {
local status=$1
shift
printf 'Ошибка: %s\n' "$*" >&2
return "$status"
}
warn() { printf 'Предупреждение: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*" >&2; }
debug() {
if [[ "${WG_MANAGER_DEBUG:-0}" == '1' ]]; then
printf 'Отладка: %s\n' "$*" >&2
fi
return 0
}
add_failure() {
local status=$1 message=$2
die "$status" "Добавление клиента: ${message}"
}
trim_space() {
local value=$1
value="${value#${value%%[!$' \t']*}}"
value="${value%${value##*[!$' \t']}}"
printf '%s' "$value"
}
is_control_free() { [[ $1 != *[$'\001'-$'\037'$'\177']* ]]; }
is_shell_syntax_free() { [[ $1 != *'$'* && $1 != *'`'* && $1 != *';'* && $1 != *'|'* && $1 != *'&'* && $1 != *'<'* && $1 != *'>'* && $1 != *'('* && $1 != *')'* && $1 != *\\* && $1 != *'*'* && $1 != *'?'* && $1 != *'{'* && $1 != *'}'* ]]; }
set_runtime_paths() {
if [[ ${WG_MANAGER_CONFIG+x} ]]; then CONFIG_PATH=$WG_MANAGER_CONFIG; else CONFIG_PATH=${SCRIPT_DIR}/wg-manager.conf; fi
if [[ ${WG_MANAGER_BACKUP_DIR+x} ]]; then BACKUP_DIR=$WG_MANAGER_BACKUP_DIR; else BACKUP_DIR=${SCRIPT_DIR}/backups; fi
if [[ ${WG_MANAGER_LOG_DIR+x} ]]; then LOG_DIR=$WG_MANAGER_LOG_DIR; else LOG_DIR=${SCRIPT_DIR}/logs; fi
if [[ ${WG_MANAGER_METADATA_DIR+x} ]]; then METADATA_DIR=$WG_MANAGER_METADATA_DIR; else METADATA_DIR=${SCRIPT_DIR}/state/clients; fi
if [[ ${WG_MANAGER_ARCHIVE_DIR+x} ]]; then ARCHIVE_DIR=$WG_MANAGER_ARCHIVE_DIR; else ARCHIVE_DIR=${SCRIPT_DIR}/state/archive; fi
if [[ ${WG_MANAGER_LOCK_FILE+x} ]]; then LOCK_FILE=$WG_MANAGER_LOCK_FILE; else LOCK_FILE=${SCRIPT_DIR}/state/wg-manager.lock; fi
local name value
for name in CONFIG_PATH BACKUP_DIR LOG_DIR METADATA_DIR ARCHIVE_DIR LOCK_FILE; do
value=${!name}
if [[ -z "$value" ]] || ! is_control_free "$value" || ! is_shell_syntax_free "$value" || ! validate_path "$value"; then
die "$EXIT_VALIDATION" "Некорректный абсолютный путь ${name}" || return $?
fi
[[ $value != *'//'* && $value != */./* && $value != */../* && $value != */.. && $value != */. ]] || { die "$EXIT_VALIDATION" "Путь ${name} не нормализован"; return $?; }
done
}
parse_config_file() {
local file=$1 line trimmed key raw value line_number=0
CONFIG_VALUES=()
[[ -r "$file" ]] || { die "$EXIT_VALIDATION" "Не удаётся прочитать конфигурацию: ${file}"; return $?; }
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1))
is_control_free "$line" || { die "$EXIT_VALIDATION" "Недопустимый управляющий символ в строке ${line_number}"; return $?; }
trimmed=$(trim_space "$line")
[[ -z "$trimmed" || ${trimmed:0:1} == '#' ]] && continue
[[ $trimmed =~ ^([A-Z][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректное присваивание в строке ${line_number}"; return $?; }
key=${BASH_REMATCH[1]}
raw=$(trim_space "${BASH_REMATCH[2]}")
case "$key" in
WG_INTERFACE|WG_CONF|CLIENT_DIR|SERVER_ENDPOINT|SERVER_PORT|SERVER_VPN_IP|CLIENT_DNS|VPN_SUBNET_PREFIX|START_HOST|END_HOST|USE_IPV6|IPV6_PREFIX|DEFAULT_CLIENT_MODE|RECENT_HANDSHAKE_SECONDS|BACKUP_RETENTION) ;;
*) die "$EXIT_VALIDATION" "Неизвестный параметр конфигурации: ${key}"; return $? ;;
esac
[[ -v "CONFIG_VALUES[$key]" ]] && { die "$EXIT_VALIDATION" "Дублирующийся параметр конфигурации: ${key}"; return $?; }
is_shell_syntax_free "$raw" || { die "$EXIT_VALIDATION" "Недопустимый синтаксис в параметре ${key}"; return $?; }
if [[ $raw == \"* ]]; then
[[ $raw == *\" && ${#raw} -ge 2 && ${raw:1:${#raw}-2} != *\"* ]] || { die "$EXIT_VALIDATION" "Некорректные кавычки в параметре ${key}"; return $?; }
value=${raw:1:${#raw}-2}
elif [[ $raw == *\"* || $raw == *\'* || $raw == *[[:space:]]* || -z "$raw" ]]; then
die "$EXIT_VALIDATION" "Некорректное значение параметра ${key}"; return $?
else value=$raw
fi
CONFIG_VALUES["$key"]=$value
done <"$file"
}
require_config_keys() {
local key
for key in WG_INTERFACE WG_CONF CLIENT_DIR SERVER_ENDPOINT SERVER_PORT SERVER_VPN_IP CLIENT_DNS VPN_SUBNET_PREFIX START_HOST END_HOST USE_IPV6 IPV6_PREFIX DEFAULT_CLIENT_MODE RECENT_HANDSHAKE_SECONDS BACKUP_RETENTION; do
[[ -v "CONFIG_VALUES[$key]" ]] || { die "$EXIT_VALIDATION" "Отсутствует параметр конфигурации: ${key}"; return $?; }
done
}
is_uint() { [[ $1 =~ ^[0-9]+$ ]]; }
validate_ipv4() {
local value=$1 part
local -a parts=()
IFS=. read -r -a parts <<<"$value"
(( ${#parts[@]} == 4 )) || return 1
for part in "${parts[@]}"; do is_uint "$part" && (( 10#$part <= 255 )) || return 1; done
}
validate_ipv6() {
local value=$1 part
local -a parts=()
[[ $value == *:* && $value != *[^0123456789abcdefABCDEF:]* && $value != *:::* ]] || return 1
if [[ $value == *'::'* ]]; then
[[ $value != *'::'*'::'* ]] || return 1
value=${value//::/:}; value=${value#:}; value=${value%:}
IFS=: read -r -a parts <<<"$value"
(( ${#parts[@]} < 8 )) || return 1
for part in "${parts[@]}"; do [[ -z "$part" || ${#part} -gt 4 ]] && return 1; done
return 0
fi
IFS=: read -r -a parts <<<"$value"
(( ${#parts[@]} == 8 )) || return 1
for part in "${parts[@]}"; do [[ -n "$part" && ${#part} -le 4 ]] || return 1; done
}
validate_ip() { validate_ipv4 "$1" || validate_ipv6 "$1"; }
validate_endpoint() {
local endpoint=$1 host=$1 label
local -a endpoint_labels=()
[[ $endpoint != *[[:space:]]* && $endpoint != *[/\\]* ]] || return 1
if [[ $endpoint == \[*\] ]]; then
host=${endpoint:1:${#endpoint}-2}
validate_ipv6 "$host"
return
elif [[ $endpoint == *'['* || $endpoint == *']'* ]]; then return 1; fi
if [[ $host =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
validate_ipv4 "$host"
return
fi
if validate_ipv6 "$host"; then return 0; fi
[[ ${#host} -le 253 && $host =~ ^([A-Za-z0-9-]+\.)*[A-Za-z0-9-]+$ ]] || return 1
IFS=. read -r -a endpoint_labels <<<"$host"
for label in "${endpoint_labels[@]}"; do
[[ ${#label} -le 63 && $label != -* && $label != *- ]] || return 1
done
}
validate_endpoint_value() {
local value=$1 host port
if [[ $value == \[*\]:* ]]; then
host=${value%%\]:*}; host=${host#\[}; port=${value##*:}
validate_ipv6 "$host" && is_uint "$port" && (( 10#$port >= 1 && 10#$port <= 65535 ))
elif [[ $value == *:* && $value != *::* ]]; then
host=${value%:*}; port=${value##*:}
validate_endpoint "$host" && is_uint "$port" && (( 10#$port >= 1 && 10#$port <= 65535 ))
else
validate_endpoint "$value"
fi
}
validate_peer_endpoint_value() {
local value=$1
if [[ $value == \[*\]:* ]]; then
validate_endpoint_value "$value"
elif [[ $value != *:*:* && $value == *:* ]]; then
validate_endpoint_value "$value"
else
return 1
fi
}
is_fwmark() {
local value=$1 number digits
if [[ $value =~ ^[0-9]+$ ]]; then
digits=$value
while [[ ${#digits} -gt 1 && ${digits:0:1} == 0 ]]; do digits=${digits:1}; done
(( ${#digits} <= 10 )) || return 1
(( 10#$digits <= 4294967295 ))
return
fi
[[ $value =~ ^0[xX][0-9A-Fa-f]+$ ]] || return 1
digits=${value#0x}; digits=${digits#0X}
(( ${#digits} <= 8 )) || return 1
number=$((value))
(( number <= 4294967295 ))
}
validate_path() { [[ $1 == /* && $1 != *[[:space:]]* && $1 != *[$'\001'-$'\037'$'\177']* && $1 != *'['* && $1 != *']'* && $1 != *//* && $1 != */./* && $1 != */../* && $1 != */.. && $1 != */. ]]; }
validate_config_values() {
local value server_host
WG_INTERFACE=${CONFIG_VALUES[WG_INTERFACE]}; WG_CONF=${CONFIG_VALUES[WG_CONF]}; CLIENT_DIR=${CONFIG_VALUES[CLIENT_DIR]}; SERVER_ENDPOINT=${CONFIG_VALUES[SERVER_ENDPOINT]}; SERVER_PORT=${CONFIG_VALUES[SERVER_PORT]}; SERVER_VPN_IP=${CONFIG_VALUES[SERVER_VPN_IP]}; CLIENT_DNS=${CONFIG_VALUES[CLIENT_DNS]}; VPN_SUBNET_PREFIX=${CONFIG_VALUES[VPN_SUBNET_PREFIX]}; START_HOST=${CONFIG_VALUES[START_HOST]}; END_HOST=${CONFIG_VALUES[END_HOST]}; USE_IPV6=${CONFIG_VALUES[USE_IPV6]}; IPV6_PREFIX=${CONFIG_VALUES[IPV6_PREFIX]}; DEFAULT_CLIENT_MODE=${CONFIG_VALUES[DEFAULT_CLIENT_MODE]}; RECENT_HANDSHAKE_SECONDS=${CONFIG_VALUES[RECENT_HANDSHAKE_SECONDS]}; BACKUP_RETENTION=${CONFIG_VALUES[BACKUP_RETENTION]}
[[ $WG_INTERFACE =~ ^[A-Za-z0-9_.-]{1,15}$ && $WG_INTERFACE != .* && $WG_INTERFACE != *..* ]] || { die "$EXIT_VALIDATION" 'Некорректное имя интерфейса'; return $?; }
validate_path "$WG_CONF" && validate_path "$CLIENT_DIR" || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации или клиентов'; return $?; }
validate_endpoint "$SERVER_ENDPOINT" || { die "$EXIT_VALIDATION" 'Некорректная конечная точка сервера'; return $?; }
is_uint "$SERVER_PORT" && (( 10#$SERVER_PORT >= 1 && 10#$SERVER_PORT <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный порт сервера'; return $?; }
validate_ipv4 "$SERVER_VPN_IP" || { die "$EXIT_VALIDATION" 'Некорректный IPv4 сервера'; return $?; }
[[ -n $CLIENT_DNS && $CLIENT_DNS != *[[:space:]]* && $CLIENT_DNS != ,* && $CLIENT_DNS != *, && $CLIENT_DNS != *,,* ]] || { die "$EXIT_VALIDATION" 'Некорректный список DNS'; return $?; }
local dns; local -a dns_values=(); IFS=, read -r -a dns_values <<<"$CLIENT_DNS"; for dns in "${dns_values[@]}"; do [[ -n $dns ]] && validate_ip "$dns" || { die "$EXIT_VALIDATION" 'Некорректный адрес DNS'; return $?; }; done
[[ $VPN_SUBNET_PREFIX =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { die "$EXIT_VALIDATION" 'Некорректный префикс VPN'; return $?; }
local -a prefix_values=(); IFS=. read -r -a prefix_values <<<"$VPN_SUBNET_PREFIX"; for value in "${prefix_values[@]}"; do is_uint "$value" && (( 10#$value <= 255 )) || { die "$EXIT_VALIDATION" 'Некорректный префикс VPN'; return $?; }; done
[[ ${SERVER_VPN_IP%.*} == "$VPN_SUBNET_PREFIX" ]] || { die "$EXIT_VALIDATION" 'Префикс VPN не совпадает с адресом сервера'; return $?; }
is_uint "$START_HOST" && is_uint "$END_HOST" && (( 10#$START_HOST >= 1 && 10#$START_HOST <= 254 && 10#$END_HOST >= 1 && 10#$END_HOST <= 254 && 10#$START_HOST <= 10#$END_HOST )) || { die "$EXIT_VALIDATION" 'Некорректный диапазон адресов клиентов'; return $?; }
server_host=${SERVER_VPN_IP##*.}; (( 10#$START_HOST > 10#$server_host || 10#$server_host > 10#$END_HOST )) || { die "$EXIT_VALIDATION" 'Диапазон клиентов содержит адрес сервера'; return $?; }
[[ $USE_IPV6 == 0 || $USE_IPV6 == 1 ]] || { die "$EXIT_VALIDATION" 'USE_IPV6 должен быть 0 или 1'; return $?; }
[[ $IPV6_PREFIX == *'::' && $IPV6_PREFIX != *'::'*'::'* ]] && validate_ipv6 "$IPV6_PREFIX" || { die "$EXIT_VALIDATION" 'Некорректный IPv6-префикс'; return $?; }
[[ $DEFAULT_CLIENT_MODE == full-tunnel || $DEFAULT_CLIENT_MODE == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; }
is_uint "$BACKUP_RETENTION" && (( 10#$BACKUP_RETENTION >= 0 && 10#$BACKUP_RETENTION <= 1000 )) || { die "$EXIT_VALIDATION" 'Некорректное число резервных копий'; return $?; }
is_uint "$RECENT_HANDSHAKE_SECONDS" && (( 10#$RECENT_HANDSHAKE_SECONDS <= 604800 )) || { die "$EXIT_VALIDATION" 'Некорректный срок handshake'; return $?; }
}
load_config() { set_runtime_paths || return $?; parse_config_file "$CONFIG_PATH" || return $?; require_config_keys || return $?; validate_config_values || return $?; }
readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
PARSE_DUPLICATE_KEYS=0
PARSE_ALLOWEDIPS_OVERLAP=0
DISK_LISTEN_PORT='-'
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED META_FILE
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
RUNTIME_AVAILABLE=0
RUNTIME_LISTEN_PORT='-'
is_wireguard_key() { [[ $1 =~ ^[A-Za-z0-9+/]{43}=$ ]]; }
ipv4_to_int() {
local -a octets=() octet
IFS=. read -r -a octets <<<"${1%/*}"
printf '%u\n' "$(( 10#${octets[0]} * 16777216 + 10#${octets[1]} * 65536 + 10#${octets[2]} * 256 + 10#${octets[3]} ))"
}
cidr_network_v4() {
local address=${1%/*} prefix=${1#*/} mask
mask=$(( prefix == 0 ? 0 : (0xffffffff << (32 - prefix)) & 0xffffffff ))
printf '%u %u\n' "$(( $(ipv4_to_int "$address") & mask ))" "$prefix"
}
ipv6_groups() {
local value=$1 left right part missing index
local -a left_values=() right_values=() groups=()
if [[ $value == *'::'* ]]; then
left=${value%%::*}; right=${value##*::}
[[ -n $left ]] && IFS=: read -r -a left_values <<<"$left"
[[ -n $right ]] && IFS=: read -r -a right_values <<<"$right"
missing=$(( 8 - ${#left_values[@]} - ${#right_values[@]} )); (( missing >= 1 )) || return 1
groups=("${left_values[@]}"); for ((index = 0; index < missing; index += 1)); do groups+=(0); done; groups+=("${right_values[@]}")
else
IFS=: read -r -a groups <<<"$value"
fi
(( ${#groups[@]} == 8 )) || return 1
for part in "${groups[@]}"; do printf '%04x\n' "$((16#$part))"; done
}
ipv6_prefix_overlap() {
local left=$1 right=$2 left_prefix=${1#*/} right_prefix=${2#*/} prefix bit group_index left_group right_group mask
local -a left_groups=() right_groups=()
prefix=$left_prefix; (( right_prefix < prefix )) && prefix=$right_prefix
mapfile -t left_groups < <(ipv6_groups "${left%/*}")
mapfile -t right_groups < <(ipv6_groups "${right%/*}")
((${#left_groups[@]} == 8 && ${#right_groups[@]} == 8)) || return 1
for ((bit = 0; bit < prefix; bit += 1)); do
group_index=$((bit / 16)); mask=$((0x8000 >> (bit % 16)))
left_group=$((16#${left_groups[$group_index]})); right_group=$((16#${right_groups[$group_index]}))
(( (left_group & mask) == (right_group & mask) )) || return 1
done
}
validate_cidr() {
local cidr=$1 address prefix
[[ $cidr == */* && $cidr != *[[:space:]]* ]] || return 1
address=${cidr%/*}; prefix=${cidr#*/}
is_uint "$prefix" || return 1
if validate_ipv4 "$address"; then (( 10#$prefix <= 32 )); return; fi
validate_ipv6 "$address" && (( 10#$prefix <= 128 ))
}
cidr_family() { validate_ipv4 "${1%/*}" && printf '4' || printf '6'; }
cidrs_overlap() {
local left=$1 right=$2 left_family right_family left_network left_prefix right_network right_prefix
left_family=$(cidr_family "$left"); right_family=$(cidr_family "$right"); [[ $left_family == "$right_family" ]] || return 1
if [[ $left_family == 4 ]]; then
read -r left_network left_prefix <<<"$(cidr_network_v4 "$left")"
read -r right_network right_prefix <<<"$(cidr_network_v4 "$right")"
local common=$left_prefix; (( right_prefix < common )) && common=$right_prefix
local mask=$(( common == 0 ? 0 : (0xffffffff << (32 - common)) & 0xffffffff ))
(( (left_network & mask) == (right_network & mask) ))
return
fi
ipv6_prefix_overlap "$left" "$right"
}
normalize_allowed_ips() {
local raw=$1 item existing index
local -a values=()
local -A seen=()
IFS=, read -r -a values <<<"$raw"
((${#values[@]} > 0)) || return 1
for index in "${!values[@]}"; do
values[$index]=$(trim_space "${values[$index]}")
done
for item in "${values[@]}"; do
[[ -n "$item" ]] && validate_cidr "$item" || return 1
[[ -z ${seen[$item]+x} ]] || return 1
seen["$item"]=1
done
for index in "${!values[@]}"; do
for existing in "${values[@]:index+1}"; do cidrs_overlap "${values[$index]}" "$existing" && return 1; done
done
printf '%s\n' "${values[@]}" | LC_ALL=C sort | paste -sd, -
}
allowed_sets_overlap() {
local left=$1 right=$2 left_item right_item
local -a left_values=() right_values=()
IFS=, read -r -a left_values <<<"$left"; IFS=, read -r -a right_values <<<"$right"
for left_item in "${left_values[@]}"; do for right_item in "${right_values[@]}"; do cidrs_overlap "$left_item" "$right_item" && return 0; done; done
return 1
}
extract_client_addresses() {
local allowed=$1 item address prefix
local -a values=() v4=() v6=()
IFS=, read -r -a values <<<"$allowed"
for item in "${values[@]}"; do
address=${item%/*}; prefix=${item#*/}
if [[ $address == *:* ]]; then v6+=("$item"); elif [[ $prefix == 32 ]]; then v4+=("$item"); fi
done
printf '%s|%s\n' "$(IFS=,; printf '%s' "${v4[*]}")" "$(IFS=,; printf '%s' "${v6[*]}")"
}
commit_disk_peer() {
local key=$1 allowed=$2
[[ -n "$key" && -n "$allowed" ]] || { die "$EXIT_VALIDATION" 'Peer не содержит PublicKey или AllowedIPs'; return $?; }
is_wireguard_key "$key" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в конфигурации'; return $?; }
[[ -z ${DISK_ALLOWED[$key]+x} ]] || { PARSE_DUPLICATE_KEYS=1; die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в конфигурации'; return $?; }
local normalized existing_key
normalized=$(normalize_allowed_ips "$allowed") || { PARSE_ALLOWEDIPS_OVERLAP=1; die "$EXIT_VALIDATION" 'Некорректные или пересекающиеся AllowedIPs'; return $?; }
for existing_key in "${!DISK_ALLOWED[@]}"; do
allowed_sets_overlap "$normalized" "${DISK_ALLOWED[$existing_key]}" && { PARSE_ALLOWEDIPS_OVERLAP=1; die "$EXIT_VALIDATION" 'Пересекающиеся AllowedIPs между peers'; return $?; }
done
DISK_ALLOWED["$key"]=$normalized
local addresses; addresses=$(extract_client_addresses "${DISK_ALLOWED[$key]}")
DISK_IPV4["$key"]=${addresses%%|*}; DISK_IPV6["$key"]=${addresses#*|}
DISK_ENDPOINT["$key"]=${peer_endpoint:-'-'}
DISK_MARKER["$key"]=${peer_marker:-}
DISK_LEGACY["$key"]=${peer_legacy:-}
peer_key=''; peer_allowed=''; peer_endpoint='-'; peer_marker=''; peer_legacy=''
}
parse_wireguard_config() {
local file=$1 line trimmed section='' key value peer_key='' peer_allowed='' peer_endpoint='-' peer_marker='' peer_legacy='' pending_marker='' pending_legacy='' line_number=0
local -A interface_fields=() peer_fields=()
DISK_ALLOWED=(); DISK_ENDPOINT=(); DISK_MARKER=(); DISK_LEGACY=(); DISK_IPV4=(); DISK_IPV6=(); PARSE_DUPLICATE_KEYS=0; PARSE_ALLOWEDIPS_OVERLAP=0
DISK_LISTEN_PORT='-'
[[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; }
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1)); is_control_free "$line" || { die "$EXIT_VALIDATION" "Управляющий символ в конфигурации WireGuard, строка ${line_number}"; return $?; }
trimmed=$(trim_space "$line"); [[ -z "$trimmed" ]] && continue
if [[ ${trimmed:0:1} == '#' ]]; then
if [[ $trimmed =~ ^#[[:space:]]*${MANAGED_MARKER_PREFIX}[[:space:]]*([A-Za-z0-9._-]+)$ ]]; then
if [[ $section == Peer && ${#peer_fields[@]} -eq 0 ]]; then peer_marker=${BASH_REMATCH[1]}; else pending_marker=${BASH_REMATCH[1]}; fi
fi
if [[ $trimmed =~ ^###[[:space:]]Client[[:space:]]([A-Za-z0-9._-]+)$ ]]; then
if [[ $section == Peer && ${#peer_fields[@]} -eq 0 ]]; then peer_legacy=${BASH_REMATCH[1]}; else pending_legacy=${BASH_REMATCH[1]}; fi
fi
continue
fi
if [[ $trimmed == '[Interface]' ]]; then
[[ -z $section ]] || { die "$EXIT_VALIDATION" 'Повторная секция Interface'; return $?; }
pending_marker=''; pending_legacy=''; section=Interface; continue
elif [[ $trimmed == '[Peer]' ]]; then
[[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" 'Peer до секции Interface'; return $?; }
[[ $section != Peer ]] || { commit_disk_peer "$peer_key" "$peer_allowed" || return $?; }
section=Peer; peer_key=''; peer_allowed=''; peer_endpoint='-'; peer_marker=$pending_marker; peer_legacy=$pending_legacy; pending_marker=''; pending_legacy=''; peer_fields=(); continue
elif [[ $trimmed == \[*\] ]]; then
die "$EXIT_VALIDATION" "Неизвестная секция WireGuard, строка ${line_number}"; return $?
fi
[[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" "Параметр вне секции, строка ${line_number}"; return $?; }
[[ $trimmed =~ ^([A-Za-z][A-Za-z0-9]*)[[:space:]]*=[[:space:]]*(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректный параметр WireGuard, строка ${line_number}"; return $?; }
key=${BASH_REMATCH[1]}; value=$(trim_space "${BASH_REMATCH[2]}"); [[ -n "$value" ]] || { die "$EXIT_VALIDATION" 'Пустой параметр WireGuard'; return $?; }
[[ $section != Interface || -z $pending_marker$pending_legacy ]] || { pending_marker=''; pending_legacy=''; }
if [[ $section == Interface ]]; then
case "$key" in
PrivateKey|ListenPort|MTU|Table|SaveConfig|FwMark) ;;
Address|DNS|PostUp|PostDown|PreUp|PreDown) continue ;;
*) die "$EXIT_VALIDATION" "Неизвестный параметр Interface: ${key}"; return $? ;;
esac
[[ -z ${interface_fields[$key]+x} ]] || { die "$EXIT_VALIDATION" "Дублирующийся параметр Interface: ${key}"; return $?; }
interface_fields["$key"]=1
if [[ $key == PrivateKey ]]; then is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный ключ Interface'; return $?; }; fi
if [[ $key == ListenPort ]]; then is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный ListenPort'; return $?; }; DISK_LISTEN_PORT=$value; fi
if [[ $key == FwMark ]]; then is_fwmark "$value" || { die "$EXIT_VALIDATION" 'Некорректный FwMark'; return $?; }; fi
continue
fi
case "$key" in
PublicKey) [[ -z ${peer_fields[PublicKey]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в peer'; return $?; }; is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в peer'; return $?; }; peer_key=$value; peer_fields[PublicKey]=1 ;;
PresharedKey) [[ -z ${peer_fields[PresharedKey]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PresharedKey в peer'; return $?; }; is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный PresharedKey'; return $?; }; peer_fields[PresharedKey]=1 ;;
AllowedIPs) [[ -z ${peer_fields[AllowedIPs]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся AllowedIPs в peer'; return $?; }; peer_allowed=$value; peer_fields[AllowedIPs]=1 ;;
Endpoint) [[ -z ${peer_fields[Endpoint]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся Endpoint в peer'; return $?; }; validate_peer_endpoint_value "$value" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint'; return $?; }; peer_endpoint=$value; peer_fields[Endpoint]=1 ;;
PersistentKeepalive) [[ -z ${peer_fields[PersistentKeepalive]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PersistentKeepalive'; return $?; }; is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный PersistentKeepalive'; return $?; }; peer_fields[PersistentKeepalive]=1 ;;
*) die "$EXIT_VALIDATION" "Неизвестный параметр Peer: ${key}"; return $? ;;
esac
done <"$file"
[[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" 'Отсутствует секция Interface'; return $?; }
[[ $section != Peer ]] || commit_disk_peer "$peer_key" "$peer_allowed"
}
parse_metadata_file() {
local file=$1 line key value line_number=0 existing_key existing_name
local name='' public_key='' ipv4='' ipv6='' created='' config='' mode='' managed=''
[[ -f "$file" && ! -L "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1)); line=$(trim_space "$line"); [[ -z "$line" ]] && continue
[[ $line =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректные метаданные: ${file}"; return $?; }
key=${BASH_REMATCH[1]}; value=${BASH_REMATCH[2]}; [[ -n "$value" ]] || { die "$EXIT_VALIDATION" 'Пустое поле метаданных'; return $?; }
case "$key" in
NAME) [[ -z $name ]] || { die "$EXIT_VALIDATION" 'Дубликат NAME в метаданных'; return $?; }; name=$value ;;
PUBLIC_KEY) [[ -z $public_key ]] || { die "$EXIT_VALIDATION" 'Дубликат PUBLIC_KEY в метаданных'; return $?; }; public_key=$value ;;
IPV4) [[ -z $ipv4 ]] || { die "$EXIT_VALIDATION" 'Дубликат IPV4 в метаданных'; return $?; }; ipv4=$value ;;
IPV6) [[ -z $ipv6 ]] || { die "$EXIT_VALIDATION" 'Дубликат IPV6 в метаданных'; return $?; }; ipv6=$value ;;
CREATED_AT) [[ -z $created ]] || { die "$EXIT_VALIDATION" 'Дубликат CREATED_AT в метаданных'; return $?; }; created=$value ;;
CONFIG_PATH) [[ -z $config ]] || { die "$EXIT_VALIDATION" 'Дубликат CONFIG_PATH в метаданных'; return $?; }; config=$value ;;
CLIENT_MODE) [[ -z $mode ]] || { die "$EXIT_VALIDATION" 'Дубликат CLIENT_MODE в метаданных'; return $?; }; mode=$value ;;
MANAGED) [[ -z $managed ]] || { die "$EXIT_VALIDATION" 'Дубликат MANAGED в метаданных'; return $?; }; managed=$value ;;
*) die "$EXIT_VALIDATION" "Неизвестное поле метаданных: ${key}"; return $? ;;
esac
done <"$file"
[[ $name =~ ^[A-Za-z0-9._-]+$ && $name != -* && -n $public_key && -n $ipv4 && $created =~ ^[0-9]+$ && $config == /* ]] || { die "$EXIT_VALIDATION" "Некорректные метаданные: ${file}"; return $?; }
[[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" "Некорректный режим в метаданных: ${file}"; return $?; }
[[ $managed == 0 || $managed == 1 ]] || { die "$EXIT_VALIDATION" "Некорректный MANAGED в метаданных: ${file}"; return $?; }
validate_path "$config" && [[ $config == "${CLIENT_DIR%/}"/* ]] && { [[ ! -e "$config" || ! -L "$config" ]]; } || { die "$EXIT_VALIDATION" "Некорректный путь клиента в метаданных: ${file}"; return $?; }
is_wireguard_key "$public_key" && validate_ipv4 "$ipv4" || { die "$EXIT_VALIDATION" "Некорректная идентичность в метаданных: ${file}"; return $?; }
if [[ -n $ipv6 ]] && ! validate_ipv6 "$ipv6"; then die "$EXIT_VALIDATION" "Некорректный IPV6 в метаданных: ${file}"; return $?; fi
for existing_key in "${!META_NAME[@]}"; do [[ $existing_key == "$public_key" ]] && { die "$EXIT_VALIDATION" 'Дублирующийся PUBLIC_KEY в метаданных'; return $?; }; done
for existing_name in "${META_NAMES[@]}"; do [[ $existing_name == "$name" ]] && { die "$EXIT_VALIDATION" 'Дублирующееся NAME в метаданных'; return $?; }; done
META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed; META_FILE["$public_key"]=$file
}
parse_metadata() {
local file
META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=(); META_FILE=()
[[ -d "$METADATA_DIR" ]] || return 0
for file in "$METADATA_DIR"/*; do [[ -e "$file" ]] || continue; parse_metadata_file "$file" || return $?; done
}
parse_runtime_dump() {
local line fields_count line_number=0 key endpoint allowed handshake rx tx keepalive
local output=$1
RUNTIME_AVAILABLE=1; RUNTIME_LISTEN_PORT='-'; RUNTIME_ENDPOINT=(); RUNTIME_HANDSHAKE=(); RUNTIME_RX=(); RUNTIME_TX=(); RUNTIME_ALLOWED=()
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1)); IFS=$'\t' read -r -a fields <<<"$line"; fields_count=${#fields[@]}
if (( line_number == 1 )); then
(( fields_count == 4 )) || { die "$EXIT_VALIDATION" 'Некорректная строка интерфейса wg dump'; return $?; }
if [[ ${fields[0]} != '(none)' ]] && ! is_wireguard_key "${fields[0]}"; then die "$EXIT_VALIDATION" 'Некорректный ключ интерфейса wg dump'; return $?; fi
if [[ ${fields[1]} != '(none)' ]] && ! is_wireguard_key "${fields[1]}"; then die "$EXIT_VALIDATION" 'Некорректный ключ интерфейса wg dump'; return $?; fi
is_uint "${fields[2]}" && (( 10#${fields[2]} <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный порт в wg dump'; return $?; }; RUNTIME_LISTEN_PORT=${fields[2]}; continue
fi
(( fields_count == 8 )) || { die "$EXIT_VALIDATION" 'Некорректная строка peer в wg dump'; return $?; }
is_wireguard_key "${fields[0]}" && { [[ ${fields[1]} == '(none)' ]] || is_wireguard_key "${fields[1]}"; } || { die "$EXIT_VALIDATION" 'Некорректный ключ в wg dump'; return $?; }
key=${fields[0]}; endpoint=${fields[2]}; allowed=${fields[3]}; handshake=${fields[4]}; rx=${fields[5]}; tx=${fields[6]}; keepalive=${fields[7]}
[[ $endpoint == '(none)' ]] && endpoint='-'
[[ $allowed == '(none)' ]] && allowed='-'
is_wireguard_key "$key" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в wg dump'; return $?; }
[[ $endpoint == '-' || -z $endpoint ]] || validate_peer_endpoint_value "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint в wg dump'; return $?; }
[[ -z ${RUNTIME_HANDSHAKE[$key]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в wg dump'; return $?; }
if [[ $allowed != '-' ]]; then normalize_allowed_ips "$allowed" >/dev/null || { die "$EXIT_VALIDATION" 'Некорректные AllowedIPs в wg dump'; return $?; }; fi
is_uint "$handshake" && is_uint "$rx" && is_uint "$tx" && is_uint "$keepalive" || { die "$EXIT_VALIDATION" 'Некорректные числа в wg dump'; return $?; }
RUNTIME_ENDPOINT["$key"]=${endpoint:--}; if [[ $allowed == '-' ]]; then RUNTIME_ALLOWED["$key"]='-'; else RUNTIME_ALLOWED["$key"]=$(normalize_allowed_ips "$allowed"); fi; RUNTIME_HANDSHAKE["$key"]=$handshake; RUNTIME_RX["$key"]=$rx; RUNTIME_TX["$key"]=$tx
done <<<"$output"
}
load_runtime_state() {
local output command_status had_errexit=0
RUNTIME_AVAILABLE=0; RUNTIME_LISTEN_PORT='-'
if ! command -v wg >/dev/null 2>&1; then return 0; fi
[[ $- == *e* ]] && had_errexit=1
set +e
output=$(wg show "$WG_INTERFACE" dump 2>/dev/null); command_status=$?
(( had_errexit == 1 )) && set -e || set +e
(( command_status == 0 )) || return 0
[[ -n "$output" ]] || return 0
parse_runtime_dump "$output"
}
current_epoch() {
if [[ ${WG_MANAGER_NOW+x} ]]; then
[[ $WG_MANAGER_NOW =~ ^[0-9]+$ ]] || return "$EXIT_VALIDATION"
printf '%s\n' "$WG_MANAGER_NOW"
else
date +%s
fi
}
build_client_records() {
local key addresses name origin handshake now state disk_ipv4 disk_ipv6 expected_allowed
local -a keys=()
local -A seen_keys=()
for key in "${!DISK_ALLOWED[@]}" "${!RUNTIME_HANDSHAKE[@]}"; do [[ -n ${seen_keys[$key]+x} ]] || { seen_keys[$key]=1; keys+=("$key"); }; done
CLIENT_KEYS=(); CLIENT_NAMES=(); CLIENT_ADDRESSES=(); CLIENT_ENDPOINTS=(); CLIENT_HANDSHAKES=(); CLIENT_RX=(); CLIENT_TX=(); CLIENT_STATES=(); CLIENT_ORIGINS=()
now=$(current_epoch) || return $?
for key in "${keys[@]}"; do
name='-'; origin=unmanaged; addresses=${DISK_ALLOWED[$key]:-${RUNTIME_ALLOWED[$key]:--}}; handshake=${RUNTIME_HANDSHAKE[$key]:-0}
disk_ipv4=${DISK_IPV4[$key]:-}; disk_ipv4=${disk_ipv4%%/*}; disk_ipv6=${DISK_IPV6[$key]:-}; disk_ipv6=${disk_ipv6%%/*}
if [[ -n ${DISK_ALLOWED[$key]+x} && ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
expected_allowed="${META_IPV4[$key]}/32"; [[ -n ${META_IPV6[$key]:-} ]] && expected_allowed+=",${META_IPV6[$key]}/128"
if [[ ${META_IPV4[$key]} == "$disk_ipv4" && ${META_IPV6[$key]:-} == "$disk_ipv6" && ${DISK_ALLOWED[$key]:-} == "$(normalize_allowed_ips "$expected_allowed")" ]]; then name=${META_NAME[$key]}; origin=managed; fi
fi
if [[ $origin != managed && -n ${DISK_MARKER[$key]:-}${DISK_LEGACY[$key]:-} ]]; then origin=legacy; name=${DISK_MARKER[$key]:-${DISK_LEGACY[$key]}}; fi
if [[ $origin == managed ]]; then
if [[ $handshake == 0 ]]; then state=never; elif (( now >= handshake && now - handshake <= RECENT_HANDSHAKE_SECONDS )); then state=active; else state=inactive; fi
else state=unmanaged; fi
CLIENT_KEYS+=("$key"); CLIENT_NAMES+=("$name"); CLIENT_ADDRESSES+=("$addresses"); CLIENT_ENDPOINTS+=("${RUNTIME_ENDPOINT[$key]:-${DISK_ENDPOINT[$key]:--}}"); CLIENT_HANDSHAKES+=("$handshake"); CLIENT_RX+=("${RUNTIME_RX[$key]:-0}"); CLIENT_TX+=("${RUNTIME_TX[$key]:-0}"); CLIENT_STATES+=("$state"); CLIENT_ORIGINS+=("$origin")
done
}
load_client_model() {
load_config || return $?; parse_wireguard_config "$WG_CONF" || return $?; parse_metadata || return $?; load_runtime_state || return $?; build_client_records
}
# Internal contract: run_transaction OPERATION TARGET BUILDER [METADATA_FILE...]. BUILDER is
# called only after the lock and a fresh config/metadata read, with one staging
# path. It must write the complete proposed config and return nonzero to abort.
TRANSACTION_TEMP_PATHS=()
TRANSACTION_BACKUP_PATH=''
TRANSACTION_FINALIZER=''
TRANSACTION_OWNED_PATHS=()
TRANSACTION_PENDING_SIGNAL=''
TRANSACTION_ROLLBACK_ACTIVE=0
TRANSACTION_ROLLBACK_INTERRUPTED=0
TRANSACTION_ROLLBACK_SIGNAL=''
TRANSACTION_OUTCOME='failed-before-apply'
transaction_test_hooks_enabled() {
local path config_path
[[ ${WG_MANAGER_TEST_MODE:-0} == 1 && -n ${TEST_ROOT:-} && $TEST_ROOT != / && $TEST_ROOT == /* && -d $TEST_ROOT && ! -L $TEST_ROOT ]] || return 1
is_control_free "$TEST_ROOT" && path_components_safe "$TEST_ROOT" || return 1
config_path=${CONFIG_PATH:-${WG_MANAGER_CONFIG:-}}
[[ -n $config_path && $config_path == "${TEST_ROOT%/}"/* ]] || return 1
path_components_safe "$config_path" || return 1
for path in "${WG_CONF:-}" "${CLIENT_DIR:-}" "${METADATA_DIR:-}" "${BACKUP_DIR:-}" "${LOCK_FILE:-}" "${LOG_DIR:-}" "${ARCHIVE_DIR:-}"; do
[[ -n $path && $path == "${TEST_ROOT%/}"/* ]] && path_components_safe "$path" || return 1
done
for path in "${ADD_PUBLISH_READY_FIFO:-}" "${ADD_PUBLISH_RELEASE_FIFO:-}" "${ADD_PUBLISH_PID_FILE:-}"; do
[[ -z $path ]] && continue
[[ $path == "${TEST_ROOT%/}"/* ]] && is_control_free "$path" && path_components_safe "$path" || return 1
done
}
transaction_cleanup() {
local incoming_status=$? path
for path in "${TRANSACTION_TEMP_PATHS[@]:-}"; do
if [[ -n "$path" && ( -e "$path" || -L "$path" ) ]]; then
if ! rm -f -- "$path"; then
printf 'Предупреждение: не удалось удалить временный файл\n' >&2
fi
fi
done
for path in "${TRANSACTION_OWNED_PATHS[@]:-}"; do
if [[ -n "$path" && ( -e "$path" || -L "$path" ) ]]; then
if ! rm -f -- "$path"; then
printf 'Предупреждение: не удалось удалить опубликованный временный файл\n' >&2
fi
fi
done
TRANSACTION_TEMP_PATHS=()
TRANSACTION_OWNED_PATHS=()
return "$incoming_status"
}
transaction_signal() {
local signal=$1
if (( TRANSACTION_ROLLBACK_ACTIVE == 1 )); then
TRANSACTION_ROLLBACK_INTERRUPTED=1
TRANSACTION_ROLLBACK_SIGNAL=$signal
else
TRANSACTION_PENDING_SIGNAL=$signal
fi
return 130
}
TRANSACTION_CREATED_TEMP=''
create_transaction_temp() {
local kind=$1 path
TRANSACTION_CREATED_TEMP=''
path=$(mktemp "${WG_CONF%/*}/.wg-manager.${kind}.XXXXXX") || return $?
if [[ ! -f "$path" || -L "$path" ]] || ! chmod 600 -- "$path"; then
rm -f -- "$path"
return "$EXIT_OPERATION"
fi
TRANSACTION_TEMP_PATHS+=("$path")
TRANSACTION_CREATED_TEMP=$path
}
transaction_stat() {
local path=$1 field=$2
case "$field" in
mode) stat -c '%a' -- "$path" ;;
uid) stat -c '%u' -- "$path" ;;
gid) stat -c '%g' -- "$path" ;;
*) return 1 ;;
esac
}
copy_restricted() {
local source=$1 destination=$2
if [[ ${TRANSACTION_METADATA_COPY_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled && [[ $destination == "${BACKUP_DIR:-}"/* && ${destination##*/} != wg.conf ]]; then return 1; fi
[[ -f "$source" && ! -L "$source" ]] || return 1
cp -- "$source" "$destination" && chmod 600 -- "$destination"
}
restore_file_metadata() {
local path=$1 mode=$2 uid=$3 gid=$4
chmod "$mode" -- "$path" || return 1
if (( EUID == 0 )); then
chown "$uid:$gid" -- "$path" || return 1
fi
}
create_backup() {
local source=$1 operation=$2 target=$3 timestamp counter backup allocated_backup='' manifest metadata mode uid gid now metadata_name backup_name backup_prefix backup_counter
local -A metadata_seen=()
if [[ ${TRANSACTION_BACKUP_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then die "$EXIT_OPERATION" 'Не удалось создать резервную копию'; return $?; fi
[[ -f "$source" && ! -L "$source" ]] || { die "$EXIT_OPERATION" 'Исходный конфиг недоступен для резервного копирования'; return $?; }
[[ ! -L "$BACKUP_DIR" ]] || { die "$EXIT_OPERATION" 'Каталог резервных копий является ссылкой'; return $?; }
if ! mkdir -p -- "$BACKUP_DIR" || ! chmod 700 -- "$BACKUP_DIR"; then die "$EXIT_OPERATION" 'Не удалось подготовить каталог резервных копий'; return $?; fi
now=$(current_epoch) || { die "$EXIT_OPERATION" 'Не удалось получить время для резервной копии'; return $?; }
timestamp=$(date -u -d "@${now}" '+%Y%m%dT%H%M%SZ') || { die "$EXIT_OPERATION" 'Не удалось получить UTC-время для резервной копии'; return $?; }
mode=$(transaction_stat "$source" mode) || return "$EXIT_OPERATION"
uid=$(transaction_stat "$source" uid) || return "$EXIT_OPERATION"
gid=$(transaction_stat "$source" gid) || return "$EXIT_OPERATION"
for metadata in "${@:4}"; do
[[ -f "$metadata" && ! -L "$metadata" && "$metadata" == "${METADATA_DIR%/}"/* && ${metadata%/*} == "${METADATA_DIR%/}" && "$metadata" != *'/../'* && "$metadata" != */.. ]] || { die "$EXIT_OPERATION" 'Некорректный файл метаданных для резервной копии'; return $?; }
path_components_safe "$metadata" || { die "$EXIT_OPERATION" 'Некорректный путь метаданных для резервной копии'; return $?; }
metadata_name=${metadata##*/}
[[ $metadata_name != '' && $metadata_name != wg.conf && $metadata_name != manifest && $metadata_name != */* && $metadata_name =~ ^[A-Za-z0-9._-]+$ && -z ${metadata_seen[$metadata_name]+x} ]] || { die "$EXIT_OPERATION" 'Некорректное имя метаданных для резервной копии'; return $?; }
metadata_seen["$metadata_name"]=1
done
counter=0
for backup in "$BACKUP_DIR/${timestamp}-${WG_INTERFACE}-"*; do
[[ -d "$backup" && ! -L "$backup" ]] || continue
backup_name=${backup##*/}
backup_prefix="${timestamp}-${WG_INTERFACE}-"
[[ $backup_name == "$backup_prefix"* ]] || continue
backup_counter=${backup_name#"$backup_prefix"}
[[ $backup_counter =~ ^[0-9]{3}$ ]] || continue
if (( 10#$backup_counter >= counter )); then counter=$((10#$backup_counter + 1)); fi
done
for ((counter = counter; counter <= 999; counter++)); do
backup=$(printf '%s/%s-%s-%03d' "$BACKUP_DIR" "$timestamp" "$WG_INTERFACE" "$counter")
if mkdir -- "$backup" 2>/dev/null; then allocated_backup=$backup; break; fi
done
[[ -n "$allocated_backup" ]] || { die "$EXIT_OPERATION" 'Не удалось создать уникальную резервную копию'; return $?; }
backup=$allocated_backup
if { [[ ${TRANSACTION_BACKUP_DIR_CHMOD_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! chmod 700 -- "$backup"; then
rm -rf -- "$backup"
die "$EXIT_OPERATION" 'Не удалось защитить резервную копию'
return $?
fi
copy_restricted "$source" "${backup}/wg.conf" || { rm -rf -- "$backup"; die "$EXIT_OPERATION" 'Не удалось сохранить конфиг в резервную копию'; return $?; }
manifest=${backup}/manifest
if { [[ ${TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! {
printf 'INTERFACE=%s\n' "$WG_INTERFACE"
printf 'TIMESTAMP=%s\nOPERATION=%s\nTARGET=%s\n' "$timestamp" "$operation" "$target"
printf 'SOURCE_MODE=%s\nSOURCE_UID=%s\nSOURCE_GID=%s\n' "$mode" "$uid" "$gid"
printf 'CONFIG=wg.conf\n'
for metadata in "${@:4}"; do
metadata_name=${metadata##*/}
copy_restricted "$metadata" "${backup}/${metadata_name}" || { rm -rf -- "$backup"; die "$EXIT_OPERATION" 'Не удалось сохранить метаданные в резервную копию'; return $?; }
printf 'METADATA=%s\n' "$metadata_name"
done
} >"$manifest"; then rm -rf -- "$backup"; die "$EXIT_OPERATION" 'Не удалось записать манифест резервной копии'; return $?; fi
if { [[ ${TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! chmod 600 -- "$manifest"; then
rm -rf -- "$backup"
die "$EXIT_OPERATION" 'Не удалось защитить манифест резервной копии'
return $?
fi
TRANSACTION_BACKUP_PATH=$backup
printf '%s\n' "$backup"
}
validate_backup_manifest() {
local backup=$1 line key value manifest_interface='' manifest_timestamp='' manifest_operation='' manifest_target='' manifest_config='' metadata_name backup_name backup_prefix backup_counter server_owner
local -A seen=() metadata_seen=()
[[ -d "$backup" && ! -L "$backup" && -f "$backup/manifest" && ! -L "$backup/manifest" && -f "$backup/wg.conf" && ! -L "$backup/wg.conf" ]] || return 1
[[ $backup == "${BACKUP_DIR%/}"/* && ${backup%/*} == "${BACKUP_DIR%/}" ]] || return 1
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return 1
[[ $(stat -c '%a:%u:%g' -- "$backup") == "700:$server_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/manifest") == "600:1:$server_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/wg.conf") == "600:1:$server_owner" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
[[ $line == *=* ]] || return 1
key=${line%%=*}; value=${line#*=}
case $key in
INTERFACE|TIMESTAMP|OPERATION|TARGET|SOURCE_MODE|SOURCE_UID|SOURCE_GID|CONFIG) ;;
METADATA) metadata_name=$value; [[ $metadata_name =~ ^[A-Za-z0-9._-]+$ && $metadata_name != wg.conf && $metadata_name != manifest && -z ${metadata_seen[$metadata_name]+x} ]] || return 1; metadata_seen[$metadata_name]=1; continue ;;
*) return 1 ;;
esac
[[ -z ${seen[$key]+x} ]] || return 1
seen[$key]=1
case $key in
INTERFACE) manifest_interface=$value ;;
TIMESTAMP) manifest_timestamp=$value ;;
OPERATION) manifest_operation=$value ;;
TARGET) manifest_target=$value ;;
SOURCE_MODE) [[ $value == 400 || $value == 600 ]] || return 1 ;;
SOURCE_UID|SOURCE_GID) [[ $value =~ ^[0-9]+$ ]] || return 1 ;;
CONFIG) manifest_config=$value ;;
esac
done <"$backup/manifest"
[[ ${seen[INTERFACE]+x} && ${seen[TIMESTAMP]+x} && ${seen[OPERATION]+x} && ${seen[TARGET]+x} && ${seen[SOURCE_MODE]+x} && ${seen[SOURCE_UID]+x} && ${seen[SOURCE_GID]+x} && ${seen[CONFIG]+x} ]] || return 1
[[ $manifest_interface == "$WG_INTERFACE" && $manifest_timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $manifest_operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $manifest_target =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ && $manifest_config == wg.conf ]] || return 1
backup_name=${backup##*/}
backup_prefix="${manifest_timestamp}-${WG_INTERFACE}-"
[[ $backup_name == "$backup_prefix"* ]] || return 1
backup_counter=${backup_name#"$backup_prefix"}
[[ $backup_counter =~ ^[0-9]{3}$ ]] || return 1
for metadata_name in "${!metadata_seen[@]}"; do
[[ -f "$backup/$metadata_name" && ! -L "$backup/$metadata_name" && $(stat -c '%a:%h:%u:%g' -- "$backup/$metadata_name") == "600:1:$server_owner" ]] || return 1
done
for line in "$backup"/*; do
[[ -e "$line" || -L "$line" ]] || continue
metadata_name=${line##*/}
case $metadata_name in
manifest|wg.conf) ;;
*) [[ -n ${metadata_seen[$metadata_name]+x} ]] || return 1 ;;
esac
done
(parse_wireguard_config "$backup/wg.conf") >/dev/null 2>&1
}
prune_backups() {
local backup candidate count=0 retention=$BACKUP_RETENTION
local -a candidates=()
(( retention < 1 )) && retention=1
for backup in "${BACKUP_DIR}"/*-"${WG_INTERFACE}"-*; do
[[ -d "$backup" && ! -L "$backup" && -f "$backup/manifest" && ! -L "$backup/manifest" && -f "$backup/wg.conf" && ! -L "$backup/wg.conf" ]] || continue
validate_backup_manifest "$backup" || continue
candidates+=("$backup")
done
((${#candidates[@]} > 1)) || return 0
while IFS= read -r candidate; do candidates[count]=$candidate; ((count += 1)); done < <(printf '%s\n' "${candidates[@]}" | LC_ALL=C sort -r)
count=0
for backup in "${candidates[@]}"; do
((count += 1))
(( count <= retention )) && continue
[[ "$backup" == "${BACKUP_DIR}"/*-"${WG_INTERFACE}"-* ]] || continue
rm -rf -- "$backup"
done
}
verify_expected_dump() {
local expected_config=$1 dump=$2 key
local -A expected=() actual=()
parse_runtime_dump "$dump" || return $?
parse_wireguard_config "$expected_config" || return $?
[[ $DISK_LISTEN_PORT == '-' || $RUNTIME_LISTEN_PORT == "$DISK_LISTEN_PORT" ]] || return 1
for key in "${!DISK_ALLOWED[@]}"; do expected["$key"]=${DISK_ALLOWED[$key]}; done
for key in "${!RUNTIME_ALLOWED[@]}"; do actual["$key"]=${RUNTIME_ALLOWED[$key]}; done
for key in "${!expected[@]}"; do [[ ${actual[$key]:-} == "${expected[$key]}" ]] || return 1; done
((${#expected[@]} == ${#actual[@]}))
}
run_syncconf() {
local interface=$1 stripped=$2
wg syncconf "$interface" "$stripped"
}
require_transaction_commands() {
local command_name
for command_name in flock wg wg-quick; do
command -v "$command_name" >/dev/null 2>&1 || { die "$EXIT_DEPENDENCY" "Не найдена обязательная команда: ${command_name}"; return $?; }
done
}
path_components_safe() {
local path=$1 component current='/'
[[ $path == /* && $path != *'//' && $path != */./* && $path != */../* && $path != */.. && $path != */. ]] || return 1
is_control_free "$path" || return 1
local -a components=()
IFS=/ read -r -a components <<<"${path#/}"
for component in "${components[@]}"; do
[[ -n $component ]] || continue
current+="${component}/"
[[ ! -L ${current%/} ]] || return 1
done
}
transaction_subshell_exit() {
local status=$?
transaction_cleanup
if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then
printf 'STATUS=%s\nBACKUP=%s\nFINALIZER=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${REMOVE_ARCHIVE_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE"
if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then
printf 'Предупреждение: не удалось защитить transaction result\n' >&2
fi
fi
exit "$status"
}
run_transaction_core() {
local operation=$1 target=$2 builder=$3 metadata lock_parent lock_fd status=0 rollback_status stage new_stripped old_stripped original_copy prior_dump current_dump
local source_mode source_uid source_gid backup_path finalizer_status=0
local transaction_path
shift 3
TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''; TRANSACTION_OUTCOME='failed-before-apply'
TRANSACTION_OWNED_PATHS=(); TRANSACTION_PENDING_SIGNAL=''; TRANSACTION_ROLLBACK_ACTIVE=0; TRANSACTION_ROLLBACK_INTERRUPTED=0; TRANSACTION_ROLLBACK_SIGNAL=''
[[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
is_control_free "$operation" && is_control_free "$target" || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
[[ $builder =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный builder транзакции'; return $?; }
declare -F "$builder" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Builder транзакции не является функцией'; return $?; }
if [[ -n ${TRANSACTION_FINALIZER:-} ]]; then
[[ $TRANSACTION_FINALIZER =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный finalizer транзакции'; return $?; }
declare -F "$TRANSACTION_FINALIZER" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Finalizer транзакции не является функцией'; return $?; }
fi
set_runtime_paths || return $?
for transaction_path in "$WG_CONF" "$LOCK_FILE" "$BACKUP_DIR" "$METADATA_DIR"; do
path_components_safe "$transaction_path" || { die "$EXIT_VALIDATION" 'Путь транзакции содержит символическую ссылку'; return $?; }
done
require_transaction_commands || return $?
lock_parent=${LOCK_FILE%/*}; [[ "$lock_parent" != "$LOCK_FILE" ]] && mkdir -p -- "$lock_parent" && chmod 700 -- "$lock_parent"
[[ ! -L "$LOCK_FILE" ]] || { die "$EXIT_VALIDATION" 'Lock-файл является ссылкой'; return $?; }
exec {lock_fd}>"$LOCK_FILE" || { die "$EXIT_OPERATION" 'Не удалось открыть lock-файл'; return $?; }
chmod 600 -- "$LOCK_FILE" || { exec {lock_fd}>&-; die "$EXIT_OPERATION" 'Не удалось защитить lock-файл'; return $?; }
if ! flock -n "$lock_fd"; then
exec {lock_fd}>&-
die "$EXIT_OPERATION" 'Другая операция уже выполняется'
return $?
fi
trap 'transaction_subshell_exit' EXIT
trap 'transaction_signal INT' INT
trap 'transaction_signal TERM' TERM
load_config || { status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
[[ $LOCK_FILE == "${TRANSACTION_PRE_LOCK_FILE:-$LOCK_FILE}" && $WG_CONF == "${TRANSACTION_PRE_WG_CONF:-$WG_CONF}" && $BACKUP_DIR == "${TRANSACTION_PRE_BACKUP_DIR:-$BACKUP_DIR}" && $METADATA_DIR == "${TRANSACTION_PRE_METADATA_DIR:-$METADATA_DIR}" && $WG_INTERFACE == "${TRANSACTION_PRE_INTERFACE:-$WG_INTERFACE}" ]] || { die "$EXIT_VALIDATION" 'Конфигурация ресурсов изменилась после lock'; status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
parse_wireguard_config "$WG_CONF" || { status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
parse_metadata || { status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
source_mode=$(transaction_stat "$WG_CONF" mode) || status=$?
source_uid=$(transaction_stat "$WG_CONF" uid) || status=$?
source_gid=$(transaction_stat "$WG_CONF" gid) || status=$?
[[ $status == 0 && ( $source_mode == 400 || $source_mode == 600 ) ]] || status=$EXIT_VALIDATION
stage=''
if ((status == 0)); then
create_transaction_temp stage || status=$?
stage=$TRANSACTION_CREATED_TEMP
fi
[[ $status == 0 ]] || { transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
export TRANSACTION_IN_CRITICAL=1
if "$builder" "$stage"; then :; else status=$?; fi
unset TRANSACTION_IN_CRITICAL
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then
printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2
status=$EXIT_OPERATION
fi
if ((status != 0)); then transaction_cleanup; exec {lock_fd}>&-; return "$status"; fi
[[ -s "$stage" && -f "$stage" && ! -L "$stage" ]] || { die "$EXIT_VALIDATION" 'Builder не создал безопасный staged config'; status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
create_transaction_temp strip.new || status=$?; new_stripped=$TRANSACTION_CREATED_TEMP
if ((status == 0)); then create_transaction_temp strip.old || status=$?; old_stripped=$TRANSACTION_CREATED_TEMP; fi
if ((status == 0)); then create_transaction_temp original || status=$?; original_copy=$TRANSACTION_CREATED_TEMP; fi
((status == 0)) || { transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
if ! wg-quick strip "$stage" >"$new_stripped"; then status=$EXIT_VALIDATION; fi
if ((status == 0)) && ! wg-quick strip "$WG_CONF" >"$old_stripped"; then status=$EXIT_OPERATION; fi
if ((status == 0)) && ! copy_restricted "$WG_CONF" "$original_copy"; then status=$EXIT_OPERATION; fi
if ((status == 0)); then
backup_path=$(create_backup "$WG_CONF" "$operation" "$target" "$@") || status=$?
TRANSACTION_BACKUP_PATH=${backup_path:-}
if [[ $status == 0 ]]; then
if ! write_audit_event backup "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит резервной копии'; fi
else
if ! write_audit_event backup "$target" failed backup-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит ошибки резервной копии'; fi
fi
if [[ $status == 0 ]]; then
if [[ ${TRANSACTION_ATOMIC_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then status=$EXIT_OPERATION
elif ! chmod 600 -- "$stage" || ! mv -- "$stage" "$WG_CONF"; then status=$EXIT_OPERATION; fi
fi
fi
if ((status == 0)); then
TRANSACTION_OUTCOME='apply-attempted'
restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid" || status=$EXIT_OPERATION
fi
if ((status == 0)) && ! run_syncconf "$WG_INTERFACE" "$new_stripped"; then status=$EXIT_OPERATION; fi
if ((status == 0)); then
current_dump=$(wg show "$WG_INTERFACE" dump 2>/dev/null) || status=$EXIT_OPERATION
[[ $status == 0 ]] && verify_expected_dump "$WG_CONF" "$current_dump" || status=$EXIT_OPERATION
fi
if (( status == 0 )) && [[ -n ${TRANSACTION_FINALIZER:-} ]]; then
if "$TRANSACTION_FINALIZER"; then
trap '' INT TERM
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then
trap 'transaction_signal INT' INT
trap 'transaction_signal TERM' TERM
status=$EXIT_OPERATION
else
TRANSACTION_OWNED_PATHS=()
fi
else
finalizer_status=$?
printf 'Ошибка: не удалось завершить финальную операцию транзакции\n' >&2
status=$finalizer_status
fi
fi
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} && $status != $EXIT_ROLLBACK ]]; then
printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2
status=$EXIT_OPERATION
fi
if ((status != 0)) && [[ -n ${backup_path:-} ]]; then
TRANSACTION_ROLLBACK_ACTIVE=1
if ! cp -- "$original_copy" "$stage" || ! chmod 600 -- "$stage" || { [[ ${TRANSACTION_ROLLBACK_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! mv -- "$stage" "$WG_CONF" || ! restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid"; then
printf 'КРИТИЧНО: откат диска не выполнен\n' >&2; status=$EXIT_ROLLBACK
elif ! run_syncconf "$WG_INTERFACE" "$old_stripped"; then
printf 'КРИТИЧНО: откат runtime не выполнен\n' >&2; status=$EXIT_ROLLBACK
else
rollback_status=0
prior_dump=$(wg show "$WG_INTERFACE" dump 2>/dev/null) || rollback_status=$EXIT_ROLLBACK
if ((rollback_status == 0)) && ! verify_expected_dump "$WG_CONF" "$prior_dump"; then rollback_status=$EXIT_ROLLBACK; fi
if ((rollback_status != 0)); then
printf 'КРИТИЧНО: проверка отката не выполнена\n' >&2
status=$EXIT_ROLLBACK
elif (( TRANSACTION_ROLLBACK_INTERRUPTED == 1 )); then
printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2
status=$EXIT_ROLLBACK
else
if (( finalizer_status == EXIT_ROLLBACK )); then status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi
TRANSACTION_OUTCOME='rolled-back'
fi
fi
if ((status == EXIT_ROLLBACK)); then TRANSACTION_OUTCOME='rollback-failed'; fi
TRANSACTION_ROLLBACK_ACTIVE=0
fi
if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then
if ! prune_backups; then
printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2
fi
fi
(( status == 0 )) && TRANSACTION_OUTCOME='success'
transaction_cleanup; exec {lock_fd}>&-; return "$status"
}
run_transaction() {
if (($# < 3)); then
die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции'
return $?
fi
local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_finalizer result_outcome had_errexit=0
shift 3
[[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; }
set_runtime_paths || return $?
load_config || return $?
result_file=$(mktemp "${WG_CONF%/*}/.wg-manager.result.XXXXXX") || { die "$EXIT_OPERATION" 'Не удалось создать transaction result'; return $?; }
if ! chmod 600 -- "$result_file"; then
rm -f -- "$result_file"
die "$EXIT_OPERATION" 'Не удалось защитить transaction result'
return $?
fi
export TRANSACTION_RESULT_FILE=$result_file TRANSACTION_PRE_LOCK_FILE=$LOCK_FILE TRANSACTION_PRE_WG_CONF=$WG_CONF TRANSACTION_PRE_BACKUP_DIR=$BACKUP_DIR TRANSACTION_PRE_METADATA_DIR=$METADATA_DIR TRANSACTION_PRE_INTERFACE=$WG_INTERFACE
[[ $- == *e* ]] && had_errexit=1
set +e
( trap - EXIT INT TERM; trap 'transaction_subshell_exit' EXIT; trap 'transaction_signal INT' INT; trap 'transaction_signal TERM' TERM; run_transaction_core "$operation" "$target" "$builder" "$@" )
result_status=$?
(( had_errexit == 1 )) && set -e || set +e
if [[ -r $result_file ]]; then
while IFS='=' read -r key value; do
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; FINALIZER) result_finalizer=$value ;; OUTCOME) result_outcome=$value ;; esac
done <"$result_file"
fi
rm -f -- "$result_file"
TRANSACTION_BACKUP_PATH=${result_backup:-}
[[ -n ${result_finalizer:-} ]] && REMOVE_ARCHIVE_RESULT=$result_finalizer
TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply}
if [[ $result_outcome == success ]]; then
if ! write_audit_event apply "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит применения'; fi
elif [[ $result_outcome == rolled-back ]]; then
if ! write_audit_event rollback "$target" success rolled-back >/dev/null 2>&1; then warn 'Не удалось записать аудит отката'; fi
elif [[ $result_outcome == rollback-failed ]]; then
if ! write_audit_event rollback "$target" failed rollback-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит критического отката'; fi
elif [[ $result_outcome == failed-before-apply ]]; then
if ! write_audit_event transaction "$target" failed operation-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит неуспешной транзакции'; fi
fi
unset TRANSACTION_RESULT_FILE TRANSACTION_PRE_LOCK_FILE TRANSACTION_PRE_WG_CONF TRANSACTION_PRE_BACKUP_DIR TRANSACTION_PRE_METADATA_DIR TRANSACTION_PRE_INTERFACE TRANSACTION_FINALIZER
return "$result_status"
}
validate_client_name() {
local name=$1
[[ -n $name && ${#name} -le 64 && $name =~ ^[A-Za-z0-9._-]+$ && $name != -* ]] || return 1
is_control_free "$name"
}
require_add_privilege() {
if (( EUID == 0 )); then return 0; fi
transaction_test_hooks_enabled || {
die "$EXIT_PERMISSION" 'Для добавления клиента требуются права root'
return $?
}
}
require_remove_privilege() {
if (( EUID == 0 )); then return 0; fi
transaction_test_hooks_enabled || {
die "$EXIT_PERMISSION" 'Для удаления клиента требуются права root'
return $?
}
}
client_paths_available() {
local name=$1 path
for path in "${METADATA_DIR}/${name}.meta" "${CLIENT_DIR}/${name}.conf" "${CLIENT_DIR}/${name}.key" "${CLIENT_DIR}/${name}.pub" "${CLIENT_DIR}/${name}.psk"; do
[[ ! -e $path && ! -L $path ]] || return 1
done
}
client_name_conflict() {
local name=$1 key
for key in "${!META_NAME[@]}"; do [[ ${META_NAME[$key]} == "$name" ]] && return 0; done
for key in "${!DISK_MARKER[@]}"; do [[ ${DISK_MARKER[$key]} == "$name" || ${DISK_LEGACY[$key]} == "$name" ]] && return 0; done
return 1
}
allocate_client_ipv4() {
local host candidate key allowed conflict
for ((host = 10#$START_HOST; host <= 10#$END_HOST; host++)); do
candidate="${VPN_SUBNET_PREFIX}.${host}/32"
conflict=0
for key in "${!DISK_ALLOWED[@]}"; do
allowed=${DISK_ALLOWED[$key]}
if allowed_sets_overlap "$candidate" "$allowed"; then conflict=1; break; fi
done
if (( conflict == 0 )); then
for key in "${!META_IPV4[@]}"; do
if allowed_sets_overlap "$candidate" "${META_IPV4[$key]}/32"; then conflict=1; break; fi
done
fi
if (( conflict == 0 )); then printf '%s\n' "$candidate"; return 0; fi
done
return 1
}
allocate_client_ipv6() {
local host=${1##*.} hex candidate key allowed
[[ $USE_IPV6 == 1 ]] || return 0
candidate=$(printf '%s%s/128' "$IPV6_PREFIX" "$(printf '%x' "$host")")
for key in "${!DISK_ALLOWED[@]}"; do
allowed=${DISK_ALLOWED[$key]}
if allowed_sets_overlap "$candidate" "$allowed"; then return 1; fi
done
for key in "${!META_IPV6[@]}"; do
[[ -n ${META_IPV6[$key]} ]] || continue
if cidrs_overlap "$candidate" "${META_IPV6[$key]}/128"; then return 1; fi
done
printf '%s\n' "$candidate"
}
client_allowed_ips() {
case $1 in
full-tunnel) printf '0.0.0.0/0,::/0' ;;
split-tunnel) printf '%s' "${ADD_SPLIT_NETWORKS:-${VPN_SUBNET_PREFIX}.0/24}" ;;
*) return 1 ;;
esac
}
CAPTURED_KEY=''
capture_single_key() {
local mode=$1 input=$2 path line
local -a lines=()
shift 2
CAPTURED_KEY=''
path=$(mktemp "${WG_CONF%/*}/.wg-manager.key.XXXXXX") || return "$EXIT_OPERATION"
chmod 600 -- "$path" || { rm -f -- "$path"; return "$EXIT_OPERATION"; }
TRANSACTION_TEMP_PATHS+=("$path")
if [[ $mode == stdin ]]; then
if ! printf '%s\n' "$input" | "$@" >"$path"; then rm -f -- "$path"; return "$EXIT_OPERATION"; fi
elif ! "$@" >"$path"; then
rm -f -- "$path"
return "$EXIT_OPERATION"
fi
mapfile -t lines <"$path"
rm -f -- "$path"
((${#lines[@]} == 1)) || return "$EXIT_OPERATION"
line=${lines[0]}
is_wireguard_key "$line" || return "$EXIT_OPERATION"
CAPTURED_KEY=$line
}
read_server_public_key() {
capture_single_key none '' wg show "$WG_INTERFACE" public-key || return "$EXIT_OPERATION"
printf '%s\n' "$CAPTURED_KEY"
}
generate_client_keys() {
local private public psk status=0 xtrace=0
[[ $- == *x* ]] && xtrace=1
set +x
if capture_single_key none '' wg genkey; then private=$CAPTURED_KEY; else status=$EXIT_OPERATION; fi
if (( status == 0 )); then if capture_single_key stdin "$private" wg pubkey; then public=$CAPTURED_KEY; else status=$EXIT_OPERATION; fi; fi
if (( status == 0 )); then if capture_single_key none '' wg genpsk; then psk=$CAPTURED_KEY; else status=$EXIT_OPERATION; fi; fi
(( status == 0 )) || { (( xtrace == 1 )) && set -x; return "$status"; }
ADD_PRIVATE_KEY=$private ADD_PUBLIC_KEY=$public ADD_PSK=$psk
(( xtrace == 1 )) && set -x
return 0
}
create_add_stage_file() {
local directory=$1 prefix=$2 path kind
kind=${prefix##*.}
if [[ ${TRANSACTION_ADD_STAGE_FAIL:-} == "$prefix" || ${TRANSACTION_ADD_STAGE_FAIL:-} == "$kind" ]] && transaction_test_hooks_enabled; then return "$EXIT_OPERATION"; fi
path=$(mktemp "$directory/.wg-manager.add.${prefix}.XXXXXX") || return "$EXIT_OPERATION"
if [[ ! -f "$path" || -L "$path" ]] || ! chmod 600 -- "$path"; then
rm -f -- "$path"
return "$EXIT_OPERATION"
fi
TRANSACTION_TEMP_PATHS+=("$path")
printf '%s\n' "$path"
}
add_client_builder() {
local stage=$1 name=$ADD_NAME mode=$ADD_MODE endpoint=$ADD_ENDPOINT ipv4 ipv6 server_public allowed_client allowed_server
local meta_file config_file key_file pub_file psk_file
set +x
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return "$EXIT_OPERATION"
validate_client_name "$name" || return "$EXIT_VALIDATION"
parse_wireguard_config "$WG_CONF" || return $?
parse_metadata || return $?
if client_name_conflict "$name"; then
add_failure "$EXIT_VALIDATION" 'имя клиента уже используется'
return $?
fi
if ! client_paths_available "$name"; then
add_failure "$EXIT_VALIDATION" 'целевой файл клиента уже существует или небезопасен'
return $?
fi
if ! ipv4=$(allocate_client_ipv4); then
add_failure "$EXIT_VALIDATION" 'диапазон IPv4-адресов клиентов исчерпан'
return $?
fi
if ! ipv6=$(allocate_client_ipv6 "${ipv4%/*}"); then
add_failure "$EXIT_VALIDATION" 'адрес IPv6 клиента конфликтует или недоступен'
return $?
fi
if ! allowed_client=$(client_allowed_ips "$mode"); then
add_failure "$EXIT_VALIDATION" 'некорректный режим адресов клиента'
return $?
fi
if ! server_public=$(read_server_public_key); then
add_failure "$EXIT_OPERATION" 'не удалось получить открытый ключ сервера'
return $?
fi
if ! generate_client_keys; then
add_failure "$EXIT_OPERATION" 'не удалось сгенерировать ключи клиента'
return $?
fi
if [[ -n ${DISK_ALLOWED[$ADD_PUBLIC_KEY]+x} ]]; then
add_failure "$EXIT_VALIDATION" 'сгенерированный открытый ключ уже используется'
return $?
fi
if [[ -n ${META_NAME[$ADD_PUBLIC_KEY]+x} ]]; then
add_failure "$EXIT_VALIDATION" 'сгенерированный открытый ключ уже есть в метаданных'
return $?
fi
allowed_server="$ipv4"; [[ -n $ipv6 ]] && allowed_server+=",$ipv6"
meta_file=${ADD_META_STAGE:?metadata stage is not prepared}
config_file=${ADD_CONFIG_STAGE:?config stage is not prepared}
key_file=${ADD_KEY_STAGE:?key stage is not prepared}
pub_file=${ADD_PUB_STAGE:?public key stage is not prepared}
psk_file=${ADD_PSK_STAGE:?psk stage is not prepared}
TRANSACTION_TEMP_PATHS+=("$meta_file" "$config_file" "$key_file" "$pub_file" "$psk_file")
{
cat -- "$WG_CONF"
printf '\n# wg-manager-client: %s\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nAllowedIPs = %s\n' "$name" "$ADD_PUBLIC_KEY" "$ADD_PSK" "$allowed_server"
} >"$stage"
printf '%s\n' "$ADD_PRIVATE_KEY" >"$key_file"
printf '%s\n' "$ADD_PUBLIC_KEY" >"$pub_file"
printf '%s\n' "$ADD_PSK" >"$psk_file"
{
printf '[Interface]\nPrivateKey = %s\nAddress = %s' "$ADD_PRIVATE_KEY" "$allowed_server"
printf '\nDNS = %s\n\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nEndpoint = %s:%s\nAllowedIPs = %s\nPersistentKeepalive = 25\n' "$CLIENT_DNS" "$server_public" "$ADD_PSK" "$endpoint" "$SERVER_PORT" "$allowed_client"
} >"$config_file"
printf 'NAME=%s\nPUBLIC_KEY=%s\nIPV4=%s\n' "$name" "$ADD_PUBLIC_KEY" "${ipv4%/*}" >"$meta_file"
[[ -n $ipv6 ]] && printf 'IPV6=%s\n' "${ipv6%/*}" >>"$meta_file"
printf 'CREATED_AT=%s\nCONFIG_PATH=%s\nCLIENT_MODE=%s\nMANAGED=1\n' "$(current_epoch)" "${CLIENT_DIR}/${name}.conf" "$mode" >>"$meta_file"
chmod 600 -- "$meta_file" "$config_file" "$key_file" "$pub_file" "$psk_file"
}
publish_add_artifacts() {
local name=${ADD_NAME:?add name is not set} stage_path final_path published=()
local -a stages=("$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE")
local -a finals=("${METADATA_DIR}/${name}.meta" "${CLIENT_DIR}/${name}.conf" "${CLIENT_DIR}/${name}.key" "${CLIENT_DIR}/${name}.pub" "${CLIENT_DIR}/${name}.psk")
local index
if [[ ${ADD_PUBLISH_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then
add_failure "$EXIT_OPERATION" 'не удалось опубликовать файлы клиента'
return "$EXIT_OPERATION"
fi
for index in "${!stages[@]}"; do
stage_path=${stages[$index]}; final_path=${finals[$index]}
if (( index == 0 )) && [[ ${ADD_PUBLISH_RACE:-0} == 1 ]] && transaction_test_hooks_enabled; then
printf 'administrator collision\n' >"$final_path"
chmod 600 -- "$final_path"
fi
if [[ ! -f "$stage_path" || -L "$stage_path" || -e "$final_path" || -L "$final_path" ]]; then
add_failure "$EXIT_OPERATION" 'конфликт целевого файла клиента при публикации'
for stage_path in "${stages[@]}" "${published[@]}"; do rm -f -- "$stage_path"; done
return "$EXIT_OPERATION"
fi
if ! ln -- "$stage_path" "$final_path"; then
add_failure "$EXIT_OPERATION" 'не удалось опубликовать файл клиента'
for stage_path in "${stages[@]}" "${published[@]}"; do rm -f -- "$stage_path"; done
return "$EXIT_OPERATION"
fi
TRANSACTION_OWNED_PATHS+=("$final_path")
if ! rm -f -- "$stage_path"; then
add_failure "$EXIT_OPERATION" 'не удалось завершить публикацию файла клиента'
rm -f -- "$final_path"
for stage_path in "${stages[@]}" "${published[@]}"; do rm -f -- "$stage_path"; done
return "$EXIT_OPERATION"
fi
published+=("$final_path")
if (( index == 0 || index == 2 || index == 4 )) && [[ -n ${ADD_PUBLISH_READY_FIFO:-} ]] && [[ -z ${ADD_PUBLISH_READY_INDEX:-} || ${ADD_PUBLISH_READY_INDEX} == "$index" ]]; then
if ! transaction_test_hooks_enabled; then
add_failure "$EXIT_OPERATION" "некорректная синхронизация публикации"
return "$EXIT_OPERATION"
fi
if ! [[ ${ADD_PUBLISH_READY_FIFO} == "${TEST_ROOT}"/* && ! -L ${ADD_PUBLISH_READY_FIFO} && -p ${ADD_PUBLISH_READY_FIFO} && ${ADD_PUBLISH_RELEASE_FIFO:-} == "${TEST_ROOT}"/* && ! -L ${ADD_PUBLISH_RELEASE_FIFO} && -p ${ADD_PUBLISH_RELEASE_FIFO} ]]; then
add_failure "$EXIT_OPERATION" "некорректная синхронизация публикации"
return "$EXIT_OPERATION"
fi
if [[ -n ${ADD_PUBLISH_PID_FILE:-} ]]; then
if ! [[ ${ADD_PUBLISH_PID_FILE} == "${TEST_ROOT}"/* && ! -L ${ADD_PUBLISH_PID_FILE} && ( ! -e ${ADD_PUBLISH_PID_FILE} || -f ${ADD_PUBLISH_PID_FILE} ) ]]; then
add_failure "$EXIT_OPERATION" "некорректный файл синхронизации публикации"
return "$EXIT_OPERATION"
fi
if ! printf '%s\n' "$BASHPID" >"$ADD_PUBLISH_PID_FILE"; then
add_failure "$EXIT_OPERATION" "не удалось подготовить синхронизацию публикации"
return "$EXIT_OPERATION"
fi
fi
if ! printf 'ready\n' >"$ADD_PUBLISH_READY_FIFO"; then
add_failure "$EXIT_OPERATION" "не удалось подготовить синхронизацию публикации"
return "$EXIT_OPERATION"
fi
if ! IFS= read -r _ <"$ADD_PUBLISH_RELEASE_FIFO"; then
add_failure "$EXIT_OPERATION" "синхронизация публикации прервана"
return "$EXIT_OPERATION"
fi
fi
done
return 0
}
clear_add_transaction_state() {
local path
for path in "$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE"; do
[[ -n ${path:-} ]] && rm -f -- "$path"
done
unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_SPLIT_NETWORKS ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX
TRANSACTION_FINALIZER=''
}
add_client_impl() {
local name mode endpoint arg split_networks='' mode_seen=0 endpoint_seen=0 split_seen=0
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
name=$1; shift
load_config || return $?
mode=$DEFAULT_CLIENT_MODE; endpoint=$SERVER_ENDPOINT
while (($#)); do
case $1 in
--mode)
(( mode_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --mode указан повторно'; return $?; }
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --mode требуется значение'; return $?; }
mode=$2; mode_seen=1; shift 2 ;;
--endpoint)
(( endpoint_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --endpoint указан повторно'; return $?; }
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --endpoint требуется значение'; return $?; }
endpoint=$2; endpoint_seen=1; shift 2 ;;
--split-networks)
(( split_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --split-networks указан повторно'; return $?; }
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --split-networks требуется значение'; return $?; }
split_networks=$2; split_seen=1; shift 2 ;;
*) die "$EXIT_USAGE" 'Неизвестный или лишний аргумент add'; return $? ;;
esac
done
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
[[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; }
if [[ $mode == split-tunnel ]]; then
[[ -n $split_networks ]] || split_networks="${VPN_SUBNET_PREFIX}.0/24"
split_networks=$(normalize_allowed_ips "$split_networks") || { die "$EXIT_VALIDATION" 'Некорректные сети split-tunnel'; return $?; }
elif [[ -n $split_networks ]]; then
die "$EXIT_VALIDATION" '--split-networks доступен только для split-tunnel'
return $?
fi
validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint клиента'; return $?; }
require_add_privilege || return $?
ADD_META_STAGE='' ADD_CONFIG_STAGE='' ADD_KEY_STAGE='' ADD_PUB_STAGE='' ADD_PSK_STAGE=''
if [[ ${TRANSACTION_ADD_DIRECTORY_FAIL:-} == mkdir ]] && transaction_test_hooks_enabled; then
add_failure "$EXIT_OPERATION" 'не удалось подготовить каталоги клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR"; then
add_failure "$EXIT_OPERATION" 'не удалось создать каталоги клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if [[ ${TRANSACTION_ADD_DIRECTORY_FAIL:-} == chmod ]] && transaction_test_hooks_enabled; then
add_failure "$EXIT_OPERATION" 'не удалось защитить каталоги клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! chmod 700 -- "$METADATA_DIR" "$CLIENT_DIR"; then
add_failure "$EXIT_OPERATION" 'не удалось защитить каталоги клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! ADD_META_STAGE=$(create_add_stage_file "$METADATA_DIR" "${name}.meta"); then
add_failure "$EXIT_OPERATION" 'не удалось подготовить метаданные клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! ADD_CONFIG_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.conf"); then
add_failure "$EXIT_OPERATION" 'не удалось подготовить конфигурацию клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! ADD_KEY_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.key"); then
add_failure "$EXIT_OPERATION" 'не удалось подготовить закрытый ключ клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! ADD_PUB_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.pub"); then
add_failure "$EXIT_OPERATION" 'не удалось подготовить открытый ключ клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
if ! ADD_PSK_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.psk"); then
add_failure "$EXIT_OPERATION" 'не удалось подготовить дополнительный ключ клиента'
clear_add_transaction_state
return "$EXIT_OPERATION"
fi
ADD_NAME=$name ADD_MODE=$mode ADD_ENDPOINT=$endpoint ADD_SPLIT_NETWORKS=$split_networks
TRANSACTION_FINALIZER=publish_add_artifacts
local status
if run_transaction add "$name" add_client_builder; then
status=0
else
status=$?
fi
if (( status != 0 )); then
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi
clear_add_transaction_state
return "$status"
fi
status=0
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" success success >/dev/null 2>&1; then
clear_add_transaction_state
die "$EXIT_OPERATION" 'Добавление выполнено, но аудит не записан (частичный результат)'
return "$EXIT_OPERATION"
fi
clear_add_transaction_state
return "$status"
}
add_client() {
local caller_xtrace=0 status
[[ $- == *x* ]] && caller_xtrace=1
set +x
if add_client_impl "$@"; then status=0; else status=$?; fi
(( caller_xtrace == 1 )) && set -x
return "$status"
}
print_status() {
local managed=0 unmanaged=0 active=0 index
for index in "${!CLIENT_KEYS[@]}"; do
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then ((managed += 1)); else ((unmanaged += 1)); fi
if [[ ${CLIENT_STATES[$index]} == active ]]; then ((active += 1)); fi
done
printf 'Интерфейс: %s\nСостояние runtime: %s\nПорт: %s\nPeers: %d\nУправляемые: %d\nНеуправляемые: %d\nАктивные: %d\n' "$WG_INTERFACE" "$([[ $RUNTIME_AVAILABLE == 1 ]] && printf 'доступен' || printf 'недоступен')" "${RUNTIME_LISTEN_PORT:--}" "${#CLIENT_KEYS[@]}" "$managed" "$unmanaged" "$active"
}
print_list() {
local index
printf 'NAME\tADDRESSES\tPUBLIC_KEY\tENDPOINT\tHANDSHAKE\tRX\tTX\tSTATE\tORIGIN\n'
for index in "${!CLIENT_KEYS[@]}"; do printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "${CLIENT_NAMES[$index]}" "${CLIENT_ADDRESSES[$index]}" "${CLIENT_KEYS[$index]:0:8}" "${CLIENT_ENDPOINTS[$index]}" "${CLIENT_HANDSHAKES[$index]}" "${CLIENT_RX[$index]}" "${CLIENT_TX[$index]}" "${CLIENT_STATES[$index]}" "${CLIENT_ORIGINS[$index]}"; done | LC_ALL=C sort -t $'\t' -k1,1 -k2,2 -k3,3
}
require_command() {
local command_name=$1 command_path
command_path=$(command -v -- "$command_name" 2>/dev/null) || { die "$EXIT_DEPENDENCY" "Не найдена обязательная команда: ${command_name}"; return $?; }
[[ -x "$command_path" ]] || { die "$EXIT_DEPENDENCY" "Команда недоступна для запуска: ${command_name}"; return $?; }
printf '%s\n' "$command_path"
}
optional_command() { local command_path; command_path=$(command -v -- "$1" 2>/dev/null) || return "$EXIT_DEPENDENCY"; [[ -f "$command_path" && -x "$command_path" ]] && printf '%s\n' "$command_path" || return "$EXIT_DEPENDENCY"; }
resolve_gum() {
local candidate
if [[ ${WG_MANAGER_GUM+x} ]]; then
[[ -f ${WG_MANAGER_GUM} && -x ${WG_MANAGER_GUM} ]] || { die "$EXIT_DEPENDENCY" 'WG_MANAGER_GUM задан, но путь недоступен'; return $?; }
printf '%s\n' "$WG_MANAGER_GUM"; return 0
fi
candidate=${SCRIPT_DIR}/bin/gum
if [[ -f "$candidate" && -x "$candidate" ]]; then printf '%s\n' "$candidate"; return 0; fi
optional_command gum || { die "$EXIT_DEPENDENCY" 'Не найден gum для TUI'; return $?; }
}
validate_gum_capabilities() {
local gum_path=$1 capability
local -a capabilities=(style choose filter input confirm table pager spin)
for capability in "${capabilities[@]}"; do
"$gum_path" "$capability" --help >/dev/null 2>&1 || {
die "$EXIT_DEPENDENCY" "gum не поддерживает команду: ${capability}"
return $?
}
done
}
gum_command() { local gum_path=${TUI_GUM_PATH:-}; [[ -n $gum_path ]] || gum_path=$(resolve_gum) || return $?; "$gum_path" "$@"; }
gum_header() { gum_command style --border double --padding '0 1' "$1"; }
gum_choose() { gum_command choose "$@"; }
gum_filter() { gum_command filter --placeholder 'Выберите клиента' "$@"; }
gum_input() { gum_command input "$@"; }
gum_input_placeholder() { gum_command input --placeholder "$1"; }
gum_input_prompt() { gum_command input --prompt "$1"; }
gum_confirm() { gum_command confirm "$@"; }
gum_table() { gum_command table "$@"; }
gum_pager() { gum_command pager "$@"; }
gum_spin() { gum_command spin "$@"; }
tui_is_terminal() {
if [[ ${WG_MANAGER_TEST_TTY+x} && ${WG_MANAGER_TEST_TTY} == both ]] && transaction_test_hooks_enabled; then return 0; fi
[[ -t 0 && -t 1 ]]
}
tui_page_command() {
local command_name=$1 output backend_status pager_status
shift
if output=$("$command_name" "$@" 2>&1); then backend_status=0; else backend_status=$?; fi
if printf '%s' "$output" | gum_pager; then pager_status=0; else pager_status=$?; fi
((backend_status != 0)) && return "$backend_status"
return "$pager_status"
}
tui_select_client() {
local choice index label base previous ordinal duplicate
local -a choices=() choice_indices=()
tui_page_command print_list || return $?
for index in "${!CLIENT_KEYS[@]}"; do
base="${CLIENT_NAMES[$index]} — ${CLIENT_KEYS[$index]:0:8}"
ordinal=1; duplicate=0
for previous in "${!CLIENT_KEYS[@]}"; do
[[ $previous -ge $index ]] && break
if [[ "${CLIENT_NAMES[$previous]} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then
((ordinal += 1)); duplicate=1
fi
done
for previous in "${!CLIENT_KEYS[@]}"; do
((previous > index)) || continue
if [[ "${CLIENT_NAMES[$previous]} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then duplicate=1; break; fi
done
if ((duplicate == 1)); then label="${CLIENT_NAMES[$index]:0:60} — ${CLIENT_KEYS[$index]:0:8} #${ordinal}"; else label=$base; fi
choices+=("$label")
choice_indices+=("$index")
done
((${#choices[@]} > 0)) || { printf 'Клиенты отсутствуют\n'; return 0; }
choice=$(gum_filter "${choices[@]}") || return 0
index=-1
for previous in "${!choices[@]}"; do
if [[ ${choices[$previous]} == "$choice" ]]; then
if ((index >= 0)); then return "$EXIT_VALIDATION"; fi
index=${choice_indices[$previous]}
fi
done
((index >= 0)) || return "$EXIT_VALIDATION"
tui_client_action_flow "${CLIENT_NAMES[$index]}" "$index"
}
tui_client_action_flow() {
local name=$1 index=$2 action config_output status
local -a actions=('Детали' 'Показать конфигурацию' 'Показать QR' 'Удалить' 'Назад')
if [[ ${CLIENT_ORIGINS[$index]} != managed ]]; then actions=('Детали' 'Назад'); fi
action=$(gum_choose "${actions[@]}") || return 0
case $action in
'Детали') tui_page_command print_client_details "$name" "$index" ;;
'Показать конфигурацию')
gum_confirm 'Показать полную конфигурацию?' || return 0
if config_output=$(export_client_config_exact "$name" "${CLIENT_KEYS[$index]}" 1); then
printf '%s' "$config_output" | gum_pager
else status=$?; return "$status"; fi
;;
'Показать QR')
gum_confirm 'Показать QR-код?' || return 0
if qr_client_exact "$name" "${CLIENT_KEYS[$index]}"; then
:
else
status=$?
if (( status == EXIT_DEPENDENCY )); then
printf 'QR-код недоступен; путь конфигурации показан выше\n' >&2
return 0
fi
printf 'QR-код недоступен или не создан\n' >&2
return "$status"
fi
;;
'Удалить') tui_remove_flow_for_index "$name" "$index" ;;
*) return 0 ;;
esac
}
tui_add_flow() {
local name mode status networks endpoint preview config_path endpoint_prompt endpoint_attempts=0
local -a add_args=()
local attempts=0
while ((attempts < 2)); do
name=$(gum_input_placeholder 'Имя клиента') || return 0
if validate_client_name "$name"; then break; fi
printf 'Некорректное имя клиента, повторите ввод\n' >&2
((attempts += 1)); ((attempts < 2)) || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
done
mode=$(gum_choose 'Полный туннель' 'Выборочные сети') || return 0
case $mode in
'Полный туннель') mode=full-tunnel ;;
'Выборочные сети') mode=split-tunnel ;;
*) return "$EXIT_VALIDATION" ;;
esac
networks=''
if [[ $mode == split-tunnel ]]; then
networks=$(gum_input_placeholder 'Сети через запятую') || return 0
networks=$(normalize_allowed_ips "$networks") || { die "$EXIT_VALIDATION" 'Некорректные сети split-tunnel'; return $?; }
fi
endpoint=$SERVER_ENDPOINT
endpoint_prompt=$(tui_wrap_value 'Использовать endpoint:' "$endpoint")
if ! gum_confirm "$endpoint_prompt"; then
while ((endpoint_attempts < 2)); do
endpoint=$(gum_input_placeholder 'Endpoint HOST') || return 0
if validate_endpoint "$endpoint"; then break; fi
printf 'Некорректный endpoint, повторите ввод\n' >&2
((endpoint_attempts += 1))
done
validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint'; return $?; }
fi
printf -v preview 'Имя: %s\nРежим: %s\nСети: %s\nEndpoint: %s' "$name" "$mode" "${networks:--}" "$endpoint"
gum_pager <<<"$preview" || return $?
gum_confirm 'Создать клиента с этими параметрами?' || return 0
add_args=("$name" --mode "$mode" --endpoint "$endpoint")
[[ -n $networks ]] && add_args+=(--split-networks "$networks")
if add_client "${add_args[@]}"; then
config_path=$(export_client_config "$name" 0) || return $?
printf 'Клиент добавлен: %s\nКонфигурация: %s\n' "$name" "$config_path"
if gum_confirm 'Показать QR-код?'; then
if qr_client "$name"; then
:
else
status=$?
if (( status == EXIT_DEPENDENCY )); then
printf 'QR-код недоступен; путь конфигурации показан выше\n' >&2
return 0
fi
printf 'QR-код недоступен или не создан\n' >&2
return "$status"
fi
fi
else status=$?; printf 'Добавление клиента не выполнено\n' >&2; return "$status"; fi
}
tui_remove_flow() {
local index selected_index=-1 name key label base previous ordinal duplicate
load_client_model || return $?
local -a choices=() choice_indices=()
for index in "${!CLIENT_KEYS[@]}"; do
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || continue
base="${CLIENT_NAMES[$index]:0:32} — ${CLIENT_ADDRESSES[$index]:0:24} — ${CLIENT_KEYS[$index]:0:8}"
ordinal=1; duplicate=0
for previous in "${!CLIENT_KEYS[@]}"; do
[[ $previous -ge $index ]] && break
[[ ${CLIENT_ORIGINS[$previous]} == managed ]] || continue
if [[ "${CLIENT_NAMES[$previous]:0:32} — ${CLIENT_ADDRESSES[$previous]:0:24} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then ((ordinal += 1)); duplicate=1; fi
done
for previous in "${!CLIENT_KEYS[@]}"; do
((previous > index)) || continue
[[ ${CLIENT_ORIGINS[$previous]} == managed ]] || continue
if [[ "${CLIENT_NAMES[$previous]:0:32} — ${CLIENT_ADDRESSES[$previous]:0:24} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then duplicate=1; break; fi
done
if ((duplicate == 1)); then label="${CLIENT_NAMES[$index]:0:28} — ${CLIENT_ADDRESSES[$index]:0:24} — ${CLIENT_KEYS[$index]:0:8} #${ordinal}"; else label=$base; fi
choices+=("$label")
choice_indices+=("$index")
done
(("${#choices[@]} > 0")) || { printf 'Управляемые клиенты отсутствуют\n'; return 0; }
key=$(gum_filter "${choices[@]}") || return 0
for previous in "${!choices[@]}"; do
if [[ ${choices[$previous]} == "$key" ]]; then
((selected_index < 0)) || return "$EXIT_VALIDATION"
selected_index=${choice_indices[$previous]}
fi
done
((selected_index >= 0)) || return "$EXIT_VALIDATION"
name=${CLIENT_NAMES[$selected_index]}; key=${CLIENT_KEYS[$selected_index]}
local confirmation address_lines
address_lines=$(tui_wrap_value 'Адреса:' "${CLIENT_ADDRESSES[$selected_index]}")
confirmation=$(printf 'Удалить клиента:\nИмя: %s\n%s\nКлюч: *%s\nАрхивировать его файлы?' "$name" "$address_lines" "$(short_public_key "$key")")
gum_confirm "$confirmation" || return 0
if remove_client_exact "$name" "$key"; then
printf 'Клиент удалён и архивирован: %s\n' "$name"
[[ -n ${REMOVE_ARCHIVE_RESULT:-} ]] && printf 'Архив: %s\n' "$REMOVE_ARCHIVE_RESULT"
unset REMOVE_ARCHIVE_RESULT
else
return $?
fi
}
tui_remove_flow_for_index() {
local name=$1 index=$2 key=${CLIENT_KEYS[$2]} status
local confirmation address_lines
address_lines=$(tui_wrap_value 'Адреса:' "${CLIENT_ADDRESSES[$index]}")
confirmation=$(printf 'Удалить клиента:\nИмя: %s\n%s\nКлюч: *%s\nАрхивировать его файлы?' "$name" "$address_lines" "$(short_public_key "$key")")
gum_confirm "$confirmation" || return 0
if remove_client_exact "$name" "$key"; then
printf 'Клиент удалён и архивирован: %s\n' "$name"
[[ -n ${REMOVE_ARCHIVE_RESULT:-} ]] && printf 'Архив: %s\n' "$REMOVE_ARCHIVE_RESULT"
unset REMOVE_ARCHIVE_RESULT
return 0
else
status=$?
unset REMOVE_ARCHIVE_RESULT
return "$status"
fi
}
tui_backup_flow() {
local action selected listing backup_id details valid_selection
local -a backups=()
action=$(gum_choose 'Создать резервную копию' 'Восстановить резервную копию' 'Отмена') || return 0
case $action in
'Создать резервную копию')
local backup_path
if backup_path=$(manual_backup); then
printf 'Резервная копия создана: %s\n' "$backup_path"
else return $?; fi
;;
'Восстановить резервную копию')
if listing=$(list_backups); then :; else return $?; fi
local -a backup_details=()
while IFS=$'\t' read -r backup_id details; do
[[ $backup_id =~ ^[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9._-]+-[0-9]{3}$ ]] || continue
backups+=("$backup_id")
backup_details+=("$details")
done <<<"$listing"
((${#backups[@]} > 0)) || { printf 'Подходящие резервные копии отсутствуют\n'; return 0; }
selected=$(gum_choose "${backups[@]}") || return 0
valid_selection=0
for backup_id in "${backups[@]}"; do [[ $backup_id == "$selected" ]] && valid_selection=1; done
((valid_selection == 1)) || { die "$EXIT_VALIDATION" 'Выбрана неизвестная резервная копия'; return $?; }
local selected_detail=''
for index in "${!backups[@]}"; do
[[ ${backups[$index]} == "$selected" ]] && selected_detail=${backup_details[$index]}
done
printf 'Резервная копия: %s\n%s\n' "$selected" "$selected_detail" | gum_pager || return $?
local typed_confirmation
typed_confirmation=$(gum_input_prompt "Введите идентификатор $selected") || return 0
[[ $typed_confirmation == "$selected" ]] || return 0
restore_backup "$selected" --yes ;;
*) return 0 ;;
esac
}
tui_run_action() {
local status
if "$@"; then return 0; else status=$?; fi
printf 'Предупреждение: действие TUI не выполнено (код %s)\n' "$status" >&2
return "$status"
}
tui_screen() {
local choice failures=0 index managed_count=0 active_count=0 warning_count=0 runtime_text dashboard status
local -a menu_labels=('Состояние' 'Клиенты' 'Диагностика' 'Журнал' 'Резервные копии' 'Добавить' 'Удалить' 'Выход')
unset TUI_GUM_PATH
tui_is_terminal || { die "$EXIT_USAGE" 'TUI требует интерактивный терминал'; return $?; }
if TUI_GUM_PATH=$(resolve_gum); then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi
validate_gum_capabilities "$TUI_GUM_PATH" || { unset TUI_GUM_PATH; return $?; }
load_client_model || { unset TUI_GUM_PATH; return $?; }
for index in "${!CLIENT_ORIGINS[@]}"; do
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then ((managed_count += 1)); fi
if [[ ${CLIENT_STATES[$index]} == active ]]; then ((active_count += 1)); fi
done
((RUNTIME_AVAILABLE == 1)) || ((warning_count += 1))
if ((RUNTIME_AVAILABLE == 1)); then runtime_text='доступен'; else runtime_text='недоступен'; fi
while ((failures < 3)); do
printf -v dashboard 'WireGuard Manager — %s\nСостояние: %s\nКлиентов: %d Управляемых: %d\nАктивны недавно: %d Предупреждения: %d' "$WG_INTERFACE" "$runtime_text" "${#CLIENT_KEYS[@]}" "$managed_count" "$active_count" "$warning_count"
if gum_header "$dashboard"; then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi
if choice=$(gum_choose "${menu_labels[@]}"); then :; else unset TUI_GUM_PATH; return 0; fi
case $choice in
Состояние) if tui_run_action tui_page_command print_status; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Клиенты) if tui_run_action tui_select_client; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Диагностика) if tui_run_action tui_page_command diagnose; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Журнал) if tui_run_action tui_page_command list_audit; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
'Резервные копии') if tui_run_action tui_backup_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Добавить) if tui_run_action tui_add_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Удалить) if tui_run_action tui_remove_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Выход|'') unset TUI_GUM_PATH; return 0 ;;
*) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;;
esac
done
unset TUI_GUM_PATH
die "$EXIT_OPERATION" 'TUI завершён после повторных ошибок'
return $?
}
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
manual_backup() {
local lock_fd path status parent pre_conf pre_backup pre_meta pre_lock pre_iface live_owner lock_owner lock_created=0 lock_identity opened_identity replacement_backup config_identity config_identity_after backup_identity_after
load_config || return $?
require_add_privilege || return $?
path_components_safe "$LOCK_FILE" || { die "$EXIT_VALIDATION" 'Некорректный путь блокировки'; return $?; }
path_components_safe "$WG_CONF" || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации'; return $?; }
parent=${LOCK_FILE%/*}; [[ -d $parent && ! -L $parent ]] || { die "$EXIT_VALIDATION" 'Каталог блокировки недоступен'; return $?; }
pre_conf=$WG_CONF; pre_backup=$BACKUP_DIR; pre_meta=$METADATA_DIR; pre_lock=$LOCK_FILE; pre_iface=$WG_INTERFACE
if [[ -e $LOCK_FILE ]]; then
[[ -f $LOCK_FILE && ! -L $LOCK_FILE && $(stat -c '%a:%h' -- "$LOCK_FILE") == 600:1 ]] || { die "$EXIT_VALIDATION" 'Файл блокировки небезопасен'; return $?; }
else
(umask 077; : >"$LOCK_FILE") || { die "$EXIT_OPERATION" 'Не удалось создать файл блокировки'; return $?; }
chmod 600 -- "$LOCK_FILE" || { rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось защитить блокировку'; return $?; }
lock_created=1
fi
lock_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$LOCK_FILE") || { ((lock_created == 1)) && rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось проверить блокировку'; return $?; }
if [[ -n ${MANUAL_BACKUP_PREOPEN_REPLACEMENT:-} ]]; then
if ! transaction_test_hooks_enabled || ! path_components_safe "$MANUAL_BACKUP_PREOPEN_REPLACEMENT" || [[ ! -f $MANUAL_BACKUP_PREOPEN_REPLACEMENT || -L $MANUAL_BACKUP_PREOPEN_REPLACEMENT || $MANUAL_BACKUP_PREOPEN_REPLACEMENT == "$LOCK_FILE" ]]; then
((lock_created == 1)) && rm -f -- "$LOCK_FILE"
die "$EXIT_VALIDATION" 'Некорректный тестовый файл блокировки'
return $?
fi
replacement_backup=${LOCK_FILE}.manual-preopen-old
rm -f -- "$replacement_backup"
mv -- "$LOCK_FILE" "$replacement_backup" || return "$EXIT_OPERATION"
cp -- "$MANUAL_BACKUP_PREOPEN_REPLACEMENT" "$LOCK_FILE" || { mv -- "$replacement_backup" "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось подготовить тестовую блокировку'; return $?; }
fi
exec {lock_fd}<>"$LOCK_FILE" || { ((lock_created == 1)) && rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось открыть блокировку'; return $?; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${lock_fd}") || { exec {lock_fd}>&-; die "$EXIT_OPERATION" 'Не удалось проверить открытую блокировку'; return $?; }
if [[ $opened_identity != "$lock_identity" ]]; then
exec {lock_fd}>&-
if [[ -n ${replacement_backup:-} && -e $replacement_backup ]]; then rm -f -- "$LOCK_FILE"; mv -- "$replacement_backup" "$LOCK_FILE"; fi
die "$EXIT_VALIDATION" 'Файл блокировки изменился перед открытием'
return $?
fi
flock -n "$lock_fd" || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$WG_INTERFACE" failed lock-contended >/dev/null 2>&1; then warn 'Не удалось записать аудит блокировки'; fi; die "$EXIT_OPERATION" 'Другая операция уже выполняется'; return $?; }
load_config || { status=$?; exec {lock_fd}>&-; return "$status"; }
if [[ $WG_CONF != "$pre_conf" || $BACKUP_DIR != "$pre_backup" || $METADATA_DIR != "$pre_meta" || $LOCK_FILE != "$pre_lock" || $WG_INTERFACE != "$pre_iface" ]]; then
exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed resources-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения ресурсов'; fi; die "$EXIT_VALIDATION" 'Ресурсы изменились после блокировки'; return $?
fi
[[ -f $WG_CONF && ! -L $WG_CONF ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-unavailable >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; die "$EXIT_VALIDATION" 'Серверная конфигурация недоступна'; return $?; }
live_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-stat >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; return "$EXIT_OPERATION"; }
[[ $(stat -c '%a:%h' -- "$WG_CONF") == 400:1 || $(stat -c '%a:%h' -- "$WG_CONF") == 600:1 ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-permissions >/dev/null 2>&1; then warn 'Не удалось записать аудит прав'; fi; die "$EXIT_VALIDATION" 'Небезопасные права серверной конфигурации'; return $?; }
[[ $(stat -c '%u:%g' -- "$WG_CONF") == "$live_owner" ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-owner >/dev/null 2>&1; then warn 'Не удалось записать аудит владельца'; fi; die "$EXIT_VALIDATION" 'Недоверенный владелец серверной конфигурации'; return $?; }
lock_owner=$(stat -c '%u:%g' -- "$LOCK_FILE") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
[[ $lock_owner == "$live_owner" ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed lock-owner >/dev/null 2>&1; then warn 'Не удалось записать аудит владельца блокировки'; fi; die "$EXIT_VALIDATION" 'Недоверенный владелец блокировки'; return $?; }
parse_wireguard_config "$WG_CONF" >/dev/null 2>&1 || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-parse >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; die "$EXIT_VALIDATION" 'Серверная конфигурация не прошла проверку'; return $?; }
config_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
if [[ -n ${MANUAL_BACKUP_PRECOPY_REPLACEMENT:-} ]]; then
if ! transaction_test_hooks_enabled || ! path_components_safe "$MANUAL_BACKUP_PRECOPY_REPLACEMENT" || [[ ! -f $MANUAL_BACKUP_PRECOPY_REPLACEMENT || -L $MANUAL_BACKUP_PRECOPY_REPLACEMENT || $MANUAL_BACKUP_PRECOPY_REPLACEMENT == "$WG_CONF" ]]; then
exec {lock_fd}>&-; die "$EXIT_VALIDATION" 'Некорректный тестовый источник конфигурации'; return $?
fi
replacement_backup=${WG_CONF}.manual-precopy-old
rm -f -- "$replacement_backup"
mv -- "$WG_CONF" "$replacement_backup" || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
cp -- "$MANUAL_BACKUP_PRECOPY_REPLACEMENT" "$WG_CONF" || { mv -- "$replacement_backup" "$WG_CONF"; exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
fi
config_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
if [[ $config_identity_after != "$config_identity" ]]; then
[[ -n ${replacement_backup:-} && -e $replacement_backup ]] && { rm -f -- "$WG_CONF"; mv -- "$replacement_backup" "$WG_CONF"; }
exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения конфигурации'; fi; die "$EXIT_VALIDATION" 'Конфигурация изменилась перед резервированием'; return $?
fi
path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") || { status=$?; exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed backup-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит резервной копии'; fi; return "$status"; }
backup_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { rm -rf -- "$path"; exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
if [[ $backup_identity_after != "$config_identity" ]]; then
rm -rf -- "$path"
[[ -n ${replacement_backup:-} && -e $replacement_backup ]] && { rm -f -- "$WG_CONF"; mv -- "$replacement_backup" "$WG_CONF"; }
exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения конфигурации'; fi; die "$EXIT_VALIDATION" 'Конфигурация изменилась во время резервирования'; return $?
fi
if ! prune_backups; then warn 'Не удалось ограничить число резервных копий'; fi
exec {lock_fd}>&-
if ! AUDIT_OPERATION=manual-backup AUDIT_TARGET="$WG_INTERFACE" AUDIT_ERROR_CATEGORY=success write_audit_event manual-backup "$WG_INTERFACE" success success; then
die "$EXIT_OPERATION" 'Резервная копия создана, но аудит не записан (частичный результат)'
return "$EXIT_OPERATION"
fi
printf '%s\n' "$path"
}
list_backups() {
local path name line timestamp operation target value invalid_count=0 root_mode root_owner server_owner manifest_identity_before manifest_identity_after manifest_fd manifest_opened_identity
local -a entries=()
load_config || return $?
if [[ ! -e $BACKUP_DIR ]]; then printf 'Резервные копии отсутствуют\n'; return 0; fi
[[ -d $BACKUP_DIR && ! -L $BACKUP_DIR ]] && path_components_safe "$BACKUP_DIR" || { die "$EXIT_VALIDATION" 'Каталог резервных копий небезопасен'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION"
root_mode=$(stat -c '%a' -- "$BACKUP_DIR") || return "$EXIT_OPERATION"
root_owner=$(stat -c '%u:%g' -- "$BACKUP_DIR") || return "$EXIT_OPERATION"
[[ $root_mode == 700 && $root_owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Каталог резервных копий небезопасен'; return $?; }
for path in "$BACKUP_DIR"/*; do
[[ -d $path && ! -L $path ]] || { [[ -e $path ]] && ((invalid_count += 1)); continue; }
manifest_identity_before=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path/manifest" 2>/dev/null) || { ((invalid_count += 1)); continue; }
exec {manifest_fd}<"$path/manifest" || { ((invalid_count += 1)); continue; }
manifest_opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${manifest_fd}" 2>/dev/null) || { exec {manifest_fd}>&-; ((invalid_count += 1)); continue; }
if [[ $manifest_opened_identity != "$manifest_identity_before" ]]; then exec {manifest_fd}>&-; ((invalid_count += 1)); continue; fi
if [[ -n ${LIST_BACKUPS_REPLACE_MANIFEST:-} && -z ${LIST_BACKUPS_REPLACE_DONE:-} && $path == "${BACKUP_DIR%/}"/* ]]; then
if transaction_test_hooks_enabled && path_components_safe "$LIST_BACKUPS_REPLACE_MANIFEST" && [[ -f $LIST_BACKUPS_REPLACE_MANIFEST && ! -L $LIST_BACKUPS_REPLACE_MANIFEST ]]; then
local list_manifest_old=${path}/.manifest-list-old
rm -f -- "$list_manifest_old"
mv -- "$path/manifest" "$list_manifest_old" && cp -- "$LIST_BACKUPS_REPLACE_MANIFEST" "$path/manifest" && chmod 600 -- "$path/manifest"
LIST_BACKUPS_REPLACE_DONE=1
fi
fi
if ! validate_backup_manifest "$path"; then
[[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; }
exec {manifest_fd}>&-
((invalid_count += 1)); continue
fi
manifest_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path/manifest" 2>/dev/null) || {
[[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; }
exec {manifest_fd}>&-
((invalid_count += 1)); continue
}
if [[ $manifest_identity_before != "$manifest_identity_after" ]]; then
[[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; }
exec {manifest_fd}>&-
((invalid_count += 1)); continue
fi
name=${path##*/}; timestamp=''; operation=''; target=''
while IFS= read -r line; do
value=${line#*=}
case $line in TIMESTAMP=*) timestamp=$value;; OPERATION=*) operation=$value;; TARGET=*) target=$value;; esac
done <&$manifest_fd
exec {manifest_fd}>&-
entries+=("${name}"$'\t'"${timestamp}"$'\t'"${operation}"$'\t'"${target}")
done
if ((${#entries[@]} == 0)); then printf 'Резервные копии отсутствуют\n'; ((invalid_count > 0)) && printf 'Предупреждение: пропущено некорректных резервных копий: %d\n' "$invalid_count" >&2; return 0; fi
printf '%s\n' "${entries[@]}" | LC_ALL=C sort -r
((invalid_count > 0)) && printf 'Предупреждение: пропущено некорректных резервных копий: %d\n' "$invalid_count" >&2
return 0
}
list_audit() {
local limit=0 line count=0 mode owner server_owner audit_file fd expected_identity opened_identity locked_identity
local -a records=() audit_lines=()
(($# <= 1)) || { die "$EXIT_USAGE" 'Лишние аргументы log'; return $?; }
(($# == 1)) && { [[ $1 =~ ^[1-9][0-9]{0,3}$ ]] && (( 10#$1 <= 1000 )) || { die "$EXIT_USAGE" 'Некорректный лимит log'; return $?; }; limit=$1; }
load_config || return $?
audit_file=$LOG_DIR/wg-manager.log
path_components_safe "$LOG_DIR" || { die "$EXIT_VALIDATION" 'Каталог журнала небезопасен'; return $?; }
[[ -d $LOG_DIR && ! -L $LOG_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог журнала недоступен'; return $?; }
[[ -e $audit_file ]] || { printf 'Журнал отсутствует\n'; return 0; }
[[ -f $audit_file && ! -L $audit_file ]] || { die "$EXIT_VALIDATION" 'Файл журнала небезопасен'; return $?; }
mode=$(stat -c '%a' -- "$LOG_DIR") || return "$EXIT_OPERATION"; [[ $mode == 700 ]] || { die "$EXIT_VALIDATION" 'Небезопасные права каталога журнала'; return $?; }
mode=$(stat -c '%a' -- "$audit_file") || return "$EXIT_OPERATION"; [[ $mode == 600 ]] || { die "$EXIT_VALIDATION" 'Небезопасные права журнала'; return $?; }
[[ $(stat -c '%h' -- "$audit_file") == 1 ]] || { die "$EXIT_VALIDATION" 'Журнал имеет небезопасные ссылки'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION"
[[ $(stat -c '%u:%g' -- "$LOG_DIR") == "$server_owner" && $(stat -c '%u:%g' -- "$audit_file") == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Недоверенный владелец журнала'; return $?; }
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION"
exec {fd}<"$audit_file" || return "$EXIT_OPERATION"
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; die "$EXIT_VALIDATION" 'Файл журнала изменился при чтении'; return $?; }
if ! flock -s "$fd"; then
exec {fd}>&-
return "$EXIT_OPERATION"
fi
locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $locked_identity == "$opened_identity" ]] || { exec {fd}>&-; die "$EXIT_VALIDATION" 'Файл журнала изменился при чтении'; return $?; }
mapfile -t audit_lines <&$fd
for line in "${audit_lines[@]}"; do
[[ $line =~ ^timestamp=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\ actor_uid=[0-9]+\ operation=[a-z][a-z0-9-]{0,31}\ target=[A-Za-z0-9._-]{1,96}\ client=[A-Za-z0-9._-]{1,96}\ key_suffix=[A-Za-z0-9+/=-]{0,16}\ outcome=[a-z][a-z0-9-]{0,31}\ error=[a-z][a-z0-9-]{0,31}$ ]] || continue
records+=("$line")
done
exec {fd}>&-
((${#records[@]} > 0)) || { printf 'В журнале нет корректных записей\n'; return 0; }
count=0
for ((count=${#records[@]}-1; count>=0; count--)); do
printf '%s\n' "${records[count]}"
((limit > 0 && ${#records[@]} - count >= limit)) && break
done
}
restore_builder() {
local stage=$1
[[ -n ${RESTORE_SNAPSHOT:-} && -f $RESTORE_SNAPSHOT && ! -L $RESTORE_SNAPSHOT ]] || return "$EXIT_VALIDATION"
cp -- "$RESTORE_SNAPSHOT" "$stage" && chmod 600 -- "$stage"
}
cleanup_restore_snapshot() {
if [[ -n ${RESTORE_SNAPSHOT:-} ]]; then rm -f -- "$RESTORE_SNAPSHOT"; fi
unset RESTORE_SNAPSHOT RESTORE_BACKUP RESTORE_BACKUP_DIGEST
}
restore_backup() {
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается идентификатор резервной копии'; return $?; }
local name=$1 flag='' backup status answer source backup_name timestamp counter reconstructed snapshot_fd expected_identity opened_identity live_owner replacement_backup=''
(($# >= 2)) && flag=$2
(($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы restore'; return $?; }
load_config || return $?
backup_name=$name; counter=${backup_name##*-}; timestamp=${backup_name%-"$counter"}; timestamp=${timestamp%-"$WG_INTERFACE"}
[[ $timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $counter =~ ^[0-9]{3}$ ]] || { die "$EXIT_VALIDATION" 'Некорректный идентификатор резервной копии'; return $?; }
reconstructed="${timestamp}-${WG_INTERFACE}-${counter}"
[[ $reconstructed == "$name" ]] || { die "$EXIT_VALIDATION" 'Некорректный идентификатор резервной копии'; return $?; }
[[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг restore --yes'; return $?; }
require_add_privilege || return $?
backup=$BACKUP_DIR/$name
source=$backup/wg.conf
live_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца конфигурации'; return $?; }
[[ -d $backup && ! -L $backup && -f $source && ! -L $source ]] || { die "$EXIT_VALIDATION" 'Резервная копия не найдена'; return $?; }
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$source") || { die "$EXIT_OPERATION" 'Не удалось проверить резервную копию'; return $?; }
validate_backup_manifest "$backup" || { die "$EXIT_VALIDATION" 'Резервная копия не прошла проверку'; return $?; }
[[ $backup == "${BACKUP_DIR%/}"/* && ${backup%/*} == "${BACKUP_DIR%/}" && ! -L $source && $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%u:%g' -- "$backup") == "$live_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/manifest") == "600:1:$live_owner" && $(stat -c '%a:%h:%u:%g' -- "$source") == "600:1:$live_owner" ]] || { die "$EXIT_VALIDATION" 'Некорректная безопасность резервной копии'; return $?; }
parse_wireguard_config "$source" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Конфигурация резервной копии не прошла проверку'; return $?; }
printf 'Резервная копия: %s; интерфейс: %s\n' "$timestamp" "$WG_INTERFACE" >&2
[[ -n $flag || ( -t 0 && -t 1 ) ]] || { die "$EXIT_USAGE" 'В неинтерактивном режиме требуется --yes'; return $?; }
if [[ -z $flag ]]; then printf 'Восстановить резервную копию %s? [y/N] ' "$name" >&2; IFS= read -r answer || answer=n; if [[ $answer != y && $answer != Y ]]; then if ! write_audit_event restore "$name" cancelled cancelled >/dev/null 2>&1; then warn 'Не удалось записать аудит отмены восстановления'; fi; cleanup_restore_snapshot; return 0; fi; fi
RESTORE_BACKUP=$backup
RESTORE_SNAPSHOT=$(mktemp "${WG_CONF%/*}/.wg-manager.restore.XXXXXX") || { die "$EXIT_OPERATION" 'Не удалось подготовить снимок восстановления'; return $?; }
chmod 600 -- "$RESTORE_SNAPSHOT" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось защитить снимок восстановления'; return $?; }
[[ $(stat -c '%d:%i:%a:%u:%g:%h' -- "$source") == "$expected_identity" ]] || { cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Резервная копия изменилась'; return $?; }
if [[ -n ${RESTORE_PREOPEN_REPLACEMENT:-} ]]; then
if ! transaction_test_hooks_enabled || ! path_components_safe "$RESTORE_PREOPEN_REPLACEMENT" || [[ ! -f $RESTORE_PREOPEN_REPLACEMENT || -L $RESTORE_PREOPEN_REPLACEMENT || $RESTORE_PREOPEN_REPLACEMENT == "$source" ]]; then
cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Некорректный тестовый источник восстановления'; return $?
fi
replacement_backup=${source}.restore-preopen-old
rm -f -- "$replacement_backup"
mv -- "$source" "$replacement_backup" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось подготовить тестовый источник восстановления'; return $?; }
cp -- "$RESTORE_PREOPEN_REPLACEMENT" "$source" || { mv -- "$replacement_backup" "$source"; cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось подготовить тестовый источник восстановления'; return $?; }
fi
exec {snapshot_fd}<"$source" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось открыть резервную копию'; return $?; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${snapshot_fd}") || { exec {snapshot_fd}>&-; cleanup_restore_snapshot; return "$EXIT_OPERATION"; }
if [[ $opened_identity != "$expected_identity" ]]; then
exec {snapshot_fd}>&-
if [[ -n ${replacement_backup:-} && -e $replacement_backup ]]; then
rm -f -- "$source"
mv -- "$replacement_backup" "$source"
fi
cleanup_restore_snapshot
die "$EXIT_VALIDATION" 'Резервная копия изменилась'
return $?
fi
cat <&$snapshot_fd >"$RESTORE_SNAPSHOT" || { exec {snapshot_fd}>&-; cleanup_restore_snapshot; return "$EXIT_OPERATION"; }
exec {snapshot_fd}>&-
parse_wireguard_config "$RESTORE_SNAPSHOT" >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Снимок восстановления не прошёл проверку'; return $?; }
if run_transaction restore "$name" restore_builder; then status=0; else status=$?; fi
if (( status == 0 )); then
write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; }
else
if ! write_audit_event restore "$name" failed transaction-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi
fi
cleanup_restore_snapshot
return "$status"
}
DIAG_FAILS=0
DIAG_QUIET=0
declare -A DIAG_IDS=()
diag_result() {
local ident=$1 status=$2 summary=$3 guidance=''
(( $# >= 4 )) && guidance=$4
[[ $ident =~ ^[a-z0-9-]+$ ]] || { ((DIAG_FAILS += 1)); return 1; }
[[ $status == OK || $status == WARN || $status == FAIL || $status == SKIP ]] || { ((DIAG_FAILS += 1)); return 1; }
if [[ -n ${DIAG_IDS[$ident]+x} ]]; then ((DIAG_FAILS += 1)); return 1; fi
DIAG_IDS[$ident]=1
if ((DIAG_QUIET == 1)); then printf '%s=%s\n' "$ident" "$status"; else printf '%s [%s] %s' "$ident" "$status" "$summary"; [[ -n $guidance ]] && printf ' — %s' "$guidance"; printf '\n'; fi
[[ $status == FAIL ]] && ((DIAG_FAILS += 1)); return 0
}
diag_command_available() {
local command_name=$1 disabled
local -a disabled_commands=()
if transaction_test_hooks_enabled; then
IFS=, read -r -a disabled_commands <<< "${DIAG_DISABLE_COMMANDS:-}"
for disabled in "${disabled_commands[@]:-}"; do [[ $disabled == "$command_name" ]] && return 1; done
fi
command -v -- "$command_name" >/dev/null 2>&1
}
diag_read_value() { local value; [[ -r $1 && ! -L $1 ]] || return 1; IFS= read -r value <"$1" || return 1; printf '%s' "$value"; }
diagnose() {
local manager_config_ok=0 output path value command_name dump_ok=0
local disk_peer_count=0 runtime_peer_count=0 route_output systemd_state runtime_match=1 disk_parse_ok=0 systemd_usable=0
local interface_name=wg0 server_vpn_ip=0.0.0.0 vpn_prefix=0 server_port=0 use_ipv6=0 ipv6_prefix='' ipv4_network=''
local lower_output='' line nat_ok=0 key token key_owner index
local address_ok=0 route_ok=0 ipv6_address_ok=0 ipv6_route_ok=0
local line_network=0 line_interface=0 line_nat=0
local -a tokens=()
DIAG_FAILS=0; DIAG_QUIET=0; DIAG_IDS=()
(($# <= 1)) || { die "$EXIT_USAGE" 'Лишние аргументы diagnose'; return $?; }
if (($# == 1)); then [[ $1 == --quiet ]] || { die "$EXIT_USAGE" 'Разрешён только флаг diagnose --quiet'; return $?; }; DIAG_QUIET=1; fi
set_runtime_paths || { diag_result paths FAIL 'Некорректные пути состояния' 'Проверьте WG_MANAGER_*'; return "$EXIT_VALIDATION"; }
if [[ -r $CONFIG_PATH && -f $CONFIG_PATH && ! -L $CONFIG_PATH ]] && path_components_safe "$CONFIG_PATH"; then
diag_result config-readable OK 'Конфигурация доступна'
if load_config >/dev/null 2>&1; then manager_config_ok=1; interface_name=$WG_INTERFACE; server_vpn_ip=$SERVER_VPN_IP; vpn_prefix=$VPN_SUBNET_PREFIX; server_port=$SERVER_PORT; use_ipv6=$USE_IPV6; ipv6_prefix=$IPV6_PREFIX; ipv4_network="${vpn_prefix}.0/24"; fi
else
diag_result config-readable FAIL 'Конфигурация недоступна'
fi
if ((manager_config_ok == 1)); then
local config_mode wg_mode config_owner wg_owner wg_config_safe=0
config_mode=$(stat -c '%a' -- "$CONFIG_PATH" 2>/dev/null) || config_mode=''
config_owner=$(stat -c '%u:%g' -- "$CONFIG_PATH" 2>/dev/null) || config_owner=''
if [[ -r $WG_CONF && -f $WG_CONF && ! -L $WG_CONF ]] && path_components_safe "$WG_CONF"; then wg_config_safe=1; fi
if ((wg_config_safe == 1)); then
wg_mode=$(stat -c '%a' -- "$WG_CONF" 2>/dev/null) || wg_mode=''
wg_owner=$(stat -c '%u:%g' -- "$WG_CONF" 2>/dev/null) || wg_owner=''
else
wg_mode=''; wg_owner=''
fi
if [[ -f $CONFIG_PATH && ! -L $CONFIG_PATH && ( $config_mode == 400 || $config_mode == 600 ) && ( $wg_mode == 400 || $wg_mode == 600 ) && $(stat -c '%h' -- "$CONFIG_PATH") == 1 && $wg_config_safe == 1 && $(stat -c '%h' -- "$WG_CONF") == 1 && $config_owner == "$wg_owner" ]]; then diag_result config-permissions OK 'Права конфигураций безопасны'; else diag_result config-permissions FAIL 'Небезопасные права конфигураций'; fi
if ((wg_config_safe == 1)) && parse_wireguard_config "$WG_CONF" >/dev/null 2>&1; then disk_parse_ok=1; diag_result config-parse OK 'Конфигурация WireGuard корректна'; else diag_result config-parse FAIL 'Конфигурация WireGuard не прошла проверку'; fi
if ((disk_parse_ok == 1)); then
(( PARSE_DUPLICATE_KEYS == 1 )) && diag_result duplicate-keys FAIL 'Обнаружены повторяющиеся поля' || diag_result duplicate-keys OK 'Повторяющихся ключей не найдено'
(( PARSE_ALLOWEDIPS_OVERLAP == 1 )) && diag_result allowedips-overlap FAIL 'Обнаружены дублирующиеся или пересекающиеся AllowedIPs' || diag_result allowedips-overlap OK 'AllowedIPs не пересекаются'
else
(( PARSE_DUPLICATE_KEYS == 1 )) && diag_result duplicate-keys FAIL 'Обнаружены повторяющиеся поля' || diag_result duplicate-keys SKIP 'Полный разбор конфигурации невозможен'
(( PARSE_ALLOWEDIPS_OVERLAP == 1 )) && diag_result allowedips-overlap FAIL 'Обнаружены дублирующиеся или пересекающиеся AllowedIPs' || diag_result allowedips-overlap SKIP 'Полный разбор конфигурации невозможен'
fi
local key_file key_mode key_links key_found=0 key_bad=0 client_dir_mode client_dir_owner
client_dir_mode=$(stat -c '%a' -- "$CLIENT_DIR" 2>/dev/null) || client_dir_mode=''; client_dir_owner=$(stat -c '%u:%g' -- "$CLIENT_DIR" 2>/dev/null) || client_dir_owner=''
if [[ -d $CLIENT_DIR && ! -L $CLIENT_DIR && $client_dir_mode == 700 && $client_dir_owner == "$wg_owner" ]] && path_components_safe "$CLIENT_DIR"; then
for key_file in "${CLIENT_DIR%/}"/*.key "${CLIENT_DIR%/}"/*.psk; do
[[ -e $key_file || -L $key_file ]] || continue
key_found=1
key_mode=$(stat -c '%a' -- "$key_file" 2>/dev/null) || key_mode=''; key_links=$(stat -c '%h' -- "$key_file" 2>/dev/null) || key_links=''
key_owner=$(stat -c '%u:%g' -- "$key_file" 2>/dev/null) || key_owner=''
if [[ ! -f $key_file || -L $key_file || ( $key_mode != 400 && $key_mode != 600 ) || $key_links != 1 || $key_owner != "$wg_owner" ]]; then key_bad=1; fi
done
if ((key_found == 0)); then diag_result key-permissions SKIP 'Отдельные ключевые файлы не найдены'; elif ((key_bad == 0)); then diag_result key-permissions OK 'Права ключевых файлов безопасны'; else diag_result key-permissions FAIL 'Небезопасные права ключевых файлов'; fi
else diag_result key-permissions FAIL 'Каталог клиентских ключей небезопасен'; fi
else diag_result config-permissions FAIL 'Параметры конфигурации недействительны'; diag_result config-parse FAIL 'Конфигурация менеджера не загружена'; diag_result duplicate-keys SKIP 'Конфигурация не загружена'; diag_result allowedips-overlap SKIP 'Конфигурация не загружена'; diag_result key-permissions SKIP 'Конфигурация не загружена'; fi
for command_name in wg wg-quick ip; do diag_command_available "$command_name" && diag_result "command-$command_name" OK "Команда $command_name доступна" || diag_result "command-$command_name" FAIL "Не найдена обязательная команда $command_name"; done
if ((manager_config_ok == 1)) && diag_command_available ip && ip link show dev "$interface_name" >/dev/null 2>&1; then diag_result interface-exists OK 'Интерфейс существует'; elif ((manager_config_ok == 1)); then diag_result interface-exists FAIL 'Интерфейс не найден'; else diag_result interface-exists SKIP 'Конфигурация менеджера не загружена'; fi
if ((manager_config_ok == 1)) && diag_command_available wg && output=$(wg show "$interface_name" dump 2>/dev/null) && parse_runtime_dump "$output" >/dev/null 2>&1; then dump_ok=1; diag_result interface-runtime OK 'Дамп WireGuard корректен'; elif ((manager_config_ok == 1)); then diag_result interface-runtime FAIL 'Дамп WireGuard недоступен или неверен'; else diag_result interface-runtime SKIP 'Конфигурация менеджера не загружена'; fi
if diag_command_available systemctl; then
if ! systemd_state=$(systemctl is-system-running 2>/dev/null); then :; fi
if [[ $systemd_state == running || $systemd_state == degraded ]]; then
systemd_usable=1
if systemd_state=$(systemctl is-active -- "wg-quick@$interface_name" 2>/dev/null); then
case $systemd_state in active) diag_result systemd OK 'Сервис WireGuard активен';; inactive|failed|dead) diag_result systemd FAIL 'Сервис WireGuard неактивен';; *) diag_result systemd WARN 'Состояние systemd неясно';; esac
else
case $systemd_state in inactive|failed|dead) diag_result systemd FAIL 'Сервис WireGuard неактивен';; *) diag_result systemd WARN 'Не удалось получить состояние systemd';; esac
fi
else diag_result systemd SKIP 'systemd не является активным init'; fi
else diag_result systemd SKIP 'systemctl не найден'; fi
path=${WG_MANAGER_IPV4_FORWARD_FILE:-/proc/sys/net/ipv4/ip_forward}; value=$(diag_read_value "$path" 2>/dev/null) || value=''; if [[ $value == 1 ]]; then diag_result forwarding-ipv4 OK 'IPv4 forwarding включён'; elif [[ -n $value ]]; then diag_result forwarding-ipv4 FAIL 'IPv4 forwarding имеет небезопасное значение'; else diag_result forwarding-ipv4 SKIP 'Нет доступа к IPv4 forwarding'; fi
if [[ $use_ipv6 == 1 ]]; then path=${WG_MANAGER_IPV6_FORWARD_FILE:-/proc/sys/net/ipv6/conf/all/forwarding}; value=$(diag_read_value "$path" 2>/dev/null) || value=''; [[ $value == 1 ]] && diag_result forwarding-ipv6 OK 'IPv6 forwarding включён' || [[ -n $value ]] && diag_result forwarding-ipv6 FAIL 'IPv6 forwarding имеет небезопасное значение' || diag_result forwarding-ipv6 SKIP 'Нет доступа к IPv6 forwarding'; else diag_result forwarding-ipv6 SKIP 'IPv6 отключён'; fi
if ((manager_config_ok == 1)) && diag_command_available ip; then
output=$(ip -o addr show dev "$interface_name" 2>/dev/null); address_ok=0
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do token=${token%%/*}; [[ $token == "$server_vpn_ip" ]] && address_ok=1; done
done <<<"$output"
((address_ok == 1)) && diag_result interface-address OK 'Адрес интерфейса подтверждён' || diag_result interface-address WARN 'Адрес интерфейса не подтверждён'
route_output=$(ip -o route show "$ipv4_network" dev "$interface_name" 2>/dev/null); tokens=(); read -r -a tokens <<<"$route_output"; route_ok=0
for ((index=0; index<${#tokens[@]}; index++)); do [[ ${tokens[index]} == "$ipv4_network" && ${tokens[index+1]:-} == dev && ${tokens[index+2]:-} == "$interface_name" ]] && route_ok=1; done
((route_ok == 1)) && diag_result routes OK 'Маршрут VPN подтверждён' || diag_result routes WARN 'Маршрут VPN не подтверждён'
else diag_result interface-address SKIP 'ip не найден'; diag_result routes SKIP 'ip не найден'; fi
if [[ $use_ipv6 == 1 ]] && diag_command_available ip; then
local expected_ipv6="${ipv6_prefix}1" expected_ipv6_route="${ipv6_prefix}/64"
output=$(ip -o addr show dev "$interface_name" 2>/dev/null); ipv6_address_ok=0
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do token=${token%%/*}; [[ $token == "$expected_ipv6" ]] && ipv6_address_ok=1; done
done <<<"$output"
((ipv6_address_ok == 1)) && diag_result interface-address-ipv6 OK 'IPv6 адрес интерфейса подтверждён' || diag_result interface-address-ipv6 WARN 'IPv6 адрес интерфейса не подтверждён'
route_output=$(ip -o route show "$expected_ipv6_route" dev "$interface_name" 2>/dev/null); tokens=(); read -r -a tokens <<<"$route_output"; ipv6_route_ok=0
for ((index=0; index<${#tokens[@]}; index++)); do [[ ${tokens[index]} == "$expected_ipv6_route" && ${tokens[index+1]:-} == dev && ${tokens[index+2]:-} == "$interface_name" ]] && ipv6_route_ok=1; done
((ipv6_route_ok == 1)) && diag_result routes-ipv6 OK 'IPv6 маршрут VPN подтверждён' || diag_result routes-ipv6 WARN 'IPv6 маршрут VPN не подтверждён'
else diag_result interface-address-ipv6 SKIP 'IPv6 отключён'; diag_result routes-ipv6 SKIP 'IPv6 отключён'; fi
if diag_command_available ss; then
local udp_ok=0 endpoint
if output=$(ss -lun 2>/dev/null); then
tokens=()
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do
token=${token//\[/}; token=${token//\]/}
[[ $token == *":$server_port" ]] || continue
endpoint=${token%:"$server_port"}
if [[ $endpoint == '*' || $endpoint == '::' || $endpoint =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then udp_ok=1; fi
done
done <<<"$output"
fi
((udp_ok == 1)) && diag_result udp-listener OK 'UDP-порт подтверждён' || diag_result udp-listener WARN 'UDP-порт не подтверждён'
else diag_result udp-listener SKIP 'ss не найден'; fi
local firewall_checked=0
if diag_command_available nft; then
if output=$(nft list ruleset 2>/dev/null); then
firewall_checked=1; nat_ok=0; lower_output=${output,,}
while IFS= read -r line; do
line=${line,,}
tokens=(); read -r -a tokens <<<"$line"; line_network=0; line_interface=0; line_nat=0
for token in "${tokens[@]}"; do token=${token//\"/}; [[ $token == "$ipv4_network" ]] && line_network=1; [[ $token == "$interface_name" ]] && line_interface=1; [[ $token == masquerade || $token == snat ]] && line_nat=1; done
((line_network == 1 && line_interface == 1 && line_nat == 1)) && nat_ok=1
((nat_ok == 1)) && break
done <<<"$lower_output"
((nat_ok == 1)) && diag_result firewall OK 'Видно ожидаемое NAT-свидетельство' || diag_result firewall WARN 'Не найдено убедительное NAT-свидетельство'
fi
fi
if ((firewall_checked == 0)) && diag_command_available iptables; then
if output=$(iptables -t nat -S 2>/dev/null); then
firewall_checked=1; nat_ok=0; lower_output=${output,,}
while IFS= read -r line; do
line=${line,,}
tokens=(); read -r -a tokens <<<"$line"; line_network=0; line_interface=0; line_nat=0
for token in "${tokens[@]}"; do token=${token//\"/}; [[ $token == "$ipv4_network" ]] && line_network=1; [[ $token == "$interface_name" ]] && line_interface=1; [[ $token == masquerade || $token == snat ]] && line_nat=1; done
((line_network == 1 && line_interface == 1 && line_nat == 1)) && nat_ok=1
((nat_ok == 1)) && break
done <<<"$lower_output"
((nat_ok == 1)) && diag_result firewall OK 'Видно ожидаемое NAT-свидетельство' || diag_result firewall WARN 'Не найдено убедительное NAT-свидетельство'
fi
fi
((firewall_checked == 1)) || { if diag_command_available nft || diag_command_available iptables; then diag_result firewall WARN 'Команда firewall недоступна'; else diag_result firewall SKIP 'Нет nftables или iptables'; fi; }
if ((manager_config_ok == 1 && disk_parse_ok == 1 && dump_ok == 1)); then
disk_peer_count=${#DISK_ALLOWED[@]}; runtime_peer_count=${#RUNTIME_ALLOWED[@]};
((disk_peer_count == runtime_peer_count)) || runtime_match=0
for key in "${!DISK_ALLOWED[@]}"; do [[ -n ${RUNTIME_ALLOWED[$key]+x} && ${RUNTIME_ALLOWED[$key]} == "${DISK_ALLOWED[$key]}" ]] || runtime_match=0; done
for key in "${!RUNTIME_ALLOWED[@]}"; do [[ -n ${DISK_ALLOWED[$key]+x} ]] || runtime_match=0; done
((runtime_match == 1)) && diag_result runtime-disk OK 'Наборы peers совпадают' || diag_result runtime-disk WARN 'Наборы peers имеют различия';
else diag_result runtime-disk SKIP 'Сравнение peers невозможно'; fi
if ((systemd_usable == 1)) && diag_command_available journalctl; then if output=$(journalctl -u "wg-quick@$interface_name" --since '24 hours ago' --no-pager 2>/dev/null); then lower_output=${output,,}; [[ $lower_output == *error* || $lower_output == *failed* ]] && diag_result journal WARN 'Журнал содержит ошибки' || diag_result journal OK 'Свежих ошибок в журнале не найдено'; else diag_result journal WARN 'Журнал недоступен'; fi; else diag_result journal SKIP 'Журнал недоступен без systemd'; fi
((DIAG_FAILS == 0)) || return "$EXIT_OPERATION"
}
resolve_named_client() {
local name=$1 index count=0 found=-1
for index in "${!CLIENT_NAMES[@]}"; do
[[ ${CLIENT_NAMES[$index]} == "$name" ]] || continue
((count += 1)); found=$index
done
(( count == 1 )) || { die "$EXIT_VALIDATION" "Клиент не найден или имя неоднозначно: ${name}"; return $?; }
printf '%s\n' "$found"
}
validate_client_config_path() {
local key=$1 name=$2 path metadata_file mode links owner metadata_owner client_dir_owner metadata_dir_owner server_owner client_dir_mode metadata_dir_mode
[[ ${CLIENT_ORIGINS[$3]:-} == managed ]] || { die "$EXIT_VALIDATION" 'Клиент не имеет подтверждённой конфигурации'; return $?; }
path=${META_CONFIG[$key]:-}; metadata_file=${META_FILE[$key]:-}
[[ -n $path && -n $metadata_file && ${path##*/} == "${name}.conf" && ${path%/*} == "${CLIENT_DIR%/}" ]] || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации клиента'; return $?; }
path_components_safe "$path" && path_components_safe "$metadata_file" || { die "$EXIT_VALIDATION" 'Путь конфигурации содержит символическую ссылку'; return $?; }
[[ -f $path && ! -L $path && -f $metadata_file && ! -L $metadata_file ]] || { die "$EXIT_VALIDATION" 'Файл конфигурации клиента недоступен'; return $?; }
[[ -d $CLIENT_DIR && ! -L $CLIENT_DIR && -d $METADATA_DIR && ! -L $METADATA_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов недоступен'; return $?; }
path_components_safe "$CLIENT_DIR" && path_components_safe "$METADATA_DIR" || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов содержит символическую ссылку'; return $?; }
client_dir_mode=$(stat -c '%a' -- "$CLIENT_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить каталог клиентов'; return $?; }
metadata_dir_mode=$(stat -c '%a' -- "$METADATA_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить каталог метаданных'; return $?; }
client_dir_owner=$(stat -c '%u:%g' -- "$CLIENT_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога клиентов'; return $?; }
metadata_dir_owner=$(stat -c '%u:%g' -- "$METADATA_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога метаданных'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
(( (8#$client_dir_mode & 077) == 0 && (8#$metadata_dir_mode & 077) == 0 )) || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов доступен группе или другим'; return $?; }
[[ $client_dir_owner == "$metadata_dir_owner" && $client_dir_owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Владельцы каталогов и серверной конфигурации не совпадают'; return $?; }
mode=$(stat -c '%a' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить режим конфигурации клиента'; return $?; }
(( (8#$mode & 077) == 0 )) || { die "$EXIT_VALIDATION" 'Файл конфигурации доступен группе или другим'; return $?; }
links=$(stat -c '%h' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить ссылки конфигурации клиента'; return $?; }
(( links == 1 )) || { die "$EXIT_VALIDATION" 'Файл конфигурации имеет лишние жёсткие ссылки'; return $?; }
owner=$(stat -c '%u:%g' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца конфигурации клиента'; return $?; }
metadata_owner=$(stat -c '%u:%g' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца метаданных клиента'; return $?; }
local metadata_mode metadata_links
metadata_mode=$(stat -c '%a' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить режим метаданных клиента'; return $?; }
metadata_links=$(stat -c '%h' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить ссылки метаданных клиента'; return $?; }
(( (8#$metadata_mode & 077) == 0 && metadata_links == 1 )) || { die "$EXIT_VALIDATION" 'Метаданные клиента небезопасны'; return $?; }
[[ $owner == "$metadata_owner" && $owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Владелец файлов клиента не совпадает с серверной конфигурацией'; return $?; }
printf '%s\n' "$path"
}
open_validated_client_config() {
local path=$1 expected_identity opened_identity fd
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path") || return "$EXIT_OPERATION"
exec {fd}<"$path" || return "$EXIT_OPERATION"
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || {
exec {fd}<&-
return "$EXIT_OPERATION"
}
if [[ $opened_identity != "$expected_identity" ]]; then
exec {fd}<&-
return "$EXIT_VALIDATION"
fi
CLIENT_CONFIG_FD=$fd
}
print_client_details() {
local name=$1 index=$2 key=${CLIENT_KEYS[$2]} config_path='-'
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then
if ! config_path=$(validate_client_config_path "$key" "$name" "$index" 2>/dev/null); then config_path='(недоступен)'; fi
fi
printf 'Имя: %s\nПубличный ключ: %s\nАдреса: %s\nEndpoint: %s\nПоследний handshake: %s\nRX: %s\nTX: %s\nСостояние: %s\nПроисхождение: %s\n' "$name" "$key" "${CLIENT_ADDRESSES[$index]}" "${CLIENT_ENDPOINTS[$index]}" "${CLIENT_HANDSHAKES[$index]}" "${CLIENT_RX[$index]}" "${CLIENT_TX[$index]}" "${CLIENT_STATES[$index]}" "${CLIENT_ORIGINS[$index]}"
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then printf 'Режим: %s\nСоздан: %s\nПуть конфигурации: %s\n' "${META_MODE[$key]}" "${META_CREATED[$key]}" "$config_path"; fi
}
export_client_config_core() {
local name=$1 show=${2:-0} index key path status=0 expected_key=${3:-}
[[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; }
load_client_model || return $?
index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
key=${CLIENT_KEYS[$index]}
[[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; }
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi
if open_validated_client_config "$path"; then :; else
status=$?
(( status == EXIT_VALIDATION )) || status=$EXIT_OPERATION
die "$status" 'Не удалось безопасно открыть конфигурацию клиента'
return "$status"
fi
if [[ $- == *x* ]]; then
set +x
if ! cat <&"$CLIENT_CONFIG_FD"; then status=$EXIT_OPERATION; fi
set -x
elif ! cat <&"$CLIENT_CONFIG_FD"; then
status=$EXIT_OPERATION
fi
exec {CLIENT_CONFIG_FD}<&-
if (( status != 0 )); then die "$EXIT_OPERATION" 'Не удалось прочитать конфигурацию клиента'; return $?; fi
}
export_client_config() {
(($# >= 1 && $# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы config'; return $?; }
export_client_config_core "$1" "${2:-0}"
}
export_client_config_exact() {
local name=$1 expected_key=$2 show=${3:-0}
export_client_config_core "$name" "$show" "$expected_key"
}
show_client() {
local name=$1 index
load_client_model || return $?
index=$(resolve_named_client "$name") || return $?
print_client_details "$name" "$index"
}
qr_client_core() {
local name=$1 expected_key=${2:-} index key path qr_path status
load_client_model || return $?
index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
key=${CLIENT_KEYS[$index]}
[[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; }
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if qr_path=$(command -v qrencode 2>/dev/null); then :; else qr_path=''; fi
if [[ -z $qr_path || ! -f $qr_path || -L $qr_path || ! -x $qr_path ]]; then
printf '%s\n' "$path"
printf 'Предупреждение: qrencode не найден, используйте путь конфигурации выше\n' >&2
return "$EXIT_DEPENDENCY"
fi
if open_validated_client_config "$path"; then :; else
status=$?
(( status == EXIT_VALIDATION )) || status=$EXIT_OPERATION
die "$status" 'Не удалось безопасно открыть конфигурацию клиента'
return "$status"
fi
if [[ $- == *x* ]]; then
set +x
if "$qr_path" -t ansiutf8 <&"$CLIENT_CONFIG_FD"; then status=0; else status=$?; fi
set -x
else
if "$qr_path" -t ansiutf8 <&"$CLIENT_CONFIG_FD"; then status=0; else status=$?; fi
fi
exec {CLIENT_CONFIG_FD}<&-
(( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; }
}
qr_client() {
(($# == 1)) || { die "$EXIT_USAGE" 'Лишние аргументы qr'; return $?; }
qr_client_core "$1"
}
qr_client_exact() {
local name=$1 expected_key=$2
qr_client_core "$name" "$expected_key"
}
short_public_key() { local key=$1; printf '%s' "${key: -8}"; }
tui_wrap_value() {
local label=$1 value=$2 width=72
printf '%s\n' "$label"
while [[ -n $value ]]; do
printf '%s\n' "${value:0:width}"
value=${value:width}
done
}
remove_peer_builder() {
local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?}
local -a lines=() kept=() block=()
local line index peer_start block_start block_end cursor=0 key marker allowed normalized matched=0 metadata_allowed
[[ ${META_NAME[$target_key]:-} == "$target_name" && ${META_MANAGED[$target_key]:-0} == 1 ]] || { die "$EXIT_VALIDATION" 'Identity клиента изменилась после lock'; return $?; }
[[ ${META_FILE[$target_key]:-} == "${REMOVE_META_FILE:-}" && ${META_CONFIG[$target_key]:-} == "${REMOVE_CONFIG_PATH:-}" ]] || { die "$EXIT_VALIDATION" 'Путь клиента изменился после lock'; return $?; }
normalized=$(normalize_allowed_ips "${DISK_ALLOWED[$target_key]:-}") || { die "$EXIT_VALIDATION" 'Адреса клиента изменились после lock'; return $?; }
[[ $normalized == "${REMOVE_ALLOWED:-}" ]] || { die "$EXIT_VALIDATION" 'Адреса клиента изменились после lock'; return $?; }
metadata_allowed=${META_IPV4[$target_key]:-}
[[ -n $metadata_allowed ]] && metadata_allowed+='/32'
[[ -n ${META_IPV6[$target_key]:-} ]] && { [[ -n $metadata_allowed ]] && metadata_allowed+=','; metadata_allowed+=${META_IPV6[$target_key]}/128; }
normalized=$(normalize_allowed_ips "$metadata_allowed") || { die "$EXIT_VALIDATION" 'Адреса metadata изменились после lock'; return $?; }
[[ $normalized == "${REMOVE_ALLOWED:-}" ]] || { die "$EXIT_VALIDATION" 'Адреса metadata изменились после lock'; return $?; }
mapfile -t lines <"$WG_CONF" || return "$EXIT_OPERATION"
while :; do
peer_start=-1
for index in "${!lines[@]}"; do
(( index >= cursor )) || continue
if [[ ${lines[$index]} =~ ^[[:space:]]*\[Peer\][[:space:]]*$ ]]; then peer_start=$index; break; fi
done
if (( peer_start < 0 )); then
kept+=("${lines[@]:$cursor}")
break
fi
block_start=$peer_start
if (( peer_start > 0 )) && [[ ${lines[$((peer_start - 1))]} == "# wg-manager-client: ${target_name}" ]]; then
block_start=$((peer_start - 1))
if (( block_start < cursor )) && ((${#kept[@]} > 0)) && [[ ${kept[$((${#kept[@]} - 1))]} == "# wg-manager-client: ${target_name}" ]]; then
unset 'kept[${#kept[@]}-1]'
fi
fi
if (( block_start > cursor )); then kept+=("${lines[@]:$cursor:$((block_start - cursor))}"); fi
block_end=${#lines[@]}
for index in "${!lines[@]}"; do
(( index > peer_start )) || continue
if [[ ${lines[$index]} =~ ^[[:space:]]*\[Peer\][[:space:]]*$ || ${lines[$index]} =~ ^[[:space:]]*\[Interface\][[:space:]]*$ ]]; then block_end=$index; break; fi
done
block=("${lines[@]:$block_start:$((block_end - block_start))}")
key=''; marker=''; allowed=''
for line in "${block[@]}"; do
[[ $line =~ ^[[:space:]]*PublicKey[[:space:]]*=[[:space:]]*(.*)$ ]] && key=$(trim_space "${BASH_REMATCH[1]}")
[[ $line =~ ^[[:space:]]*AllowedIPs[[:space:]]*=[[:space:]]*(.*)$ ]] && allowed=$(trim_space "${BASH_REMATCH[1]}")
if [[ ${block[0]:-} == "# wg-manager-client: ${target_name}" ]]; then marker=1; fi
done
normalized=$(normalize_allowed_ips "$allowed") || return "$EXIT_VALIDATION"
if [[ $key == "$target_key" && $marker == 1 && $normalized == "${REMOVE_ALLOWED:-}" ]]; then
# A conventional marker immediately before the next [Peer] belongs to
# that next peer and must survive removal of this block.
local trailing_index=$(( ${#block[@]} - 1 ))
while (( trailing_index >= 0 )) && [[ -z ${block[$trailing_index]} ]]; do ((trailing_index -= 1)); done
if (( trailing_index >= 0 )) && [[ ${block[$trailing_index]} =~ ^#\ (wg-manager-client:|###\ Client\ ) ]] && [[ ${block[$trailing_index]} != "# wg-manager-client: ${target_name}" ]]; then
kept+=("${block[$trailing_index]}")
fi
else
kept+=("${block[@]}")
fi
if [[ $key == "$target_key" && $marker == 1 && $normalized == "${REMOVE_ALLOWED:-}" ]]; then ((matched += 1)); fi
cursor=$block_end
done
(( matched == 1 )) || { die "$EXIT_VALIDATION" 'Не найден единственный точный managed-блок клиента'; return $?; }
: >"$stage" || return "$EXIT_OPERATION"
printf '%s\n' "${kept[@]}" >"$stage"
}
remove_archive_sources() {
local path base mode owner server_owner
REMOVE_ARCHIVE_SOURCES=("$REMOVE_META_FILE" "$REMOVE_CONFIG_PATH")
for base in key pub psk; do
path="${CLIENT_DIR}/${REMOVE_NAME}.${base}"
if [[ -e $path || -L $path ]]; then REMOVE_ARCHIVE_SOURCES+=("$path"); fi
done
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do
[[ -f $path && ! -L $path ]] || { die "$EXIT_VALIDATION" 'Небезопасный файл архивации'; return $?; }
mode=$(stat -c '%a' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить режим файла архивации'; return $?; }
owner=$(stat -c '%u:%g' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца файла архивации'; return $?; }
[[ $mode == 600 && $owner == "$server_owner" && $(stat -c '%h' -- "$path") == 1 ]] || { die "$EXIT_VALIDATION" 'Файл архивации имеет небезопасные атрибуты'; return $?; }
done
}
allocate_remove_archive() {
local now stamp base candidate suffix
now=$(current_epoch) || { die "$EXIT_OPERATION" 'Не удалось получить время архивации'; return $?; }
stamp=$(date -u -d "@${now}" +%Y%m%dT%H%M%SZ) || { die "$EXIT_OPERATION" 'Не удалось получить UTC-время архивации'; return $?; }
base="${ARCHIVE_DIR}/${stamp}-${WG_INTERFACE}-${REMOVE_NAME}"
for ((suffix = 0; suffix <= 999; suffix++)); do
if (( suffix == 0 )); then candidate=$base; else candidate="${base}-${suffix}"; fi
if mkdir -- "$candidate" 2>/dev/null; then
if ! chmod 700 -- "$candidate"; then
rm -rf -- "$candidate"
die "$EXIT_OPERATION" 'Не удалось защитить каталог архива'
return $?
fi
REMOVE_ARCHIVE_PATH=$candidate
return 0
fi
done
die "$EXIT_OPERATION" 'Не удалось создать уникальный архив'
return $?
}
archive_remove_artifacts() {
local path base mode owner server_owner remove_index=0 copy_index=0
local -a archived=()
set_runtime_paths || return $?
[[ ${REMOVE_NAME:?} =~ ^[A-Za-z0-9._-]+$ ]] || return "$EXIT_VALIDATION"
path_components_safe "$ARCHIVE_DIR" && path_components_safe "$CLIENT_DIR" && path_components_safe "$METADATA_DIR" || { die "$EXIT_VALIDATION" 'Каталоги архивации содержат небезопасный путь'; return $?; }
[[ ! -L $ARCHIVE_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог архива является ссылкой'; return $?; }
[[ $REMOVE_META_FILE == "${METADATA_DIR}/${REMOVE_NAME}.meta" && $REMOVE_CONFIG_PATH == "${CLIENT_DIR}/${REMOVE_NAME}.conf" ]] || { die "$EXIT_VALIDATION" 'Файлы клиента не соответствуют его имени'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
if [[ -e $ARCHIVE_DIR ]]; then
[[ -d $ARCHIVE_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог архива недоступен'; return $?; }
mode=$(stat -c '%a' -- "$ARCHIVE_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить режим каталога архива'; return $?; }
owner=$(stat -c '%u:%g' -- "$ARCHIVE_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога архива'; return $?; }
[[ $mode == 700 && $owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Каталог архива имеет небезопасные атрибуты'; return $?; }
else
mkdir -- "$ARCHIVE_DIR" || { die "$EXIT_OPERATION" 'Не удалось создать каталог архива'; return $?; }
if ! chmod 700 -- "$ARCHIVE_DIR"; then
if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi
die "$EXIT_OPERATION" 'Не удалось защитить каталог архива'
return $?
fi
if ! owner=$(stat -c '%u:%g' -- "$ARCHIVE_DIR"); then
if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi
die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога архива'
return $?
fi
if [[ $owner != "$server_owner" ]]; then
if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi
die "$EXIT_VALIDATION" 'Владелец каталога архива не совпадает'
return $?
fi
fi
remove_archive_sources || return $?
allocate_remove_archive || return $?
for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do
base=${path##*/}
if [[ ${M6_ARCHIVE_COPY_FAIL_INDEX:-} == "$copy_index" ]] && transaction_test_hooks_enabled; then
rm -rf -- "$REMOVE_ARCHIVE_PATH"
die "$EXIT_OPERATION" 'Не удалось сохранить файл клиента в архив'
return $?
fi
if ! copy_restricted "$path" "${REMOVE_ARCHIVE_PATH}/${base}"; then
rm -rf -- "$REMOVE_ARCHIVE_PATH"
die "$EXIT_OPERATION" 'Не удалось сохранить файл клиента в архив'
return $?
fi
archived+=("$path")
((copy_index += 1))
done
for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do
if [[ ${M6_ARCHIVE_REMOVE_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then
for base in "${archived[@]}"; do
[[ -e $base ]] && continue
if [[ ${M6_ARCHIVE_RECOVERY_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then
die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'
return $?
fi
cp -- "${REMOVE_ARCHIVE_PATH}/${base##*/}" "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'; return $?; }
chmod 600 -- "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось защитить восстановленный файл'; return $?; }
done
die "$EXIT_OPERATION" 'Не удалось удалить исходный файл клиента после архивации'
return $?
fi
if ! rm -f -- "$path"; then
for base in "${archived[@]}"; do
[[ -e $base ]] && continue
if [[ ${M6_ARCHIVE_RECOVERY_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then
die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'
return $?
fi
cp -- "${REMOVE_ARCHIVE_PATH}/${base##*/}" "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'; return $?; }
chmod 600 -- "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось защитить восстановленный файл'; return $?; }
done
die "$EXIT_OPERATION" 'Не удалось завершить архивацию файлов клиента'
return $?
fi
((remove_index += 1))
done
}
write_audit_event() {
local operation=$1 target=$2 outcome=$3 error_category=$4
local audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity locked_identity write_status
[[ $operation =~ ^[a-z][a-z0-9-]{0,31}$ && $target =~ ^[A-Za-z0-9._-]{1,96}$ && $outcome =~ ^[a-z][a-z0-9-]{0,31}$ && $error_category =~ ^[a-z][a-z0-9-]{0,31}$ ]] || return "$EXIT_VALIDATION"
path_components_safe "$LOG_DIR" || return "$EXIT_VALIDATION"
[[ ! -L $LOG_DIR ]] || return "$EXIT_VALIDATION"
if [[ ! -e $LOG_DIR ]]; then
mkdir -- "$LOG_DIR" || return "$EXIT_OPERATION"
chmod 700 -- "$LOG_DIR" || return "$EXIT_OPERATION"
fi
[[ -d $LOG_DIR ]] || return "$EXIT_OPERATION"
mode=$(stat -c '%a' -- "$LOG_DIR") || return "$EXIT_OPERATION"
[[ $mode == 700 ]] || return "$EXIT_VALIDATION"
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION"
owner=$(stat -c '%u:%g' -- "$LOG_DIR") || return "$EXIT_OPERATION"
[[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION"
if [[ ${M6_AUDIT_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then
return "$EXIT_OPERATION"
fi
if [[ -e $audit_file || -L $audit_file ]]; then
[[ -f $audit_file && ! -L $audit_file && $(stat -c '%h' -- "$audit_file") == 1 ]] || return "$EXIT_VALIDATION"
mode=$(stat -c '%a' -- "$audit_file") || return "$EXIT_OPERATION"
[[ $mode == 600 ]] || return "$EXIT_VALIDATION"
owner=$(stat -c '%u:%g' -- "$audit_file") || return "$EXIT_OPERATION"
[[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION"
else
if ! (umask 077; set -C; : >"$audit_file") 2>/dev/null; then
[[ -f $audit_file && ! -L $audit_file ]] || return "$EXIT_OPERATION"
else
chmod 600 -- "$audit_file" || return "$EXIT_OPERATION"
fi
fi
timestamp=$(current_epoch) || return "$EXIT_OPERATION"
timestamp=$(date -u -d "@${timestamp}" +%Y-%m-%dT%H:%M:%SZ) || return "$EXIT_OPERATION"
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION"
exec {fd}>>"$audit_file" || return "$EXIT_OPERATION"
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; }
if ! flock "$fd"; then exec {fd}>&-; return "$EXIT_OPERATION"; fi
locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $locked_identity == "$opened_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; }
if printf 'timestamp=%s actor_uid=%s operation=%s target=%s client=%s key_suffix=%s outcome=%s error=%s\n' "$timestamp" "$EUID" "$operation" "$target" "${AUDIT_CLIENT:-$target}" "${AUDIT_KEY_SUFFIX:-}" "$outcome" "$error_category" >&$fd; then write_status=0; else write_status=$EXIT_OPERATION; fi
exec {fd}>&-
return "$write_status"
}
remove_audit() {
local outcome=$1
AUDIT_CLIENT=${REMOVE_NAME:-remove} AUDIT_KEY_SUFFIX=$(short_public_key "${REMOVE_KEY:-}") write_audit_event remove "${REMOVE_NAME:-remove}" "$outcome" "${REMOVE_ERROR_CATEGORY:-$outcome}"
}
confirm_remove() {
local name=$1 key=$2 addresses=$3 answer
printf 'Удалить клиента %s (ключ *%s, адреса %s)? [y/N] ' "$name" "$(short_public_key "$key")" "$addresses" >&2
IFS= read -r answer || answer=n
[[ $answer == y || $answer == Y ]]
}
remove_is_interactive() { [[ -t 0 && -t 1 ]]; }
remove_client_core() {
local name=$1 expected_key=$2 flag=$3 index key path status answer
REMOVE_ARCHIVE_RESULT=''
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
load_client_model || return $?
path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; }
require_remove_privilege || return $?
index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; }
key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if [[ -n $expected_key && $expected_key != "$key" ]]; then
die "$EXIT_VALIDATION" 'Идентичность клиента изменилась до удаления'
return $?
fi
REMOVE_NAME=$name REMOVE_KEY=$key REMOVE_CONFIG_PATH=$path REMOVE_META_FILE=${META_FILE[$key]}
REMOVE_ALLOWED=${DISK_ALLOWED[$key]}
if [[ -z $flag ]] && ! remove_is_interactive; then
REMOVE_ERROR_CATEGORY=confirmation_required
remove_audit cancelled
die "$EXIT_USAGE" 'В неинтерактивном режиме требуется флаг --yes'
return $?
fi
if [[ -n $flag ]]; then answer=y; else if confirm_remove "$name" "$key" "${CLIENT_ADDRESSES[$index]}"; then answer=y; else answer=n; fi; fi
if [[ $answer != y && $answer != Y ]]; then
REMOVE_ERROR_CATEGORY=cancelled
if ! remove_audit cancelled; then printf 'Предупреждение: отмена выполнена, но аудит не записан\n' >&2; fi
printf 'Операция отменена\n' >&2
return 0
fi
TRANSACTION_FINALIZER=archive_remove_artifacts
if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi
if (( status == 0 )); then
[[ -n ${REMOVE_ARCHIVE_PATH:-} ]] && REMOVE_ARCHIVE_RESULT=$REMOVE_ARCHIVE_PATH
REMOVE_ERROR_CATEGORY=success
if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi
elif { REMOVE_ERROR_CATEGORY=transaction-failed; ! remove_audit failed; }; then
printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2
fi
unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER
return "$status"
}
remove_client_impl() {
local name='' flag='' arg
if (($# > 0)); then name=$1; shift; fi
while (($#)); do
arg=$1
case $arg in
--yes) [[ -z $flag ]] || { die "$EXIT_USAGE" 'Флаг remove --yes указан повторно'; return $?; }; flag=--yes ;;
*) die "$EXIT_USAGE" 'Лишние аргументы remove'; return $? ;;
esac
shift
done
remove_client_core "$name" '' "$flag"
}
remove_client() {
local xtrace=0 status
[[ $- == *x* ]] && xtrace=1; set +x
if remove_client_impl "$@"; then status=0; else status=$?; fi
unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH REMOVE_ERROR_CATEGORY TRANSACTION_FINALIZER REMOVE_ARCHIVE_SOURCES
(( xtrace == 1 )) && set -x
return "$status"
}
remove_client_exact() {
local name=$1 expected_key=$2 status xtrace=0
[[ $- == *x* ]] && xtrace=1
set +x
if remove_client_core "$name" "$expected_key" --yes; then status=0; else status=$?; fi
(( xtrace == 1 )) && set -x
return "$status"
}
main() {
local status
if (( $# == 0 )); then
load_config || return $?
tui_screen
return $?
fi
if [[ $1 == add ]]; then
shift
if (( $# == 0 )); then
if die "$EXIT_USAGE" 'Ожидается имя клиента'; then status=0; else status=$?; fi
print_usage >&2
return "$status"
fi
add_client "$@"
return $?
fi
if [[ $1 == show || $1 == config || $1 == qr ]]; then
[[ $# -ge 2 ]] || { die "$EXIT_USAGE" "Для команды $1 требуется имя клиента"; print_usage >&2; return $?; }
local command=$1 name=$2
shift 2
case $command in
show|qr) (($# == 0)) || { die "$EXIT_USAGE" 'Лишние аргументы команды'; return $?; } ;;
config)
if (($# == 0)); then export_client_config "$name" 0; return $?; fi
[[ $# == 1 && $1 == --show ]] || { die "$EXIT_USAGE" 'Разрешён только флаг config --show'; return $?; }
export_client_config "$name" 1; return $?
;;
esac
case $command in show) show_client "$name" ;; qr) qr_client "$name" ;; esac
return $?
fi
if [[ $1 == diagnose ]]; then
shift
diagnose "$@"
return $?
fi
if [[ $1 == restore ]]; then
(($# >= 2 && $# <= 3)) || { die "$EXIT_USAGE" 'Ожидается идентификатор резервной копии'; return $?; }
shift
restore_backup "$@"
return $?
fi
if [[ $1 == log ]]; then
shift
list_audit "$@"
return $?
fi
if (( $# != 1 )); then
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2
return "$status"
fi
case "$1" in
help) print_usage ;;
version) print_version ;;
status) load_client_model || return $?; print_status ;;
list) load_client_model || return $?; print_list ;;
remove)
shift
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
remove_client "$@" ;;
backup) manual_backup ;;
backups) list_backups ;;
tui) (($# == 1)) || { die "$EXIT_USAGE" 'Команда tui не принимает аргументы'; return $?; }; load_config || return $?; tui_screen ;;
logs) run_placeholder "$1" ;;
*)
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2
return "$status"
;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi