#!/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] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose|logs|backups|restore|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 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} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в конфигурации'; return $?; } local normalized existing_key normalized=$(normalize_allowed_ips "$allowed") || { die "$EXIT_VALIDATION" 'Некорректные или пересекающиеся AllowedIPs'; return $?; } for existing_key in "${!DISK_ALLOWED[@]}"; do allowed_sets_overlap "$normalized" "${DISK_ALLOWED[$existing_key]}" && { 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=() 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_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 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 [[ $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%a' -- "$backup/manifest") == 600 && $(stat -c '%a' -- "$backup/wg.conf") == 600 ]] || 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-z][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' -- "$backup/$metadata_name") == 600 ]] || 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\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" >"$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_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-z][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 [[ ${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 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 fi fi TRANSACTION_ROLLBACK_ACTIVE=0 fi if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then if ! prune_backups; then printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2 fi fi 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 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 ;; esac done <"$result_file" fi rm -f -- "$result_file" TRANSACTION_BACKUP_PATH=${result_backup:-} 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.0/24' "$VPN_SUBNET_PREFIX" ;; *) 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_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 mode_seen=0 endpoint_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 ;; *) 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 $?; } 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 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 clear_add_transaction_state return "$status" fi status=0 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 $?; } } print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; } run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; } 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() { local name=$1 show=${2:-0} index key path status=0 [[ $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]} 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 } show_client() { local name=$1 index load_client_model || return $? index=$(resolve_named_client "$name") || return $? print_client_details "$name" "$index" } qr_client() { local name=$1 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]} 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"; } } short_public_key() { local key=$1; printf '%s' "${key: -8}"; } 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 } remove_audit() { local outcome=$1 audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity 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 : >"$audit_file" || return "$EXIT_OPERATION" chmod 600 -- "$audit_file" || return "$EXIT_OPERATION" 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 ! printf 'operation=remove client=%s target=%s key_suffix=%s timestamp=%s outcome=%s actor_uid=%s error=%s\n' "$REMOVE_NAME" "$REMOVE_NAME" "$(short_public_key "$REMOVE_KEY")" "$timestamp" "$outcome" "$EUID" "${REMOVE_ERROR_CATEGORY:-$outcome}" >&$fd; then exec {fd}>&- return "$EXIT_OPERATION" fi exec {fd}>&- } 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_impl() { local name=$1 flag=${2:-} index key path status answer (($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы remove'; return $?; } validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; } [[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг remove --yes'; 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 $? 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 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() { 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" } main() { local status if (( $# == 0 )); then if die "$EXIT_USAGE" 'Ожидается команда'; then status=0; else status=$?; fi print_usage >&2 return "$status" 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 )); 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 "$@" ;; diagnose|logs|backups|restore|tui) 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