feat(core): add validated configuration and CLI foundation
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
# shellcheck source=../wg-manager.sh
|
||||||
|
source "$1"
|
||||||
|
resolve_gum
|
||||||
+436
-2
@@ -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
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
# shellcheck source=../wg-manager.sh
|
||||||
|
source "$1"
|
||||||
|
printf 'sourced'
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user