diff --git a/PLANS.md b/PLANS.md index fee993a..92f08bc 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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: diff --git a/README.md b/README.md index 8e63c1a..2c4261c 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/tests/fixtures/m5/managed-client.conf b/tests/fixtures/m5/managed-client.conf new file mode 100644 index 0000000..a68f0f1 --- /dev/null +++ b/tests/fixtures/m5/managed-client.conf @@ -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 diff --git a/tests/mocks/bin/qrencode b/tests/mocks/bin/qrencode index 5c9ca66..1c67ab2 100755 --- a/tests/mocks/bin/qrencode +++ b/tests/mocks/bin/qrencode @@ -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' diff --git a/tests/run.sh b/tests/run.sh index 9b97590..85af0fd 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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 diff --git a/wg-manager.sh b/wg-manager.sh index a5c94c2..5565516 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -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