diff --git a/PLANS.md b/PLANS.md index f4a09a3..0f6c2c6 100644 --- a/PLANS.md +++ b/PLANS.md @@ -268,7 +268,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. | M5 | DONE | Client detail, config export, and QR display | | M6 | DONE | Safe client removal and archival | | M7 | DONE | Diagnostics engine and report | -| M8 | NOT STARTED | Backup listing/restoration and audit log UX | +| M8 | DONE | Backup listing/restoration and audit log UX | | M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens | | M10 | NOT STARTED | Complete TUI mutation workflows | | M11 | NOT STARTED | Hardening, documentation, and MVP acceptance | @@ -1663,6 +1663,20 @@ Use this template: - Suggested commit message: `feat(diagnostics): add read-only WireGuard health checks` - Next milestone: M8 +## 2026-10-13 — M8 — DONE + +- Implemented: structured secret-safe audit events for mutations, apply, backup, and rollback outcomes; bounded newest-first `log`; serialized explicit `backup`; validated `backups` listing; and confirmed, interface-bound `restore BACKUP [--yes]` with pinned source identity, current-state backup, transactional apply, and disk/runtime rollback. +- Coordinator: `gpt-5.6-sol` (`high`) +- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`) +- Repair cycles: 11 +- Sol review: replaced shallow aliased M8 tests; corrected literal and false audit outcomes; added truthful transaction/backup/apply/rollback events; hardened concurrent audit creation, locking, identity checks, filtering, and limits; fixed backup-list pipeline, ownership, link, containment, malformed-entry, and manifest-swap handling; added manual-backup post-lock revalidation, retention, failure audit, and replacement detection; and made restore identifiers strict, source snapshots identity-pinned, confirmations explicit, and apply/rollback failures fully covered. +- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/mocks/bin/flock`, `bash tests/run.sh audit`, `bash tests/run.sh audit-no-secrets`, `bash tests/run.sh backup-list`, `bash tests/run.sh restore`, `bash tests/run.sh restore-invalid`, `bash tests/run.sh restore-rollback`, `bash tests/run.sh backup-path-safety`, `bash tests/run.sh runtime-file-modes`, `bash tests/run.sh`, `git diff --check`, permission, scope, opened-file identity, retention, concurrency, rollback, and secret-pattern review. +- Result: all required checks passed; audit/audit-no-secrets/backup-list/restore/restore-invalid/restore-rollback/backup-path-safety/runtime-file-modes passed 17/5/11/5/8/5/7/14 assertions, and the full regression suite passed 706 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, real terminal confirmation, and deployment-server behavior were not accessed or tested; M8 verification is fixture- and mock-based as required. Restore intentionally covers only the server configuration, not archived client artifacts. +- Suggested commit message: `feat(backup): add audited backup listing and restoration` +- Next milestone: M9 + # 8. Final completion rule The MVP is complete only when: diff --git a/README.md b/README.md index 54770ce..d4b8641 100644 --- a/README.md +++ b/README.md @@ -92,3 +92,9 @@ ordinary `show`, `config`, and `qr` output remains secret-safe. `remove` возвращает `64` для ошибки аргументов, `65` для неподтверждённой или небезопасной identity/path, `69` для отсутствующей обязательной зависимости и `70` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния. `diagnose [--quiet]` выполняет только read-only проверки конфигурации и прав, наличия интерфейса, `wg` dump, forwarding, адресов/маршрутов IPv4 и IPv6, UDP-порта, firewall, systemd, runtime-vs-disk peer set и журнала. Отдельные записи `config-parse`, `duplicate-keys`, `allowedips-overlap` и `key-permissions` не скрывают независимые причины сбоя. Каждая запись имеет один из статусов `OK`, `WARN`, `FAIL` или `SKIP`; `--quiet` выводит только стабильные строки `id=STATUS`. Обязательны `wg`, `wg-quick` и `ip`; `systemctl`, `journalctl`, `nft`, `iptables` и `ss` являются optional и при отсутствии дают собственный `SKIP`. На хосте без активного systemd проверки systemd и журнала пропускаются. `firewall=OK` означает только видимое ожидаемое NAT-свидетельство, а не полную проверку политики firewall. Ненулевой код (`70`) возвращается при любом `FAIL`, `64` — при неверных аргументах. Команда не изменяет конфигурацию, runtime, маршруты, firewall или sysctl; handshake и журнал имеют ограниченную историческую видимость. Тесты используют только изолированные fixtures/mocks и не доказывают состояние реального сервера. + +## M8 резервные копии, восстановление и аудит + +`backup` создаёт ручную резервную копию текущей серверной конфигурации под lock; `backups` показывает только проверенные прямые дочерние каталоги, в порядке от новых к старым. Повреждённые или неполные записи не исполняются и не читаются как shell-код. `restore BACKUP [--yes]` восстанавливает только серверную конфигурацию: перед заменой текущая версия также резервируется, а ошибки применения проверяют и откатывают disk/runtime. Восстановление другой WireGuard-интерфейсной конфигурации отклоняется. Без `--yes` требуется подтверждение в терминале; в неинтерактивном режиме команда возвращает ошибку использования. Клиентские metadata/key-файлы восстановлением не изменяются. + +`log [LIMIT]` выводит только корректные структурированные audit-события в newest-first порядке; `LIMIT` находится в диапазоне 1..1000. При отсутствии журнала выводится безопасное сообщение. Записи содержат UTC timestamp, actor UID, operation, target, outcome и короткую категорию ошибки; секреты, команды и полные конфигурации не записываются. Ошибки аудита после успешной мутации сообщаются как частичный операционный результат. Основные коды: `64` — аргументы, `65` — небезопасная/невалидная backup identity, `70` — ошибка операции; критический неудачный rollback сохраняет отдельный rollback status. diff --git a/tests/mocks/bin/flock b/tests/mocks/bin/flock index e50913b..fbe4e85 100755 --- a/tests/mocks/bin/flock +++ b/tests/mocks/bin/flock @@ -5,11 +5,44 @@ command_name=${0##*/} printf '%q' "$command_name" >>"$TEST_MOCK_LOG" for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done printf '\n' >>"$TEST_MOCK_LOG" -[[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]] || exit 99 if [[ -n ${MOCK_FLOCK_CHANGE_TO:-} && -z ${MOCK_FLOCK_CHANGED_MARKER:-} && -f $MOCK_FLOCK_CHANGE_TO && -n ${WG_MANAGER_CONFIG:-} ]]; then cp -- "$MOCK_FLOCK_CHANGE_TO" "$WG_MANAGER_CONFIG" export MOCK_FLOCK_CHANGED_MARKER=1 fi -if [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]]; then exit 1; fi -if [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]]; then exit 97; fi -exit 0 +if [[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]]; then + [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1 + [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97 + exit 0 +fi +if [[ $# -ge 3 && $1 == -n && $2 =~ ^[0-9]+$ && ( $3 == printf || $3 == cat ) ]]; then + [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1 + [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97 + shift 2 + command "$@" + exit $? +fi +if [[ $# == 1 && $1 =~ ^[0-9]+$ || $# == 2 && $1 == -s && $2 =~ ^[0-9]+$ ]]; then + [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1 + [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97 + real_flock=${REAL_FLOCK:-} + if [[ -z $real_flock ]]; then + for candidate in /usr/bin/flock /bin/flock; do + if [[ -x $candidate ]]; then real_flock=$candidate; break; fi + done + fi + [[ -n $real_flock ]] || exit 98 + exec "$real_flock" "$@" +fi +if [[ $# -ge 2 && ( $1 == -s || $1 != -* ) && ${1#-} =~ ^[0-9]+$ || $1 == -s && ${2:-} =~ ^[0-9]+$ ]]; then + [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1 + [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97 + real_flock=${REAL_FLOCK:-} + if [[ -z $real_flock ]]; then + for candidate in /usr/bin/flock /bin/flock; do + if [[ -x $candidate ]]; then real_flock=$candidate; break; fi + done + fi + [[ -n $real_flock ]] || exit 98 + exec "$real_flock" "$@" +fi +exit 99 diff --git a/tests/run.sh b/tests/run.sh index a3a2554..f950442 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|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes) ;; *) 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|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac test_group=${1-} @@ -913,7 +913,7 @@ run_m4_tests() { assert_contains '192.0.2.3/32' "$(<"${CLIENT_DIR}/alice.conf")" 'allocated address' assert_contains 'PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=' "$(<"$fixture")" 'server peer preshared key' assert_equal '' "$(grep -E 'EEEE|FFFF|GGGG|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'mock log without key material' - local -a add_log_lines=(); mapfile -t add_log_lines <"$TEST_MOCK_LOG" + local -a add_log_lines=(); mapfile -t add_log_lines < <(grep -E '^(flock -n [0-9]+$|wg show wg0 (public-key|dump)$|wg genkey$|wg pubkey$|wg genpsk$|wg-quick strip |wg syncconf )' "$TEST_MOCK_LOG") assert_equal 9 "${#add_log_lines[@]}" 'полный порядок add внешних вызовов' assert_equal 1 "$(grep -c '^wg show wg0 public-key$' "$TEST_MOCK_LOG" || true)" 'ровно один server public-key' assert_equal 1 "$(grep -c '^wg genkey$' "$TEST_MOCK_LOG" || true)" 'ровно один genkey' @@ -2029,7 +2029,7 @@ run_m3_tests() { assert_contains $'wg-quick strip ' "$transaction_log" 'argv strip' assert_contains $'wg syncconf wg0 ' "$transaction_log" 'argv syncconf' assert_contains $'wg show wg0 dump' "$transaction_log" 'argv verification dump' - mapfile -t transaction_lines <"$TEST_MOCK_LOG" + mapfile -t transaction_lines < <(grep -E '^(flock -n [0-9]+$|wg-quick strip |wg syncconf |wg show )' "$TEST_MOCK_LOG") assert_equal 5 "${#transaction_lines[@]}" 'точно пять внешних transaction вызовов' assert_equal 1 "$([[ ${transaction_lines[0]} =~ ^flock\ -n\ [0-9]+$ ]] && printf 1 || printf 0)" 'первый вызов flock' assert_equal 1 "$([[ ${transaction_lines[1]} == 'wg-quick strip '* && ${transaction_lines[2]} == 'wg-quick strip '* ]] && printf 1 || printf 0)" 'два strip подряд' @@ -2788,6 +2788,223 @@ EOF fi } +run_m8_tests() { + local config_file=$TEST_ROOT/m8.conf fixture=$TEST_ROOT/m8-wg.conf output status backup audit_content audit_text first_backup second_backup replacement restore_before valid_count manual_now newest_stamp manifest_replacement retained manifest_before_hash + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"; chmod 600 -- "$fixture" + write_m2_config "$config_file" "$fixture" + replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/m8-clients\"" + export WG_MANAGER_TEST_MODE=1 WG_MANAGER_CONFIG=$config_file WG_MANAGER_BACKUP_DIR=$TEST_ROOT/m8-backups WG_MANAGER_LOG_DIR=$TEST_ROOT/m8-logs WG_MANAGER_METADATA_DIR=$TEST_ROOT/m8-meta WG_MANAGER_ARCHIVE_DIR=$TEST_ROOT/m8-archive WG_MANAGER_LOCK_FILE=$TEST_ROOT/m8.lock WG_MANAGER_NOW=1700000100 WG_MOCK_INTERFACE=wg0 + mkdir -p -- "$TEST_ROOT/m8-backups" "$TEST_ROOT/m8-logs" "$TEST_ROOT/m8-meta" "$TEST_ROOT/m8-clients" "$TEST_ROOT/m8-archive" + chmod 700 -- "$TEST_ROOT/m8-backups" "$TEST_ROOT/m8-logs" "$TEST_ROOT/m8-meta" "$TEST_ROOT/m8-clients" "$TEST_ROOT/m8-archive" + set_runtime_paths; load_config + transaction_builder_copy() { cp -- "$WG_CONF" "$1"; } + transaction_builder_fail() { return "$EXIT_VALIDATION"; } + case $test_group in + audit) + begin_test 'audit covers transaction backup apply and rollback outcomes' + export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" + : >"$TEST_MOCK_LOG" + set +e; run_transaction update audit transaction_builder_copy >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'successful transaction is audited' + audit_text=$(<"$LOG_DIR/wg-manager.log") + assert_equal 1 "$(grep -c '^.*operation=backup .*outcome=success ' <<<"$audit_text")" 'backup event is emitted once' + assert_equal 1 "$(grep -c '^.*operation=apply .*outcome=success ' <<<"$audit_text")" 'apply event is emitted once' + export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-sync-fail" + set +e; run_transaction update audit transaction_builder_copy >/dev/null 2>&1; status=$?; set -e + unset WG_SYNC_FAIL_ONCE_FILE + assert_equal "$EXIT_OPERATION" "$status" 'failed apply with successful rollback keeps operation status' + assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'successful rollback is audited' + export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-sync-fail-critical" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 + set +e; run_transaction update audit transaction_builder_copy >/dev/null 2>&1; status=$?; set -e + unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL + assert_equal "$EXIT_ROLLBACK" "$status" 'rollback failure keeps critical status' + assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'critical rollback is audited' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|BBBBBBBBBBBBBBBB' "$LOG_DIR/wg-manager.log" || true)" 'transaction audit contains no secrets' + : >"$LOG_DIR/wg-manager.log" + set +e; run_transaction update preflight transaction_builder_fail >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'pre-apply builder failure retains validation status' + assert_equal 0 "$(grep -Ec 'operation=(apply|rollback) ' "$LOG_DIR/wg-manager.log" || true)" 'pre-apply failure emits no apply or rollback event' + set +e; output=$(list_audit 1 2>&1); status=$?; set -e + assert_equal 0 "$status" 'audit listing accepts limit one' + assert_equal 1 "$(grep -c '^timestamp=' <<<"$output")" 'audit listing limit one returns one record' + set +e; list_audit 0 >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'audit listing rejects zero limit' + set +e; list_audit 1001 >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'audit listing rejects excessive limit' + : >"$LOG_DIR/wg-manager.log" + local audit_index audit_pid audit_wait_status + local -a audit_pids=() + for audit_index in {1..10}; do + ( AUDIT_CLIENT=worker write_audit_event audit "worker${audit_index}" success success ) & audit_pids+=($!) + done + audit_wait_status=0 + for audit_pid in "${audit_pids[@]}"; do wait "$audit_pid" || audit_wait_status=1; done + assert_equal 0 "$audit_wait_status" 'concurrent audit writers all complete' + assert_equal 10 "$(grep -Ec '^timestamp=.* operation=audit target=worker[0-9]+ ' "$LOG_DIR/wg-manager.log" || true)" 'concurrent audit records remain complete' + assert_equal 8 "$(awk 'length($0)>0 {print NF}' "$LOG_DIR/wg-manager.log" | sort -u | tr -d '\n')" 'concurrent audit records have stable field counts' + ;; + audit-no-secrets) + begin_test 'audit rejects unsafe fields and filters hostile records' + set +e; write_audit_event 'bad operation' alice success success >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'unsafe operation is rejected' + audit_content=''; [[ -e $LOG_DIR/wg-manager.log ]] && audit_content=$(grep -E 'PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' "$LOG_DIR/wg-manager.log" || true) + assert_equal '' "$audit_content" 'audit output has no config content' + printf '%s\n' 'PrivateKey=EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' 'malformed hostile line' >"$LOG_DIR/wg-manager.log" + set +e; output=$(list_audit 2>&1); status=$?; set -e + assert_equal 0 "$status" 'malformed audit input is handled safely' + assert_equal '' "$(grep -E 'PrivateKey|EEEE|malformed' <<<"$output" || true)" 'hostile audit input is never emitted' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|BBBBBBBBBBBBBBBB' <<<"$output" || true)" 'filtered audit output is secret-free' + ;; + backup-list) + begin_test 'backup listing returns valid newest entries and ignores hostile entries' + backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") + first_backup=${backup##*/} + export WG_MANAGER_NOW=1700000200 + backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") + second_backup=${backup##*/} + export WG_MANAGER_NOW=1700000100 + mkdir -- "$BACKUP_DIR/20231114T221500Z-wg0-777"; chmod 700 -- "$BACKUP_DIR/20231114T221500Z-wg0-777" + printf 'INTERFACE=wg0\nTIMESTAMP=20231114T221500Z\nOPERATION=manual\nTARGET=bad\nSOURCE_MODE=600\nSOURCE_UID=0\nSOURCE_GID=0\nCONFIG=wg.conf\nEXEC=touch %s\n' "$TEST_ROOT/m8-hostile-sentinel" >"$BACKUP_DIR/20231114T221500Z-wg0-777/manifest"; chmod 600 -- "$BACKUP_DIR/20231114T221500Z-wg0-777/manifest" + set +e; output=$(list_backups 2>&1); status=$?; set -e + assert_equal 0 "$status" 'backup list succeeds' + assert_contains "$first_backup" "$output" 'first valid backup appears in list' + assert_contains "$second_backup" "$output" 'newer valid backup appears in list' + assert_equal 0 "$(grep -c '20231114T221500Z-wg0-777' <<<"$output")" 'invalid backup name is not listed' + assert_file_absent "$TEST_ROOT/m8-hostile-sentinel" 'manifest payload is never executed' + assert_equal 0 "$(grep -c 'Резервные копии отсутствуют' <<<"$output")" 'valid list is not reported empty' + mv -- "$BACKUP_DIR" "$TEST_ROOT/m8-backups-missing" + set +e; output=$(list_backups 2>&1); status=$?; set -e + assert_equal 0 "$status" 'missing backup root is an empty list' + assert_contains 'Резервные копии отсутствуют' "$output" 'missing backup root is reported safely' + mv -- "$TEST_ROOT/m8-backups-missing" "$BACKUP_DIR" + manifest_replacement=$TEST_ROOT/m8-manifest-replacement + manifest_before_hash=$(sha256sum -- "$BACKUP_DIR/$first_backup/manifest" | awk '{print $1}') + cp -- "$BACKUP_DIR/$first_backup/manifest" "$manifest_replacement" + replace_config_line "$manifest_replacement" 'TARGET=' 'TARGET=swapped' + export LIST_BACKUPS_REPLACE_MANIFEST=$manifest_replacement + set +e; output=$(list_backups 2>&1); status=$?; set -e + unset LIST_BACKUPS_REPLACE_MANIFEST LIST_BACKUPS_REPLACE_DONE + assert_equal 0 "$status" 'backup list rejects a swapped manifest safely' + assert_equal 0 "$(grep -Ec '(^|[[:space:]])swapped([[:space:]]|$)' <<<"$output")" 'swapped manifest fields are never displayed' + assert_equal "$manifest_before_hash" "$(sha256sum -- "$BACKUP_DIR/$first_backup/manifest" | awk '{print $1}')" 'swapped manifest is restored byte-for-byte' + ;; + restore) + begin_test 'restore applies pinned backup and creates current-state backup' + local old_config restore_id restored_hash current_backup_count + old_config=$TEST_ROOT/m8-old.conf; cp -- "$fixture" "$old_config" + backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} + printf '# changed-live-state\n' >>"$fixture" + export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" + set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e + assert_equal 0 "$status" 'restore succeeds' + assert_equal "$(sha256sum -- "$old_config" | awk '{print $1}')" "$(sha256sum -- "$fixture" | awk '{print $1}')" 'restore replaces live config with backup bytes' + assert_contains "operation=restore" "$(<"$LOG_DIR/wg-manager.log")" 'restore audit is emitted' + current_backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l) + assert_equal 1 "$(( current_backup_count >= 2 ? 1 : 0 ))" 'restore keeps source and current-state backups' + assert_contains 'Резервная копия:' "$output" 'restore preview identifies timestamp' + ;; + restore-invalid) + begin_test 'restore rejects unsafe identifiers and noninteractive confirmation' + local invalid_restore_status restore_id + for invalid_id in '../unsafe' '-bad' '2023X114T221500Z-wg0-001' 'nested/item'; do + set +e; restore_backup "$invalid_id" --yes >/dev/null 2>&1; invalid_restore_status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$invalid_restore_status" "restore rejects $invalid_id" + done + backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} + set +e; restore_backup "$restore_id" >/dev/null 2>&1; invalid_restore_status=$?; set -e + assert_equal "$EXIT_USAGE" "$invalid_restore_status" 'restore requires --yes without a terminal' + cp -- "$fixture" "$TEST_ROOT/m8-invalid-live.conf" + replace_config_line "$backup/manifest" 'INTERFACE=' 'INTERFACE=wg9' + set +e; restore_backup "$restore_id" --yes >/dev/null 2>&1; invalid_restore_status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$invalid_restore_status" 'restore rejects a backup for another interface' + cp -- "$backup/manifest" "$TEST_ROOT/m8-invalid-manifest" + replace_config_line "$TEST_ROOT/m8-invalid-manifest" 'INTERFACE=' "INTERFACE=$WG_INTERFACE" + cp -- "$TEST_ROOT/m8-invalid-manifest" "$backup/manifest" + printf '[Broken]\n' >>"$backup/wg.conf" + set +e; restore_backup "$restore_id" --yes >/dev/null 2>&1; invalid_restore_status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$invalid_restore_status" 'restore rejects a malformed backup config' + assert_equal "$(sha256sum -- "$TEST_ROOT/m8-invalid-live.conf" | awk '{print $1}')" "$(sha256sum -- "$fixture" | awk '{print $1}')" 'invalid restore leaves live config unchanged' + ;; + restore-rollback) + begin_test 'restore apply failure rolls back disk and reports audit outcome' + local restore_source restore_id rollback_before + restore_source=$TEST_ROOT/m8-rollback.conf; cp -- "$fixture" "$restore_source" + backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} + printf '# restore-candidate\n' >>"$fixture"; rollback_before=$(sha256sum -- "$fixture") + export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-restore-sync-fail" WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump" + set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e + unset WG_SYNC_FAIL_ONCE_FILE + assert_equal "$EXIT_OPERATION" "$status" 'restore apply failure returns operation status' + assert_equal "$rollback_before" "$(sha256sum -- "$fixture")" 'failed restore restores live disk' + assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'restore rollback is audited' + backup=$(create_backup "$fixture" manual "$WG_INTERFACE"); restore_id=${backup##*/} + printf '# restore-critical-candidate\n' >>"$fixture" + export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m8-restore-sync-fail-critical" TRANSACTION_ROLLBACK_REPLACE_FAIL=1 + set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e + unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL + assert_equal "$EXIT_ROLLBACK" "$status" 'restore rollback failure is critical' + assert_contains 'КРИТИЧНО' "$output" 'critical restore rollback is diagnosed' + ;; + backup-path-safety) + begin_test 'backup path safety rejects symlinked root' + mv -- "$BACKUP_DIR" "$TEST_ROOT/m8-backups-real"; ln -s -- "$TEST_ROOT/m8-backups-real" "$BACKUP_DIR" + set +e; output=$(list_backups 2>&1); status=$?; set -e + assert_failure "$status" 'symlinked backup directory is rejected' + rm -f -- "$BACKUP_DIR"; mv -- "$TEST_ROOT/m8-backups-real" "$BACKUP_DIR" + export MOCK_FLOCK_BUSY=1 + set +e; output=$(manual_backup 2>&1); status=$?; set -e + unset MOCK_FLOCK_BUSY + assert_equal "$EXIT_OPERATION" "$status" 'manual backup respects lock contention' + backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup##*/} + replacement=$TEST_ROOT/m8-replacement.conf; cp -- "$WG_CONF" "$replacement"; printf '# replacement-source\n' >>"$replacement" + restore_before=$(sha256sum -- "$WG_CONF" | awk '{print $1}') + export RESTORE_PREOPEN_REPLACEMENT=$replacement + set +e; output=$(restore_backup "$restore_id" --yes 2>&1); status=$?; set -e + unset RESTORE_PREOPEN_REPLACEMENT + assert_equal "$EXIT_VALIDATION" "$status" 'restore rejects source replacement before open' + assert_equal "$restore_before" "$(sha256sum -- "$WG_CONF" | awk '{print $1}')" 'pre-open replacement does not alter live config' + replacement=$TEST_ROOT/m8-lock-replacement; printf 'lock replacement\n' >"$replacement"; chmod 600 -- "$replacement" + export MANUAL_BACKUP_PREOPEN_REPLACEMENT=$replacement + set +e; output=$(manual_backup 2>&1); status=$?; set -e + unset MANUAL_BACKUP_PREOPEN_REPLACEMENT + assert_equal "$EXIT_VALIDATION" "$status" 'manual backup rejects lock replacement before open' + replacement=$TEST_ROOT/m8-config-replacement; cp -- "$WG_CONF" "$replacement"; printf '# config replacement\n' >>"$replacement" + restore_before=$(sha256sum -- "$WG_CONF" | awk '{print $1}') + export MANUAL_BACKUP_PRECOPY_REPLACEMENT=$replacement + set +e; output=$(manual_backup 2>&1); status=$?; set -e + unset MANUAL_BACKUP_PRECOPY_REPLACEMENT + assert_equal "$EXIT_VALIDATION" "$status" 'manual backup rejects config replacement before copy' + assert_equal "$restore_before" "$(sha256sum -- "$WG_CONF" | awk '{print $1}')" 'config replacement restores original source' + ;; + runtime-file-modes) + begin_test 'manual backup runtime files are restrictive' + set +e; backup=$(manual_backup 2>"$TEST_ROOT/m8-manual-backup.err"); status=$?; set -e + assert_equal 0 "$status" 'manual backup succeeds through locked path' + assert_file_exists "$backup/manifest" 'manual backup manifest exists' + assert_equal 700 "$(stat -c '%a' -- "$BACKUP_DIR")" 'backup root is 0700' + assert_equal 700 "$(stat -c '%a' -- "$backup")" 'backup directory is 0700' + assert_equal 600 "$(stat -c '%a' -- "$backup/manifest")" 'manifest is 0600' + assert_equal 600 "$(stat -c '%a' -- "$backup/wg.conf")" 'backup config is 0600' + assert_contains 'operation=manual-backup' "$(<"$LOG_DIR/wg-manager.log")" 'manual backup is audited' + replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="2"' + for manual_now in 1700000300 1700000400 1700000500; do + export WG_MANAGER_NOW=$manual_now + set +e; manual_backup >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" "manual backup succeeds at timestamp $manual_now" + done + valid_count=0 + for retained in "$BACKUP_DIR"/*; do + validate_backup_manifest "$retained" && ((valid_count += 1)) + done + assert_equal 2 "$valid_count" 'manual backup retention keeps exactly two valid backups' + newest_stamp=$(date -u -d '@1700000500' '+%Y%m%dT%H%M%SZ') + assert_equal 1 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "${newest_stamp}-wg0-*" | wc -l)" 'retention keeps newest manual backup' + assert_equal 700 "$(stat -c '%a' -- "$LOG_DIR")" 'log directory remains 0700' + assert_equal 600 "$(stat -c '%a' -- "$LOCK_FILE")" 'manual lock remains 0600' + ;; + esac +} + run_m7_tests() { local config_file=$TEST_ROOT/diag.conf fixture=$TEST_ROOT/diag-wg0.conf output status saved_test_root rm -rf -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients" "$TEST_ROOT/diag-backups" "$TEST_ROOT/diag-logs" "$TEST_ROOT/diag-archive" "$TEST_ROOT/diag.lock" "$TEST_ROOT/ipv4-forward" "$TEST_ROOT/runtime-diff.dump" @@ -3107,6 +3324,8 @@ elif [[ $test_group == add-client || $test_group == address-allocation || $test_ run_m4_tests elif [[ $test_group == remove-client || $test_group == remove-cancel || $test_group == remove-exact-block || $test_group == remove-rollback || $test_group == archive-client || $test_group == archive-path-safety || $test_group == remove-preserves-neighbors ]]; then run_m6_tests +elif [[ $test_group == audit || $test_group == audit-no-secrets || $test_group == backup-list || $test_group == restore || $test_group == restore-invalid || $test_group == restore-rollback || $test_group == backup-path-safety || $test_group == runtime-file-modes ]]; then + run_m8_tests elif [[ $test_group == diagnostics || $test_group == diagnostics-systemd || $test_group == diagnostics-firewall || $test_group == diagnostics-forwarding || $test_group == diagnostics-runtime-diff || $test_group == diagnostics-read-only || $test_group == diagnostics-exit-codes ]]; then run_m7_tests else @@ -3126,6 +3345,11 @@ else run_m7_tests done unset m7_group + for m8_group in audit audit-no-secrets backup-list restore restore-invalid restore-rollback backup-path-safety runtime-file-modes; do + test_group=$m8_group + run_m8_tests + done + unset m8_group test_group='' fi diff --git a/wg-manager.sh b/wg-manager.sh index 4474d2a..9142e7f 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 NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|logs|backups|restore|tui} + wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui} Команды управления WireGuard будут добавлены в следующих версиях. USAGE @@ -577,6 +577,7 @@ TRANSACTION_PENDING_SIGNAL='' TRANSACTION_ROLLBACK_ACTIVE=0 TRANSACTION_ROLLBACK_INTERRUPTED=0 TRANSACTION_ROLLBACK_SIGNAL='' +TRANSACTION_OUTCOME='failed-before-apply' transaction_test_hooks_enabled() { local path config_path @@ -727,10 +728,12 @@ create_backup() { } validate_backup_manifest() { - local backup=$1 line key value manifest_interface='' manifest_timestamp='' manifest_operation='' manifest_target='' manifest_config='' metadata_name backup_name backup_prefix backup_counter + local backup=$1 line key value manifest_interface='' manifest_timestamp='' manifest_operation='' manifest_target='' manifest_config='' metadata_name backup_name backup_prefix backup_counter server_owner local -A seen=() metadata_seen=() [[ -d "$backup" && ! -L "$backup" && -f "$backup/manifest" && ! -L "$backup/manifest" && -f "$backup/wg.conf" && ! -L "$backup/wg.conf" ]] || return 1 - [[ $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%a' -- "$backup/manifest") == 600 && $(stat -c '%a' -- "$backup/wg.conf") == 600 ]] || return 1 + [[ $backup == "${BACKUP_DIR%/}"/* && ${backup%/*} == "${BACKUP_DIR%/}" ]] || return 1 + server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return 1 + [[ $(stat -c '%a:%u:%g' -- "$backup") == "700:$server_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/manifest") == "600:1:$server_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/wg.conf") == "600:1:$server_owner" ]] || return 1 while IFS= read -r line || [[ -n "$line" ]]; do [[ $line == *=* ]] || return 1 key=${line%%=*}; value=${line#*=} @@ -752,14 +755,14 @@ validate_backup_manifest() { esac done <"$backup/manifest" [[ ${seen[INTERFACE]+x} && ${seen[TIMESTAMP]+x} && ${seen[OPERATION]+x} && ${seen[TARGET]+x} && ${seen[SOURCE_MODE]+x} && ${seen[SOURCE_UID]+x} && ${seen[SOURCE_GID]+x} && ${seen[CONFIG]+x} ]] || return 1 - [[ $manifest_interface == "$WG_INTERFACE" && $manifest_timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $manifest_operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $manifest_target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ && $manifest_config == wg.conf ]] || return 1 + [[ $manifest_interface == "$WG_INTERFACE" && $manifest_timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $manifest_operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $manifest_target =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ && $manifest_config == wg.conf ]] || return 1 backup_name=${backup##*/} backup_prefix="${manifest_timestamp}-${WG_INTERFACE}-" [[ $backup_name == "$backup_prefix"* ]] || return 1 backup_counter=${backup_name#"$backup_prefix"} [[ $backup_counter =~ ^[0-9]{3}$ ]] || return 1 for metadata_name in "${!metadata_seen[@]}"; do - [[ -f "$backup/$metadata_name" && ! -L "$backup/$metadata_name" && $(stat -c '%a' -- "$backup/$metadata_name") == 600 ]] || return 1 + [[ -f "$backup/$metadata_name" && ! -L "$backup/$metadata_name" && $(stat -c '%a:%h:%u:%g' -- "$backup/$metadata_name") == "600:1:$server_owner" ]] || return 1 done for line in "$backup"/*; do [[ -e "$line" || -L "$line" ]] || continue @@ -833,7 +836,7 @@ transaction_subshell_exit() { local status=$? transaction_cleanup if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then - printf 'STATUS=%s\nBACKUP=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" >"$TRANSACTION_RESULT_FILE" + printf 'STATUS=%s\nBACKUP=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE" if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then printf 'Предупреждение: не удалось защитить transaction result\n' >&2 fi @@ -846,9 +849,9 @@ run_transaction_core() { local source_mode source_uid source_gid backup_path finalizer_status=0 local transaction_path shift 3 - TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH='' + TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''; TRANSACTION_OUTCOME='failed-before-apply' TRANSACTION_OWNED_PATHS=(); TRANSACTION_PENDING_SIGNAL=''; TRANSACTION_ROLLBACK_ACTIVE=0; TRANSACTION_ROLLBACK_INTERRUPTED=0; TRANSACTION_ROLLBACK_SIGNAL='' - [[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; } + [[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; } is_control_free "$operation" && is_control_free "$target" || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; } [[ $builder =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный builder транзакции'; return $?; } declare -F "$builder" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Builder транзакции не является функцией'; return $?; } @@ -906,12 +909,18 @@ run_transaction_core() { if ((status == 0)); then backup_path=$(create_backup "$WG_CONF" "$operation" "$target" "$@") || status=$? TRANSACTION_BACKUP_PATH=${backup_path:-} + if [[ $status == 0 ]]; then + if ! write_audit_event backup "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит резервной копии'; fi + else + if ! write_audit_event backup "$target" failed backup-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит ошибки резервной копии'; fi + fi if [[ $status == 0 ]]; then if [[ ${TRANSACTION_ATOMIC_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then status=$EXIT_OPERATION elif ! chmod 600 -- "$stage" || ! mv -- "$stage" "$WG_CONF"; then status=$EXIT_OPERATION; fi fi fi if ((status == 0)); then + TRANSACTION_OUTCOME='apply-attempted' restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid" || status=$EXIT_OPERATION fi if ((status == 0)) && ! run_syncconf "$WG_INTERFACE" "$new_stripped"; then status=$EXIT_OPERATION; fi @@ -956,9 +965,11 @@ run_transaction_core() { printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2 status=$EXIT_ROLLBACK else - if (( finalizer_status == EXIT_ROLLBACK )); then status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi + if (( finalizer_status == EXIT_ROLLBACK )); then status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi + TRANSACTION_OUTCOME='rolled-back' fi fi + if ((status == EXIT_ROLLBACK)); then TRANSACTION_OUTCOME='rollback-failed'; fi TRANSACTION_ROLLBACK_ACTIVE=0 fi if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then @@ -966,6 +977,7 @@ run_transaction_core() { printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2 fi fi + (( status == 0 )) && TRANSACTION_OUTCOME='success' transaction_cleanup; exec {lock_fd}>&-; return "$status" } @@ -974,7 +986,7 @@ run_transaction() { die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции' return $? fi - local operation=$1 target=$2 builder=$3 result_file result_status result_backup had_errexit=0 + local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_outcome had_errexit=0 shift 3 [[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; } set_runtime_paths || return $? @@ -993,11 +1005,21 @@ run_transaction() { (( had_errexit == 1 )) && set -e || set +e if [[ -r $result_file ]]; then while IFS='=' read -r key value; do - case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; esac + case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; OUTCOME) result_outcome=$value ;; esac done <"$result_file" fi rm -f -- "$result_file" TRANSACTION_BACKUP_PATH=${result_backup:-} + TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply} + if [[ $result_outcome == success ]]; then + if ! write_audit_event apply "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит применения'; fi + elif [[ $result_outcome == rolled-back ]]; then + if ! write_audit_event rollback "$target" success rolled-back >/dev/null 2>&1; then warn 'Не удалось записать аудит отката'; fi + elif [[ $result_outcome == rollback-failed ]]; then + if ! write_audit_event rollback "$target" failed rollback-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит критического отката'; fi + elif [[ $result_outcome == failed-before-apply ]]; then + if ! write_audit_event transaction "$target" failed operation-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит неуспешной транзакции'; fi + fi unset TRANSACTION_RESULT_FILE TRANSACTION_PRE_LOCK_FILE TRANSACTION_PRE_WG_CONF TRANSACTION_PRE_BACKUP_DIR TRANSACTION_PRE_METADATA_DIR TRANSACTION_PRE_INTERFACE TRANSACTION_FINALIZER return "$result_status" } @@ -1354,10 +1376,16 @@ add_client_impl() { status=$? fi if (( status != 0 )); then + if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi clear_add_transaction_state return "$status" fi status=0 + if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" success success >/dev/null 2>&1; then + clear_add_transaction_state + die "$EXIT_OPERATION" 'Добавление выполнено, но аудит не записан (частичный результат)' + return "$EXIT_OPERATION" + fi clear_add_transaction_state return "$status" } @@ -1407,6 +1435,244 @@ resolve_gum() { print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; } run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; } +manual_backup() { + local lock_fd path status parent pre_conf pre_backup pre_meta pre_lock pre_iface live_owner lock_owner lock_created=0 lock_identity opened_identity replacement_backup config_identity config_identity_after backup_identity_after + load_config || return $? + require_add_privilege || return $? + path_components_safe "$LOCK_FILE" || { die "$EXIT_VALIDATION" 'Некорректный путь блокировки'; return $?; } + path_components_safe "$WG_CONF" || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации'; return $?; } + parent=${LOCK_FILE%/*}; [[ -d $parent && ! -L $parent ]] || { die "$EXIT_VALIDATION" 'Каталог блокировки недоступен'; return $?; } + pre_conf=$WG_CONF; pre_backup=$BACKUP_DIR; pre_meta=$METADATA_DIR; pre_lock=$LOCK_FILE; pre_iface=$WG_INTERFACE + if [[ -e $LOCK_FILE ]]; then + [[ -f $LOCK_FILE && ! -L $LOCK_FILE && $(stat -c '%a:%h' -- "$LOCK_FILE") == 600:1 ]] || { die "$EXIT_VALIDATION" 'Файл блокировки небезопасен'; return $?; } + else + (umask 077; : >"$LOCK_FILE") || { die "$EXIT_OPERATION" 'Не удалось создать файл блокировки'; return $?; } + chmod 600 -- "$LOCK_FILE" || { rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось защитить блокировку'; return $?; } + lock_created=1 + fi + lock_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$LOCK_FILE") || { ((lock_created == 1)) && rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось проверить блокировку'; return $?; } + if [[ -n ${MANUAL_BACKUP_PREOPEN_REPLACEMENT:-} ]]; then + if ! transaction_test_hooks_enabled || ! path_components_safe "$MANUAL_BACKUP_PREOPEN_REPLACEMENT" || [[ ! -f $MANUAL_BACKUP_PREOPEN_REPLACEMENT || -L $MANUAL_BACKUP_PREOPEN_REPLACEMENT || $MANUAL_BACKUP_PREOPEN_REPLACEMENT == "$LOCK_FILE" ]]; then + ((lock_created == 1)) && rm -f -- "$LOCK_FILE" + die "$EXIT_VALIDATION" 'Некорректный тестовый файл блокировки' + return $? + fi + replacement_backup=${LOCK_FILE}.manual-preopen-old + rm -f -- "$replacement_backup" + mv -- "$LOCK_FILE" "$replacement_backup" || return "$EXIT_OPERATION" + cp -- "$MANUAL_BACKUP_PREOPEN_REPLACEMENT" "$LOCK_FILE" || { mv -- "$replacement_backup" "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось подготовить тестовую блокировку'; return $?; } + fi + exec {lock_fd}<>"$LOCK_FILE" || { ((lock_created == 1)) && rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось открыть блокировку'; return $?; } + opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${lock_fd}") || { exec {lock_fd}>&-; die "$EXIT_OPERATION" 'Не удалось проверить открытую блокировку'; return $?; } + if [[ $opened_identity != "$lock_identity" ]]; then + exec {lock_fd}>&- + if [[ -n ${replacement_backup:-} && -e $replacement_backup ]]; then rm -f -- "$LOCK_FILE"; mv -- "$replacement_backup" "$LOCK_FILE"; fi + die "$EXIT_VALIDATION" 'Файл блокировки изменился перед открытием' + return $? + fi + flock -n "$lock_fd" || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$WG_INTERFACE" failed lock-contended >/dev/null 2>&1; then warn 'Не удалось записать аудит блокировки'; fi; die "$EXIT_OPERATION" 'Другая операция уже выполняется'; return $?; } + load_config || { status=$?; exec {lock_fd}>&-; return "$status"; } + if [[ $WG_CONF != "$pre_conf" || $BACKUP_DIR != "$pre_backup" || $METADATA_DIR != "$pre_meta" || $LOCK_FILE != "$pre_lock" || $WG_INTERFACE != "$pre_iface" ]]; then + exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed resources-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения ресурсов'; fi; die "$EXIT_VALIDATION" 'Ресурсы изменились после блокировки'; return $? + fi + [[ -f $WG_CONF && ! -L $WG_CONF ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-unavailable >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; die "$EXIT_VALIDATION" 'Серверная конфигурация недоступна'; return $?; } + live_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-stat >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; return "$EXIT_OPERATION"; } + [[ $(stat -c '%a:%h' -- "$WG_CONF") == 400:1 || $(stat -c '%a:%h' -- "$WG_CONF") == 600:1 ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-permissions >/dev/null 2>&1; then warn 'Не удалось записать аудит прав'; fi; die "$EXIT_VALIDATION" 'Небезопасные права серверной конфигурации'; return $?; } + [[ $(stat -c '%u:%g' -- "$WG_CONF") == "$live_owner" ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-owner >/dev/null 2>&1; then warn 'Не удалось записать аудит владельца'; fi; die "$EXIT_VALIDATION" 'Недоверенный владелец серверной конфигурации'; return $?; } + lock_owner=$(stat -c '%u:%g' -- "$LOCK_FILE") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; } + [[ $lock_owner == "$live_owner" ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed lock-owner >/dev/null 2>&1; then warn 'Не удалось записать аудит владельца блокировки'; fi; die "$EXIT_VALIDATION" 'Недоверенный владелец блокировки'; return $?; } + parse_wireguard_config "$WG_CONF" >/dev/null 2>&1 || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-parse >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; die "$EXIT_VALIDATION" 'Серверная конфигурация не прошла проверку'; return $?; } + config_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; } + if [[ -n ${MANUAL_BACKUP_PRECOPY_REPLACEMENT:-} ]]; then + if ! transaction_test_hooks_enabled || ! path_components_safe "$MANUAL_BACKUP_PRECOPY_REPLACEMENT" || [[ ! -f $MANUAL_BACKUP_PRECOPY_REPLACEMENT || -L $MANUAL_BACKUP_PRECOPY_REPLACEMENT || $MANUAL_BACKUP_PRECOPY_REPLACEMENT == "$WG_CONF" ]]; then + exec {lock_fd}>&-; die "$EXIT_VALIDATION" 'Некорректный тестовый источник конфигурации'; return $? + fi + replacement_backup=${WG_CONF}.manual-precopy-old + rm -f -- "$replacement_backup" + mv -- "$WG_CONF" "$replacement_backup" || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; } + cp -- "$MANUAL_BACKUP_PRECOPY_REPLACEMENT" "$WG_CONF" || { mv -- "$replacement_backup" "$WG_CONF"; exec {lock_fd}>&-; return "$EXIT_OPERATION"; } + fi + config_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; } + if [[ $config_identity_after != "$config_identity" ]]; then + [[ -n ${replacement_backup:-} && -e $replacement_backup ]] && { rm -f -- "$WG_CONF"; mv -- "$replacement_backup" "$WG_CONF"; } + exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения конфигурации'; fi; die "$EXIT_VALIDATION" 'Конфигурация изменилась перед резервированием'; return $? + fi + path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") || { status=$?; exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed backup-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит резервной копии'; fi; return "$status"; } + backup_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { rm -rf -- "$path"; exec {lock_fd}>&-; return "$EXIT_OPERATION"; } + if [[ $backup_identity_after != "$config_identity" ]]; then + rm -rf -- "$path" + [[ -n ${replacement_backup:-} && -e $replacement_backup ]] && { rm -f -- "$WG_CONF"; mv -- "$replacement_backup" "$WG_CONF"; } + exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения конфигурации'; fi; die "$EXIT_VALIDATION" 'Конфигурация изменилась во время резервирования'; return $? + fi + if ! prune_backups; then warn 'Не удалось ограничить число резервных копий'; fi + exec {lock_fd}>&- + if ! AUDIT_OPERATION=manual-backup AUDIT_TARGET="$WG_INTERFACE" AUDIT_ERROR_CATEGORY=success write_audit_event manual-backup "$WG_INTERFACE" success success; then + die "$EXIT_OPERATION" 'Резервная копия создана, но аудит не записан (частичный результат)' + return "$EXIT_OPERATION" + fi + printf '%s\n' "$path" +} +list_backups() { + local path name line timestamp operation target value invalid_count=0 root_mode root_owner server_owner manifest_identity_before manifest_identity_after manifest_fd manifest_opened_identity + local -a entries=() + load_config || return $? + if [[ ! -e $BACKUP_DIR ]]; then printf 'Резервные копии отсутствуют\n'; return 0; fi + [[ -d $BACKUP_DIR && ! -L $BACKUP_DIR ]] && path_components_safe "$BACKUP_DIR" || { die "$EXIT_VALIDATION" 'Каталог резервных копий небезопасен'; return $?; } + server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION" + root_mode=$(stat -c '%a' -- "$BACKUP_DIR") || return "$EXIT_OPERATION" + root_owner=$(stat -c '%u:%g' -- "$BACKUP_DIR") || return "$EXIT_OPERATION" + [[ $root_mode == 700 && $root_owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Каталог резервных копий небезопасен'; return $?; } + for path in "$BACKUP_DIR"/*; do + [[ -d $path && ! -L $path ]] || { [[ -e $path ]] && ((invalid_count += 1)); continue; } + manifest_identity_before=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path/manifest" 2>/dev/null) || { ((invalid_count += 1)); continue; } + exec {manifest_fd}<"$path/manifest" || { ((invalid_count += 1)); continue; } + manifest_opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${manifest_fd}" 2>/dev/null) || { exec {manifest_fd}>&-; ((invalid_count += 1)); continue; } + if [[ $manifest_opened_identity != "$manifest_identity_before" ]]; then exec {manifest_fd}>&-; ((invalid_count += 1)); continue; fi + if [[ -n ${LIST_BACKUPS_REPLACE_MANIFEST:-} && -z ${LIST_BACKUPS_REPLACE_DONE:-} && $path == "${BACKUP_DIR%/}"/* ]]; then + if transaction_test_hooks_enabled && path_components_safe "$LIST_BACKUPS_REPLACE_MANIFEST" && [[ -f $LIST_BACKUPS_REPLACE_MANIFEST && ! -L $LIST_BACKUPS_REPLACE_MANIFEST ]]; then + local list_manifest_old=${path}/.manifest-list-old + rm -f -- "$list_manifest_old" + mv -- "$path/manifest" "$list_manifest_old" && cp -- "$LIST_BACKUPS_REPLACE_MANIFEST" "$path/manifest" && chmod 600 -- "$path/manifest" + LIST_BACKUPS_REPLACE_DONE=1 + fi + fi + if ! validate_backup_manifest "$path"; then + [[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; } + exec {manifest_fd}>&- + ((invalid_count += 1)); continue + fi + manifest_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path/manifest" 2>/dev/null) || { + [[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; } + exec {manifest_fd}>&- + ((invalid_count += 1)); continue + } + if [[ $manifest_identity_before != "$manifest_identity_after" ]]; then + [[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; } + exec {manifest_fd}>&- + ((invalid_count += 1)); continue + fi + name=${path##*/}; timestamp=''; operation=''; target='' + while IFS= read -r line; do + value=${line#*=} + case $line in TIMESTAMP=*) timestamp=$value;; OPERATION=*) operation=$value;; TARGET=*) target=$value;; esac + done <&$manifest_fd + exec {manifest_fd}>&- + entries+=("${name}"$'\t'"${timestamp}"$'\t'"${operation}"$'\t'"${target}") + done + if ((${#entries[@]} == 0)); then printf 'Резервные копии отсутствуют\n'; ((invalid_count > 0)) && printf 'Предупреждение: пропущено некорректных резервных копий: %d\n' "$invalid_count" >&2; return 0; fi + printf '%s\n' "${entries[@]}" | LC_ALL=C sort -r + ((invalid_count > 0)) && printf 'Предупреждение: пропущено некорректных резервных копий: %d\n' "$invalid_count" >&2 + return 0 +} +list_audit() { + local limit=0 line count=0 mode owner server_owner audit_file fd expected_identity opened_identity locked_identity + local -a records=() audit_lines=() + (($# <= 1)) || { die "$EXIT_USAGE" 'Лишние аргументы log'; return $?; } + (($# == 1)) && { [[ $1 =~ ^[1-9][0-9]{0,3}$ ]] && (( 10#$1 <= 1000 )) || { die "$EXIT_USAGE" 'Некорректный лимит log'; return $?; }; limit=$1; } + load_config || return $? + audit_file=$LOG_DIR/wg-manager.log + path_components_safe "$LOG_DIR" || { die "$EXIT_VALIDATION" 'Каталог журнала небезопасен'; return $?; } + [[ -d $LOG_DIR && ! -L $LOG_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог журнала недоступен'; return $?; } + [[ -e $audit_file ]] || { printf 'Журнал отсутствует\n'; return 0; } + [[ -f $audit_file && ! -L $audit_file ]] || { die "$EXIT_VALIDATION" 'Файл журнала небезопасен'; return $?; } + mode=$(stat -c '%a' -- "$LOG_DIR") || return "$EXIT_OPERATION"; [[ $mode == 700 ]] || { die "$EXIT_VALIDATION" 'Небезопасные права каталога журнала'; return $?; } + mode=$(stat -c '%a' -- "$audit_file") || return "$EXIT_OPERATION"; [[ $mode == 600 ]] || { die "$EXIT_VALIDATION" 'Небезопасные права журнала'; return $?; } + [[ $(stat -c '%h' -- "$audit_file") == 1 ]] || { die "$EXIT_VALIDATION" 'Журнал имеет небезопасные ссылки'; return $?; } + server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION" + [[ $(stat -c '%u:%g' -- "$LOG_DIR") == "$server_owner" && $(stat -c '%u:%g' -- "$audit_file") == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Недоверенный владелец журнала'; return $?; } + expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION" + exec {fd}<"$audit_file" || return "$EXIT_OPERATION" + opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; } + [[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; die "$EXIT_VALIDATION" 'Файл журнала изменился при чтении'; return $?; } + if ! flock -s "$fd"; then + exec {fd}>&- + return "$EXIT_OPERATION" + fi + locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; } + opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; } + [[ $locked_identity == "$opened_identity" ]] || { exec {fd}>&-; die "$EXIT_VALIDATION" 'Файл журнала изменился при чтении'; return $?; } + mapfile -t audit_lines <&$fd + for line in "${audit_lines[@]}"; do + [[ $line =~ ^timestamp=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\ actor_uid=[0-9]+\ operation=[a-z][a-z0-9-]{0,31}\ target=[A-Za-z0-9._-]{1,96}\ client=[A-Za-z0-9._-]{1,96}\ key_suffix=[A-Za-z0-9+/=-]{0,16}\ outcome=[a-z][a-z0-9-]{0,31}\ error=[a-z][a-z0-9-]{0,31}$ ]] || continue + records+=("$line") + done + exec {fd}>&- + ((${#records[@]} > 0)) || { printf 'В журнале нет корректных записей\n'; return 0; } + count=0 + for ((count=${#records[@]}-1; count>=0; count--)); do + printf '%s\n' "${records[count]}" + ((limit > 0 && ${#records[@]} - count >= limit)) && break + done +} +restore_builder() { + local stage=$1 + [[ -n ${RESTORE_SNAPSHOT:-} && -f $RESTORE_SNAPSHOT && ! -L $RESTORE_SNAPSHOT ]] || return "$EXIT_VALIDATION" + cp -- "$RESTORE_SNAPSHOT" "$stage" && chmod 600 -- "$stage" +} +cleanup_restore_snapshot() { + if [[ -n ${RESTORE_SNAPSHOT:-} ]]; then rm -f -- "$RESTORE_SNAPSHOT"; fi + unset RESTORE_SNAPSHOT RESTORE_BACKUP RESTORE_BACKUP_DIGEST +} +restore_backup() { + (($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается идентификатор резервной копии'; return $?; } + local name=$1 flag='' backup status answer source backup_name timestamp counter reconstructed snapshot_fd expected_identity opened_identity live_owner replacement_backup='' + (($# >= 2)) && flag=$2 + (($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы restore'; return $?; } + load_config || return $? + backup_name=$name; counter=${backup_name##*-}; timestamp=${backup_name%-"$counter"}; timestamp=${timestamp%-"$WG_INTERFACE"} + [[ $timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $counter =~ ^[0-9]{3}$ ]] || { die "$EXIT_VALIDATION" 'Некорректный идентификатор резервной копии'; return $?; } + reconstructed="${timestamp}-${WG_INTERFACE}-${counter}" + [[ $reconstructed == "$name" ]] || { die "$EXIT_VALIDATION" 'Некорректный идентификатор резервной копии'; return $?; } + [[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг restore --yes'; return $?; } + require_add_privilege || return $? + backup=$BACKUP_DIR/$name + source=$backup/wg.conf + live_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца конфигурации'; return $?; } + [[ -d $backup && ! -L $backup && -f $source && ! -L $source ]] || { die "$EXIT_VALIDATION" 'Резервная копия не найдена'; return $?; } + expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$source") || { die "$EXIT_OPERATION" 'Не удалось проверить резервную копию'; return $?; } + validate_backup_manifest "$backup" || { die "$EXIT_VALIDATION" 'Резервная копия не прошла проверку'; return $?; } + [[ $backup == "${BACKUP_DIR%/}"/* && ${backup%/*} == "${BACKUP_DIR%/}" && ! -L $source && $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%u:%g' -- "$backup") == "$live_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/manifest") == "600:1:$live_owner" && $(stat -c '%a:%h:%u:%g' -- "$source") == "600:1:$live_owner" ]] || { die "$EXIT_VALIDATION" 'Некорректная безопасность резервной копии'; return $?; } + parse_wireguard_config "$source" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Конфигурация резервной копии не прошла проверку'; return $?; } + printf 'Резервная копия: %s; интерфейс: %s\n' "$timestamp" "$WG_INTERFACE" >&2 + [[ -n $flag || ( -t 0 && -t 1 ) ]] || { die "$EXIT_USAGE" 'В неинтерактивном режиме требуется --yes'; return $?; } + if [[ -z $flag ]]; then printf 'Восстановить резервную копию %s? [y/N] ' "$name" >&2; IFS= read -r answer || answer=n; if [[ $answer != y && $answer != Y ]]; then if ! write_audit_event restore "$name" cancelled cancelled >/dev/null 2>&1; then warn 'Не удалось записать аудит отмены восстановления'; fi; cleanup_restore_snapshot; return 0; fi; fi + RESTORE_BACKUP=$backup + RESTORE_SNAPSHOT=$(mktemp "${WG_CONF%/*}/.wg-manager.restore.XXXXXX") || { die "$EXIT_OPERATION" 'Не удалось подготовить снимок восстановления'; return $?; } + chmod 600 -- "$RESTORE_SNAPSHOT" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось защитить снимок восстановления'; return $?; } + [[ $(stat -c '%d:%i:%a:%u:%g:%h' -- "$source") == "$expected_identity" ]] || { cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Резервная копия изменилась'; return $?; } + if [[ -n ${RESTORE_PREOPEN_REPLACEMENT:-} ]]; then + if ! transaction_test_hooks_enabled || ! path_components_safe "$RESTORE_PREOPEN_REPLACEMENT" || [[ ! -f $RESTORE_PREOPEN_REPLACEMENT || -L $RESTORE_PREOPEN_REPLACEMENT || $RESTORE_PREOPEN_REPLACEMENT == "$source" ]]; then + cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Некорректный тестовый источник восстановления'; return $? + fi + replacement_backup=${source}.restore-preopen-old + rm -f -- "$replacement_backup" + mv -- "$source" "$replacement_backup" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось подготовить тестовый источник восстановления'; return $?; } + cp -- "$RESTORE_PREOPEN_REPLACEMENT" "$source" || { mv -- "$replacement_backup" "$source"; cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось подготовить тестовый источник восстановления'; return $?; } + fi + exec {snapshot_fd}<"$source" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось открыть резервную копию'; return $?; } + opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${snapshot_fd}") || { exec {snapshot_fd}>&-; cleanup_restore_snapshot; return "$EXIT_OPERATION"; } + if [[ $opened_identity != "$expected_identity" ]]; then + exec {snapshot_fd}>&- + if [[ -n ${replacement_backup:-} && -e $replacement_backup ]]; then + rm -f -- "$source" + mv -- "$replacement_backup" "$source" + fi + cleanup_restore_snapshot + die "$EXIT_VALIDATION" 'Резервная копия изменилась' + return $? + fi + cat <&$snapshot_fd >"$RESTORE_SNAPSHOT" || { exec {snapshot_fd}>&-; cleanup_restore_snapshot; return "$EXIT_OPERATION"; } + exec {snapshot_fd}>&- + parse_wireguard_config "$RESTORE_SNAPSHOT" >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Снимок восстановления не прошёл проверку'; return $?; } + if run_transaction restore "$name" restore_builder; then status=0; else status=$?; fi + if (( status == 0 )); then + write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; } + else + if ! write_audit_event restore "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi + fi + cleanup_restore_snapshot + return "$status" +} DIAG_FAILS=0 DIAG_QUIET=0 declare -A DIAG_IDS=() @@ -1885,8 +2151,10 @@ archive_remove_artifacts() { done } -remove_audit() { - local outcome=$1 audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity +write_audit_event() { + local operation=$1 target=$2 outcome=$3 error_category=$4 + local audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity locked_identity write_status + [[ $operation =~ ^[a-z][a-z0-9-]{0,31}$ && $target =~ ^[A-Za-z0-9._-]{1,96}$ && $outcome =~ ^[a-z][a-z0-9-]{0,31}$ && $error_category =~ ^[a-z][a-z0-9-]{0,31}$ ]] || return "$EXIT_VALIDATION" path_components_safe "$LOG_DIR" || return "$EXIT_VALIDATION" [[ ! -L $LOG_DIR ]] || return "$EXIT_VALIDATION" if [[ ! -e $LOG_DIR ]]; then @@ -1909,8 +2177,11 @@ remove_audit() { owner=$(stat -c '%u:%g' -- "$audit_file") || return "$EXIT_OPERATION" [[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION" else - : >"$audit_file" || return "$EXIT_OPERATION" - chmod 600 -- "$audit_file" || return "$EXIT_OPERATION" + if ! (umask 077; set -C; : >"$audit_file") 2>/dev/null; then + [[ -f $audit_file && ! -L $audit_file ]] || return "$EXIT_OPERATION" + else + chmod 600 -- "$audit_file" || return "$EXIT_OPERATION" + fi fi timestamp=$(current_epoch) || return "$EXIT_OPERATION" timestamp=$(date -u -d "@${timestamp}" +%Y-%m-%dT%H:%M:%SZ) || return "$EXIT_OPERATION" @@ -1918,11 +2189,18 @@ remove_audit() { exec {fd}>>"$audit_file" || return "$EXIT_OPERATION" opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; } [[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; } - if ! printf 'operation=remove client=%s target=%s key_suffix=%s timestamp=%s outcome=%s actor_uid=%s error=%s\n' "$REMOVE_NAME" "$REMOVE_NAME" "$(short_public_key "$REMOVE_KEY")" "$timestamp" "$outcome" "$EUID" "${REMOVE_ERROR_CATEGORY:-$outcome}" >&$fd; then - exec {fd}>&- - return "$EXIT_OPERATION" - fi + if ! flock "$fd"; then exec {fd}>&-; return "$EXIT_OPERATION"; fi + locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; } + opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; } + [[ $locked_identity == "$opened_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; } + if printf 'timestamp=%s actor_uid=%s operation=%s target=%s client=%s key_suffix=%s outcome=%s error=%s\n' "$timestamp" "$EUID" "$operation" "$target" "${AUDIT_CLIENT:-$target}" "${AUDIT_KEY_SUFFIX:-}" "$outcome" "$error_category" >&$fd; then write_status=0; else write_status=$EXIT_OPERATION; fi exec {fd}>&- + return "$write_status" +} + +remove_audit() { + local outcome=$1 + AUDIT_CLIENT=${REMOVE_NAME:-remove} AUDIT_KEY_SUFFIX=$(short_public_key "${REMOVE_KEY:-}") write_audit_event remove "${REMOVE_NAME:-remove}" "$outcome" "${REMOVE_ERROR_CATEGORY:-$outcome}" } confirm_remove() { @@ -2018,6 +2296,17 @@ main() { diagnose "$@" return $? fi + if [[ $1 == restore ]]; then + (($# >= 2 && $# <= 3)) || { die "$EXIT_USAGE" 'Ожидается идентификатор резервной копии'; return $?; } + shift + restore_backup "$@" + return $? + fi + if [[ $1 == log ]]; then + shift + list_audit "$@" + return $? + fi if (( $# != 1 )); then if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi print_usage >&2 @@ -2032,7 +2321,9 @@ main() { shift (($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; } remove_client "$@" ;; - logs|backups|restore|tui) run_placeholder "$1" ;; + backup) manual_backup ;; + backups) list_backups ;; + logs|tui) run_placeholder "$1" ;; *) if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi print_usage >&2