Files
wireguard-scripts/wg-manager.sh
T

259 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
resolve_script_dir() {
local source_path=${BASH_SOURCE[0]} source_dir
if [[ $source_path == /* ]]; then
if [[ $source_path == */* ]]; then source_dir=${source_path%/*}; else source_dir=/; fi
else
if [[ $source_path == */* ]]; then source_dir=${PWD}/${source_path%/*}; else source_dir=$PWD; fi
fi
[[ -n $source_dir ]] || source_dir=/
cd -- "$source_dir" && pwd
}
SCRIPT_DIR="$(resolve_script_dir)"
readonly SCRIPT_DIR
readonly WG_MANAGER_VERSION='0.1.0'
readonly EXIT_USAGE=64
readonly EXIT_VALIDATION=65
readonly EXIT_OPERATION=70
# shellcheck disable=SC2034
readonly EXIT_SUCCESS=0
# shellcheck disable=SC2034
readonly EXIT_ROLLBACK=71
# shellcheck disable=SC2034
readonly EXIT_PERMISSION=77
readonly EXIT_DEPENDENCY=69
CONFIG_PATH=''
BACKUP_DIR=''
LOG_DIR=''
METADATA_DIR=''
ARCHIVE_DIR=''
LOCK_FILE=''
WG_INTERFACE=''
WG_CONF=''
CLIENT_DIR=''
SERVER_ENDPOINT=''
SERVER_PORT=''
SERVER_VPN_IP=''
CLIENT_DNS=''
VPN_SUBNET_PREFIX=''
START_HOST=''
END_HOST=''
USE_IPV6=''
IPV6_PREFIX=''
DEFAULT_CLIENT_MODE=''
RECENT_HANDSHAKE_SECONDS=''
BACKUP_RETENTION=''
declare -A CONFIG_VALUES=()
print_usage() {
cat <<'USAGE'
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add|show|config|qr|remove|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
}
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_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 $?; }
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 >= 1 && 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 $?; }
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» пока не реализована"; }
main() {
if (( $# != 1 )); then
local status
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|list|add|show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
*)
local status
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