feat(backup): add audited backup listing and restoration
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+37
-4
@@ -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
|
||||||
exit 0
|
[[ ${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
|
||||||
|
|||||||
+227
-3
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user