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

This commit is contained in:
2026-10-13 00:52:56 +03:00
parent 2e40d5bd1a
commit 2bec75aa9d
6 changed files with 527 additions and 22 deletions
+15 -1
View File
@@ -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:
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -54,7 +54,7 @@ print_usage() {
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show|config|qr|remove|diagnose|logs|backups|restore|tui}
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove|diagnose|logs|backups|restore|tui}
Команды управления WireGuard будут добавлены в следующих версиях.
USAGE
@@ -262,7 +262,7 @@ readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
DISK_LISTEN_PORT='-'
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED META_FILE
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
RUNTIME_AVAILABLE=0
RUNTIME_LISTEN_PORT='-'
@@ -480,12 +480,12 @@ parse_metadata_file() {
if [[ -n $ipv6 ]] && ! validate_ipv6 "$ipv6"; then die "$EXIT_VALIDATION" "Некорректный IPV6 в метаданных: ${file}"; return $?; fi
for existing_key in "${!META_NAME[@]}"; do [[ $existing_key == "$public_key" ]] && { die "$EXIT_VALIDATION" 'Дублирующийся PUBLIC_KEY в метаданных'; return $?; }; done
for existing_name in "${META_NAMES[@]}"; do [[ $existing_name == "$name" ]] && { die "$EXIT_VALIDATION" 'Дублирующееся NAME в метаданных'; return $?; }; done
META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed
META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed; META_FILE["$public_key"]=$file
}
parse_metadata() {
local file
META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=()
META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=(); META_FILE=()
[[ -d "$METADATA_DIR" ]] || return 0
for file in "$METADATA_DIR"/*; do [[ -e "$file" ]] || continue; parse_metadata_file "$file" || return $?; done
}
@@ -517,12 +517,13 @@ parse_runtime_dump() {
}
load_runtime_state() {
local output command_status
local output command_status had_errexit=0
RUNTIME_AVAILABLE=0; RUNTIME_LISTEN_PORT='-'
if ! command -v wg >/dev/null 2>&1; then return 0; fi
[[ $- == *e* ]] && had_errexit=1
set +e
output=$(wg show "$WG_INTERFACE" dump 2>/dev/null); command_status=$?
set -e
(( had_errexit == 1 )) && set -e || set +e
(( command_status == 0 )) || return 0
[[ -n "$output" ]] || return 0
parse_runtime_dump "$output"
@@ -547,7 +548,7 @@ build_client_records() {
for key in "${keys[@]}"; do
name='-'; origin=unmanaged; addresses=${DISK_ALLOWED[$key]:-${RUNTIME_ALLOWED[$key]:--}}; handshake=${RUNTIME_HANDSHAKE[$key]:-0}
disk_ipv4=${DISK_IPV4[$key]:-}; disk_ipv4=${disk_ipv4%%/*}; disk_ipv6=${DISK_IPV6[$key]:-}; disk_ipv6=${disk_ipv6%%/*}
if [[ ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
if [[ -n ${DISK_ALLOWED[$key]+x} && ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
expected_allowed="${META_IPV4[$key]}/32"; [[ -n ${META_IPV6[$key]:-} ]] && expected_allowed+=",${META_IPV6[$key]}/128"
if [[ ${META_IPV4[$key]} == "$disk_ipv4" && ${META_IPV6[$key]:-} == "$disk_ipv6" && ${DISK_ALLOWED[$key]:-} == "$(normalize_allowed_ips "$expected_allowed")" ]]; then name=${META_NAME[$key]}; origin=managed; fi
fi
@@ -1395,6 +1396,133 @@ resolve_gum() {
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
resolve_named_client() {
local name=$1 index count=0 found=-1
for index in "${!CLIENT_NAMES[@]}"; do
[[ ${CLIENT_NAMES[$index]} == "$name" ]] || continue
((count += 1)); found=$index
done
(( count == 1 )) || { die "$EXIT_VALIDATION" "Клиент не найден или имя неоднозначно: ${name}"; return $?; }
printf '%s\n' "$found"
}
validate_client_config_path() {
local key=$1 name=$2 path metadata_file mode links owner metadata_owner client_dir_owner metadata_dir_owner server_owner client_dir_mode metadata_dir_mode
[[ ${CLIENT_ORIGINS[$3]:-} == managed ]] || { die "$EXIT_VALIDATION" 'Клиент не имеет подтверждённой конфигурации'; return $?; }
path=${META_CONFIG[$key]:-}; metadata_file=${META_FILE[$key]:-}
[[ -n $path && -n $metadata_file && ${path##*/} == "${name}.conf" && ${path%/*} == "${CLIENT_DIR%/}" ]] || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации клиента'; return $?; }
path_components_safe "$path" && path_components_safe "$metadata_file" || { die "$EXIT_VALIDATION" 'Путь конфигурации содержит символическую ссылку'; return $?; }
[[ -f $path && ! -L $path && -f $metadata_file && ! -L $metadata_file ]] || { die "$EXIT_VALIDATION" 'Файл конфигурации клиента недоступен'; return $?; }
[[ -d $CLIENT_DIR && ! -L $CLIENT_DIR && -d $METADATA_DIR && ! -L $METADATA_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов недоступен'; return $?; }
path_components_safe "$CLIENT_DIR" && path_components_safe "$METADATA_DIR" || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов содержит символическую ссылку'; return $?; }
client_dir_mode=$(stat -c '%a' -- "$CLIENT_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить каталог клиентов'; return $?; }
metadata_dir_mode=$(stat -c '%a' -- "$METADATA_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить каталог метаданных'; return $?; }
client_dir_owner=$(stat -c '%u:%g' -- "$CLIENT_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога клиентов'; return $?; }
metadata_dir_owner=$(stat -c '%u:%g' -- "$METADATA_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога метаданных'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
(( (8#$client_dir_mode & 077) == 0 && (8#$metadata_dir_mode & 077) == 0 )) || { die "$EXIT_VALIDATION" 'Каталог клиентских файлов доступен группе или другим'; return $?; }
[[ $client_dir_owner == "$metadata_dir_owner" && $client_dir_owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Владельцы каталогов и серверной конфигурации не совпадают'; return $?; }
mode=$(stat -c '%a' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить режим конфигурации клиента'; return $?; }
(( (8#$mode & 077) == 0 )) || { die "$EXIT_VALIDATION" 'Файл конфигурации доступен группе или другим'; return $?; }
links=$(stat -c '%h' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить ссылки конфигурации клиента'; return $?; }
(( links == 1 )) || { die "$EXIT_VALIDATION" 'Файл конфигурации имеет лишние жёсткие ссылки'; return $?; }
owner=$(stat -c '%u:%g' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца конфигурации клиента'; return $?; }
metadata_owner=$(stat -c '%u:%g' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца метаданных клиента'; return $?; }
local metadata_mode metadata_links
metadata_mode=$(stat -c '%a' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить режим метаданных клиента'; return $?; }
metadata_links=$(stat -c '%h' -- "$metadata_file") || { die "$EXIT_OPERATION" 'Не удалось проверить ссылки метаданных клиента'; return $?; }
(( (8#$metadata_mode & 077) == 0 && metadata_links == 1 )) || { die "$EXIT_VALIDATION" 'Метаданные клиента небезопасны'; return $?; }
[[ $owner == "$metadata_owner" && $owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Владелец файлов клиента не совпадает с серверной конфигурацией'; return $?; }
printf '%s\n' "$path"
}
open_validated_client_config() {
local path=$1 expected_identity opened_identity fd
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path") || return "$EXIT_OPERATION"
exec {fd}<"$path" || return "$EXIT_OPERATION"
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || {
exec {fd}<&-
return "$EXIT_OPERATION"
}
if [[ $opened_identity != "$expected_identity" ]]; then
exec {fd}<&-
return "$EXIT_VALIDATION"
fi
CLIENT_CONFIG_FD=$fd
}
print_client_details() {
local name=$1 index=$2 key=${CLIENT_KEYS[$2]} config_path='-'
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then
if ! config_path=$(validate_client_config_path "$key" "$name" "$index" 2>/dev/null); then config_path='(недоступен)'; fi
fi
printf 'Имя: %s\nПубличный ключ: %s\nАдреса: %s\nEndpoint: %s\nПоследний handshake: %s\nRX: %s\nTX: %s\nСостояние: %s\nПроисхождение: %s\n' "$name" "$key" "${CLIENT_ADDRESSES[$index]}" "${CLIENT_ENDPOINTS[$index]}" "${CLIENT_HANDSHAKES[$index]}" "${CLIENT_RX[$index]}" "${CLIENT_TX[$index]}" "${CLIENT_STATES[$index]}" "${CLIENT_ORIGINS[$index]}"
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then printf 'Режим: %s\nСоздан: %s\nПуть конфигурации: %s\n' "${META_MODE[$key]}" "${META_CREATED[$key]}" "$config_path"; fi
}
export_client_config() {
local name=$1 show=${2:-0} index key path status=0
[[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; }
load_client_model || return $?
index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
key=${CLIENT_KEYS[$index]}
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi
if open_validated_client_config "$path"; then :; else
status=$?
(( status == EXIT_VALIDATION )) || status=$EXIT_OPERATION
die "$status" 'Не удалось безопасно открыть конфигурацию клиента'
return "$status"
fi
if [[ $- == *x* ]]; then
set +x
if ! cat <&"$CLIENT_CONFIG_FD"; then status=$EXIT_OPERATION; fi
set -x
elif ! cat <&"$CLIENT_CONFIG_FD"; then
status=$EXIT_OPERATION
fi
exec {CLIENT_CONFIG_FD}<&-
if (( status != 0 )); then die "$EXIT_OPERATION" 'Не удалось прочитать конфигурацию клиента'; return $?; fi
}
show_client() {
local name=$1 index
load_client_model || return $?
index=$(resolve_named_client "$name") || return $?
print_client_details "$name" "$index"
}
qr_client() {
local name=$1 index key path qr_path status
load_client_model || return $?
index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
key=${CLIENT_KEYS[$index]}
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if qr_path=$(command -v qrencode 2>/dev/null); then :; else qr_path=''; fi
if [[ -z $qr_path || ! -f $qr_path || -L $qr_path || ! -x $qr_path ]]; then
printf '%s\n' "$path"
printf 'Предупреждение: qrencode не найден, используйте путь конфигурации выше\n' >&2
return "$EXIT_DEPENDENCY"
fi
if open_validated_client_config "$path"; then :; else
status=$?
(( status == EXIT_VALIDATION )) || status=$EXIT_OPERATION
die "$status" 'Не удалось безопасно открыть конфигурацию клиента'
return "$status"
fi
if [[ $- == *x* ]]; then
set +x
if "$qr_path" -t ansiutf8 <&"$CLIENT_CONFIG_FD"; then status=0; else status=$?; fi
set -x
else
if "$qr_path" -t ansiutf8 <&"$CLIENT_CONFIG_FD"; then status=0; else status=$?; fi
fi
exec {CLIENT_CONFIG_FD}<&-
(( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; }
}
main() {
local status
if (( $# == 0 )); then
@@ -1412,6 +1540,21 @@ main() {
add_client "$@"
return $?
fi
if [[ $1 == show || $1 == config || $1 == qr ]]; then
[[ $# -ge 2 ]] || { die "$EXIT_USAGE" "Для команды $1 требуется имя клиента"; print_usage >&2; return $?; }
local command=$1 name=$2
shift 2
case $command in
show|qr) (($# == 0)) || { die "$EXIT_USAGE" 'Лишние аргументы команды'; return $?; } ;;
config)
if (($# == 0)); then export_client_config "$name" 0; return $?; fi
[[ $# == 1 && $1 == --show ]] || { die "$EXIT_USAGE" 'Разрешён только флаг config --show'; return $?; }
export_client_config "$name" 1; return $?
;;
esac
case $command in show) show_client "$name" ;; qr) qr_client "$name" ;; esac
return $?
fi
if (( $# != 1 )); then
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2
@@ -1422,7 +1565,7 @@ main() {
version) print_version ;;
status) load_client_model || return $?; print_status ;;
list) load_client_model || return $?; print_list ;;
show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
*)
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2