From 987c0003200fbd5358b0b11fd90e55efccbf5107 Mon Sep 17 00:00:00 2001 From: sasa Date: Sun, 11 Oct 2026 23:34:01 +0300 Subject: [PATCH] feat(core): add validated configuration and CLI foundation --- PLANS.md | 16 +- README.md | 19 ++ tests/helpers.sh | 14 +- tests/resolve-gum.sh | 5 + tests/run.sh | 438 +++++++++++++++++++++++++++++++++++++++- tests/source-entry.sh | 5 + wg-manager.conf.example | 1 + wg-manager.sh | 247 ++++++++++++++++++++-- 8 files changed, 728 insertions(+), 17 deletions(-) create mode 100755 tests/resolve-gum.sh create mode 100755 tests/source-entry.sh diff --git a/PLANS.md b/PLANS.md index 6ec1fb3..a3e2334 100644 --- a/PLANS.md +++ b/PLANS.md @@ -261,7 +261,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. | Milestone | Status | Outcome | |---|---|---| | 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` | | M3 | NOT STARTED | Locking, backups, atomic apply, and rollback engine | | 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` - 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 `, 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 The MVP is complete only when: diff --git a/README.md b/README.md index f60a9a8..ef501eb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,21 @@ # 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. + diff --git a/tests/helpers.sh b/tests/helpers.sh index 5ebc672..807679f 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -2,8 +2,10 @@ set -Eeuo pipefail umask 077 -readonly HELPERS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -readonly PROJECT_DIR="$(cd -- "${HELPERS_DIR}/.." && pwd)" +HELPERS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly HELPERS_DIR +PROJECT_DIR="$(cd -- "${HELPERS_DIR}/.." && pwd)" +readonly PROJECT_DIR TEST_ROOT='' TEST_MOCK_LOG='' TEST_FAILURES=0 @@ -67,6 +69,14 @@ assert_file_exists() { fi } +assert_file_absent() { + local path=$1 message=${2:-файл отсутствует} + ((TEST_ASSERTIONS += 1)) + if [[ -e "$path" ]]; then + record_failure "${message}: найден [${path}]" + fi +} + assert_file_mode() { local expected=$1 path=$2 actual message actual=$(stat -c '%a' -- "$path") diff --git a/tests/resolve-gum.sh b/tests/resolve-gum.sh new file mode 100755 index 0000000..5682e25 --- /dev/null +++ b/tests/resolve-gum.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# shellcheck source=../wg-manager.sh +source "$1" +resolve_gum diff --git a/tests/run.sh b/tests/run.sh index da65193..b28b1bf 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,14 +2,420 @@ set -Eeuo pipefail 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 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 +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" <"$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' -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" 'безопасная загрузка' begin_test 'help succeeds without root or dependencies' @@ -19,6 +425,24 @@ help_status=$? set -e assert_success "$help_status" 'команда help' 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' set +e @@ -81,5 +505,15 @@ 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 diff --git a/tests/source-entry.sh b/tests/source-entry.sh new file mode 100755 index 0000000..fa0446b --- /dev/null +++ b/tests/source-entry.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# shellcheck source=../wg-manager.sh +source "$1" +printf 'sourced' diff --git a/wg-manager.conf.example b/wg-manager.conf.example index 0eb5664..07429be 100644 --- a/wg-manager.conf.example +++ b/wg-manager.conf.example @@ -17,3 +17,4 @@ IPV6_PREFIX="2001:db8:ffff::" DEFAULT_CLIENT_MODE="full-tunnel" RECENT_HANDSHAKE_SECONDS="180" +BACKUP_RETENTION="10" diff --git a/wg-manager.sh b/wg-manager.sh index 4a180eb..43f2c63 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -2,34 +2,257 @@ 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 Manager будут добавлены в следующих версиях. +Команды управления 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 - printf 'Ошибка: ожидается одна команда.\n' >&2 + local status + if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi print_usage >&2 - return 64 + return "$status" fi - case "$1" in - help) - print_usage - ;; + help) 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 - return 64 + return "$status" ;; esac } -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi