feat(core): add validated configuration and CLI foundation

This commit is contained in:
2026-10-11 23:34:01 +03:00
parent 216b09474e
commit 987c000320
8 changed files with 728 additions and 17 deletions
+15 -1
View File
@@ -261,7 +261,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
| Milestone | Status | Outcome | | Milestone | Status | Outcome |
|---|---|---| |---|---|---|
| M0 | DONE | Test harness and safe project skeleton | | M0 | DONE | Test harness and safe project skeleton |
| M1 | NOT STARTED | Runtime foundation, CLI shell, and config loading | | M1 | DONE | Runtime foundation, CLI shell, and config loading |
| M2 | NOT STARTED | WireGuard parser, client model, `status`, and `list` | | M2 | NOT STARTED | WireGuard parser, client model, `status`, and `list` |
| M3 | NOT STARTED | Locking, backups, atomic apply, and rollback engine | | M3 | NOT STARTED | Locking, backups, atomic apply, and rollback engine |
| M4 | NOT STARTED | Safe client creation | | M4 | NOT STARTED | Safe client creation |
@@ -1565,6 +1565,20 @@ Use this template:
- Suggested commit message: `test: add isolated Bash test harness and project skeleton` - Suggested commit message: `test: add isolated Bash test harness and project skeleton`
- Next milestone: M1 - Next milestone: M1
## 2026-10-11 — M1 — DONE
- Implemented: stable exit codes and output helpers, caller-independent project paths with scoped runtime overrides, restricted non-executing configuration parsing and semantic validation, dependency discovery, fixed-order `gum` resolution, version/help and known-command CLI dispatch, focused configuration tests, and M1 usage documentation.
- Coordinator: `gpt-5.6-sol` (`high`)
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
- Repair cycles: 3
- Sol review: replaced duplicate-masked semantic tests with single-cause fixtures; added real focused-test dispatch and missing parser/validator/gum/dependency/CLI branches; made disabled debug strict-mode safe; fixed server-address range containment, empty/trailing DNS elements, numeric endpoint and hostname validation, gum regular-file checks, malformed IPv6 compression, and bracket-glob paths; removed unintended staging from two test helpers.
- Verification: `bash -n wg-manager.sh`, `bash -n tests/*.sh`, `bash -n tests/mocks/bin/*`, `bash tests/run.sh`, `./wg-manager.sh help`, `./wg-manager.sh version`, `(cd /tmp && /home/sasa/share/bash/wireguard-scripts/wg-manager.sh help)`, `bash tests/run.sh config`, `git diff --check`, `git diff --no-index --check /dev/null <new-file>`, redirected-output checks, and independent IPv6/path/debug boundary probes.
- Result: all required checks passed; the full suite passed 158 assertions and the focused configuration suite passed 109 assertions. Help and version required no configuration, root, WireGuard, or `gum`; malicious configuration remained inert; alternate runtime paths and gum precedence passed through isolated fixtures and mocks. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
- Decisions: none
- Known limitations: real WireGuard, networking, service, firewall, route, sysctl, journal, and deployment-server checks were not run; M1 verification is fixture- and mock-based as required.
- Suggested commit message: `feat(core): add validated configuration and CLI foundation`
- Next milestone: M2
# 8. Final completion rule # 8. Final completion rule
The MVP is complete only when: The MVP is complete only when:
+19
View File
@@ -1,2 +1,21 @@
# wireguard-scripts # wireguard-scripts
## M1 runtime foundation
The current entry point provides dependency-free `help` and `version` commands. The
planned operational commands are recognized but remain unavailable until later
milestones.
Runtime locations default to the project directory and can be redirected for an
isolated run with absolute paths using these environment variables:
`WG_MANAGER_CONFIG`, `WG_MANAGER_BACKUP_DIR`, `WG_MANAGER_LOG_DIR`,
`WG_MANAGER_METADATA_DIR`, `WG_MANAGER_ARCHIVE_DIR`, and `WG_MANAGER_LOCK_FILE`.
`WG_MANAGER_GUM` selects an executable `gum` explicitly; otherwise the bundled
`bin/gum` and then `PATH` are checked in that order. Configuration is parsed as
restricted `KEY=VALUE` data and is never executed as shell code. Blank lines and
full-line `#` comments are ignored; spaces around the key, `=`, and value are
trimmed. Values are either one unquoted token or a double-quoted value without
escapes, and only documented keys are accepted.
+12 -2
View File
@@ -2,8 +2,10 @@
set -Eeuo pipefail set -Eeuo pipefail
umask 077 umask 077
readonly HELPERS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" HELPERS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_DIR="$(cd -- "${HELPERS_DIR}/.." && pwd)" readonly HELPERS_DIR
PROJECT_DIR="$(cd -- "${HELPERS_DIR}/.." && pwd)"
readonly PROJECT_DIR
TEST_ROOT='' TEST_ROOT=''
TEST_MOCK_LOG='' TEST_MOCK_LOG=''
TEST_FAILURES=0 TEST_FAILURES=0
@@ -67,6 +69,14 @@ assert_file_exists() {
fi fi
} }
assert_file_absent() {
local path=$1 message=${2:-файл отсутствует}
((TEST_ASSERTIONS += 1))
if [[ -e "$path" ]]; then
record_failure "${message}: найден [${path}]"
fi
}
assert_file_mode() { assert_file_mode() {
local expected=$1 path=$2 actual message local expected=$1 path=$2 actual message
actual=$(stat -c '%a' -- "$path") actual=$(stat -c '%a' -- "$path")
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=../wg-manager.sh
source "$1"
resolve_gum
+436 -2
View File
@@ -2,14 +2,420 @@
set -Eeuo pipefail set -Eeuo pipefail
umask 077 umask 077
readonly TESTS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" TESTS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly TESTS_DIR
# shellcheck source=helpers.sh # shellcheck source=helpers.sh
source "${TESTS_DIR}/helpers.sh" source "${TESTS_DIR}/helpers.sh"
# shellcheck source=../wg-manager.sh
source "${PROJECT_DIR}/wg-manager.sh"
case $# in
0) ;;
1) [[ $1 == config ]] || { printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64; } ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac
test_group=${1-}
setup_test_environment setup_test_environment
write_valid_config() {
local output_file=$1 override_key=${2-} override_value=${3-}
local wg_interface=wg0 wg_conf="${TEST_ROOT}/wg0.conf" client_dir="${TEST_ROOT}/clients"
local server_endpoint=vpn.example.invalid server_port=51820 server_vpn_ip=192.0.2.1
local client_dns='192.0.2.53,2001:db8::53' vpn_subnet_prefix=192.0.2 start_host=2 end_host=254
local use_ipv6=0 ipv6_prefix=2001:db8:ffff:: default_client_mode=full-tunnel
local recent_handshake_seconds=180 backup_retention=10
case $override_key in
'') ;;
WG_INTERFACE) wg_interface=$override_value ;;
WG_CONF) wg_conf=$override_value ;;
CLIENT_DIR) client_dir=$override_value ;;
SERVER_ENDPOINT) server_endpoint=$override_value ;;
SERVER_PORT) server_port=$override_value ;;
SERVER_VPN_IP) server_vpn_ip=$override_value ;;
CLIENT_DNS) client_dns=$override_value ;;
VPN_SUBNET_PREFIX) vpn_subnet_prefix=$override_value ;;
START_HOST) start_host=$override_value ;;
END_HOST) end_host=$override_value ;;
USE_IPV6) use_ipv6=$override_value ;;
IPV6_PREFIX) ipv6_prefix=$override_value ;;
DEFAULT_CLIENT_MODE) default_client_mode=$override_value ;;
RECENT_HANDSHAKE_SECONDS) recent_handshake_seconds=$override_value ;;
BACKUP_RETENTION) backup_retention=$override_value ;;
RANGE_REVERSED) start_host=254; end_host=2 ;;
*) printf 'unknown test config key: %s\n' "$override_key" >&2; return 64 ;;
esac
cat >"$output_file" <<EOF
# Safe test configuration
WG_INTERFACE="${wg_interface}"
WG_CONF="${wg_conf}"
CLIENT_DIR="${client_dir}"
SERVER_ENDPOINT="${server_endpoint}"
SERVER_PORT="${server_port}"
SERVER_VPN_IP="${server_vpn_ip}"
CLIENT_DNS="${client_dns}"
VPN_SUBNET_PREFIX="${vpn_subnet_prefix}"
START_HOST="${start_host}"
END_HOST="${end_host}"
USE_IPV6="${use_ipv6}"
IPV6_PREFIX="${ipv6_prefix}"
DEFAULT_CLIENT_MODE="${default_client_mode}"
RECENT_HANDSHAKE_SECONDS="${recent_handshake_seconds}"
BACKUP_RETENTION="${backup_retention}"
EOF
}
remove_config_key() {
local input_file=$1 key=$2 line temp_file
temp_file="${input_file}.tmp"
while IFS= read -r line || [[ -n $line ]]; do
[[ $line == "${key}="* ]] && continue
printf '%s\n' "$line"
done <"$input_file" >"$temp_file"
mv -- "$temp_file" "$input_file"
}
replace_config_line() {
local input_file=$1 prefix=$2 replacement=$3 line temp_file
temp_file="${input_file}.tmp"
while IFS= read -r line || [[ -n $line ]]; do
if [[ $line == "${prefix}"* ]]; then printf '%s\n' "$replacement"; else printf '%s\n' "$line"; fi
done <"$input_file" >"$temp_file"
mv -- "$temp_file" "$input_file"
}
run_config_tests() {
local valid_config="${TEST_ROOT}/valid.conf" status output marker
write_valid_config "$valid_config"
export WG_MANAGER_CONFIG="$valid_config"
export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups"
export WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs"
export WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients"
export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive"
export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock"
begin_test 'valid restricted configuration loads with isolated paths'
load_config
assert_equal wg0 "$WG_INTERFACE" 'имя интерфейса'
assert_equal "$TEST_ROOT/wg0.conf" "$WG_CONF" 'путь WG_CONF'
assert_equal "$TEST_ROOT/clients" "$CLIENT_DIR" 'путь клиентов'
assert_equal "$TEST_ROOT/backups" "$BACKUP_DIR" 'путь резервных копий'
assert_equal "$TEST_ROOT/logs" "$LOG_DIR" 'путь журнала'
assert_equal "$TEST_ROOT/clients" "$METADATA_DIR" 'путь метаданных'
assert_equal "$TEST_ROOT/archive" "$ARCHIVE_DIR" 'путь архива'
assert_equal "$TEST_ROOT/manager.lock" "$LOCK_FILE" 'путь lock-файла'
begin_test 'required keys and parser quoting branches are covered'
write_valid_config "${TEST_ROOT}/missing.conf"
remove_config_key "${TEST_ROOT}/missing.conf" WG_INTERFACE
WG_MANAGER_CONFIG="${TEST_ROOT}/missing.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'отсутствующий параметр'
assert_contains 'Отсутствует параметр' "$output" 'диагностика отсутствующего параметра'
write_valid_config "${TEST_ROOT}/quotes.conf" SERVER_ENDPOINT 'vpn.example.invalid"'
WG_MANAGER_CONFIG="${TEST_ROOT}/quotes.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'встроенные кавычки'
assert_contains 'Некорректные кавычки' "$output" 'диагностика кавычек'
write_valid_config "${TEST_ROOT}/ipv6-on.conf" USE_IPV6 1
WG_MANAGER_CONFIG="${TEST_ROOT}/ipv6-on.conf"
load_config
assert_equal 1 "$USE_IPV6" 'включённый IPv6'
write_valid_config "${TEST_ROOT}/whitespace.conf"
replace_config_line "${TEST_ROOT}/whitespace.conf" 'SERVER_PORT=' ' SERVER_PORT = 51820 '
printf '\n# Full-line comment\n' >>"${TEST_ROOT}/whitespace.conf"
WG_MANAGER_CONFIG="${TEST_ROOT}/whitespace.conf"
load_config
assert_equal 51820 "$SERVER_PORT" 'пробелы и комментарии'
write_valid_config "${TEST_ROOT}/unquoted.conf"
replace_config_line "${TEST_ROOT}/unquoted.conf" 'SERVER_PORT=' 'SERVER_PORT=51820'
WG_MANAGER_CONFIG="${TEST_ROOT}/unquoted.conf"
load_config
assert_equal 51820 "$SERVER_PORT" 'неквотированное значение'
begin_test 'runtime path defaults derive from SCRIPT_DIR'
unset WG_MANAGER_CONFIG WG_MANAGER_BACKUP_DIR WG_MANAGER_LOG_DIR WG_MANAGER_METADATA_DIR WG_MANAGER_ARCHIVE_DIR WG_MANAGER_LOCK_FILE
set_runtime_paths
assert_equal "${SCRIPT_DIR}/wg-manager.conf" "$CONFIG_PATH" 'конфигурация по умолчанию'
assert_equal "${SCRIPT_DIR}/backups" "$BACKUP_DIR" 'резервные копии по умолчанию'
assert_equal "${SCRIPT_DIR}/logs" "$LOG_DIR" 'журнал по умолчанию'
assert_equal "${SCRIPT_DIR}/state/clients" "$METADATA_DIR" 'метаданные по умолчанию'
assert_equal "${SCRIPT_DIR}/state/archive" "$ARCHIVE_DIR" 'архив по умолчанию'
assert_equal "${SCRIPT_DIR}/state/wg-manager.lock" "$LOCK_FILE" 'lock по умолчанию'
export WG_MANAGER_CONFIG="$valid_config"
export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups"
export WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs"
export WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients"
export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive"
export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock"
begin_test 'known command validates config then reports not implemented'
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1)
status=$?
set -e
assert_equal "$EXIT_OPERATION" "$status" 'статус команды-заглушки'
assert_contains 'пока не реализована' "$output" 'сообщение заглушки'
begin_test 'invalid configurations are rejected without execution'
marker="${TEST_ROOT}/marker"
local invalid_file
write_valid_config "${TEST_ROOT}/duplicate.conf"
for invalid_file in unknown malformed; do write_valid_config "${TEST_ROOT}/${invalid_file}.conf"; done
write_valid_config "${TEST_ROOT}/substitution.conf" SERVER_ENDPOINT "\$(touch ${marker})"
write_valid_config "${TEST_ROOT}/backtick.conf" SERVER_ENDPOINT "\`touch ${marker}\`"
write_valid_config "${TEST_ROOT}/semicolon.conf" SERVER_ENDPOINT "vpn.example.invalid;touch ${marker}"
write_valid_config "${TEST_ROOT}/wildcard.conf" SERVER_ENDPOINT 'vpn*.example.invalid'
write_valid_config "${TEST_ROOT}/escape.conf" SERVER_ENDPOINT 'vpn\.example.invalid'
write_valid_config "${TEST_ROOT}/bad_ipv4.conf" SERVER_VPN_IP '192.0.999.1'
write_valid_config "${TEST_ROOT}/bad_port.conf" SERVER_PORT 0
write_valid_config "${TEST_ROOT}/bad_mode.conf" DEFAULT_CLIENT_MODE unsafe
write_valid_config "${TEST_ROOT}/bad_range.conf" RANGE_REVERSED 1
write_valid_config "${TEST_ROOT}/bad_path.conf" WG_CONF relative.conf
printf 'WG_INTERFACE="wg1"\n' >>"${TEST_ROOT}/duplicate.conf"
printf 'NOT_ALLOWED="x"\n' >>"${TEST_ROOT}/unknown.conf"
printf 'BROKEN LINE\n' >>"${TEST_ROOT}/malformed.conf"
for invalid_file in duplicate unknown malformed substitution backtick semicolon wildcard escape bad_ipv4 bad_port bad_mode bad_range bad_path; do
export WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "отклонение ${invalid_file}"
case $invalid_file in
duplicate) assert_contains 'Дублирующийся параметр' "$output" 'ветка duplicate' ;;
unknown) assert_contains 'Неизвестный параметр' "$output" 'ветка unknown' ;;
malformed) assert_contains 'Некорректное присваивание' "$output" 'ветка malformed' ;;
substitution|backtick|semicolon|wildcard|escape) assert_contains 'Недопустимый синтаксис' "$output" "ветка ${invalid_file}" ;;
bad_ipv4) assert_contains 'Некорректный IPv4' "$output" 'ветка IPv4' ;;
bad_port) assert_contains 'Некорректный порт' "$output" 'ветка port' ;;
bad_mode) assert_contains 'Некорректный режим' "$output" 'ветка mode' ;;
bad_range) assert_contains 'Некорректный диапазон' "$output" 'ветка range' ;;
bad_path) assert_contains 'Некорректный путь' "$output" 'ветка path' ;;
esac
done
assert_file_absent "$marker" 'конфигурация не выполняет текст'
begin_test 'semantic validation rejects one invalid value at a time'
local semantic_case semantic_output
begin_test 'IPv6 validator rejects malformed compression and accepts valid forms'
for invalid_value in ::: 2001:db8:::1; do
set +e
validate_ipv6 "$invalid_value"
status=$?
set -e
assert_failure "$status" "отклонён IPv6 ${invalid_value}"
done
for valid_value in :: 2001:db8::1 2001:db8:0:1:2:3:4:5; do
set +e
validate_ipv6 "$valid_value"
status=$?
set -e
assert_success "$status" "принят IPv6 ${valid_value}"
done
declare -a semantic_cases=(
'server_inside|SERVER_VPN_IP|192.0.2.100|Диапазон клиентов содержит'
'empty_dns|CLIENT_DNS||Некорректный список DNS'
'trailing_dns|CLIENT_DNS|192.0.2.53,|Некорректный список DNS'
'dotted_endpoint|SERVER_ENDPOINT|999.999.999.999|Некорректная конечная точка'
'bracketed_hostname|SERVER_ENDPOINT|[vpn.example.invalid]|Некорректная конечная точка'
'invalid_interface|WG_INTERFACE|bad/name|Некорректное имя интерфейса'
'invalid_client_path|CLIENT_DIR|relative|Некорректный путь'
'invalid_dns|CLIENT_DNS|192.0.2.53,,2001:db8::53|Некорректный список DNS'
'invalid_prefix|VPN_SUBNET_PREFIX|192.0|Некорректный префикс VPN'
'mismatched_prefix|VPN_SUBNET_PREFIX|192.0.3|Префикс VPN не совпадает'
'nonnumeric_port|SERVER_PORT|abc|Некорректный порт'
'nonnumeric_range|START_HOST|abc|Некорректный диапазон'
'out_of_range|START_HOST|0|Некорректный диапазон'
'invalid_ipv6|IPV6_PREFIX|gg::1|Некорректный IPv6'
'triple_ipv6_prefix|IPV6_PREFIX|:::|Некорректный IPv6'
'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS'
'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка'
'invalid_ipv6_mode|USE_IPV6|2|USE_IPV6 должен'
'invalid_retention|BACKUP_RETENTION|0|Некорректное число резервных копий'
'invalid_handshake|RECENT_HANDSHAKE_SECONDS|604801|Некорректный срок handshake'
)
for semantic_case in "${semantic_cases[@]}"; do
IFS='|' read -r invalid_file invalid_key invalid_value expected_text <<<"$semantic_case"
write_valid_config "${TEST_ROOT}/${invalid_file}.conf" "$invalid_key" "$invalid_value"
WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf"
set +e
semantic_output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "отклонение ${invalid_file}"
assert_contains "$expected_text" "$semantic_output" "диагностика ${invalid_file}"
done
begin_test 'valid endpoint forms and split tunnel are accepted'
local valid_case valid_output
declare -a valid_cases=(
'documentation_ipv4|SERVER_ENDPOINT|192.0.2.10'
'plain_ipv6|SERVER_ENDPOINT|2001:db8::10'
'bracketed_ipv6|SERVER_ENDPOINT|[2001:db8::10]'
'split_tunnel|DEFAULT_CLIENT_MODE|split-tunnel'
)
for valid_case in "${valid_cases[@]}"; do
IFS='|' read -r invalid_file invalid_key invalid_value <<<"$valid_case"
write_valid_config "${TEST_ROOT}/${invalid_file}.conf" "$invalid_key" "$invalid_value"
WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf"
set +e
valid_output=$(load_config 2>&1)
status=$?
set -e
assert_success "$status" "принимается ${invalid_file}"
assert_equal '' "$valid_output" "нет ошибки ${invalid_file}"
done
begin_test 'runtime path overrides reject unsafe values'
WG_MANAGER_CONFIG='relative.conf'
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'относительный override отклонён'
WG_MANAGER_CONFIG=''
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'пустой override отклонён'
WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/../escape"
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'traversal override отклонён'
WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/[unsafe]"
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'скобки в override отклонены'
write_valid_config "${TEST_ROOT}/bracket-path.conf" WG_CONF "${TEST_ROOT}/[unsafe]"
export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups"
WG_MANAGER_CONFIG="${TEST_ROOT}/bracket-path.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'скобки в пути конфигурации отклонены'
assert_contains 'Некорректный путь' "$output" 'диагностика скобок в пути'
export WG_MANAGER_CONFIG="$valid_config"
}
run_gum_tests() {
local copied_script="${TEST_ROOT}/manager-copy.sh" output status
cp -- "${PROJECT_DIR}/wg-manager.sh" "$copied_script"
chmod 700 "$copied_script"
mkdir -p "${TEST_ROOT}/bin" "${TEST_ROOT}/empty-path"
printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/override-gum"
chmod 700 "${TEST_ROOT}/override-gum"
printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/bin/gum"
chmod 700 "${TEST_ROOT}/bin/gum"
begin_test 'gum environment override has priority'
output=$(WG_MANAGER_GUM="${TEST_ROOT}/override-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script")
assert_equal "${TEST_ROOT}/override-gum" "$output" 'WG_MANAGER_GUM'
begin_test 'local gum binary precedes PATH'
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script")
assert_equal "${TEST_ROOT}/bin/gum" "$output" 'локальный gum'
begin_test 'PATH gum is used when local binary is absent'
rm -- "${TEST_ROOT}/bin/gum"
printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/empty-path/gum"
chmod 700 "${TEST_ROOT}/empty-path/gum"
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script")
assert_equal "${TEST_ROOT}/empty-path/gum" "$output" 'gum из PATH'
rm -- "${TEST_ROOT}/empty-path/gum"
begin_test 'gum completely absent reports dependency failure'
set +e
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'gum отсутствует'
assert_contains 'Не найден gum' "$output" 'диагностика отсутствующего gum'
mkdir -- "${TEST_ROOT}/directory-gum"
begin_test 'gum directory override is rejected'
set +e
output=$(WG_MANAGER_GUM="${TEST_ROOT}/directory-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'каталог WG_MANAGER_GUM'
mkdir -- "${TEST_ROOT}/bin/gum"
begin_test 'local gum directory is rejected'
set +e
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'каталог local gum'
begin_test 'unusable gum override fails without fallback'
set +e
output=$(WG_MANAGER_GUM="${TEST_ROOT}/missing-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'ошибка WG_MANAGER_GUM'
assert_contains 'WG_MANAGER_GUM' "$output" 'сообщение WG_MANAGER_GUM'
}
run_dependency_tests() {
local output status code
local -a exit_codes=("$EXIT_USAGE" "$EXIT_VALIDATION" "$EXIT_OPERATION" "$EXIT_ROLLBACK" "$EXIT_PERMISSION" "$EXIT_DEPENDENCY")
declare -A seen_codes=()
begin_test 'stable exit categories are distinct'
assert_equal 0 "$EXIT_SUCCESS" 'EXIT_SUCCESS'
for code in "${exit_codes[@]}"; do
assert_failure "$code" "ненулевой код ${code}"
if [[ -v "seen_codes[$code]" ]]; then
record_failure "коды не различаются: ${code}"
else
seen_codes["$code"]=1
((TEST_ASSERTIONS += 1))
fi
done
begin_test 'required and optional dependency checks are controlled'
output=$(require_command wg)
assert_contains '/tests/mocks/bin/wg' "$output" 'обязательная команда через mock PATH'
output=$(optional_command gum)
assert_contains '/tests/mocks/bin/gum' "$output" 'необязательная команда через mock PATH'
set +e
require_command definitely-missing >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'отсутствующая обязательная команда'
set +e
optional_command definitely-missing >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'отсутствующая необязательная команда'
}
run_debug_tests() {
local output status
begin_test 'disabled debug is a successful no-op'
unset WG_MANAGER_DEBUG
set +e
output=$(debug hidden 2>&1)
status=$?
set -e
assert_success "$status" 'отключённая отладка'
assert_equal '' "$output" 'отключённая отладка без вывода'
begin_test 'enabled debug uses stderr only'
: >"${TEST_ROOT}/debug.stdout"
: >"${TEST_ROOT}/debug.stderr"
WG_MANAGER_DEBUG=1 debug visible >"${TEST_ROOT}/debug.stdout" 2>"${TEST_ROOT}/debug.stderr"
assert_equal '' "$(<"${TEST_ROOT}/debug.stdout")" 'debug stdout пуст'
assert_contains 'Отладка: visible' "$(<"${TEST_ROOT}/debug.stderr")" 'debug stderr'
}
if [[ -z $test_group ]]; then
begin_test 'sourcing entry point does not execute main' begin_test 'sourcing entry point does not execute main'
source_output=$(bash -c 'source "$1"; printf sourced' _ "${PROJECT_DIR}/wg-manager.sh") source_output=''
source_output=$(/bin/bash "${TESTS_DIR}/source-entry.sh" "${PROJECT_DIR}/wg-manager.sh")
assert_equal sourced "$source_output" 'безопасная загрузка' assert_equal sourced "$source_output" 'безопасная загрузка'
begin_test 'help succeeds without root or dependencies' begin_test 'help succeeds without root or dependencies'
@@ -19,6 +425,24 @@ help_status=$?
set -e set -e
assert_success "$help_status" 'команда help' assert_success "$help_status" 'команда help'
assert_contains 'Использование:' "$help_output" 'справка' assert_contains 'Использование:' "$help_output" 'справка'
assert_equal "$help_output" "${help_output//$'\e'/}" 'help без ANSI'
begin_test 'version succeeds without config or dependencies'
set +e
version_output=$(env -u WG_MANAGER_CONFIG -u WG_MANAGER_GUM PATH=/usr/bin:/bin "${PROJECT_DIR}/wg-manager.sh" version 2>&1)
version_status=$?
set -e
assert_success "$version_status" 'команда version'
assert_contains 'WireGuard Manager 0.1.0' "$version_output" 'версия'
assert_equal "$version_output" "${version_output//$'\e'/}" 'version без ANSI'
begin_test 'extra arguments fail with usage status'
set +e
extra_output=$("${PROJECT_DIR}/wg-manager.sh" help extra 2>&1)
extra_status=$?
set -e
assert_equal "$EXIT_USAGE" "$extra_status" 'лишний аргумент'
assert_contains 'Ожидается одна команда' "$extra_output" 'диагностика лишнего аргумента'
begin_test 'unknown command fails' begin_test 'unknown command fails'
set +e set +e
@@ -81,5 +505,15 @@ scan_test_sources_for_host_path() {
} }
scan_test_sources_for_host_path scan_test_sources_for_host_path
fi
if [[ $test_group == config ]]; then
run_config_tests
else
run_config_tests
run_gum_tests
run_dependency_tests
run_debug_tests
fi
finish_tests finish_tests
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=../wg-manager.sh
source "$1"
printf 'sourced'
+1
View File
@@ -17,3 +17,4 @@ IPV6_PREFIX="2001:db8:ffff::"
DEFAULT_CLIENT_MODE="full-tunnel" DEFAULT_CLIENT_MODE="full-tunnel"
RECENT_HANDSHAKE_SECONDS="180" RECENT_HANDSHAKE_SECONDS="180"
BACKUP_RETENTION="10"
+235 -12
View File
@@ -2,34 +2,257 @@
set -Eeuo pipefail set -Eeuo pipefail
umask 077 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() { print_usage() {
cat <<'USAGE' cat <<'USAGE'
Использование: Использование:
wg-manager.sh help wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add|show|config|qr|remove|diagnose|logs|backups|restore|tui}
Команды WireGuard Manager будут добавлены в следующих версиях. Команды управления WireGuard будут добавлены в следующих версиях.
USAGE 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() { main() {
if (( $# != 1 )); then if (( $# != 1 )); then
printf 'Ошибка: ожидается одна команда.\n' >&2 local status
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2 print_usage >&2
return 64 return "$status"
fi fi
case "$1" in case "$1" in
help) help) print_usage ;;
print_usage version) print_version ;;
;; status|list|add|show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
*) *)
printf 'Ошибка: неизвестная команда: %s\n' "$1" >&2 local status
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2 print_usage >&2
return 64 return "$status"
;; ;;
esac esac
} }
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi
main "$@"
fi