feat(clients): add client details and secure config export

This commit is contained in:
2026-10-13 00:52:56 +03:00
parent 2e40d5bd1a
commit 2bec75aa9d
6 changed files with 527 additions and 22 deletions
+151 -8
View File
@@ -54,7 +54,7 @@ print_usage() {
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show|config|qr|remove|diagnose|logs|backups|restore|tui}
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove|diagnose|logs|backups|restore|tui}
Команды управления WireGuard будут добавлены в следующих версиях.
USAGE
@@ -262,7 +262,7 @@ 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
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='-'
@@ -480,12 +480,12 @@ parse_metadata_file() {
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_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_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
}
@@ -517,12 +517,13 @@ parse_runtime_dump() {
}
load_runtime_state() {
local output command_status
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=$?
set -e
(( had_errexit == 1 )) && set -e || set +e
(( command_status == 0 )) || return 0
[[ -n "$output" ]] || return 0
parse_runtime_dump "$output"
@@ -547,7 +548,7 @@ build_client_records() {
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 [[ ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
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
@@ -1395,6 +1396,133 @@ resolve_gum() {
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"; }
}
main() {
local status
if (( $# == 0 )); then
@@ -1412,6 +1540,21 @@ main() {
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
@@ -1422,7 +1565,7 @@ main() {
version) print_version ;;
status) load_client_model || return $?; print_status ;;
list) load_client_model || return $?; print_list ;;
show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
*)
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2