feat(safety): add atomic WireGuard mutation and rollback engine
This commit is contained in:
@@ -263,7 +263,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
|
|||||||
| M0 | DONE | Test harness and safe project skeleton |
|
| M0 | DONE | Test harness and safe project skeleton |
|
||||||
| M1 | DONE | Runtime foundation, CLI shell, and config loading |
|
| M1 | DONE | Runtime foundation, CLI shell, and config loading |
|
||||||
| M2 | DONE | WireGuard parser, client model, `status`, and `list` |
|
| M2 | DONE | WireGuard parser, client model, `status`, and `list` |
|
||||||
| M3 | NOT STARTED | Locking, backups, atomic apply, and rollback engine |
|
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
|
||||||
| M4 | NOT STARTED | Safe client creation |
|
| M4 | NOT STARTED | Safe client creation |
|
||||||
| M5 | NOT STARTED | Client detail, config export, and QR display |
|
| M5 | NOT STARTED | Client detail, config export, and QR display |
|
||||||
| M6 | NOT STARTED | Safe client removal and archival |
|
| M6 | NOT STARTED | Safe client removal and archival |
|
||||||
@@ -1593,6 +1593,20 @@ Use this template:
|
|||||||
- Suggested commit message: `feat(clients): add WireGuard state parsing and client listing`
|
- Suggested commit message: `feat(clients): add WireGuard state parsing and client listing`
|
||||||
- Next milestone: M3
|
- Next milestone: M3
|
||||||
|
|
||||||
|
## 2026-10-12 — M3 — DONE
|
||||||
|
|
||||||
|
- Implemented: reusable internal mutation transaction with nonblocking `flock` serialization, post-lock state revalidation, same-filesystem restrictive staging, `wg-quick strip` validation, timestamped manifest backups with bounded safe retention, atomic config replacement, `wg syncconf` apply verification, and disk/runtime rollback with a distinct critical recovery status.
|
||||||
|
- Coordinator: `gpt-5.6-sol` (`high`)
|
||||||
|
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
|
||||||
|
- Repair cycles: 11
|
||||||
|
- Sol review: fixed incomplete rollback verification, unsafe temporary-file allocation and trap handling, false-positive lock/mode tests, missing failure and interruption coverage, permissive mock contracts, backup-manifest and retention gaps, runtime-dump verification gaps, unconditional pruning after failed mutations, unchecked backup writes, and backup-counter exhaustion that could reuse `-999`; all defects received regression coverage.
|
||||||
|
- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/helpers.sh`, `bash -n tests/mocks/bin/*`, `bash tests/run.sh transaction`, `bash tests/run.sh rollback`, `bash tests/run.sh locking`, `bash tests/run.sh backups`, `bash tests/run.sh`, `bash tests/run.sh mutation-preserves-source`, `bash tests/run.sh secret-modes`, `git diff --check`, per-file `git diff --no-index --check /dev/null <new-file>`, permission and secret-pattern review.
|
||||||
|
- Result: all required checks passed; transaction/rollback/locking/backups/full/mutation-preserves-source/secret-modes passed 51/65/19/34/297/47/27 assertions. Concurrent mutation, INT/TERM cleanup, exact command order, source preservation, restrictive active temp and backup modes, apply rollback, critical rollback failures, hostile runtime dumps, safe pruning, and counter exhaustion were verified exclusively with fixtures and mocks. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
|
||||||
|
- Decisions: none
|
||||||
|
- Known limitations: real WireGuard, networking, systemd, firewall, route, sysctl, journal, and deployment-server behavior was not accessed or tested; M3 verification is fixture- and mock-based as required. No add/remove/restore CLI command is exposed until later milestones.
|
||||||
|
- Suggested commit message: `feat(safety): add atomic WireGuard mutation and rollback engine`
|
||||||
|
- Next milestone: M4
|
||||||
|
|
||||||
# 8. Final completion rule
|
# 8. Final completion rule
|
||||||
|
|
||||||
The MVP is complete only when:
|
The MVP is complete only when:
|
||||||
|
|||||||
@@ -33,3 +33,17 @@ The explicit peer comment marker is `# wg-manager-client: NAME`; it is only a
|
|||||||
display candidate. A peer is considered managed only when its exact public key
|
display candidate. A peer is considered managed only when its exact public key
|
||||||
and configured addresses match a valid metadata record with `MANAGED=1`.
|
and configured addresses match a valid metadata record with `MANAGED=1`.
|
||||||
|
|
||||||
|
## M3 transaction foundation
|
||||||
|
|
||||||
|
The internal `run_transaction OPERATION TARGET BUILDER [METADATA_FILE...]`
|
||||||
|
primitive acquires `WG_MANAGER_LOCK_FILE`, then
|
||||||
|
reloads and validates the live configuration before calling its builder. It
|
||||||
|
creates a restrictive, timestamped backup, validates a same-filesystem staged
|
||||||
|
configuration with `wg-quick strip`, replaces the live file atomically, applies
|
||||||
|
it with `wg syncconf`, and verifies the active peer set. Apply or verification
|
||||||
|
failures restore both disk and runtime; a failed restoration is reported as a
|
||||||
|
critical rollback error. No M3 mutation command is exposed yet.
|
||||||
|
|
||||||
|
M3 tests use only isolated overrides and deny-by-default command mocks; they do
|
||||||
|
not inspect or modify host WireGuard state.
|
||||||
|
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
[Interface]
|
||||||
|
Address = 192.0.2.1/24
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
|
||||||
|
AllowedIPs = 192.0.2.2/32; do-not-execute
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 1700000100 100 200 25
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
[Interface]
|
||||||
|
Address = 192.0.2.1/24
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
# wg-manager-client: fixture
|
||||||
|
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
|
||||||
|
AllowedIPs = 192.0.2.2/32
|
||||||
|
Endpoint = vpn.example.invalid:51820
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
command_name=${0##*/}
|
||||||
|
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}"
|
||||||
|
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
|
||||||
|
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
|
||||||
|
printf '\n' >>"$TEST_MOCK_LOG"
|
||||||
|
[[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]] || exit 99
|
||||||
|
if [[ -n ${MOCK_FLOCK_CHANGE_TO:-} && -z ${MOCK_FLOCK_CHANGED_MARKER:-} && -f $MOCK_FLOCK_CHANGE_TO && -n ${WG_MANAGER_CONFIG:-} ]]; then
|
||||||
|
cp -- "$MOCK_FLOCK_CHANGE_TO" "$WG_MANAGER_CONFIG"
|
||||||
|
export MOCK_FLOCK_CHANGED_MARKER=1
|
||||||
|
fi
|
||||||
|
if [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]]; then exit 1; fi
|
||||||
|
if [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]]; then exit 97; fi
|
||||||
|
exit 0
|
||||||
+44
-1
@@ -6,10 +6,53 @@ allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
|
|||||||
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"
|
||||||
if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -n ${WG_MOCK_DUMP_FILE:-} && -f $WG_MOCK_DUMP_FILE ]]; then
|
if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -z ${WG_MOCK_DUMP_SEQUENCE_DIR:-} && -n ${WG_MOCK_DUMP_FILE:-} && -f $WG_MOCK_DUMP_FILE ]]; then
|
||||||
|
if [[ -n ${WG_MOCK_DUMP_FAIL_ON_CALL:-} ]]; then
|
||||||
|
dump_counter=${WG_MOCK_DUMP_FAIL_COUNTER_FILE:-${TEST_ROOT}/dump-counter}
|
||||||
|
dump_calls=0; [[ -f $dump_counter ]] && read -r dump_calls <"$dump_counter"; ((dump_calls += 1)); printf '%s\n' "$dump_calls" >"$dump_counter"
|
||||||
|
[[ $dump_calls != "$WG_MOCK_DUMP_FAIL_ON_CALL" ]] || exit 98
|
||||||
|
fi
|
||||||
cat -- "$WG_MOCK_DUMP_FILE"
|
cat -- "$WG_MOCK_DUMP_FILE"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -n ${WG_MOCK_DUMP_SEQUENCE_DIR:-} ]]; then
|
||||||
|
[[ ${WG_MOCK_DUMP_SEQUENCE_DIR} == "${TEST_ROOT}"/* && -d ${WG_MOCK_DUMP_SEQUENCE_DIR} && ! -L ${WG_MOCK_DUMP_SEQUENCE_DIR} ]] || exit 99
|
||||||
|
sequence_counter=${WG_MOCK_DUMP_SEQUENCE_DIR}/counter
|
||||||
|
[[ ! -L "$sequence_counter" && ( -f "$sequence_counter" || ! -e "$sequence_counter" ) ]] || exit 99
|
||||||
|
sequence_number=0
|
||||||
|
[[ -f "$sequence_counter" ]] && read -r sequence_number <"$sequence_counter"
|
||||||
|
((sequence_number += 1))
|
||||||
|
printf '%s\n' "$sequence_number" >"$sequence_counter"
|
||||||
|
sequence_file=${WG_MOCK_DUMP_SEQUENCE_DIR}/${sequence_number}
|
||||||
|
[[ -f "$sequence_file" ]] && cat -- "$sequence_file" && exit 0
|
||||||
|
printf 'Mock wg dump sequence exhausted\n' >&2
|
||||||
|
exit 98
|
||||||
|
fi
|
||||||
|
if [[ $# == 3 && $1 == syncconf ]]; then
|
||||||
|
[[ -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" ]] || { printf 'Mock wg wrong interface\n' >&2; exit 99; }
|
||||||
|
if [[ ${WG_SYNC_FAIL_ONCE_FILE:-} && ! -e ${WG_SYNC_FAIL_ONCE_FILE} ]]; then
|
||||||
|
: >"$WG_SYNC_FAIL_ONCE_FILE"
|
||||||
|
printf 'Mock wg syncconf failure once\n' >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
if [[ -n ${WG_SYNC_FAIL_ON_CALL:-} ]]; then
|
||||||
|
sync_counter=${WG_SYNC_FAIL_COUNTER_FILE:-${TEST_ROOT}/sync-counter}
|
||||||
|
sync_calls=0; [[ -f $sync_counter ]] && read -r sync_calls <"$sync_counter"; ((sync_calls += 1)); printf '%s\n' "$sync_calls" >"$sync_counter"
|
||||||
|
[[ $sync_calls != "$WG_SYNC_FAIL_ON_CALL" ]] || { printf 'Mock wg syncconf failure\n' >&2; exit 97; }
|
||||||
|
fi
|
||||||
|
[[ -f $3 && ! -L $3 && ${WG_SYNC_FAIL:-0} != 1 && -n ${TEST_ROOT:-} && $3 == "${TEST_ROOT}"/* ]] || { printf 'Mock wg syncconf failure\n' >&2; exit 97; }
|
||||||
|
[[ $(stat -c '%a' -- "$3") == 600 ]] || { printf 'Mock wg syncconf unsafe mode\n' >&2; exit 97; }
|
||||||
|
if [[ -n ${WG_SYNC_EXPECTED_STRIP:-} ]]; then
|
||||||
|
[[ -f $WG_SYNC_EXPECTED_STRIP ]] || exit 97
|
||||||
|
cmp -s -- "$3" "$WG_SYNC_EXPECTED_STRIP" || { printf 'Mock wg syncconf unexpected stripped content\n' >&2; exit 97; }
|
||||||
|
fi
|
||||||
|
if [[ -n ${WG_SYNC_READY_FIFO:-} ]]; then
|
||||||
|
[[ ${WG_SYNC_READY_FIFO} == "${TEST_ROOT}"/* && -p ${WG_SYNC_READY_FIFO} && ${WG_SYNC_RELEASE_FIFO:-} == "${TEST_ROOT}"/* && -p ${WG_SYNC_RELEASE_FIFO} ]] || exit 99
|
||||||
|
printf 'ready\n' >"$WG_SYNC_READY_FIFO"
|
||||||
|
IFS= read -r _ <"${WG_SYNC_RELEASE_FIFO:?WG_SYNC_RELEASE_FIFO must be set}"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
|
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
|
||||||
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
|
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
|
||||||
exit 99
|
exit 99
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
|
|||||||
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"
|
||||||
|
if [[ $# == 2 && $1 == strip ]]; then
|
||||||
|
[[ -f $2 && ! -L $2 && $(stat -c '%a' -- "$2") == 600 && -n ${TEST_ROOT:-} && $2 == "${TEST_ROOT}"/* ]] || { printf 'Mock wg-quick strip failure\n' >&2; exit 97; }
|
||||||
|
case ${WG_QUICK_STRIP_FAIL:-0}:${2##*/} in
|
||||||
|
1:*|staged:*.stage.*|live:wg0.conf) printf 'Mock wg-quick strip failure\n' >&2; exit 97 ;;
|
||||||
|
esac
|
||||||
|
if [[ -n ${WG_QUICK_STRIP_OUTPUT:-} && -f $WG_QUICK_STRIP_OUTPUT ]]; then cat -- "$WG_QUICK_STRIP_OUTPUT"; fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
|
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
|
||||||
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
|
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
|
||||||
exit 99
|
exit 99
|
||||||
|
|||||||
+634
-2
@@ -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) ;; *) 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) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
|
||||||
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
|
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
|
||||||
esac
|
esac
|
||||||
test_group=${1-}
|
test_group=${1-}
|
||||||
@@ -237,7 +237,7 @@ run_config_tests() {
|
|||||||
'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS'
|
'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS'
|
||||||
'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка'
|
'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка'
|
||||||
'invalid_ipv6_mode|USE_IPV6|2|USE_IPV6 должен'
|
'invalid_ipv6_mode|USE_IPV6|2|USE_IPV6 должен'
|
||||||
'invalid_retention|BACKUP_RETENTION|0|Некорректное число резервных копий'
|
'invalid_retention|BACKUP_RETENTION|-1|Некорректное число резервных копий'
|
||||||
'invalid_handshake|RECENT_HANDSHAKE_SECONDS|604801|Некорректный срок handshake'
|
'invalid_handshake|RECENT_HANDSHAKE_SECONDS|604801|Некорректный срок handshake'
|
||||||
)
|
)
|
||||||
for semantic_case in "${semantic_cases[@]}"; do
|
for semantic_case in "${semantic_cases[@]}"; do
|
||||||
@@ -781,6 +781,635 @@ run_m2_tests() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transaction_builder_copy() {
|
||||||
|
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
|
||||||
|
sed -e 's/^Address = 192\.0\.2\.1\/24$/Address = 192.0.2.1\/25/' "$WG_CONF" >"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_builder_invalid() {
|
||||||
|
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
|
||||||
|
printf '[broken\n' >"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_builder_fail() {
|
||||||
|
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
|
||||||
|
return 77
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_builder_symlink() {
|
||||||
|
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
|
||||||
|
rm -- "$1"
|
||||||
|
ln -s -- "$WG_CONF" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_m3_tests() {
|
||||||
|
local config_file="${TEST_ROOT}/m3.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before after backup_count manifest_content parent_exit parent_int parent_term
|
||||||
|
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
|
||||||
|
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
|
||||||
|
write_m2_config "$config_file" "$fixture"
|
||||||
|
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
|
||||||
|
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_QUICK_STRIP_FAIL TRANSACTION_METADATA_COPY_FAIL TRANSACTION_BACKUP_FAIL TRANSACTION_BACKUP_DIR_CHMOD_FAIL TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL TRANSACTION_ATOMIC_REPLACE_FAIL MOCK_FLOCK_BUSY MOCK_FLOCK_FAIL WG_MOCK_DUMP_SEQUENCE_DIR WG_MOCK_DUMP_FAIL_ON_CALL
|
||||||
|
set_runtime_paths
|
||||||
|
load_config
|
||||||
|
write_alice_metadata
|
||||||
|
|
||||||
|
begin_test 'fresh process transaction discovers config before lock'
|
||||||
|
local fresh_transaction_script="${TEST_ROOT}/fresh-transaction.sh" fresh_status
|
||||||
|
printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'transaction_builder_fresh() { cp -- "$WG_CONF" "$1"; }' 'run_transaction update fresh transaction_builder_fresh "$WG_MANAGER_METADATA_DIR/alice.meta"' >"$fresh_transaction_script"
|
||||||
|
chmod 700 "$fresh_transaction_script"
|
||||||
|
set +e; /bin/bash "$fresh_transaction_script" "$PROJECT_DIR/wg-manager.sh" >/dev/null 2>&1; fresh_status=$?; set -e
|
||||||
|
assert_equal 0 "$fresh_status" 'fresh process transaction'
|
||||||
|
|
||||||
|
if [[ -n $test_group && $test_group != transaction ]]; then
|
||||||
|
case $test_group in
|
||||||
|
locking)
|
||||||
|
begin_test 'lock contention never enters the mutation callback'
|
||||||
|
: >"${TEST_ROOT}/builder-marker"
|
||||||
|
transaction_builder_marker() { : >"${TEST_ROOT}/builder-marker.entered"; return 0; }
|
||||||
|
export MOCK_FLOCK_BUSY=1
|
||||||
|
set +e; run_transaction test lock transaction_builder_marker >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset MOCK_FLOCK_BUSY
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'lock contention status'
|
||||||
|
assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'callback не вызван'
|
||||||
|
begin_test 'real isolated flock serializes concurrent transactions'
|
||||||
|
local flock_path="${TEST_ROOT}/flock-path" first_pid real_flock first_status
|
||||||
|
mkdir -p "$flock_path"
|
||||||
|
for command_name in bash env cp chmod mv mkdir mktemp stat date rm sed sort paste cat; do ln -s -- "$(type -P "$command_name")" "${flock_path}/${command_name}"; done
|
||||||
|
real_flock=$(PATH=/usr/bin:/bin type -P flock)
|
||||||
|
ln -s -- "$real_flock" "${flock_path}/flock"
|
||||||
|
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "${flock_path}/wg"
|
||||||
|
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg-quick" "${flock_path}/wg-quick"
|
||||||
|
mkfifo "${TEST_ROOT}/builder-ready" "${TEST_ROOT}/builder-release"
|
||||||
|
transaction_builder_hold() { printf 'ready\n' >"${TEST_ROOT}/builder-ready"; IFS= read -r _ <"${TEST_ROOT}/builder-release"; cp -- "$WG_CONF" "$1"; }
|
||||||
|
local saved_path=$PATH
|
||||||
|
PATH=$flock_path
|
||||||
|
: >"${TEST_ROOT}/first-status"
|
||||||
|
run_transaction test concurrency transaction_builder_hold >"${TEST_ROOT}/first-status" 2>&1 & first_pid=$!
|
||||||
|
IFS= read -r ready <"${TEST_ROOT}/builder-ready"
|
||||||
|
assert_equal ready "$ready" 'первый builder вошёл'
|
||||||
|
set +e; run_transaction test concurrency transaction_builder_marker >/dev/null 2>&1; status=$?; set -e
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'второй transaction заблокирован'
|
||||||
|
assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'второй builder не вошёл'
|
||||||
|
printf 'release\n' >"${TEST_ROOT}/builder-release"
|
||||||
|
set +e; wait "$first_pid"; first_status=$?; set -e
|
||||||
|
assert_equal 0 "$first_status" 'первый transaction завершён успешно'
|
||||||
|
PATH=$saved_path
|
||||||
|
local env_path
|
||||||
|
env_path=$(command -v env)
|
||||||
|
for interrupt_signal in INT TERM; do
|
||||||
|
begin_test "${interrupt_signal} interruption cleans transaction and releases lock"
|
||||||
|
local interrupt_script="${TEST_ROOT}/interrupt-${interrupt_signal}.sh" interrupt_pid interrupt_status interrupt_before interrupt_attrs
|
||||||
|
mkfifo "${TEST_ROOT}/interrupt-${interrupt_signal}-ready" "${TEST_ROOT}/interrupt-${interrupt_signal}-hold"
|
||||||
|
interrupt_before=$(sha256sum "$fixture"); interrupt_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'trap - INT' 'source "$1"' 'hold_builder() { printf "%s\\n" "$BASHPID" >"$INTERRUPT_READY"; cat "$INTERRUPT_HOLD" >/dev/null; cp -- "$WG_CONF" "$1"; }' 'set +e; run_transaction update interrupt hold_builder; status=$?; exit "$status"' >"$interrupt_script"
|
||||||
|
chmod 700 "$interrupt_script"
|
||||||
|
"$env_path" --default-signal=INT --default-signal=TERM WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" INTERRUPT_READY="${TEST_ROOT}/interrupt-${interrupt_signal}-ready" INTERRUPT_HOLD="${TEST_ROOT}/interrupt-${interrupt_signal}-hold" /bin/bash "$interrupt_script" "$PROJECT_DIR/wg-manager.sh" >"${TEST_ROOT}/interrupt-${interrupt_signal}.log" 2>&1 & interrupt_pid=$!
|
||||||
|
if ! IFS= read -r -t 5 ready <"${TEST_ROOT}/interrupt-${interrupt_signal}-ready"; then
|
||||||
|
kill -TERM "$interrupt_pid" 2>/dev/null || :
|
||||||
|
set +e; wait "$interrupt_pid"; interrupt_status=$?; set -e
|
||||||
|
record_failure "${interrupt_signal} builder did not reach synchronization point: $(<"${TEST_ROOT}/interrupt-${interrupt_signal}.log")"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
kill -"$interrupt_signal" "$ready"
|
||||||
|
printf 'release\n' >"${TEST_ROOT}/interrupt-${interrupt_signal}-hold"
|
||||||
|
kill -"$interrupt_signal" "$ready" 2>/dev/null || :
|
||||||
|
set +e; wait "$interrupt_pid"; interrupt_status=$?; set -e
|
||||||
|
assert_equal "$EXIT_OPERATION" "$interrupt_status" "${interrupt_signal} status"
|
||||||
|
assert_contains "сигналом ${interrupt_signal}" "$(<"${TEST_ROOT}/interrupt-${interrupt_signal}.log")" "${interrupt_signal} diagnostic"
|
||||||
|
assert_equal "$interrupt_before" "$(sha256sum "$fixture")" "source after ${interrupt_signal}"
|
||||||
|
assert_equal "$interrupt_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "attributes after ${interrupt_signal}"
|
||||||
|
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" "temps after ${interrupt_signal}"
|
||||||
|
run_transaction update interrupt transaction_builder_copy >/dev/null
|
||||||
|
done
|
||||||
|
begin_test 'resource change after lock is rejected before builder'
|
||||||
|
transaction_builder_marker() { : >"${TEST_ROOT}/resource-builder-entered"; return 0; }
|
||||||
|
local changed_resource_config="${TEST_ROOT}/changed-resource.conf"
|
||||||
|
cp -- "$config_file" "$changed_resource_config"
|
||||||
|
replace_config_line "$changed_resource_config" 'WG_CONF=' 'WG_CONF="/tmp/unsafe-resource.conf"'
|
||||||
|
rm -f -- "${TEST_ROOT}/resource-builder-entered"
|
||||||
|
export MOCK_FLOCK_CHANGE_TO="$changed_resource_config"
|
||||||
|
set +e; run_transaction update resource transaction_builder_marker >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset MOCK_FLOCK_CHANGE_TO
|
||||||
|
assert_equal "$EXIT_VALIDATION" "$status" 'resource change after lock rejected'
|
||||||
|
assert_file_absent "${TEST_ROOT}/resource-builder-entered" 'builder not entered after resource change'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
backups)
|
||||||
|
begin_test 'backup retention and unrelated-file safety'
|
||||||
|
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
|
||||||
|
mkdir -p "${TEST_ROOT}/backups"
|
||||||
|
printf 'unrelated\n' >"${TEST_ROOT}/backups/unrelated.txt"
|
||||||
|
local collision_index
|
||||||
|
for collision_index in {1..12}; do run_transaction update client transaction_builder_copy >/dev/null; done
|
||||||
|
backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" | wc -l)
|
||||||
|
assert_equal 1 "$backup_count" 'ограничение резервных копий'
|
||||||
|
newest_backup=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" -printf '%f\n' | LC_ALL=C sort | tail -n 1)
|
||||||
|
assert_equal 012 "${newest_backup##*-}" 'счетчик backup имеет стабильный порядок'
|
||||||
|
assert_equal "INTERFACE=wg0" "$(sed -n '1p' "$BACKUP_DIR/$newest_backup/manifest")" 'manifest interface exact'
|
||||||
|
assert_equal 600 "$(stat -c '%a' "$BACKUP_DIR/$newest_backup/manifest")" 'manifest защищен'
|
||||||
|
mkdir -p "$BACKUP_DIR/20240101T000000Z-wg0-999"
|
||||||
|
printf 'invalid\n' >"$BACKUP_DIR/20240101T000000Z-wg0-999/manifest"
|
||||||
|
assert_file_exists "$BACKUP_DIR/20240101T000000Z-wg0-999/manifest" 'некорректный backup сохранен'
|
||||||
|
assert_file_exists "${TEST_ROOT}/backups/unrelated.txt" 'чужой файл сохранён'
|
||||||
|
begin_test 'backup manifest decoys remain untouched during pruning'
|
||||||
|
local valid_backup decoy_base decoy_path
|
||||||
|
valid_backup="$BACKUP_DIR/$newest_backup"
|
||||||
|
make_decoy() { decoy_path="$BACKUP_DIR/$1"; cp -a -- "$valid_backup" "$decoy_path"; }
|
||||||
|
make_decoy 20240101T000000Z-wg9-001; local wrong_interface=$decoy_path
|
||||||
|
make_decoy 20240101T000000Z-wg0-002; local timestamp_mismatch=$decoy_path
|
||||||
|
make_decoy 20231114T221500Z-wg0-003; local unsafe_dir=$decoy_path; chmod 755 "$unsafe_dir"
|
||||||
|
make_decoy 20231114T221500Z-wg0-004; local unsafe_manifest=$decoy_path; chmod 644 "$unsafe_manifest/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-005; local unsafe_config=$decoy_path; chmod 644 "$unsafe_config/wg.conf"
|
||||||
|
ln -s -- "$valid_backup" "$BACKUP_DIR/20231114T221500Z-wg0-006"; local symlink_dir="$BACKUP_DIR/20231114T221500Z-wg0-006"
|
||||||
|
make_decoy 20231114T221500Z-wg0-007; local symlink_manifest=$decoy_path; rm -- "$symlink_manifest/manifest"; ln -s -- "$valid_backup/manifest" "$symlink_manifest/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-008; local symlink_config=$decoy_path; rm -- "$symlink_config/wg.conf"; ln -s -- "$valid_backup/wg.conf" "$symlink_config/wg.conf"
|
||||||
|
make_decoy 20231114T221500Z-wg0-009; local malformed=$decoy_path; printf 'BROKEN\n' >>"$malformed/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-010; local duplicate_field=$decoy_path; printf 'INTERFACE=wg0\n' >>"$duplicate_field/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-011; local unknown_field=$decoy_path; printf 'UNKNOWN=x\n' >>"$unknown_field/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-016; local missing_field=$decoy_path; sed -i '/^INTERFACE=/d' "$missing_field/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-017; local missing_metadata=$decoy_path; printf 'METADATA=missing.meta\n' >>"$missing_metadata/manifest"
|
||||||
|
make_decoy 20231114T221500Z-wg0-018; local bad_metadata=$decoy_path; printf 'metadata\n' >"$bad_metadata/alice.meta"; chmod 644 "$bad_metadata/alice.meta"
|
||||||
|
make_decoy 20231114T221500Z-wg0-019; local undeclared_payload=$decoy_path; printf 'payload\n' >"$undeclared_payload/extra"
|
||||||
|
mkdir -p "$BACKUP_DIR/unrelated-dir"; printf 'unrelated\n' >"$BACKUP_DIR/unrelated-file"
|
||||||
|
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null
|
||||||
|
for decoy_path in "$wrong_interface" "$timestamp_mismatch" "$unsafe_dir" "$unsafe_manifest" "$unsafe_config" "$symlink_dir" "$symlink_manifest" "$symlink_config" "$malformed" "$duplicate_field" "$unknown_field" "$missing_field" "$missing_metadata" "$bad_metadata" "$undeclared_payload" "$BACKUP_DIR/unrelated-dir" "$BACKUP_DIR/unrelated-file"; do
|
||||||
|
[[ -e "$decoy_path" || -L "$decoy_path" ]] && assert_equal 1 1 "decoy preserved ${decoy_path##*/}" || assert_equal 1 0 "decoy preserved ${decoy_path##*/}"
|
||||||
|
done
|
||||||
|
begin_test 'failed mutation does not prune known-good backups'
|
||||||
|
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="10"'
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null
|
||||||
|
local known_good_one=$TRANSACTION_BACKUP_PATH
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null
|
||||||
|
local known_good_two=$TRANSACTION_BACKUP_PATH
|
||||||
|
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
|
||||||
|
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/failed-no-prune"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset WG_SYNC_FAIL_ONCE_FILE
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'failed mutation status'
|
||||||
|
assert_file_exists "$known_good_one/wg.conf" 'old known-good backup one preserved'
|
||||||
|
assert_file_exists "$known_good_two/wg.conf" 'old known-good backup two preserved'
|
||||||
|
begin_test 'retention zero preserves sole valid backup'
|
||||||
|
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="0"'
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null
|
||||||
|
local valid_count=0 candidate_backup
|
||||||
|
for candidate_backup in "$BACKUP_DIR"/*; do
|
||||||
|
[[ -d "$candidate_backup" ]] || continue
|
||||||
|
if validate_backup_manifest "$candidate_backup"; then ((valid_count += 1)); fi
|
||||||
|
done
|
||||||
|
assert_equal 1 "$valid_count" 'retention zero keeps one valid backup'
|
||||||
|
ln -s -- "$BACKUP_DIR" "$TEST_ROOT/backup-symlink"
|
||||||
|
assert_file_exists "$TEST_ROOT/backup-symlink" 'symlink candidate preserved'
|
||||||
|
begin_test 'metadata backup sources are constrained and required'
|
||||||
|
local metadata_before metadata_status
|
||||||
|
metadata_before=$(sha256sum "$fixture")
|
||||||
|
printf 'outside\n' >"${TEST_ROOT}/outside.meta"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy "${TEST_ROOT}/outside.meta" >/dev/null 2>&1; metadata_status=$?; set -e
|
||||||
|
assert_equal "$EXIT_OPERATION" "$metadata_status" 'внешние метаданные отклонены'
|
||||||
|
assert_equal "$metadata_before" "$(sha256sum "$fixture")" 'внешняя metadata не меняет source'
|
||||||
|
ln -s -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/linked.meta"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy "${METADATA_DIR}/linked.meta" >/dev/null 2>&1; metadata_status=$?; set -e
|
||||||
|
assert_equal "$EXIT_OPERATION" "$metadata_status" 'symlink metadata отклонена'
|
||||||
|
rm -- "${METADATA_DIR}/linked.meta"
|
||||||
|
begin_test 'backup counter exhaustion fails without overwriting 999'
|
||||||
|
local exhaustion_index exhaustion_dir exhaustion_status exhaustion_before
|
||||||
|
for exhaustion_index in $(seq -w 0 999); do
|
||||||
|
exhaustion_dir="$BACKUP_DIR/20231114T221500Z-wg0-${exhaustion_index}"
|
||||||
|
if [[ ! -e "$exhaustion_dir" && ! -L "$exhaustion_dir" ]]; then mkdir -p "$exhaustion_dir"; fi
|
||||||
|
done
|
||||||
|
printf 'sentinel\n' >"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel"
|
||||||
|
exhaustion_before=$(<"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel")
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; exhaustion_status=$?; set -e
|
||||||
|
assert_equal "$EXIT_OPERATION" "$exhaustion_status" 'counter exhaustion status'
|
||||||
|
assert_equal "$exhaustion_before" "$(<"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel")" 'counter 999 sentinel preserved'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
mutation-preserves-source)
|
||||||
|
begin_test 'pre-replacement validation preserves source bytes'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_QUICK_STRIP_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset WG_QUICK_STRIP_FAIL
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal "$EXIT_VALIDATION" "$status" 'ошибка staged strip'
|
||||||
|
assert_equal "$before" "$after" 'байты исходника сохранены'
|
||||||
|
begin_test 'old live strip failure preserves source'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
export WG_QUICK_STRIP_FAIL=live
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset WG_QUICK_STRIP_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка live strip'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после live strip'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после live strip'
|
||||||
|
begin_test 'metadata copy failure preserves source'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
export TRANSACTION_METADATA_COPY_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_METADATA_COPY_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка копирования metadata'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после metadata failure'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после metadata failure'
|
||||||
|
begin_test 'backup creation failure preserves source'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
export TRANSACTION_BACKUP_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_BACKUP_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка создания backup'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после backup failure'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после backup failure'
|
||||||
|
begin_test 'backup directory protection failure cleans incomplete backup'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)
|
||||||
|
export TRANSACTION_BACKUP_DIR_CHMOD_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_BACKUP_DIR_CHMOD_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка chmod каталога backup'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после chmod каталога backup'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после chmod каталога backup'
|
||||||
|
assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после chmod'
|
||||||
|
begin_test 'backup manifest write failure cleans incomplete backup'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)
|
||||||
|
export TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка записи manifest'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после записи manifest'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после записи manifest'
|
||||||
|
assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после manifest'
|
||||||
|
begin_test 'backup manifest mode failure cleans incomplete backup'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)
|
||||||
|
export TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка chmod manifest'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после chmod manifest'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после chmod manifest'
|
||||||
|
assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после chmod manifest'
|
||||||
|
begin_test 'builder failure preserves source attributes'
|
||||||
|
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
set +e; run_transaction update client transaction_builder_fail >/dev/null 2>&1; status=$?; set -e
|
||||||
|
assert_equal 77 "$status" 'builder failure status'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после builder failure'
|
||||||
|
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после builder failure'
|
||||||
|
chmod 644 "$fixture"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
chmod 600 "$fixture"
|
||||||
|
assert_equal "$EXIT_VALIDATION" "$status" 'небезопасный режим source отклонён'
|
||||||
|
set +e; run_transaction $'bad\noperation' client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
assert_equal "$EXIT_VALIDATION" "$status" 'unsafe operation отклонена до mutation'
|
||||||
|
begin_test 'each transaction temp allocation fails closed'
|
||||||
|
local mktemp_bin="${TEST_ROOT}/mktemp-bin" saved_path fail_kind backup_count_before
|
||||||
|
mkdir -p "$mktemp_bin"
|
||||||
|
printf '%s\n' '#!/usr/bin/env bash' 'case ${1##*/} in' ' *.stage.XXXXXX) kind=stage ;;' ' *.strip.new.XXXXXX) kind=strip.new ;;' ' *.strip.old.XXXXXX) kind=strip.old ;;' ' *.original.XXXXXX) kind=original ;;' ' *.result.XXXXXX) kind=result ;;' ' *) kind=other ;;' 'esac' '[[ ${MOCK_MKTEMP_FAIL_KIND:-} == "$kind" ]] && exit 97' 'exec /usr/bin/mktemp "$@"' >"${mktemp_bin}/mktemp"
|
||||||
|
chmod 700 "${mktemp_bin}/mktemp"
|
||||||
|
saved_path=$PATH
|
||||||
|
PATH="${mktemp_bin}:$PATH"
|
||||||
|
for fail_kind in stage strip.new strip.old original; do
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export MOCK_MKTEMP_FAIL_KIND=$fail_kind
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset MOCK_MKTEMP_FAIL_KIND
|
||||||
|
assert_failure "$status" "mktemp ${fail_kind} отклонён"
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" "source сохранён при mktemp ${fail_kind}"
|
||||||
|
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' -type f -o -type l | wc -l)" "нет temp после mktemp ${fail_kind}"
|
||||||
|
done
|
||||||
|
PATH=$saved_path
|
||||||
|
begin_test 'each required transaction dependency fails before mutation'
|
||||||
|
local dependency_path missing_dependency dependency_status
|
||||||
|
dependency_path="${TEST_ROOT}/dependency-path"
|
||||||
|
mkdir -p "$dependency_path"
|
||||||
|
for dependency_command in mktemp stat mkdir chmod rm cp mv date; do ln -s -- "$(PATH=/usr/bin:/bin type -P "$dependency_command")" "$dependency_path/$dependency_command"; done
|
||||||
|
for missing_dependency in flock wg wg-quick; do
|
||||||
|
rm -f -- "$dependency_path/flock" "$dependency_path/wg" "$dependency_path/wg-quick"
|
||||||
|
for dependency_command in flock wg wg-quick; do
|
||||||
|
[[ $dependency_command == "$missing_dependency" ]] && continue
|
||||||
|
ln -sf -- "${PROJECT_DIR}/tests/mocks/bin/${dependency_command}" "$dependency_path/$dependency_command"
|
||||||
|
done
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
PATH=$dependency_path
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; dependency_status=$?; set -e
|
||||||
|
PATH=$saved_path
|
||||||
|
assert_equal "$EXIT_DEPENDENCY" "$dependency_status" "отсутствует ${missing_dependency}"
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" "source не меняется без ${missing_dependency}"
|
||||||
|
done
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
rollback)
|
||||||
|
begin_test 'apply failure restores disk and runtime'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset WG_SYNC_FAIL_ONCE_FILE
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply с успешным rollback'
|
||||||
|
assert_equal "$before" "$after" 'байты после rollback'
|
||||||
|
begin_test 'rollback verification failure is critical'
|
||||||
|
local rollback_sequence="${TEST_ROOT}/rollback-sequence" rollback_mismatch
|
||||||
|
mkdir -p "$rollback_sequence"
|
||||||
|
rollback_mismatch="${TEST_ROOT}/rollback-mismatch.dump"
|
||||||
|
sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$rollback_mismatch"
|
||||||
|
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
|
||||||
|
cp -- "$rollback_mismatch" "${rollback_sequence}/2"
|
||||||
|
: >"$TEST_MOCK_LOG"
|
||||||
|
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence"
|
||||||
|
unset WG_MOCK_DUMP_FILE
|
||||||
|
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
|
||||||
|
unset WG_MOCK_DUMP_SEQUENCE_DIR
|
||||||
|
export WG_MOCK_DUMP_FILE="$runtime_file"
|
||||||
|
assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка проверки rollback'
|
||||||
|
assert_contains 'КРИТИЧНО' "$output" 'critical rollback verification'
|
||||||
|
assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" 'два dump при rollback'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'диск восстановлен при ошибке проверки rollback'
|
||||||
|
begin_test 'forward atomic replace failure preserves disk and skips runtime'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export TRANSACTION_ATOMIC_REPLACE_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_ATOMIC_REPLACE_FAIL
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка atomic replace'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'source после atomic replace'
|
||||||
|
begin_test 'syncconf failure restores disk and runtime'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once-2"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset WG_SYNC_FAIL_ONCE_FILE
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка syncconf с rollback'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'source после syncconf'
|
||||||
|
begin_test 'forward verification command failure rolls back'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_MOCK_DUMP_FAIL_ON_CALL=1 WG_MOCK_DUMP_FAIL_COUNTER_FILE="${TEST_ROOT}/dump-fail-counter"
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_COUNTER_FILE
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка команды verification'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'source после verification command'
|
||||||
|
begin_test 'disk rollback replacement failure is critical'
|
||||||
|
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-disk"
|
||||||
|
export TRANSACTION_ROLLBACK_REPLACE_FAIL=1
|
||||||
|
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
|
||||||
|
unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL
|
||||||
|
assert_equal "$EXIT_ROLLBACK" "$status" 'disk rollback failure status'
|
||||||
|
assert_contains 'КРИТИЧНО' "$output" 'disk rollback critical'
|
||||||
|
begin_test 'runtime rollback syncconf failure is critical'
|
||||||
|
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
|
||||||
|
cp -- "$runtime_file" "${rollback_sequence}/2"
|
||||||
|
rm -f -- "${rollback_sequence}/counter"
|
||||||
|
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence"
|
||||||
|
export WG_SYNC_FAIL_ON_CALL=2 WG_SYNC_FAIL_COUNTER_FILE="${TEST_ROOT}/sync-fail-counter"
|
||||||
|
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
|
||||||
|
unset WG_SYNC_FAIL_ON_CALL WG_SYNC_FAIL_COUNTER_FILE WG_MOCK_DUMP_SEQUENCE_DIR
|
||||||
|
export WG_MOCK_DUMP_FILE="$runtime_file"
|
||||||
|
assert_equal "$EXIT_ROLLBACK" "$status" 'runtime rollback failure status'
|
||||||
|
assert_contains 'КРИТИЧНО' "$output" 'runtime rollback critical'
|
||||||
|
begin_test 'rollback verification command failure is critical'
|
||||||
|
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
|
||||||
|
rm -f -- "${rollback_sequence}/2"
|
||||||
|
rm -f -- "${rollback_sequence}/counter"
|
||||||
|
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence"
|
||||||
|
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
|
||||||
|
unset WG_MOCK_DUMP_SEQUENCE_DIR
|
||||||
|
export WG_MOCK_DUMP_FILE="$runtime_file"
|
||||||
|
assert_equal "$EXIT_ROLLBACK" "$status" 'rollback verification command status'
|
||||||
|
assert_contains 'КРИТИЧНО' "$output" 'rollback verification command critical'
|
||||||
|
begin_test 'forward runtime dump variants roll back safely'
|
||||||
|
local variant_name variant_file variant_status variant_before variant_attrs
|
||||||
|
declare -a variant_names=(malformed missing extra duplicate allowed-mismatch listen-mismatch)
|
||||||
|
for variant_name in "${variant_names[@]}"; do
|
||||||
|
variant_file="${rollback_sequence}/${variant_name}.dump"
|
||||||
|
case $variant_name in
|
||||||
|
malformed) printf 'malformed\n' >"$variant_file" ;;
|
||||||
|
missing) head -n 1 "$runtime_file" >"$variant_file" ;;
|
||||||
|
extra) { cat "$runtime_file"; printf '%s\t(none)\t(none)\t192.0.2.3/32\t0\t0\t0\t0\n' CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=; } >"$variant_file" ;;
|
||||||
|
duplicate) { cat "$runtime_file"; tail -n 1 "$runtime_file"; } >"$variant_file" ;;
|
||||||
|
allowed-mismatch) sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$variant_file" ;;
|
||||||
|
listen-mismatch) sed '1s/51820/51821/' "$runtime_file" >"$variant_file" ;;
|
||||||
|
esac
|
||||||
|
rm -f -- "${rollback_sequence}/1" "${rollback_sequence}/2" "${rollback_sequence}/counter"
|
||||||
|
cp -- "$variant_file" "${rollback_sequence}/1"
|
||||||
|
cp -- "$runtime_file" "${rollback_sequence}/2"
|
||||||
|
: >"$TEST_MOCK_LOG"
|
||||||
|
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" WG_MOCK_DUMP_FILE="$runtime_file"
|
||||||
|
variant_before=$(sha256sum "$fixture"); variant_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; variant_status=$?; set -e
|
||||||
|
assert_equal "$EXIT_OPERATION" "$variant_status" "variant ${variant_name} status"
|
||||||
|
assert_equal "$variant_before" "$(sha256sum "$fixture")" "variant ${variant_name} bytes"
|
||||||
|
assert_equal "$variant_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "variant ${variant_name} attrs"
|
||||||
|
assert_equal 2 "$(grep -c '^wg syncconf wg0 ' "$TEST_MOCK_LOG" || true)" "variant ${variant_name} syncconf count"
|
||||||
|
assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" "variant ${variant_name} dump count"
|
||||||
|
assert_equal 2 "$(<"${rollback_sequence}/counter")" "variant ${variant_name} sequence count"
|
||||||
|
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" "variant ${variant_name} temp cleanup"
|
||||||
|
done
|
||||||
|
unset WG_MOCK_DUMP_SEQUENCE_DIR
|
||||||
|
begin_test 'malformed rollback verification is critical'
|
||||||
|
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
|
||||||
|
printf 'malformed\n' >"${rollback_sequence}/2"
|
||||||
|
rm -f -- "${rollback_sequence}/counter"
|
||||||
|
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" WG_MOCK_DUMP_FILE="$runtime_file"
|
||||||
|
variant_before=$(sha256sum "$fixture"); variant_attrs=$(stat -c '%a:%u:%g' "$fixture")
|
||||||
|
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
|
||||||
|
unset WG_MOCK_DUMP_SEQUENCE_DIR
|
||||||
|
assert_equal "$EXIT_ROLLBACK" "$status" 'malformed rollback status'
|
||||||
|
assert_contains 'КРИТИЧНО' "$output" 'malformed rollback critical'
|
||||||
|
assert_equal "$variant_before" "$(sha256sum "$fixture")" 'malformed rollback bytes'
|
||||||
|
assert_equal "$variant_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'malformed rollback attrs'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
secret-modes)
|
||||||
|
begin_test 'backup modes and secret-safe mock log'
|
||||||
|
: >"$TEST_MOCK_LOG"
|
||||||
|
run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" >/dev/null
|
||||||
|
assert_file_mode 700 "$BACKUP_DIR" 'режим backup каталога'
|
||||||
|
assert_file_mode 700 "$(dirname "$TRANSACTION_BACKUP_PATH")" 'режим backup root'
|
||||||
|
assert_file_mode 700 "$TRANSACTION_BACKUP_PATH" 'режим backup subdir'
|
||||||
|
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/wg.conf" 'режим backup config'
|
||||||
|
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/manifest" 'режим manifest'
|
||||||
|
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/alice.meta" 'режим metadata'
|
||||||
|
assert_file_mode 600 "$LOCK_FILE" 'режим lock'
|
||||||
|
assert_file_mode 600 "$fixture" 'режим live config'
|
||||||
|
assert_equal '' "$(grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'секреты не в mock log'
|
||||||
|
begin_test 'temporary transaction files are mode 0600 while active'
|
||||||
|
local mode_script="${TEST_ROOT}/active-mode.sh" mode_pid mode_status mode_ready fixture_dir mode_file mode_class
|
||||||
|
local -a mode_matches=()
|
||||||
|
mkfifo "${TEST_ROOT}/active-mode-ready" "${TEST_ROOT}/active-mode-release"
|
||||||
|
printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'builder() { cp -- "$WG_CONF" "$1"; }' 'run_transaction update active builder' >"$mode_script"
|
||||||
|
chmod 700 "$mode_script"
|
||||||
|
WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" WG_SYNC_READY_FIFO="${TEST_ROOT}/active-mode-ready" WG_SYNC_RELEASE_FIFO="${TEST_ROOT}/active-mode-release" /bin/bash "$mode_script" "$PROJECT_DIR/wg-manager.sh" >"${TEST_ROOT}/active-mode.out" 2>"${TEST_ROOT}/active-mode.err" & mode_pid=$!
|
||||||
|
IFS= read -r -t 5 mode_ready <"${TEST_ROOT}/active-mode-ready"
|
||||||
|
fixture_dir=${WG_CONF%/*}
|
||||||
|
shopt -s nullglob
|
||||||
|
for mode_class in result strip.new strip.old original; do
|
||||||
|
mode_matches=("$fixture_dir"/.wg-manager."$mode_class".*)
|
||||||
|
assert_equal 1 "${#mode_matches[@]}" "ровно один active ${mode_class} temp"
|
||||||
|
mode_file=${mode_matches[0]}
|
||||||
|
assert_equal 600 "$(stat -c '%a' "$mode_file")" "активный temp mode ${mode_file##*/}"
|
||||||
|
assert_equal 0 "$( [[ -f "$mode_file" && ! -L "$mode_file" && "$mode_file" == "$TEST_ROOT"/* ]] && printf 0 || printf 1 )" "активный temp confined ${mode_file##*/}"
|
||||||
|
done
|
||||||
|
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.stage.*' | wc -l)" 'stage отсутствует после rename'
|
||||||
|
assert_file_mode 600 "$fixture" 'live mode while syncconf blocked'
|
||||||
|
printf 'release\n' >"${TEST_ROOT}/active-mode-release"
|
||||||
|
set +e; wait "$mode_pid"; mode_status=$?; set -e
|
||||||
|
assert_equal 0 "$mode_status" 'active mode transaction succeeds'
|
||||||
|
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" 'временные transaction files очищены'
|
||||||
|
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|\[Interface\]|\[Peer\]|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "${TEST_ROOT}/active-mode.out" "${TEST_ROOT}/active-mode.err" "$TEST_MOCK_LOG" || true)" 'active output/logs secret-free'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
begin_test 'successful transaction uses lock, backup, strip, syncconf, and verification'
|
||||||
|
parent_exit=$(trap -p EXIT); parent_int=$(trap -p INT); parent_term=$(trap -p TERM)
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
: >"$TEST_MOCK_LOG"
|
||||||
|
set +e
|
||||||
|
run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_success "$status" 'успешная транзакция'
|
||||||
|
assert_equal 1 "$([[ "$before" != "$after" ]] && printf 1 || printf 0)" 'предложение действительно изменяет конфиг'
|
||||||
|
assert_file_exists "$TRANSACTION_BACKUP_PATH/wg.conf" 'резервная копия конфига'
|
||||||
|
assert_file_exists "$TRANSACTION_BACKUP_PATH/manifest" 'манифест резервной копии'
|
||||||
|
assert_file_exists "$TRANSACTION_BACKUP_PATH/alice.meta" 'копия метаданных'
|
||||||
|
assert_file_mode 700 "$(dirname "$TRANSACTION_BACKUP_PATH")" 'режим каталога резервных копий'
|
||||||
|
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/wg.conf" 'режим резервного конфига'
|
||||||
|
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/manifest" 'режим манифеста'
|
||||||
|
assert_file_mode 600 "$fixture" 'режим live конфига'
|
||||||
|
manifest_content=$(<"$TRANSACTION_BACKUP_PATH/manifest")
|
||||||
|
assert_contains $'INTERFACE=wg0\n' "$manifest_content" 'manifest interface'
|
||||||
|
assert_contains $'TIMESTAMP=' "$manifest_content" 'manifest timestamp'
|
||||||
|
assert_contains $'OPERATION=update\n' "$manifest_content" 'manifest operation'
|
||||||
|
assert_contains $'TARGET=client\n' "$manifest_content" 'manifest target'
|
||||||
|
assert_contains $'SOURCE_MODE=600\n' "$manifest_content" 'manifest source mode'
|
||||||
|
assert_contains $'CONFIG=wg.conf\nMETADATA=alice.meta' "$manifest_content" 'manifest config metadata'
|
||||||
|
assert_equal "$(<"${METADATA_DIR}/alice.meta")" "$(<"$TRANSACTION_BACKUP_PATH/alice.meta")" 'metadata backup bytes'
|
||||||
|
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/alice.meta" 'режим metadata backup'
|
||||||
|
local transaction_log
|
||||||
|
local -a transaction_lines=()
|
||||||
|
transaction_log=$(<"$TEST_MOCK_LOG")
|
||||||
|
assert_contains $'flock -n ' "$transaction_log" 'argv flock'
|
||||||
|
assert_contains $'wg-quick strip ' "$transaction_log" 'argv strip'
|
||||||
|
assert_contains $'wg syncconf wg0 ' "$transaction_log" 'argv syncconf'
|
||||||
|
assert_contains $'wg show wg0 dump' "$transaction_log" 'argv verification dump'
|
||||||
|
mapfile -t transaction_lines <"$TEST_MOCK_LOG"
|
||||||
|
assert_equal 5 "${#transaction_lines[@]}" 'точно пять внешних transaction вызовов'
|
||||||
|
assert_equal 1 "$([[ ${transaction_lines[0]} =~ ^flock\ -n\ [0-9]+$ ]] && printf 1 || printf 0)" 'первый вызов flock'
|
||||||
|
assert_equal 1 "$([[ ${transaction_lines[1]} == 'wg-quick strip '* && ${transaction_lines[2]} == 'wg-quick strip '* ]] && printf 1 || printf 0)" 'два strip подряд'
|
||||||
|
assert_equal 1 "$([[ ${transaction_lines[3]} == 'wg syncconf wg0 '* && ${transaction_lines[4]} == 'wg show wg0 dump' ]] && printf 1 || printf 0)" 'syncconf затем dump'
|
||||||
|
assert_equal 1 "$( [[ ${transaction_lines[1]} =~ ^wg-quick\ strip\ ${TEST_ROOT//./\.}/\.wg-manager\.stage\.[A-Za-z0-9]+$ && ${transaction_lines[2]} == "wg-quick strip ${fixture}" && ${transaction_lines[3]} =~ ^wg\ syncconf\ wg0\ ${TEST_ROOT//./\.}/\.wg-manager\.strip\.new\.[A-Za-z0-9]+$ ]] && printf 1 || printf 0 )" 'точные классы stage/live/syncconf'
|
||||||
|
assert_equal '' "$(grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'mock log без ключей'
|
||||||
|
assert_equal "$parent_exit" "$(trap -p EXIT)" 'EXIT trap восстановлен'
|
||||||
|
assert_equal "$parent_int" "$(trap -p INT)" 'INT trap восстановлен'
|
||||||
|
assert_equal "$parent_term" "$(trap -p TERM)" 'TERM trap восстановлен'
|
||||||
|
begin_test 'run_transaction rejects fewer than three arguments'
|
||||||
|
set +e; run_transaction update client >/dev/null 2>&1; status=$?; set -e
|
||||||
|
assert_equal "$EXIT_USAGE" "$status" 'неполный публичный API'
|
||||||
|
|
||||||
|
begin_test 'legacy generic transaction hooks do not affect normal mode'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
old_test_mode=${WG_MANAGER_TEST_MODE:-0}
|
||||||
|
export WG_MANAGER_TEST_MODE=0 TRANSACTION_METADATA_COPY_FAIL=1 TRANSACTION_BACKUP_FAIL=1 TRANSACTION_ATOMIC_REPLACE_FAIL=1 TRANSACTION_ROLLBACK_REPLACE_FAIL=1
|
||||||
|
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
|
||||||
|
unset TRANSACTION_METADATA_COPY_FAIL TRANSACTION_BACKUP_FAIL TRANSACTION_ATOMIC_REPLACE_FAIL TRANSACTION_ROLLBACK_REPLACE_FAIL
|
||||||
|
export WG_MANAGER_TEST_MODE=$old_test_mode
|
||||||
|
assert_success "$status" 'старые generic hooks не меняют production mode'
|
||||||
|
assert_equal "$before" "$(sha256sum "$fixture")" 'обычная транзакция завершилась без подмены source'
|
||||||
|
|
||||||
|
begin_test 'staging validation failure preserves source'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_QUICK_STRIP_FAIL=1
|
||||||
|
set +e
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
unset WG_QUICK_STRIP_FAIL
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal "$EXIT_VALIDATION" "$status" 'ошибка strip'
|
||||||
|
assert_equal "$before" "$after" 'источник после ошибки strip'
|
||||||
|
|
||||||
|
begin_test 'builder failure cleans staging and preserves source'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
set +e
|
||||||
|
run_transaction update client transaction_builder_fail >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal 77 "$status" 'статус ошибки builder'
|
||||||
|
assert_equal "$before" "$after" 'источник после ошибки builder'
|
||||||
|
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.stage.*' -type f | wc -l)" 'staging очищен'
|
||||||
|
|
||||||
|
begin_test 'builder symlink replacement is rejected'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
set +e
|
||||||
|
run_transaction update client transaction_builder_symlink >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal "$EXIT_VALIDATION" "$status" 'staged symlink отклонён'
|
||||||
|
assert_equal "$before" "$after" 'источник после staged symlink'
|
||||||
|
|
||||||
|
begin_test 'apply failure rolls back disk and runtime'
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once"
|
||||||
|
set +e
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
unset WG_SYNC_FAIL_ONCE_FILE
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply с успешным rollback'
|
||||||
|
assert_equal "$before" "$after" 'источник после rollback apply'
|
||||||
|
|
||||||
|
begin_test 'verification failure rolls back disk and runtime'
|
||||||
|
local sequence_dir="${TEST_ROOT}/dump-sequence" mismatch_dump
|
||||||
|
mkdir -p "$sequence_dir"
|
||||||
|
mismatch_dump="${TEST_ROOT}/mismatch.dump"
|
||||||
|
sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$mismatch_dump"
|
||||||
|
cp -- "$mismatch_dump" "${sequence_dir}/1"
|
||||||
|
cp -- "$runtime_file" "${sequence_dir}/2"
|
||||||
|
before=$(sha256sum "$fixture")
|
||||||
|
export WG_MOCK_DUMP_SEQUENCE_DIR="$sequence_dir"
|
||||||
|
unset WG_MOCK_DUMP_FILE
|
||||||
|
set +e
|
||||||
|
run_transaction update client transaction_builder_copy >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
unset WG_MOCK_DUMP_SEQUENCE_DIR
|
||||||
|
export WG_MOCK_DUMP_FILE="$runtime_file"
|
||||||
|
after=$(sha256sum "$fixture")
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'ошибка verification с успешным rollback'
|
||||||
|
assert_equal "$before" "$after" 'источник после rollback verification'
|
||||||
|
|
||||||
|
begin_test 'lock contention does not enter builder'
|
||||||
|
: >"${TEST_ROOT}/builder-marker"
|
||||||
|
transaction_builder_marker() { : >"${TEST_ROOT}/builder-marker.entered"; return 0; }
|
||||||
|
export MOCK_FLOCK_BUSY=1
|
||||||
|
set +e
|
||||||
|
run_transaction test lock transaction_builder_marker >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
unset MOCK_FLOCK_BUSY
|
||||||
|
assert_equal "$EXIT_OPERATION" "$status" 'lock contention status'
|
||||||
|
assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'builder не вошёл при contention'
|
||||||
|
|
||||||
|
begin_test 'retention keeps newest valid backup and unrelated files'
|
||||||
|
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
|
||||||
|
printf 'unrelated\n' >"${TEST_ROOT}/backups/unrelated.txt"
|
||||||
|
for collision_index in {1..12}; do run_transaction update client transaction_builder_copy >/dev/null; done
|
||||||
|
backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" | wc -l)
|
||||||
|
assert_equal 1 "$backup_count" 'ограничение резервных копий'
|
||||||
|
assert_file_exists "${TEST_ROOT}/backups/unrelated.txt" 'чужой файл сохранён'
|
||||||
|
|
||||||
|
begin_test 'rollback failure is distinct and critical'
|
||||||
|
export WG_SYNC_FAIL=1
|
||||||
|
set +e
|
||||||
|
output=$(run_transaction update client transaction_builder_copy 2>&1)
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
unset WG_SYNC_FAIL
|
||||||
|
assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка rollback'
|
||||||
|
assert_contains 'КРИТИЧНО' "$output" 'critical rollback diagnostic'
|
||||||
|
}
|
||||||
|
|
||||||
run_m2_focused() {
|
run_m2_focused() {
|
||||||
local group=$1 config_file="${TEST_ROOT}/focused-m2.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" output reversed_output status
|
local group=$1 config_file="${TEST_ROOT}/focused-m2.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" output reversed_output status
|
||||||
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
|
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
|
||||||
@@ -978,12 +1607,15 @@ if [[ $test_group == config ]]; then
|
|||||||
run_config_tests
|
run_config_tests
|
||||||
elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then
|
elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then
|
||||||
run_m2_focused "$test_group"
|
run_m2_focused "$test_group"
|
||||||
|
elif [[ $test_group == transaction || $test_group == rollback || $test_group == locking || $test_group == backups || $test_group == mutation-preserves-source || $test_group == secret-modes ]]; then
|
||||||
|
run_m3_tests
|
||||||
else
|
else
|
||||||
run_config_tests
|
run_config_tests
|
||||||
run_gum_tests
|
run_gum_tests
|
||||||
run_dependency_tests
|
run_dependency_tests
|
||||||
run_debug_tests
|
run_debug_tests
|
||||||
run_m2_tests
|
run_m2_tests
|
||||||
|
run_m3_tests
|
||||||
fi
|
fi
|
||||||
|
|
||||||
finish_tests
|
finish_tests
|
||||||
|
|||||||
+372
-2
@@ -247,7 +247,7 @@ validate_config_values() {
|
|||||||
[[ $USE_IPV6 == 0 || $USE_IPV6 == 1 ]] || { die "$EXIT_VALIDATION" 'USE_IPV6 должен быть 0 или 1'; return $?; }
|
[[ $USE_IPV6 == 0 || $USE_IPV6 == 1 ]] || { die "$EXIT_VALIDATION" 'USE_IPV6 должен быть 0 или 1'; return $?; }
|
||||||
validate_ipv6 "$IPV6_PREFIX" || { die "$EXIT_VALIDATION" 'Некорректный IPv6-префикс'; return $?; }
|
validate_ipv6 "$IPV6_PREFIX" || { die "$EXIT_VALIDATION" 'Некорректный IPv6-префикс'; return $?; }
|
||||||
[[ $DEFAULT_CLIENT_MODE == full-tunnel || $DEFAULT_CLIENT_MODE == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; }
|
[[ $DEFAULT_CLIENT_MODE == full-tunnel || $DEFAULT_CLIENT_MODE == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; }
|
||||||
is_uint "$BACKUP_RETENTION" && (( 10#$BACKUP_RETENTION >= 1 && 10#$BACKUP_RETENTION <= 1000 )) || { die "$EXIT_VALIDATION" 'Некорректное число резервных копий'; return $?; }
|
is_uint "$BACKUP_RETENTION" && (( 10#$BACKUP_RETENTION >= 0 && 10#$BACKUP_RETENTION <= 1000 )) || { die "$EXIT_VALIDATION" 'Некорректное число резервных копий'; return $?; }
|
||||||
is_uint "$RECENT_HANDSHAKE_SECONDS" && (( 10#$RECENT_HANDSHAKE_SECONDS <= 604800 )) || { die "$EXIT_VALIDATION" 'Некорректный срок handshake'; return $?; }
|
is_uint "$RECENT_HANDSHAKE_SECONDS" && (( 10#$RECENT_HANDSHAKE_SECONDS <= 604800 )) || { die "$EXIT_VALIDATION" 'Некорректный срок handshake'; return $?; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +256,7 @@ load_config() { set_runtime_paths || return $?; parse_config_file "$CONFIG_PATH"
|
|||||||
readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
|
readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
|
||||||
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
|
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
|
||||||
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
|
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
|
||||||
|
DISK_LISTEN_PORT='-'
|
||||||
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED
|
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED
|
||||||
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
|
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
|
||||||
RUNTIME_AVAILABLE=0
|
RUNTIME_AVAILABLE=0
|
||||||
@@ -392,6 +393,7 @@ parse_wireguard_config() {
|
|||||||
local file=$1 line trimmed section='' key value peer_key='' peer_allowed='' peer_endpoint='-' peer_marker='' peer_legacy='' pending_marker='' pending_legacy='' line_number=0
|
local file=$1 line trimmed section='' key value peer_key='' peer_allowed='' peer_endpoint='-' peer_marker='' peer_legacy='' pending_marker='' pending_legacy='' line_number=0
|
||||||
local -A interface_fields=() peer_fields=()
|
local -A interface_fields=() peer_fields=()
|
||||||
DISK_ALLOWED=(); DISK_ENDPOINT=(); DISK_MARKER=(); DISK_LEGACY=(); DISK_IPV4=(); DISK_IPV6=()
|
DISK_ALLOWED=(); DISK_ENDPOINT=(); DISK_MARKER=(); DISK_LEGACY=(); DISK_IPV4=(); DISK_IPV6=()
|
||||||
|
DISK_LISTEN_PORT='-'
|
||||||
[[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; }
|
[[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; }
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
((line_number += 1)); is_control_free "$line" || { die "$EXIT_VALIDATION" "Управляющий символ в конфигурации WireGuard, строка ${line_number}"; return $?; }
|
((line_number += 1)); is_control_free "$line" || { die "$EXIT_VALIDATION" "Управляющий символ в конфигурации WireGuard, строка ${line_number}"; return $?; }
|
||||||
@@ -428,7 +430,7 @@ parse_wireguard_config() {
|
|||||||
[[ -z ${interface_fields[$key]+x} ]] || { die "$EXIT_VALIDATION" "Дублирующийся параметр Interface: ${key}"; return $?; }
|
[[ -z ${interface_fields[$key]+x} ]] || { die "$EXIT_VALIDATION" "Дублирующийся параметр Interface: ${key}"; return $?; }
|
||||||
interface_fields["$key"]=1
|
interface_fields["$key"]=1
|
||||||
if [[ $key == PrivateKey ]]; then is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный ключ Interface'; return $?; }; fi
|
if [[ $key == PrivateKey ]]; then is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный ключ Interface'; return $?; }; fi
|
||||||
if [[ $key == ListenPort ]]; then is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный ListenPort'; return $?; }; fi
|
if [[ $key == ListenPort ]]; then is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный ListenPort'; return $?; }; DISK_LISTEN_PORT=$value; fi
|
||||||
if [[ $key == FwMark ]]; then is_fwmark "$value" || { die "$EXIT_VALIDATION" 'Некорректный FwMark'; return $?; }; fi
|
if [[ $key == FwMark ]]; then is_fwmark "$value" || { die "$EXIT_VALIDATION" 'Некорректный FwMark'; return $?; }; fi
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -556,6 +558,374 @@ load_client_model() {
|
|||||||
load_config || return $?; parse_wireguard_config "$WG_CONF" || return $?; parse_metadata || return $?; load_runtime_state || return $?; build_client_records
|
load_config || return $?; parse_wireguard_config "$WG_CONF" || return $?; parse_metadata || return $?; load_runtime_state || return $?; build_client_records
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Internal contract: run_transaction OPERATION TARGET BUILDER [METADATA_FILE...]. BUILDER is
|
||||||
|
# called only after the lock and a fresh config/metadata read, with one staging
|
||||||
|
# path. It must write the complete proposed config and return nonzero to abort.
|
||||||
|
TRANSACTION_TEMP_PATHS=()
|
||||||
|
TRANSACTION_BACKUP_PATH=''
|
||||||
|
|
||||||
|
transaction_test_hooks_enabled() {
|
||||||
|
[[ ${WG_MANAGER_TEST_MODE:-0} == 1 && -n ${TEST_ROOT:-} && $TEST_ROOT == /* ]] || return 1
|
||||||
|
is_control_free "$TEST_ROOT" && path_components_safe "$TEST_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_cleanup() {
|
||||||
|
local incoming_status=$? path
|
||||||
|
for path in "${TRANSACTION_TEMP_PATHS[@]:-}"; do
|
||||||
|
if [[ -n "$path" && ( -e "$path" || -L "$path" ) ]]; then
|
||||||
|
if ! rm -f -- "$path"; then
|
||||||
|
printf 'Предупреждение: не удалось удалить временный файл\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
TRANSACTION_TEMP_PATHS=()
|
||||||
|
return "$incoming_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_signal() {
|
||||||
|
local signal=$1
|
||||||
|
transaction_cleanup
|
||||||
|
printf 'Ошибка: операция прервана сигналом %s\n' "$signal" >&2
|
||||||
|
exit "$EXIT_OPERATION"
|
||||||
|
}
|
||||||
|
|
||||||
|
TRANSACTION_CREATED_TEMP=''
|
||||||
|
create_transaction_temp() {
|
||||||
|
local kind=$1 path
|
||||||
|
TRANSACTION_CREATED_TEMP=''
|
||||||
|
path=$(mktemp "${WG_CONF%/*}/.wg-manager.${kind}.XXXXXX") || return $?
|
||||||
|
if [[ ! -f "$path" || -L "$path" ]] || ! chmod 600 -- "$path"; then
|
||||||
|
rm -f -- "$path"
|
||||||
|
return "$EXIT_OPERATION"
|
||||||
|
fi
|
||||||
|
TRANSACTION_TEMP_PATHS+=("$path")
|
||||||
|
TRANSACTION_CREATED_TEMP=$path
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_stat() {
|
||||||
|
local path=$1 field=$2
|
||||||
|
case "$field" in
|
||||||
|
mode) stat -c '%a' -- "$path" ;;
|
||||||
|
uid) stat -c '%u' -- "$path" ;;
|
||||||
|
gid) stat -c '%g' -- "$path" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_restricted() {
|
||||||
|
local source=$1 destination=$2
|
||||||
|
if [[ ${TRANSACTION_METADATA_COPY_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled && [[ $destination == "${BACKUP_DIR:-}"/* && ${destination##*/} != wg.conf ]]; then return 1; fi
|
||||||
|
[[ -f "$source" && ! -L "$source" ]] || return 1
|
||||||
|
cp -- "$source" "$destination" && chmod 600 -- "$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_file_metadata() {
|
||||||
|
local path=$1 mode=$2 uid=$3 gid=$4
|
||||||
|
chmod "$mode" -- "$path" || return 1
|
||||||
|
if (( EUID == 0 )); then
|
||||||
|
chown "$uid:$gid" -- "$path" || return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_backup() {
|
||||||
|
local source=$1 operation=$2 target=$3 timestamp counter backup allocated_backup='' manifest metadata mode uid gid now metadata_name backup_name backup_prefix backup_counter
|
||||||
|
local -A metadata_seen=()
|
||||||
|
if [[ ${TRANSACTION_BACKUP_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then die "$EXIT_OPERATION" 'Не удалось создать резервную копию'; return $?; fi
|
||||||
|
[[ -f "$source" && ! -L "$source" ]] || { die "$EXIT_OPERATION" 'Исходный конфиг недоступен для резервного копирования'; return $?; }
|
||||||
|
[[ ! -L "$BACKUP_DIR" ]] || { die "$EXIT_OPERATION" 'Каталог резервных копий является ссылкой'; return $?; }
|
||||||
|
if ! mkdir -p -- "$BACKUP_DIR" || ! chmod 700 -- "$BACKUP_DIR"; then die "$EXIT_OPERATION" 'Не удалось подготовить каталог резервных копий'; return $?; fi
|
||||||
|
now=$(current_epoch) || { die "$EXIT_OPERATION" 'Не удалось получить время для резервной копии'; return $?; }
|
||||||
|
timestamp=$(date -u -d "@${now}" '+%Y%m%dT%H%M%SZ') || { die "$EXIT_OPERATION" 'Не удалось получить UTC-время для резервной копии'; return $?; }
|
||||||
|
mode=$(transaction_stat "$source" mode) || return "$EXIT_OPERATION"
|
||||||
|
uid=$(transaction_stat "$source" uid) || return "$EXIT_OPERATION"
|
||||||
|
gid=$(transaction_stat "$source" gid) || return "$EXIT_OPERATION"
|
||||||
|
for metadata in "${@:4}"; do
|
||||||
|
[[ -f "$metadata" && ! -L "$metadata" && "$metadata" == "${METADATA_DIR%/}"/* && ${metadata%/*} == "${METADATA_DIR%/}" && "$metadata" != *'/../'* && "$metadata" != */.. ]] || { die "$EXIT_OPERATION" 'Некорректный файл метаданных для резервной копии'; return $?; }
|
||||||
|
path_components_safe "$metadata" || { die "$EXIT_OPERATION" 'Некорректный путь метаданных для резервной копии'; return $?; }
|
||||||
|
metadata_name=${metadata##*/}
|
||||||
|
[[ $metadata_name != '' && $metadata_name != wg.conf && $metadata_name != manifest && $metadata_name != */* && $metadata_name =~ ^[A-Za-z0-9._-]+$ && -z ${metadata_seen[$metadata_name]+x} ]] || { die "$EXIT_OPERATION" 'Некорректное имя метаданных для резервной копии'; return $?; }
|
||||||
|
metadata_seen["$metadata_name"]=1
|
||||||
|
done
|
||||||
|
counter=0
|
||||||
|
for backup in "$BACKUP_DIR/${timestamp}-${WG_INTERFACE}-"*; do
|
||||||
|
[[ -d "$backup" && ! -L "$backup" ]] || continue
|
||||||
|
backup_name=${backup##*/}
|
||||||
|
backup_prefix="${timestamp}-${WG_INTERFACE}-"
|
||||||
|
[[ $backup_name == "$backup_prefix"* ]] || continue
|
||||||
|
backup_counter=${backup_name#"$backup_prefix"}
|
||||||
|
[[ $backup_counter =~ ^[0-9]{3}$ ]] || continue
|
||||||
|
if (( 10#$backup_counter >= counter )); then counter=$((10#$backup_counter + 1)); fi
|
||||||
|
done
|
||||||
|
for ((counter = counter; counter <= 999; counter++)); do
|
||||||
|
backup=$(printf '%s/%s-%s-%03d' "$BACKUP_DIR" "$timestamp" "$WG_INTERFACE" "$counter")
|
||||||
|
if mkdir -- "$backup" 2>/dev/null; then allocated_backup=$backup; break; fi
|
||||||
|
done
|
||||||
|
[[ -n "$allocated_backup" ]] || { die "$EXIT_OPERATION" 'Не удалось создать уникальную резервную копию'; return $?; }
|
||||||
|
backup=$allocated_backup
|
||||||
|
if { [[ ${TRANSACTION_BACKUP_DIR_CHMOD_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! chmod 700 -- "$backup"; then
|
||||||
|
rm -rf -- "$backup"
|
||||||
|
die "$EXIT_OPERATION" 'Не удалось защитить резервную копию'
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
copy_restricted "$source" "${backup}/wg.conf" || { rm -rf -- "$backup"; die "$EXIT_OPERATION" 'Не удалось сохранить конфиг в резервную копию'; return $?; }
|
||||||
|
manifest=${backup}/manifest
|
||||||
|
if { [[ ${TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! {
|
||||||
|
printf 'INTERFACE=%s\n' "$WG_INTERFACE"
|
||||||
|
printf 'TIMESTAMP=%s\nOPERATION=%s\nTARGET=%s\n' "$timestamp" "$operation" "$target"
|
||||||
|
printf 'SOURCE_MODE=%s\nSOURCE_UID=%s\nSOURCE_GID=%s\n' "$mode" "$uid" "$gid"
|
||||||
|
printf 'CONFIG=wg.conf\n'
|
||||||
|
for metadata in "${@:4}"; do
|
||||||
|
metadata_name=${metadata##*/}
|
||||||
|
copy_restricted "$metadata" "${backup}/${metadata_name}" || { rm -rf -- "$backup"; die "$EXIT_OPERATION" 'Не удалось сохранить метаданные в резервную копию'; return $?; }
|
||||||
|
printf 'METADATA=%s\n' "$metadata_name"
|
||||||
|
done
|
||||||
|
} >"$manifest"; then rm -rf -- "$backup"; die "$EXIT_OPERATION" 'Не удалось записать манифест резервной копии'; return $?; fi
|
||||||
|
if { [[ ${TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! chmod 600 -- "$manifest"; then
|
||||||
|
rm -rf -- "$backup"
|
||||||
|
die "$EXIT_OPERATION" 'Не удалось защитить манифест резервной копии'
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
TRANSACTION_BACKUP_PATH=$backup
|
||||||
|
printf '%s\n' "$backup"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_backup_manifest() {
|
||||||
|
local backup=$1 line key value manifest_interface='' manifest_timestamp='' manifest_operation='' manifest_target='' manifest_config='' metadata_name backup_name backup_prefix backup_counter
|
||||||
|
local -A seen=() metadata_seen=()
|
||||||
|
[[ -d "$backup" && ! -L "$backup" && -f "$backup/manifest" && ! -L "$backup/manifest" && -f "$backup/wg.conf" && ! -L "$backup/wg.conf" ]] || return 1
|
||||||
|
[[ $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%a' -- "$backup/manifest") == 600 && $(stat -c '%a' -- "$backup/wg.conf") == 600 ]] || return 1
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
[[ $line == *=* ]] || return 1
|
||||||
|
key=${line%%=*}; value=${line#*=}
|
||||||
|
case $key in
|
||||||
|
INTERFACE|TIMESTAMP|OPERATION|TARGET|SOURCE_MODE|SOURCE_UID|SOURCE_GID|CONFIG) ;;
|
||||||
|
METADATA) metadata_name=$value; [[ $metadata_name =~ ^[A-Za-z0-9._-]+$ && $metadata_name != wg.conf && $metadata_name != manifest && -z ${metadata_seen[$metadata_name]+x} ]] || return 1; metadata_seen[$metadata_name]=1; continue ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[[ -z ${seen[$key]+x} ]] || return 1
|
||||||
|
seen[$key]=1
|
||||||
|
case $key in
|
||||||
|
INTERFACE) manifest_interface=$value ;;
|
||||||
|
TIMESTAMP) manifest_timestamp=$value ;;
|
||||||
|
OPERATION) manifest_operation=$value ;;
|
||||||
|
TARGET) manifest_target=$value ;;
|
||||||
|
SOURCE_MODE) [[ $value == 400 || $value == 600 ]] || return 1 ;;
|
||||||
|
SOURCE_UID|SOURCE_GID) [[ $value =~ ^[0-9]+$ ]] || return 1 ;;
|
||||||
|
CONFIG) manifest_config=$value ;;
|
||||||
|
esac
|
||||||
|
done <"$backup/manifest"
|
||||||
|
[[ ${seen[INTERFACE]+x} && ${seen[TIMESTAMP]+x} && ${seen[OPERATION]+x} && ${seen[TARGET]+x} && ${seen[SOURCE_MODE]+x} && ${seen[SOURCE_UID]+x} && ${seen[SOURCE_GID]+x} && ${seen[CONFIG]+x} ]] || return 1
|
||||||
|
[[ $manifest_interface == "$WG_INTERFACE" && $manifest_timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $manifest_operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $manifest_target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ && $manifest_config == wg.conf ]] || return 1
|
||||||
|
backup_name=${backup##*/}
|
||||||
|
backup_prefix="${manifest_timestamp}-${WG_INTERFACE}-"
|
||||||
|
[[ $backup_name == "$backup_prefix"* ]] || return 1
|
||||||
|
backup_counter=${backup_name#"$backup_prefix"}
|
||||||
|
[[ $backup_counter =~ ^[0-9]{3}$ ]] || return 1
|
||||||
|
for metadata_name in "${!metadata_seen[@]}"; do
|
||||||
|
[[ -f "$backup/$metadata_name" && ! -L "$backup/$metadata_name" && $(stat -c '%a' -- "$backup/$metadata_name") == 600 ]] || return 1
|
||||||
|
done
|
||||||
|
for line in "$backup"/*; do
|
||||||
|
[[ -e "$line" || -L "$line" ]] || continue
|
||||||
|
metadata_name=${line##*/}
|
||||||
|
case $metadata_name in
|
||||||
|
manifest|wg.conf) ;;
|
||||||
|
*) [[ -n ${metadata_seen[$metadata_name]+x} ]] || return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
(parse_wireguard_config "$backup/wg.conf") >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_backups() {
|
||||||
|
local backup candidate count=0 retention=$BACKUP_RETENTION
|
||||||
|
local -a candidates=()
|
||||||
|
(( retention < 1 )) && retention=1
|
||||||
|
for backup in "${BACKUP_DIR}"/*-"${WG_INTERFACE}"-*; do
|
||||||
|
[[ -d "$backup" && ! -L "$backup" && -f "$backup/manifest" && ! -L "$backup/manifest" && -f "$backup/wg.conf" && ! -L "$backup/wg.conf" ]] || continue
|
||||||
|
validate_backup_manifest "$backup" || continue
|
||||||
|
candidates+=("$backup")
|
||||||
|
done
|
||||||
|
((${#candidates[@]} > 1)) || return 0
|
||||||
|
while IFS= read -r candidate; do candidates[count]=$candidate; ((count += 1)); done < <(printf '%s\n' "${candidates[@]}" | LC_ALL=C sort -r)
|
||||||
|
count=0
|
||||||
|
for backup in "${candidates[@]}"; do
|
||||||
|
((count += 1))
|
||||||
|
(( count <= retention )) && continue
|
||||||
|
[[ "$backup" == "${BACKUP_DIR}"/*-"${WG_INTERFACE}"-* ]] || continue
|
||||||
|
rm -rf -- "$backup"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_expected_dump() {
|
||||||
|
local expected_config=$1 dump=$2 key
|
||||||
|
local -A expected=() actual=()
|
||||||
|
parse_runtime_dump "$dump" || return $?
|
||||||
|
parse_wireguard_config "$expected_config" || return $?
|
||||||
|
[[ $DISK_LISTEN_PORT == '-' || $RUNTIME_LISTEN_PORT == "$DISK_LISTEN_PORT" ]] || return 1
|
||||||
|
for key in "${!DISK_ALLOWED[@]}"; do expected["$key"]=${DISK_ALLOWED[$key]}; done
|
||||||
|
for key in "${!RUNTIME_ALLOWED[@]}"; do actual["$key"]=${RUNTIME_ALLOWED[$key]}; done
|
||||||
|
for key in "${!expected[@]}"; do [[ ${actual[$key]:-} == "${expected[$key]}" ]] || return 1; done
|
||||||
|
((${#expected[@]} == ${#actual[@]}))
|
||||||
|
}
|
||||||
|
|
||||||
|
run_syncconf() {
|
||||||
|
local interface=$1 stripped=$2
|
||||||
|
wg syncconf "$interface" "$stripped"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_transaction_commands() {
|
||||||
|
local command_name
|
||||||
|
for command_name in flock wg wg-quick; do
|
||||||
|
command -v "$command_name" >/dev/null 2>&1 || { die "$EXIT_DEPENDENCY" "Не найдена обязательная команда: ${command_name}"; return $?; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
path_components_safe() {
|
||||||
|
local path=$1 component current='/'
|
||||||
|
[[ $path == /* && $path != *'//' && $path != */./* && $path != */../* && $path != */.. && $path != */. ]] || return 1
|
||||||
|
is_control_free "$path" || return 1
|
||||||
|
local -a components=()
|
||||||
|
IFS=/ read -r -a components <<<"${path#/}"
|
||||||
|
for component in "${components[@]}"; do
|
||||||
|
[[ -n $component ]] || continue
|
||||||
|
current+="${component}/"
|
||||||
|
[[ ! -L ${current%/} ]] || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction_subshell_exit() {
|
||||||
|
local status=$?
|
||||||
|
transaction_cleanup
|
||||||
|
if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then
|
||||||
|
printf 'STATUS=%s\nBACKUP=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" >"$TRANSACTION_RESULT_FILE"
|
||||||
|
if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then
|
||||||
|
printf 'Предупреждение: не удалось защитить transaction result\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_transaction_core() {
|
||||||
|
local operation=$1 target=$2 builder=$3 metadata lock_parent lock_fd status=0 rollback_status stage new_stripped old_stripped original_copy prior_dump current_dump
|
||||||
|
local source_mode source_uid source_gid backup_path
|
||||||
|
local transaction_path
|
||||||
|
shift 3
|
||||||
|
TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''
|
||||||
|
[[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
|
||||||
|
is_control_free "$operation" && is_control_free "$target" || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
|
||||||
|
[[ $builder =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный builder транзакции'; return $?; }
|
||||||
|
declare -F "$builder" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Builder транзакции не является функцией'; return $?; }
|
||||||
|
set_runtime_paths || return $?
|
||||||
|
for transaction_path in "$WG_CONF" "$LOCK_FILE" "$BACKUP_DIR" "$METADATA_DIR"; do
|
||||||
|
path_components_safe "$transaction_path" || { die "$EXIT_VALIDATION" 'Путь транзакции содержит символическую ссылку'; return $?; }
|
||||||
|
done
|
||||||
|
require_transaction_commands || return $?
|
||||||
|
lock_parent=${LOCK_FILE%/*}; [[ "$lock_parent" != "$LOCK_FILE" ]] && mkdir -p -- "$lock_parent" && chmod 700 -- "$lock_parent"
|
||||||
|
[[ ! -L "$LOCK_FILE" ]] || { die "$EXIT_VALIDATION" 'Lock-файл является ссылкой'; return $?; }
|
||||||
|
exec {lock_fd}>"$LOCK_FILE" || { die "$EXIT_OPERATION" 'Не удалось открыть lock-файл'; return $?; }
|
||||||
|
chmod 600 -- "$LOCK_FILE" || { exec {lock_fd}>&-; die "$EXIT_OPERATION" 'Не удалось защитить lock-файл'; return $?; }
|
||||||
|
if ! flock -n "$lock_fd"; then
|
||||||
|
exec {lock_fd}>&-
|
||||||
|
die "$EXIT_OPERATION" 'Другая операция уже выполняется'
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
trap 'transaction_subshell_exit' EXIT
|
||||||
|
trap 'transaction_signal INT' INT
|
||||||
|
trap 'transaction_signal TERM' TERM
|
||||||
|
load_config || { status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
[[ $LOCK_FILE == "${TRANSACTION_PRE_LOCK_FILE:-$LOCK_FILE}" && $WG_CONF == "${TRANSACTION_PRE_WG_CONF:-$WG_CONF}" && $BACKUP_DIR == "${TRANSACTION_PRE_BACKUP_DIR:-$BACKUP_DIR}" && $METADATA_DIR == "${TRANSACTION_PRE_METADATA_DIR:-$METADATA_DIR}" && $WG_INTERFACE == "${TRANSACTION_PRE_INTERFACE:-$WG_INTERFACE}" ]] || { die "$EXIT_VALIDATION" 'Конфигурация ресурсов изменилась после lock'; status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
parse_wireguard_config "$WG_CONF" || { status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
parse_metadata || { status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
source_mode=$(transaction_stat "$WG_CONF" mode) || status=$?
|
||||||
|
source_uid=$(transaction_stat "$WG_CONF" uid) || status=$?
|
||||||
|
source_gid=$(transaction_stat "$WG_CONF" gid) || status=$?
|
||||||
|
[[ $status == 0 && ( $source_mode == 400 || $source_mode == 600 ) ]] || status=$EXIT_VALIDATION
|
||||||
|
stage=''
|
||||||
|
if ((status == 0)); then
|
||||||
|
create_transaction_temp stage || status=$?
|
||||||
|
stage=$TRANSACTION_CREATED_TEMP
|
||||||
|
fi
|
||||||
|
[[ $status == 0 ]] || { transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
export TRANSACTION_IN_CRITICAL=1
|
||||||
|
if "$builder" "$stage"; then :; else status=$?; fi
|
||||||
|
unset TRANSACTION_IN_CRITICAL
|
||||||
|
if ((status != 0)); then transaction_cleanup; exec {lock_fd}>&-; return "$status"; fi
|
||||||
|
[[ -s "$stage" && -f "$stage" && ! -L "$stage" ]] || { die "$EXIT_VALIDATION" 'Builder не создал безопасный staged config'; status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
create_transaction_temp strip.new || status=$?; new_stripped=$TRANSACTION_CREATED_TEMP
|
||||||
|
if ((status == 0)); then create_transaction_temp strip.old || status=$?; old_stripped=$TRANSACTION_CREATED_TEMP; fi
|
||||||
|
if ((status == 0)); then create_transaction_temp original || status=$?; original_copy=$TRANSACTION_CREATED_TEMP; fi
|
||||||
|
((status == 0)) || { transaction_cleanup; exec {lock_fd}>&-; return "$status"; }
|
||||||
|
if ! wg-quick strip "$stage" >"$new_stripped"; then status=$EXIT_VALIDATION; fi
|
||||||
|
if ((status == 0)) && ! wg-quick strip "$WG_CONF" >"$old_stripped"; then status=$EXIT_OPERATION; fi
|
||||||
|
if ((status == 0)) && ! copy_restricted "$WG_CONF" "$original_copy"; then status=$EXIT_OPERATION; fi
|
||||||
|
if ((status == 0)); then
|
||||||
|
backup_path=$(create_backup "$WG_CONF" "$operation" "$target" "$@") || status=$?
|
||||||
|
TRANSACTION_BACKUP_PATH=${backup_path:-}
|
||||||
|
if [[ $status == 0 ]]; then
|
||||||
|
if [[ ${TRANSACTION_ATOMIC_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then status=$EXIT_OPERATION
|
||||||
|
elif ! chmod 600 -- "$stage" || ! mv -- "$stage" "$WG_CONF"; then status=$EXIT_OPERATION; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if ((status == 0)); then
|
||||||
|
restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid" || status=$EXIT_OPERATION
|
||||||
|
fi
|
||||||
|
if ((status == 0)) && ! run_syncconf "$WG_INTERFACE" "$new_stripped"; then status=$EXIT_OPERATION; fi
|
||||||
|
if ((status == 0)); then
|
||||||
|
current_dump=$(wg show "$WG_INTERFACE" dump 2>/dev/null) || status=$EXIT_OPERATION
|
||||||
|
[[ $status == 0 ]] && verify_expected_dump "$WG_CONF" "$current_dump" || status=$EXIT_OPERATION
|
||||||
|
fi
|
||||||
|
if ((status != 0)) && [[ -n ${backup_path:-} ]]; then
|
||||||
|
if ! cp -- "$original_copy" "$stage" || ! chmod 600 -- "$stage" || { [[ ${TRANSACTION_ROLLBACK_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! mv -- "$stage" "$WG_CONF" || ! restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid"; then
|
||||||
|
printf 'КРИТИЧНО: откат диска не выполнен\n' >&2; status=$EXIT_ROLLBACK
|
||||||
|
elif ! run_syncconf "$WG_INTERFACE" "$old_stripped"; then
|
||||||
|
printf 'КРИТИЧНО: откат runtime не выполнен\n' >&2; status=$EXIT_ROLLBACK
|
||||||
|
else
|
||||||
|
rollback_status=0
|
||||||
|
prior_dump=$(wg show "$WG_INTERFACE" dump 2>/dev/null) || rollback_status=$EXIT_ROLLBACK
|
||||||
|
if ((rollback_status == 0)) && ! verify_expected_dump "$WG_CONF" "$prior_dump"; then rollback_status=$EXIT_ROLLBACK; fi
|
||||||
|
if ((rollback_status != 0)); then printf 'КРИТИЧНО: проверка отката не выполнена\n' >&2; status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then
|
||||||
|
if ! prune_backups; then
|
||||||
|
printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
transaction_cleanup; exec {lock_fd}>&-; return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_transaction() {
|
||||||
|
if (($# < 3)); then
|
||||||
|
die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции'
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
local operation=$1 target=$2 builder=$3 result_file result_status result_backup had_errexit=0
|
||||||
|
shift 3
|
||||||
|
[[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; }
|
||||||
|
set_runtime_paths || return $?
|
||||||
|
load_config || return $?
|
||||||
|
result_file=$(mktemp "${WG_CONF%/*}/.wg-manager.result.XXXXXX") || { die "$EXIT_OPERATION" 'Не удалось создать transaction result'; return $?; }
|
||||||
|
if ! chmod 600 -- "$result_file"; then
|
||||||
|
rm -f -- "$result_file"
|
||||||
|
die "$EXIT_OPERATION" 'Не удалось защитить transaction result'
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
export TRANSACTION_RESULT_FILE=$result_file TRANSACTION_PRE_LOCK_FILE=$LOCK_FILE TRANSACTION_PRE_WG_CONF=$WG_CONF TRANSACTION_PRE_BACKUP_DIR=$BACKUP_DIR TRANSACTION_PRE_METADATA_DIR=$METADATA_DIR TRANSACTION_PRE_INTERFACE=$WG_INTERFACE
|
||||||
|
[[ $- == *e* ]] && had_errexit=1
|
||||||
|
set +e
|
||||||
|
( trap - EXIT INT TERM; trap 'transaction_subshell_exit' EXIT; trap 'transaction_signal INT' INT; trap 'transaction_signal TERM' TERM; run_transaction_core "$operation" "$target" "$builder" "$@" )
|
||||||
|
result_status=$?
|
||||||
|
(( had_errexit == 1 )) && set -e || set +e
|
||||||
|
if [[ -r $result_file ]]; then
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; esac
|
||||||
|
done <"$result_file"
|
||||||
|
fi
|
||||||
|
rm -f -- "$result_file"
|
||||||
|
TRANSACTION_BACKUP_PATH=${result_backup:-}
|
||||||
|
unset TRANSACTION_RESULT_FILE TRANSACTION_PRE_LOCK_FILE TRANSACTION_PRE_WG_CONF TRANSACTION_PRE_BACKUP_DIR TRANSACTION_PRE_METADATA_DIR TRANSACTION_PRE_INTERFACE
|
||||||
|
return "$result_status"
|
||||||
|
}
|
||||||
|
|
||||||
print_status() {
|
print_status() {
|
||||||
local managed=0 unmanaged=0 active=0 index
|
local managed=0 unmanaged=0 active=0 index
|
||||||
for index in "${!CLIENT_KEYS[@]}"; do
|
for index in "${!CLIENT_KEYS[@]}"; do
|
||||||
|
|||||||
Reference in New Issue
Block a user