feat(backup): add audited backup listing and restoration

This commit is contained in:
2026-10-13 22:23:50 +03:00
parent 67a9e3fe03
commit 72c0d21977
5 changed files with 596 additions and 28 deletions
+15 -1
View File
@@ -268,7 +268,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
| M5 | DONE | Client detail, config export, and QR display | | M5 | DONE | Client detail, config export, and QR display |
| M6 | DONE | Safe client removal and archival | | M6 | DONE | Safe client removal and archival |
| M7 | DONE | Diagnostics engine and report | | 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 | | M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens |
| M10 | NOT STARTED | Complete TUI mutation workflows | | M10 | NOT STARTED | Complete TUI mutation workflows |
| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance | | 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` - Suggested commit message: `feat(diagnostics): add read-only WireGuard health checks`
- Next milestone: M8 - 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 # 8. Final completion rule
The MVP is complete only when: The MVP is complete only when:
+6
View File
@@ -92,3 +92,9 @@ ordinary `show`, `config`, and `qr` output remains secret-safe.
`remove` возвращает `64` для ошибки аргументов, `65` для неподтверждённой или небезопасной identity/path, `69` для отсутствующей обязательной зависимости и `70` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния. `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 и не доказывают состояние реального сервера. `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.
+36 -3
View File
@@ -5,11 +5,44 @@ command_name=${0##*/}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG" printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
printf '\n' >>"$TEST_MOCK_LOG" 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 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" cp -- "$MOCK_FLOCK_CHANGE_TO" "$WG_MANAGER_CONFIG"
export MOCK_FLOCK_CHANGED_MARKER=1 export MOCK_FLOCK_CHANGED_MARKER=1
fi fi
if [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]]; then exit 1; fi if [[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]]; then
if [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]]; then exit 97; fi [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1
[[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97
exit 0 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
+227 -3
View File
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
case $# in case $# in
0) ;; 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 ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac esac
test_group=${1-} 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 '192.0.2.3/32' "$(<"${CLIENT_DIR}/alice.conf")" 'allocated address'
assert_contains 'PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=' "$(<"$fixture")" 'server peer preshared key' 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' 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 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 show wg0 public-key$' "$TEST_MOCK_LOG" || true)" 'ровно один server public-key'
assert_equal 1 "$(grep -c '^wg genkey$' "$TEST_MOCK_LOG" || true)" 'ровно один genkey' 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-quick strip ' "$transaction_log" 'argv strip'
assert_contains $'wg syncconf wg0 ' "$transaction_log" 'argv syncconf' assert_contains $'wg syncconf wg0 ' "$transaction_log" 'argv syncconf'
assert_contains $'wg show wg0 dump' "$transaction_log" 'argv verification dump' 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 5 "${#transaction_lines[@]}" 'точно пять внешних transaction вызовов'
assert_equal 1 "$([[ ${transaction_lines[0]} =~ ^flock\ -n\ [0-9]+$ ]] && printf 1 || printf 0)" 'первый вызов flock' 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 подряд' 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 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() { run_m7_tests() {
local config_file=$TEST_ROOT/diag.conf fixture=$TEST_ROOT/diag-wg0.conf output status saved_test_root 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" 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 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 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 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 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 run_m7_tests
else else
@@ -3126,6 +3345,11 @@ else
run_m7_tests run_m7_tests
done done
unset m7_group 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='' test_group=''
fi fi
+309 -18
View File
@@ -54,7 +54,7 @@ print_usage() {
Использование: Использование:
wg-manager.sh help wg-manager.sh help
wg-manager.sh version 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 будут добавлены в следующих версиях. Команды управления WireGuard будут добавлены в следующих версиях.
USAGE USAGE
@@ -577,6 +577,7 @@ TRANSACTION_PENDING_SIGNAL=''
TRANSACTION_ROLLBACK_ACTIVE=0 TRANSACTION_ROLLBACK_ACTIVE=0
TRANSACTION_ROLLBACK_INTERRUPTED=0 TRANSACTION_ROLLBACK_INTERRUPTED=0
TRANSACTION_ROLLBACK_SIGNAL='' TRANSACTION_ROLLBACK_SIGNAL=''
TRANSACTION_OUTCOME='failed-before-apply'
transaction_test_hooks_enabled() { transaction_test_hooks_enabled() {
local path config_path local path config_path
@@ -727,10 +728,12 @@ create_backup() {
} }
validate_backup_manifest() { 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=() 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 [[ -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 while IFS= read -r line || [[ -n "$line" ]]; do
[[ $line == *=* ]] || return 1 [[ $line == *=* ]] || return 1
key=${line%%=*}; value=${line#*=} key=${line%%=*}; value=${line#*=}
@@ -752,14 +755,14 @@ validate_backup_manifest() {
esac esac
done <"$backup/manifest" 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 [[ ${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_name=${backup##*/}
backup_prefix="${manifest_timestamp}-${WG_INTERFACE}-" backup_prefix="${manifest_timestamp}-${WG_INTERFACE}-"
[[ $backup_name == "$backup_prefix"* ]] || return 1 [[ $backup_name == "$backup_prefix"* ]] || return 1
backup_counter=${backup_name#"$backup_prefix"} backup_counter=${backup_name#"$backup_prefix"}
[[ $backup_counter =~ ^[0-9]{3}$ ]] || return 1 [[ $backup_counter =~ ^[0-9]{3}$ ]] || return 1
for metadata_name in "${!metadata_seen[@]}"; do 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 done
for line in "$backup"/*; do for line in "$backup"/*; do
[[ -e "$line" || -L "$line" ]] || continue [[ -e "$line" || -L "$line" ]] || continue
@@ -833,7 +836,7 @@ transaction_subshell_exit() {
local status=$? local status=$?
transaction_cleanup transaction_cleanup
if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then 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 if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then
printf 'Предупреждение: не удалось защитить transaction result\n' >&2 printf 'Предупреждение: не удалось защитить transaction result\n' >&2
fi fi
@@ -846,9 +849,9 @@ run_transaction_core() {
local source_mode source_uid source_gid backup_path finalizer_status=0 local source_mode source_uid source_gid backup_path finalizer_status=0
local transaction_path local transaction_path
shift 3 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='' 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 $?; } 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 $?; } [[ $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 $?; } declare -F "$builder" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Builder транзакции не является функцией'; return $?; }
@@ -906,12 +909,18 @@ run_transaction_core() {
if ((status == 0)); then if ((status == 0)); then
backup_path=$(create_backup "$WG_CONF" "$operation" "$target" "$@") || status=$? backup_path=$(create_backup "$WG_CONF" "$operation" "$target" "$@") || status=$?
TRANSACTION_BACKUP_PATH=${backup_path:-} 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 [[ $status == 0 ]]; then
if [[ ${TRANSACTION_ATOMIC_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then status=$EXIT_OPERATION 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 elif ! chmod 600 -- "$stage" || ! mv -- "$stage" "$WG_CONF"; then status=$EXIT_OPERATION; fi
fi fi
fi fi
if ((status == 0)); then if ((status == 0)); then
TRANSACTION_OUTCOME='apply-attempted'
restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid" || status=$EXIT_OPERATION restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid" || status=$EXIT_OPERATION
fi fi
if ((status == 0)) && ! run_syncconf "$WG_INTERFACE" "$new_stripped"; then status=$EXIT_OPERATION; fi if ((status == 0)) && ! run_syncconf "$WG_INTERFACE" "$new_stripped"; then status=$EXIT_OPERATION; fi
@@ -957,8 +966,10 @@ run_transaction_core() {
status=$EXIT_ROLLBACK status=$EXIT_ROLLBACK
else 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
fi fi
if ((status == EXIT_ROLLBACK)); then TRANSACTION_OUTCOME='rollback-failed'; fi
TRANSACTION_ROLLBACK_ACTIVE=0 TRANSACTION_ROLLBACK_ACTIVE=0
fi fi
if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then
@@ -966,6 +977,7 @@ run_transaction_core() {
printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2 printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2
fi fi
fi fi
(( status == 0 )) && TRANSACTION_OUTCOME='success'
transaction_cleanup; exec {lock_fd}>&-; return "$status" transaction_cleanup; exec {lock_fd}>&-; return "$status"
} }
@@ -974,7 +986,7 @@ run_transaction() {
die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции' die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции'
return $? return $?
fi 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 shift 3
[[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; } [[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; }
set_runtime_paths || return $? set_runtime_paths || return $?
@@ -993,11 +1005,21 @@ run_transaction() {
(( had_errexit == 1 )) && set -e || set +e (( had_errexit == 1 )) && set -e || set +e
if [[ -r $result_file ]]; then if [[ -r $result_file ]]; then
while IFS='=' read -r key value; do 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" done <"$result_file"
fi fi
rm -f -- "$result_file" rm -f -- "$result_file"
TRANSACTION_BACKUP_PATH=${result_backup:-} 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 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" return "$result_status"
} }
@@ -1354,10 +1376,16 @@ add_client_impl() {
status=$? status=$?
fi fi
if (( status != 0 )); then 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 clear_add_transaction_state
return "$status" return "$status"
fi fi
status=0 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 clear_add_transaction_state
return "$status" return "$status"
} }
@@ -1407,6 +1435,244 @@ resolve_gum() {
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; } print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; } 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_FAILS=0
DIAG_QUIET=0 DIAG_QUIET=0
declare -A DIAG_IDS=() declare -A DIAG_IDS=()
@@ -1885,8 +2151,10 @@ archive_remove_artifacts() {
done done
} }
remove_audit() { write_audit_event() {
local outcome=$1 audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity 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" path_components_safe "$LOG_DIR" || return "$EXIT_VALIDATION"
[[ ! -L $LOG_DIR ]] || return "$EXIT_VALIDATION" [[ ! -L $LOG_DIR ]] || return "$EXIT_VALIDATION"
if [[ ! -e $LOG_DIR ]]; then if [[ ! -e $LOG_DIR ]]; then
@@ -1909,20 +2177,30 @@ remove_audit() {
owner=$(stat -c '%u:%g' -- "$audit_file") || return "$EXIT_OPERATION" owner=$(stat -c '%u:%g' -- "$audit_file") || return "$EXIT_OPERATION"
[[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION" [[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION"
else else
: >"$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" chmod 600 -- "$audit_file" || return "$EXIT_OPERATION"
fi fi
fi
timestamp=$(current_epoch) || return "$EXIT_OPERATION" timestamp=$(current_epoch) || return "$EXIT_OPERATION"
timestamp=$(date -u -d "@${timestamp}" +%Y-%m-%dT%H:%M:%SZ) || return "$EXIT_OPERATION" timestamp=$(date -u -d "@${timestamp}" +%Y-%m-%dT%H:%M:%SZ) || return "$EXIT_OPERATION"
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION" expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION"
exec {fd}>>"$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=$(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"; } [[ $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 if ! flock "$fd"; then exec {fd}>&-; return "$EXIT_OPERATION"; fi
exec {fd}>&- locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
return "$EXIT_OPERATION" opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
fi [[ $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}>&- 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() { confirm_remove() {
@@ -2018,6 +2296,17 @@ main() {
diagnose "$@" diagnose "$@"
return $? return $?
fi 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 (( $# != 1 )); then
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2 print_usage >&2
@@ -2032,7 +2321,9 @@ main() {
shift shift
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; } (($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
remove_client "$@" ;; 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 if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2 print_usage >&2