feat(clients): add client details and secure config export
This commit is contained in:
@@ -265,7 +265,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
|
||||
| M2 | DONE | WireGuard parser, client model, `status`, and `list` |
|
||||
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
|
||||
| M4 | DONE | Safe client creation |
|
||||
| M5 | NOT STARTED | Client detail, config export, and QR display |
|
||||
| M5 | DONE | Client detail, config export, and QR display |
|
||||
| M6 | NOT STARTED | Safe client removal and archival |
|
||||
| M7 | NOT STARTED | Diagnostics engine and report |
|
||||
| M8 | NOT STARTED | Backup listing/restoration and audit log UX |
|
||||
@@ -1621,6 +1621,20 @@ Use this template:
|
||||
- Suggested commit message: `feat(clients): add transactional client creation`
|
||||
- Next milestone: M5
|
||||
|
||||
## 2026-10-13 — M5 — DONE
|
||||
|
||||
- Implemented: non-secret `show NAME` client details; validated path-only `config NAME` output with explicit `--show` full-config export; stdin-only `qrencode -t ansiutf8` integration with a clear missing-dependency fallback; exact managed identity checks; and config, metadata, ownership, mode, hardlink, symlink, and open-time identity validation.
|
||||
- Coordinator: `gpt-5.6-sol` (`high`)
|
||||
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
|
||||
- Repair cycles: 5
|
||||
- Sol review: restored the full M0–M4 regression dispatch; fixed failure-status capture and caller `errexit` preservation; required persistent-disk identity for managed association; added open-time file-identity verification and actionable `stat` diagnostics; strengthened missing-client, unmanaged, ambiguous, mismatched, runtime-only, CLI, QR, path, permission, ownership, hardlink, symlink, and secret-safety tests; and removed a misleading unused fixture.
|
||||
- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/mocks/bin/qrencode`, `bash tests/run.sh show-client`, `bash tests/run.sh config-export`, `bash tests/run.sh qr`, `bash tests/run.sh path-safety`, `bash tests/run.sh no-secret-default-output`, `bash tests/run.sh`, `bash tests/run.sh no-secret-logs`, `git diff --check`, permission, scope, and secret-pattern review.
|
||||
- Result: all required checks passed; show-client/config-export/qr/path-safety/no-secret-default-output/no-secret-logs suites passed 44/16/13/31/45/41 assertions, and the full regression suite passed 399 assertions. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
|
||||
- Decisions: none
|
||||
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, and deployment-server behavior were not accessed or tested; M5 verification is fixture- and mock-based as required. Full config output is deliberately available only through the explicit noninteractive `config NAME --show` action.
|
||||
- Suggested commit message: `feat(clients): add client details and secure config export`
|
||||
- Next milestone: M6
|
||||
|
||||
# 8. Final completion rule
|
||||
|
||||
The MVP is complete only when:
|
||||
|
||||
@@ -60,3 +60,27 @@ permissions and are removed if the server transaction or rollback fails.
|
||||
Generated key material is supplied to `wg` through stdin or stdout and is not
|
||||
placed in command arguments or diagnostic output.
|
||||
|
||||
## M5 inspection and export
|
||||
|
||||
`show NAME` displays only non-secret peer details and a config path after
|
||||
identity, ownership, mode, and path validation. `config NAME` prints only that
|
||||
validated path. Full client content requires the explicit noninteractive
|
||||
`config NAME --show` form; it never reads separate `.key` or `.psk` files.
|
||||
|
||||
`qr NAME` feeds the validated config to `qrencode -t ansiutf8` through stdin.
|
||||
If the optional `qrencode` dependency is unavailable, it prints the safe config
|
||||
path and returns status `69`; QR failures return the operation status. Routine
|
||||
show/export/path/error output is secret-free.
|
||||
|
||||
The inspection commands do not require `gum` or `qrencode` for `show` and
|
||||
`config`. `qrencode` is the only optional QR dependency; when it is absent,
|
||||
`qr NAME` writes the validated configuration path to stdout and an actionable
|
||||
warning to stderr, then returns status `69`. A nonzero `qrencode` result
|
||||
returns status `70` without printing configuration content in the diagnostic.
|
||||
|
||||
Common command statuses are `64` for usage errors, `65` for invalid or unsafe
|
||||
client identity/path data, `69` for a missing optional dependency, and `70`
|
||||
for an operational read/export/QR failure. `config NAME --show` is the only
|
||||
inspection form that writes the complete client configuration to stdout;
|
||||
ordinary `show`, `config`, and `qr` output remains secret-safe.
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[Interface]
|
||||
PrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=
|
||||
Address = 192.0.2.3/32
|
||||
DNS = 192.0.2.53,2001:db8::53
|
||||
|
||||
[Peer]
|
||||
PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=
|
||||
PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=
|
||||
Endpoint = vpn.example.invalid:51820
|
||||
AllowedIPs = 0.0.0.0/0,::/0
|
||||
PersistentKeepalive = 25
|
||||
+13
-12
@@ -1,15 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
command_name=${0##*/}
|
||||
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}"
|
||||
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
|
||||
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
|
||||
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
|
||||
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
|
||||
exit 99
|
||||
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify isolated log}"
|
||||
printf 'qrencode' >>"$TEST_MOCK_LOG"
|
||||
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
|
||||
printf '\n' >>"$TEST_MOCK_LOG"
|
||||
[[ $# == 2 && $1 == -t && $2 == ansiutf8 ]] || { printf 'Unexpected qrencode arguments\n' >&2; exit 99; }
|
||||
if [[ -n ${QR_MOCK_EXPECTED_FILE:-} ]]; then
|
||||
[[ -n ${TEST_ROOT:-} && ${QR_MOCK_EXPECTED_FILE} == "${TEST_ROOT}"/* && -f ${QR_MOCK_EXPECTED_FILE} && ! -L ${QR_MOCK_EXPECTED_FILE} ]] || { printf 'Unexpected QR fixture path\n' >&2; exit 98; }
|
||||
cmp -s -- "$QR_MOCK_EXPECTED_FILE" - || { printf 'Unexpected QR input\n' >&2; exit 98; }
|
||||
else
|
||||
printf 'QR expected fixture is required\n' >&2
|
||||
exit 98
|
||||
fi
|
||||
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
|
||||
exit 99
|
||||
[[ ${QR_MOCK_FAIL:-0} != 1 ]] || { printf 'Mock qrencode failure\n' >&2; exit 97; }
|
||||
printf 'MOCK-QR\n'
|
||||
|
||||
+313
-1
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
|
||||
|
||||
case $# in
|
||||
0) ;;
|
||||
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
|
||||
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
|
||||
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
|
||||
esac
|
||||
test_group=${1-}
|
||||
@@ -830,6 +830,7 @@ transaction_builder_symlink() {
|
||||
|
||||
run_m4_tests() {
|
||||
local config_file="${TEST_ROOT}/m4.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before
|
||||
unset TRANSACTION_FINALIZER TRANSACTION_ROLLBACK_ACTIVE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX ADD_PUBLISH_FAIL ADD_PUBLISH_RACE TRANSACTION_ADD_STAGE_FAIL TRANSACTION_ADD_DIRECTORY_FAIL
|
||||
cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"
|
||||
write_m2_config "$config_file" "$fixture"
|
||||
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MOCK_INTERFACE=wg0
|
||||
@@ -1234,6 +1235,312 @@ EOF
|
||||
esac
|
||||
}
|
||||
|
||||
run_m5_tests() {
|
||||
local config_file="${TEST_ROOT}/m5.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output expected qr_path mode
|
||||
cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"
|
||||
write_m2_config "$config_file" "$fixture"
|
||||
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_TEST_MODE=1 WG_MOCK_INTERFACE=wg0
|
||||
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_file"
|
||||
rm -rf -- "${TEST_ROOT}/dump-sequence"; mkdir -p "${TEST_ROOT}/dump-sequence"; cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/1"; cp -- "${PROJECT_DIR}/tests/fixtures/m3/runtime.dump" "${TEST_ROOT}/dump-sequence/2"
|
||||
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence"
|
||||
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_SYNC_FAIL_COUNTER_FILE WG_QUICK_STRIP_FAIL QR_MOCK_FAIL TRANSACTION_ADD_STAGE_FAIL TRANSACTION_ADD_DIRECTORY_FAIL ADD_PUBLISH_FAIL ADD_PUBLISH_RACE
|
||||
set_runtime_paths; load_config; rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p "$METADATA_DIR" "$CLIENT_DIR"
|
||||
add_client alice >/dev/null
|
||||
: >"$TEST_MOCK_LOG"
|
||||
case $test_group in
|
||||
''|show-client|no-secret-default-output)
|
||||
begin_test 'show exposes only non-secret managed details'
|
||||
set +e; output=$(show_client alice 2>&1); status=$?; set -e
|
||||
assert_equal 0 "$status" 'show succeeds'
|
||||
assert_contains 'Имя: alice' "$output" 'show name'
|
||||
assert_contains 'Путь конфигурации:' "$output" 'show validated path'
|
||||
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'show without secrets'
|
||||
local cli_stdout="${TEST_ROOT}/cli.stdout" cli_stderr="${TEST_ROOT}/cli.stderr"
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show alice >"$cli_stdout" 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal 0 "$status" 'CLI show succeeds'
|
||||
assert_contains 'Имя: alice' "$(<"$cli_stdout")" 'CLI show name'
|
||||
assert_contains 'Публичный ключ:' "$(<"$cli_stdout")" 'CLI show public key'
|
||||
assert_contains 'Адреса:' "$(<"$cli_stdout")" 'CLI show addresses'
|
||||
assert_contains 'Endpoint:' "$(<"$cli_stdout")" 'CLI show endpoint'
|
||||
assert_contains 'Последний handshake:' "$(<"$cli_stdout")" 'CLI show handshake'
|
||||
assert_contains 'RX:' "$(<"$cli_stdout")" 'CLI show RX'
|
||||
assert_contains 'TX:' "$(<"$cli_stdout")" 'CLI show TX'
|
||||
assert_contains 'Режим:' "$(<"$cli_stdout")" 'CLI show mode'
|
||||
assert_contains 'Создан:' "$(<"$cli_stdout")" 'CLI show creation time'
|
||||
assert_contains 'Происхождение: managed' "$(<"$cli_stdout")" 'CLI show origin'
|
||||
assert_equal '' "$(<"$cli_stderr")" 'CLI show stderr clean'
|
||||
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$cli_stdout" || true)" 'CLI show secret-free'
|
||||
assert_equal '' "$(grep -F $'\033' "$cli_stdout" || true)" 'CLI show has no ANSI'
|
||||
for cli_args in 'show' 'config' 'qr'; do
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" $cli_args >"$cli_stdout" 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" "CLI ${cli_args} missing name"
|
||||
done
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show absent >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'CLI missing client name validation'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show alice extra >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'CLI show extra argument'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" qr alice extra >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'CLI qr extra argument'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" config alice --show --show >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'CLI duplicate config show'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show fixture >"$cli_stdout" 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal 0 "$status" 'CLI show legacy peer succeeds'
|
||||
assert_contains 'Происхождение: legacy' "$(<"$cli_stdout")" 'legacy origin shown'
|
||||
assert_equal '' "$(grep -F 'Путь конфигурации:' "$cli_stdout" || true)" 'legacy has no config path'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" config fixture >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'legacy config export rejected'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" qr fixture >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'legacy QR rejected'
|
||||
begin_test 'ambiguous legacy and mismatched identities never grant access'
|
||||
cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"
|
||||
cat >>"$fixture" <<'EOF'
|
||||
|
||||
[Peer]
|
||||
### Client fixture
|
||||
PublicKey = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=
|
||||
AllowedIPs = 192.0.2.3/32
|
||||
EOF
|
||||
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_file"
|
||||
unset WG_MOCK_DUMP_SEQUENCE_DIR
|
||||
export WG_MOCK_DUMP_FILE="$runtime_file"
|
||||
rm -f -- "${METADATA_DIR}/alice.meta"
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show fixture >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'duplicate legacy name is ambiguous'
|
||||
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|sentinel' "$cli_stderr" || true)" 'ambiguous identity diagnostic is secret-free'
|
||||
write_alice_metadata
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.99'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show alice >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'mismatched metadata address is rejected'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" config alice >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'mismatched metadata config is rejected'
|
||||
write_alice_metadata
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH='
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" qr alice >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'mismatched metadata key QR is rejected'
|
||||
cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"
|
||||
write_alice_metadata
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF='
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.3'
|
||||
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_file"
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" show alice >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only peer cannot use metadata path'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" config alice >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only peer config is rejected'
|
||||
set +e; "${PROJECT_DIR}/wg-manager.sh" qr alice >/dev/null 2>"$cli_stderr"; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only peer QR is rejected'
|
||||
set -e
|
||||
if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi
|
||||
assert_equal 1 "$runtime_errexit" 'errexit enabled before runtime load'
|
||||
load_runtime_state
|
||||
if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi
|
||||
assert_equal 1 "$runtime_errexit" 'errexit preserved after enabled runtime load'
|
||||
set +e
|
||||
if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi
|
||||
assert_equal 0 "$runtime_errexit" 'errexit disabled before runtime load'
|
||||
load_runtime_state
|
||||
if [[ $- == *e* ]]; then runtime_errexit=1; else runtime_errexit=0; fi
|
||||
assert_equal 0 "$runtime_errexit" 'errexit preserved after disabled runtime load'
|
||||
set -e
|
||||
write_alice_metadata
|
||||
rm -f -- "${CLIENT_DIR}/alice.conf"
|
||||
set +e; output=$(show_client alice 2>&1); status=$?; set -e
|
||||
assert_equal 0 "$status" 'show tolerates unavailable client config'
|
||||
assert_contains '(недоступен)' "$output" 'show marks unavailable config'
|
||||
if [[ $test_group == no-secret-default-output ]]; then assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'default mock log without secrets'; fi
|
||||
return
|
||||
;;
|
||||
config-export)
|
||||
begin_test 'config prints path by default and content only with --show'
|
||||
expected="${CLIENT_DIR}/alice.conf"
|
||||
output=$(export_client_config alice)
|
||||
assert_equal "$expected" "$output" 'default config export path'
|
||||
local cli_path_bin="${TEST_ROOT}/config-path" cli_out="${TEST_ROOT}/config.stdout" cli_err="${TEST_ROOT}/config.stderr"
|
||||
mkdir -p -- "$cli_path_bin"
|
||||
for utility in wg cat stat sort paste date; do ln -s -- "$(command -v "$utility")" "$cli_path_bin/$utility"; done
|
||||
set +e; PATH="$cli_path_bin" /bin/bash "${PROJECT_DIR}/wg-manager.sh" config alice >"$cli_out" 2>"$cli_err"; status=$?; set -e
|
||||
assert_equal 0 "$status" 'fresh CLI config succeeds without gum or qrencode'
|
||||
assert_equal "$expected" "$(<"$cli_out")" 'fresh CLI config prints one exact path'
|
||||
assert_equal '' "$(<"$cli_err")" 'fresh CLI config stderr is empty'
|
||||
assert_equal '' "$(grep -F $'\033' "$cli_out" || true)" 'fresh CLI config has no ANSI'
|
||||
set +e; PATH="$cli_path_bin" /bin/bash "${PROJECT_DIR}/wg-manager.sh" config alice --show >"$cli_out" 2>"$cli_err"; status=$?; set -e
|
||||
assert_equal 0 "$status" 'fresh CLI explicit config succeeds'
|
||||
assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m5/managed-client.conf")" "$(<"$cli_out")" 'fresh CLI explicit config exact bytes'
|
||||
assert_equal '' "$(<"$cli_err")" 'fresh CLI explicit config stderr is empty'
|
||||
set +e; PATH="$cli_path_bin" /bin/bash "${PROJECT_DIR}/wg-manager.sh" config alice --bad >"$cli_out" 2>"$cli_err"; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'fresh CLI unknown config flag rejected'
|
||||
set +e; output=$(export_client_config alice 1); status=$?; set -e
|
||||
assert_equal 0 "$status" 'explicit full export'
|
||||
assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m5/managed-client.conf")" "$output" 'explicit config content matches fixture'
|
||||
set +e; export_client_config alice 2 >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_USAGE" "$status" 'invalid export mode rejected'
|
||||
rm -f -- "${CLIENT_DIR}/alice.conf"
|
||||
set +e; output=$(export_client_config alice 1 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'missing export config rejected'
|
||||
assert_contains 'Файл конфигурации клиента' "$output" 'missing export diagnostic'
|
||||
printf 'placeholder\n' >"${CLIENT_DIR}/alice.conf"; chmod 600 "${CLIENT_DIR}/alice.conf"
|
||||
open_validated_client_config() { return "$EXIT_OPERATION"; }
|
||||
set +e; output=$(export_client_config alice 1 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_OPERATION" "$status" 'export open failure status preserved'
|
||||
assert_contains 'безопасно открыть' "$output" 'export open failure diagnostic'
|
||||
return
|
||||
;;
|
||||
qr)
|
||||
begin_test 'qr uses exact argv and stdin without logging config'
|
||||
export QR_MOCK_EXPECTED_FILE="${CLIENT_DIR}/alice.conf"
|
||||
: >"$TEST_MOCK_LOG"
|
||||
output=$(qr_client alice)
|
||||
assert_contains 'MOCK-QR' "$output" 'mock QR output'
|
||||
assert_equal 1 "$(grep -c '^qrencode -t ansiutf8$' "$TEST_MOCK_LOG" || true)" 'exact QR invocation count'
|
||||
assert_equal 'qrencode -t ansiutf8' "$(tail -n 1 "$TEST_MOCK_LOG")" 'exact QR argv'
|
||||
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'QR log without secrets'
|
||||
export QR_MOCK_FAIL=1
|
||||
set +e; qr_client alice >"${TEST_ROOT}/qr-fail.stdout" 2>"${TEST_ROOT}/qr-fail.stderr"; status=$?; set -e
|
||||
unset QR_MOCK_FAIL
|
||||
assert_equal "$EXIT_OPERATION" "$status" 'QR failure status'
|
||||
assert_equal '' "$(<"${TEST_ROOT}/qr-fail.stdout")" 'QR failure stdout is empty'
|
||||
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' "${TEST_ROOT}/qr-fail.stderr" || true)" 'QR failure diagnostic is secret-free'
|
||||
local saved_path=$PATH
|
||||
mkdir -p -- "${TEST_ROOT}/empty-path"
|
||||
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "${TEST_ROOT}/empty-path/wg"
|
||||
ln -s -- "$(command -v cat)" "${TEST_ROOT}/empty-path/cat"
|
||||
ln -s -- "$(command -v stat)" "${TEST_ROOT}/empty-path/stat"
|
||||
ln -s -- "$(command -v sort)" "${TEST_ROOT}/empty-path/sort"
|
||||
ln -s -- "$(command -v paste)" "${TEST_ROOT}/empty-path/paste"
|
||||
ln -s -- "$(command -v date)" "${TEST_ROOT}/empty-path/date"
|
||||
PATH="${TEST_ROOT}/empty-path"
|
||||
set +e; qr_client alice >"${TEST_ROOT}/qr-missing.stdout" 2>"${TEST_ROOT}/qr-missing.stderr"; status=$?; set -e
|
||||
PATH=$saved_path
|
||||
assert_equal "$EXIT_DEPENDENCY" "$status" 'missing qrencode dependency status'
|
||||
assert_equal "${CLIENT_DIR}/alice.conf" "$(<"${TEST_ROOT}/qr-missing.stdout")" 'missing qrencode safe path only on stdout'
|
||||
assert_contains 'qrencode не найден' "$(<"${TEST_ROOT}/qr-missing.stderr")" 'missing qrencode diagnostic on stderr'
|
||||
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "${TEST_ROOT}/qr-missing.stderr" || true)" 'missing QR diagnostic is secret-free'
|
||||
open_validated_client_config() { return "$EXIT_VALIDATION"; }
|
||||
set +e; output=$(qr_client alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'QR identity failure status preserved'
|
||||
assert_contains 'безопасно открыть' "$output" 'QR identity failure diagnostic'
|
||||
unset QR_MOCK_EXPECTED_FILE
|
||||
return
|
||||
;;
|
||||
path-safety)
|
||||
begin_test 'unsafe or permissive config paths are rejected'
|
||||
cp -- "${METADATA_DIR}/alice.meta" "${TEST_ROOT}/alice-meta.base"
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
printf 'NAME=alice\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s\nconf\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "$CLIENT_DIR/alice" >"${METADATA_DIR}/alice.meta"
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'metadata embedded newline/control record rejected'
|
||||
assert_equal '' "$(printf '%s' "$output" | grep -E 'sentinel|PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'newline metadata diagnostic is secret-free'
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
local real_stat
|
||||
real_stat=$(command -v stat)
|
||||
stat() {
|
||||
if [[ ${2:-} == '%u:%g' && ${4:-} == "${METADATA_DIR}/alice.meta" ]]; then
|
||||
printf '999:999\n'
|
||||
return 0
|
||||
fi
|
||||
"$real_stat" "$@"
|
||||
}
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
unset -f stat
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'metadata owner mismatch is rejected'
|
||||
assert_contains 'Владелец' "$output" 'metadata owner mismatch diagnostic'
|
||||
assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'owner mismatch diagnostic is secret-free'
|
||||
real_stat=$(command -v stat)
|
||||
stat() {
|
||||
if [[ ${2:-} == '%a' && ${4:-} == "${CLIENT_DIR}/alice.conf" ]]; then
|
||||
return 1
|
||||
fi
|
||||
"$real_stat" "$@"
|
||||
}
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
unset -f stat
|
||||
assert_equal "$EXIT_OPERATION" "$status" 'config stat failure is operation error'
|
||||
assert_contains 'режим конфигурации клиента' "$output" 'config stat failure diagnostic'
|
||||
assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'config stat diagnostic is secret-free'
|
||||
real_stat=$(command -v stat)
|
||||
stat() {
|
||||
if [[ ${2:-} == '%a' && ${4:-} == "${METADATA_DIR}/alice.meta" ]]; then
|
||||
return 1
|
||||
fi
|
||||
"$real_stat" "$@"
|
||||
}
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
unset -f stat
|
||||
assert_equal "$EXIT_OPERATION" "$status" 'metadata stat failure is operation error'
|
||||
assert_contains 'режим метаданных клиента' "$output" 'metadata stat failure diagnostic'
|
||||
assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey|BBBB|EEEE|GGGG' || true)" 'metadata stat diagnostic is secret-free'
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
for unsafe_path in "${CLIENT_DIR}/nested/alice.conf" "${CLIENT_DIR}/alice file.conf" "${CLIENT_DIR}/-alice.conf" "${CLIENT_DIR}/alice\$'\\n'conf"; do
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'CONFIG_PATH=' "CONFIG_PATH=${unsafe_path}"
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" "unsafe path rejected: ${unsafe_path@Q}"
|
||||
assert_equal '' "$(printf '%s' "$output" | grep -E 'sentinel|PrivateKey|PresharedKey|EEEE|GGGG' || true)" 'unsafe path diagnostic is secret-free'
|
||||
done
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
mkdir -p -- "${CLIENT_DIR}/nested"
|
||||
printf 'sentinel\n' >"${CLIENT_DIR}/nested/alice.conf"
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'CONFIG_PATH=' "CONFIG_PATH=${CLIENT_DIR}/nested/alice.conf"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'nested client path rejected'
|
||||
rm -f -- "${CLIENT_DIR}/nested/alice.conf"; rmdir -- "${CLIENT_DIR}/nested"
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
rm -- "${CLIENT_DIR}/alice.conf"; mkdir -- "${CLIENT_DIR}/alice.conf"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'directory client target rejected'
|
||||
rmdir -- "${CLIENT_DIR}/alice.conf"
|
||||
printf 'placeholder\n' >"${CLIENT_DIR}/alice.conf"; chmod 600 "${CLIENT_DIR}/alice.conf"
|
||||
cp -- "${TEST_ROOT}/alice-meta.base" "${METADATA_DIR}/alice.meta"
|
||||
mv -- "$CLIENT_DIR" "${TEST_ROOT}/clients-real"
|
||||
ln -s -- "${TEST_ROOT}/clients-real" "$CLIENT_DIR"
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'symlink client directory rejected'
|
||||
rm -- "$CLIENT_DIR"; mv -- "${TEST_ROOT}/clients-real" "$CLIENT_DIR"
|
||||
mv -- "$METADATA_DIR" "${TEST_ROOT}/metadata-real"
|
||||
ln -s -- "${TEST_ROOT}/metadata-real" "$METADATA_DIR"
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'symlink metadata directory rejected'
|
||||
rm -- "$METADATA_DIR"; mv -- "${TEST_ROOT}/metadata-real" "$METADATA_DIR"
|
||||
chmod 755 "$CLIENT_DIR" "$METADATA_DIR"
|
||||
set +e; output=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'permissive client directories rejected'
|
||||
chmod 700 "$CLIENT_DIR" "$METADATA_DIR"
|
||||
replace_config_line "${METADATA_DIR}/alice.meta" 'CONFIG_PATH=' 'CONFIG_PATH="${TEST_ROOT}/outside.conf"'
|
||||
set +e; config_path=$(export_client_config alice 2>&1); status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'outside path rejected'
|
||||
sed -i "s#CONFIG_PATH=.*#CONFIG_PATH=${CLIENT_DIR}/alice.conf#" "${METADATA_DIR}/alice.meta"
|
||||
chmod 644 "${CLIENT_DIR}/alice.conf"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'permissive config rejected'
|
||||
chmod 600 "${CLIENT_DIR}/alice.conf"
|
||||
chmod 644 "${METADATA_DIR}/alice.meta"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'permissive metadata rejected'
|
||||
chmod 600 "${METADATA_DIR}/alice.meta"
|
||||
ln -- "${METADATA_DIR}/alice.meta" "${TEST_ROOT}/alice-meta-hardlink"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked metadata rejected'
|
||||
rm -f -- "${TEST_ROOT}/alice-meta-hardlink"
|
||||
ln -- "${CLIENT_DIR}/alice.conf" "${TEST_ROOT}/alice-hardlink.conf"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked config rejected'
|
||||
rm -f -- "${TEST_ROOT}/alice-hardlink.conf"
|
||||
mv -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice-real.conf"
|
||||
ln -s -- "${CLIENT_DIR}/alice-real.conf" "${CLIENT_DIR}/alice.conf"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'symlink config rejected'
|
||||
rm -- "${CLIENT_DIR}/alice.conf"
|
||||
mv -- "${CLIENT_DIR}/alice-real.conf" "${CLIENT_DIR}/alice.conf"
|
||||
printf 'sentinel\n' >"${TEST_ROOT}/outside-meta"
|
||||
mv -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/alice.real.meta"
|
||||
rm -f -- "${METADATA_DIR}/alice.real.meta"
|
||||
ln -s -- "${TEST_ROOT}/outside-meta" "${METADATA_DIR}/alice.meta"
|
||||
set +e; export_client_config alice >/dev/null 2>&1; status=$?; set -e
|
||||
assert_equal "$EXIT_VALIDATION" "$status" 'symlink metadata rejected'
|
||||
return
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_m3_tests() {
|
||||
local config_file="${TEST_ROOT}/m3.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before after backup_count manifest_content parent_exit parent_int parent_term
|
||||
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
|
||||
@@ -2037,6 +2344,8 @@ fi
|
||||
|
||||
if [[ $test_group == config ]]; then
|
||||
run_config_tests
|
||||
elif [[ $test_group == show-client || $test_group == config-export || $test_group == qr || $test_group == path-safety || $test_group == no-secret-default-output ]]; then
|
||||
run_m5_tests
|
||||
elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then
|
||||
run_m2_focused "$test_group"
|
||||
elif [[ $test_group == transaction || $test_group == rollback || $test_group == locking || $test_group == backups || $test_group == mutation-preserves-source || $test_group == secret-modes ]]; then
|
||||
@@ -2052,6 +2361,9 @@ else
|
||||
run_m2_tests
|
||||
run_m3_tests
|
||||
run_m4_tests
|
||||
test_group=show-client
|
||||
run_m5_tests
|
||||
test_group=''
|
||||
fi
|
||||
|
||||
finish_tests
|
||||
|
||||
+151
-8
@@ -54,7 +54,7 @@ print_usage() {
|
||||
Использование:
|
||||
wg-manager.sh help
|
||||
wg-manager.sh version
|
||||
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show|config|qr|remove|diagnose|logs|backups|restore|tui}
|
||||
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove|diagnose|logs|backups|restore|tui}
|
||||
|
||||
Команды управления WireGuard будут добавлены в следующих версиях.
|
||||
USAGE
|
||||
@@ -262,7 +262,7 @@ readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
|
||||
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
|
||||
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
|
||||
DISK_LISTEN_PORT='-'
|
||||
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED
|
||||
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED META_FILE
|
||||
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
|
||||
RUNTIME_AVAILABLE=0
|
||||
RUNTIME_LISTEN_PORT='-'
|
||||
@@ -480,12 +480,12 @@ parse_metadata_file() {
|
||||
if [[ -n $ipv6 ]] && ! validate_ipv6 "$ipv6"; then die "$EXIT_VALIDATION" "Некорректный IPV6 в метаданных: ${file}"; return $?; fi
|
||||
for existing_key in "${!META_NAME[@]}"; do [[ $existing_key == "$public_key" ]] && { die "$EXIT_VALIDATION" 'Дублирующийся PUBLIC_KEY в метаданных'; return $?; }; done
|
||||
for existing_name in "${META_NAMES[@]}"; do [[ $existing_name == "$name" ]] && { die "$EXIT_VALIDATION" 'Дублирующееся NAME в метаданных'; return $?; }; done
|
||||
META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed
|
||||
META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed; META_FILE["$public_key"]=$file
|
||||
}
|
||||
|
||||
parse_metadata() {
|
||||
local file
|
||||
META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=()
|
||||
META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=(); META_FILE=()
|
||||
[[ -d "$METADATA_DIR" ]] || return 0
|
||||
for file in "$METADATA_DIR"/*; do [[ -e "$file" ]] || continue; parse_metadata_file "$file" || return $?; done
|
||||
}
|
||||
@@ -517,12 +517,13 @@ parse_runtime_dump() {
|
||||
}
|
||||
|
||||
load_runtime_state() {
|
||||
local output command_status
|
||||
local output command_status had_errexit=0
|
||||
RUNTIME_AVAILABLE=0; RUNTIME_LISTEN_PORT='-'
|
||||
if ! command -v wg >/dev/null 2>&1; then return 0; fi
|
||||
[[ $- == *e* ]] && had_errexit=1
|
||||
set +e
|
||||
output=$(wg show "$WG_INTERFACE" dump 2>/dev/null); command_status=$?
|
||||
set -e
|
||||
(( had_errexit == 1 )) && set -e || set +e
|
||||
(( command_status == 0 )) || return 0
|
||||
[[ -n "$output" ]] || return 0
|
||||
parse_runtime_dump "$output"
|
||||
@@ -547,7 +548,7 @@ build_client_records() {
|
||||
for key in "${keys[@]}"; do
|
||||
name='-'; origin=unmanaged; addresses=${DISK_ALLOWED[$key]:-${RUNTIME_ALLOWED[$key]:--}}; handshake=${RUNTIME_HANDSHAKE[$key]:-0}
|
||||
disk_ipv4=${DISK_IPV4[$key]:-}; disk_ipv4=${disk_ipv4%%/*}; disk_ipv6=${DISK_IPV6[$key]:-}; disk_ipv6=${disk_ipv6%%/*}
|
||||
if [[ ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
|
||||
if [[ -n ${DISK_ALLOWED[$key]+x} && ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
|
||||
expected_allowed="${META_IPV4[$key]}/32"; [[ -n ${META_IPV6[$key]:-} ]] && expected_allowed+=",${META_IPV6[$key]}/128"
|
||||
if [[ ${META_IPV4[$key]} == "$disk_ipv4" && ${META_IPV6[$key]:-} == "$disk_ipv6" && ${DISK_ALLOWED[$key]:-} == "$(normalize_allowed_ips "$expected_allowed")" ]]; then name=${META_NAME[$key]}; origin=managed; fi
|
||||
fi
|
||||
@@ -1395,6 +1396,133 @@ resolve_gum() {
|
||||
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
|
||||
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
|
||||
|
||||
resolve_named_client() {
|
||||
local name=$1 index count=0 found=-1
|
||||
for index in "${!CLIENT_NAMES[@]}"; do
|
||||
[[ ${CLIENT_NAMES[$index]} == "$name" ]] || continue
|
||||
((count += 1)); found=$index
|
||||
done
|
||||
(( count == 1 )) || { die "$EXIT_VALIDATION" "Клиент не найден или имя неоднозначно: ${name}"; return $?; }
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
|
||||
validate_client_config_path() {
|
||||
local key=$1 name=$2 path metadata_file mode links owner metadata_owner client_dir_owner metadata_dir_owner server_owner client_dir_mode metadata_dir_mode
|
||||
[[ ${CLIENT_ORIGINS[$3]:-} == managed ]] || { die "$EXIT_VALIDATION" 'Клиент не имеет подтверждённой конфигурации'; return $?; }
|
||||
path=${META_CONFIG[$key]:-}; metadata_file=${META_FILE[$key]:-}
|
||||
[[ -n $path && -n $metadata_file && ${path##*/} == "${name}.conf" && ${path%/*} == "${CLIENT_DIR%/}" ]] || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации клиента'; return $?; }
|
||||
path_components_safe "$path" && path_components_safe "$metadata_file" || { die "$EXIT_VALIDATION" 'Путь конфигурации содержит символическую ссылку'; return $?; }
|
||||
[[ -f $path && ! -L $path && -f $metadata_file && ! -L $metadata_file ]] || { die "$EXIT_VALIDATION" 'Файл конфигурации клиента недоступен'; return $?; }
|
||||
[[ -d $CLIENT_DIR && ! -L $CLIENT_DIR && -d $METADATA_DIR && ! -L $METADATA_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов недоступен'; return $?; }
|
||||
path_components_safe "$CLIENT_DIR" && path_components_safe "$METADATA_DIR" || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов содержит символическую ссылку'; return $?; }
|
||||
client_dir_mode=$(stat -c '%a' -- "$CLIENT_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить каталог клиентов'; return $?; }
|
||||
metadata_dir_mode=$(stat -c '%a' -- "$METADATA_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить каталог метаданных'; return $?; }
|
||||
client_dir_owner=$(stat -c '%u:%g' -- "$CLIENT_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога клиентов'; return $?; }
|
||||
metadata_dir_owner=$(stat -c '%u:%g' -- "$METADATA_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога метаданных'; return $?; }
|
||||
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
|
||||
(( (8#$client_dir_mode & 077) == 0 && (8#$metadata_dir_mode & 077) == 0 )) || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов доступен группе или другим'; return $?; }
|
||||
[[ $client_dir_owner == "$metadata_dir_owner" && $client_dir_owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Владельцы каталогов и серверной конфигурации не совпадают'; return $?; }
|
||||
mode=$(stat -c '%a' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить режим конфигурации клиента'; return $?; }
|
||||
(( (8#$mode & 077) == 0 )) || { die "$EXIT_VALIDATION" 'Файл конфигурации доступен группе или другим'; return $?; }
|
||||
links=$(stat -c '%h' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить ссылки конфигурации клиента'; return $?; }
|
||||
(( links == 1 )) || { die "$EXIT_VALIDATION" 'Файл конфигурации имеет лишние жёсткие ссылки'; return $?; }
|
||||
owner=$(stat -c '%u:%g' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца конфигурации клиента'; return $?; }
|
||||
metadata_owner=$(stat -c '%u:%g' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца метаданных клиента'; return $?; }
|
||||
local metadata_mode metadata_links
|
||||
metadata_mode=$(stat -c '%a' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить режим метаданных клиента'; return $?; }
|
||||
metadata_links=$(stat -c '%h' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить ссылки метаданных клиента'; return $?; }
|
||||
(( (8#$metadata_mode & 077) == 0 && metadata_links == 1 )) || { die "$EXIT_VALIDATION" 'Метаданные клиента небезопасны'; return $?; }
|
||||
[[ $owner == "$metadata_owner" && $owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Владелец файлов клиента не совпадает с серверной конфигурацией'; return $?; }
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
open_validated_client_config() {
|
||||
local path=$1 expected_identity opened_identity fd
|
||||
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path") || return "$EXIT_OPERATION"
|
||||
exec {fd}<"$path" || return "$EXIT_OPERATION"
|
||||
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || {
|
||||
exec {fd}<&-
|
||||
return "$EXIT_OPERATION"
|
||||
}
|
||||
if [[ $opened_identity != "$expected_identity" ]]; then
|
||||
exec {fd}<&-
|
||||
return "$EXIT_VALIDATION"
|
||||
fi
|
||||
CLIENT_CONFIG_FD=$fd
|
||||
}
|
||||
|
||||
print_client_details() {
|
||||
local name=$1 index=$2 key=${CLIENT_KEYS[$2]} config_path='-'
|
||||
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then
|
||||
if ! config_path=$(validate_client_config_path "$key" "$name" "$index" 2>/dev/null); then config_path='(недоступен)'; fi
|
||||
fi
|
||||
printf 'Имя: %s\nПубличный ключ: %s\nАдреса: %s\nEndpoint: %s\nПоследний handshake: %s\nRX: %s\nTX: %s\nСостояние: %s\nПроисхождение: %s\n' "$name" "$key" "${CLIENT_ADDRESSES[$index]}" "${CLIENT_ENDPOINTS[$index]}" "${CLIENT_HANDSHAKES[$index]}" "${CLIENT_RX[$index]}" "${CLIENT_TX[$index]}" "${CLIENT_STATES[$index]}" "${CLIENT_ORIGINS[$index]}"
|
||||
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then printf 'Режим: %s\nСоздан: %s\nПуть конфигурации: %s\n' "${META_MODE[$key]}" "${META_CREATED[$key]}" "$config_path"; fi
|
||||
}
|
||||
|
||||
export_client_config() {
|
||||
local name=$1 show=${2:-0} index key path status=0
|
||||
[[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; }
|
||||
load_client_model || return $?
|
||||
index=$(resolve_named_client "$name") || return $?
|
||||
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
|
||||
key=${CLIENT_KEYS[$index]}
|
||||
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
|
||||
if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi
|
||||
if open_validated_client_config "$path"; then :; else
|
||||
status=$?
|
||||
(( status == EXIT_VALIDATION )) || status=$EXIT_OPERATION
|
||||
die "$status" 'Не удалось безопасно открыть конфигурацию клиента'
|
||||
return "$status"
|
||||
fi
|
||||
if [[ $- == *x* ]]; then
|
||||
set +x
|
||||
if ! cat <&"$CLIENT_CONFIG_FD"; then status=$EXIT_OPERATION; fi
|
||||
set -x
|
||||
elif ! cat <&"$CLIENT_CONFIG_FD"; then
|
||||
status=$EXIT_OPERATION
|
||||
fi
|
||||
exec {CLIENT_CONFIG_FD}<&-
|
||||
if (( status != 0 )); then die "$EXIT_OPERATION" 'Не удалось прочитать конфигурацию клиента'; return $?; fi
|
||||
}
|
||||
|
||||
show_client() {
|
||||
local name=$1 index
|
||||
load_client_model || return $?
|
||||
index=$(resolve_named_client "$name") || return $?
|
||||
print_client_details "$name" "$index"
|
||||
}
|
||||
|
||||
qr_client() {
|
||||
local name=$1 index key path qr_path status
|
||||
load_client_model || return $?
|
||||
index=$(resolve_named_client "$name") || return $?
|
||||
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
|
||||
key=${CLIENT_KEYS[$index]}
|
||||
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
|
||||
if qr_path=$(command -v qrencode 2>/dev/null); then :; else qr_path=''; fi
|
||||
if [[ -z $qr_path || ! -f $qr_path || -L $qr_path || ! -x $qr_path ]]; then
|
||||
printf '%s\n' "$path"
|
||||
printf 'Предупреждение: qrencode не найден, используйте путь конфигурации выше\n' >&2
|
||||
return "$EXIT_DEPENDENCY"
|
||||
fi
|
||||
if open_validated_client_config "$path"; then :; else
|
||||
status=$?
|
||||
(( status == EXIT_VALIDATION )) || status=$EXIT_OPERATION
|
||||
die "$status" 'Не удалось безопасно открыть конфигурацию клиента'
|
||||
return "$status"
|
||||
fi
|
||||
if [[ $- == *x* ]]; then
|
||||
set +x
|
||||
if "$qr_path" -t ansiutf8 <&"$CLIENT_CONFIG_FD"; then status=0; else status=$?; fi
|
||||
set -x
|
||||
else
|
||||
if "$qr_path" -t ansiutf8 <&"$CLIENT_CONFIG_FD"; then status=0; else status=$?; fi
|
||||
fi
|
||||
exec {CLIENT_CONFIG_FD}<&-
|
||||
(( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; }
|
||||
}
|
||||
|
||||
main() {
|
||||
local status
|
||||
if (( $# == 0 )); then
|
||||
@@ -1412,6 +1540,21 @@ main() {
|
||||
add_client "$@"
|
||||
return $?
|
||||
fi
|
||||
if [[ $1 == show || $1 == config || $1 == qr ]]; then
|
||||
[[ $# -ge 2 ]] || { die "$EXIT_USAGE" "Для команды $1 требуется имя клиента"; print_usage >&2; return $?; }
|
||||
local command=$1 name=$2
|
||||
shift 2
|
||||
case $command in
|
||||
show|qr) (($# == 0)) || { die "$EXIT_USAGE" 'Лишние аргументы команды'; return $?; } ;;
|
||||
config)
|
||||
if (($# == 0)); then export_client_config "$name" 0; return $?; fi
|
||||
[[ $# == 1 && $1 == --show ]] || { die "$EXIT_USAGE" 'Разрешён только флаг config --show'; return $?; }
|
||||
export_client_config "$name" 1; return $?
|
||||
;;
|
||||
esac
|
||||
case $command in show) show_client "$name" ;; qr) qr_client "$name" ;; esac
|
||||
return $?
|
||||
fi
|
||||
if (( $# != 1 )); then
|
||||
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
|
||||
print_usage >&2
|
||||
@@ -1422,7 +1565,7 @@ main() {
|
||||
version) print_version ;;
|
||||
status) load_client_model || return $?; print_status ;;
|
||||
list) load_client_model || return $?; print_list ;;
|
||||
show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
|
||||
remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
|
||||
*)
|
||||
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
|
||||
print_usage >&2
|
||||
|
||||
Reference in New Issue
Block a user