Compare commits

..

3 Commits

11 changed files with 1909 additions and 32 deletions
+45 -3
View File
@@ -266,9 +266,9 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
| M4 | DONE | Safe client creation |
| M5 | DONE | Client detail, config export, and QR display |
| M6 | NOT STARTED | Safe client removal and archival |
| M7 | NOT STARTED | Diagnostics engine and report |
| M8 | NOT STARTED | Backup listing/restoration and audit log UX |
| M6 | DONE | Safe client removal and archival |
| M7 | DONE | Diagnostics engine and report |
| M8 | DONE | Backup listing/restoration and audit log UX |
| M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens |
| M10 | NOT STARTED | Complete TUI mutation workflows |
| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance |
@@ -1635,6 +1635,48 @@ Use this template:
- Suggested commit message: `feat(clients): add client details and secure config export`
- Next milestone: M6
## 2026-10-13 — M6 — DONE
- Implemented: managed-only `remove NAME [--yes]`; safe interactive and noninteractive confirmation; exact public-key/marker/address block removal with post-lock identity revalidation; M3 transactional apply and rollback; restrictive timestamped archival of associated client artifacts; recovery handling for partial archival; and identity-protected, secret-free removal audit events.
- Coordinator: `gpt-5.6-sol` (`high`)
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
- Repair cycles: 5
- Sol review: replaced aliased and tautological focused tests with exact neighbor preservation and isolated identity cases; added privilege and confirmation gates; hardened archive source, owner, mode, hardlink, symlink, collision, partial-copy, partial-delete, and recovery handling; added post-lock metadata revalidation; protected audit files with restrictive ownership and opened-FD identity checks; preserved critical artifact-recovery status through server rollback; corrected test contamination, false original-path assertions, deterministic timestamps, and documentation ordering.
- 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 remove-client`, `bash tests/run.sh remove-cancel`, `bash tests/run.sh remove-exact-block`, `bash tests/run.sh remove-rollback`, `bash tests/run.sh archive-client`, `bash tests/run.sh archive-path-safety`, `bash tests/run.sh remove-preserves-neighbors`, `bash tests/run.sh no-secret-logs`, `bash tests/run.sh`, `git diff --check`, permission, scope, and secret-pattern review.
- Result: all required checks passed; remove-client/remove-cancel/remove-exact-block/remove-rollback/archive-client/archive-path-safety/remove-preserves-neighbors/no-secret-logs passed 17/16/5/7/58/31/5/41 assertions, and the full regression suite passed 461 assertions. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
- Decisions: none
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, real terminal confirmation, and deployment-server behavior were not accessed or tested; M6 verification is fixture- and mock-based as required. Permanent purge is intentionally not implemented.
- Suggested commit message: `feat(clients): add safe client removal and archival`
- Next milestone: M7
## 2026-10-13 — M7 — DONE
- Implemented: read-only `diagnose [--quiet]` with stable `OK`, `WARN`, `FAIL`, and `SKIP` records; required-command, configuration and key permission, parser invariant, interface/runtime, systemd, forwarding, IPv4/IPv6 address and route, UDP listener, NAT visibility, disk/runtime peer, and bounded journal checks; plus fixture-controlled mocks and machine-friendly output.
- Coordinator: `gpt-5.6-sol` (`high`)
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
- Repair cycles: 6
- Sol review: replaced aliased shallow tests and diagnostic stubs; separated independent checks; corrected fresh-process configuration loading, exact command contracts, systemd capability detection, IPv4/IPv6 and UDP parsing, exact NAT evidence, config/key symlink and permission safety, duplicate/overlap classification, disk/runtime AllowedIPs comparison, exit codes, and read-only proof; added regression coverage for every material finding.
- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/mocks/bin/*`, `bash tests/run.sh diagnostics`, `bash tests/run.sh diagnostics-systemd`, `bash tests/run.sh diagnostics-firewall`, `bash tests/run.sh diagnostics-forwarding`, `bash tests/run.sh diagnostics-runtime-diff`, `bash tests/run.sh diagnostics-read-only`, `bash tests/run.sh diagnostics-exit-codes`, `bash tests/run.sh`, `git diff --check`, permission, scope, command-contract, and secret-pattern review.
- Result: all required checks passed; diagnostics/systemd/firewall/forwarding/runtime-diff/read-only/exit-code suites passed 20/12/11/14/31/42/43 assertions, and the full regression suite passed 634 assertions. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
- Decisions: none
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, and deployment-server behavior were not accessed or tested; M7 verification is fixture- and mock-based as required. Firewall `OK` confirms only expected NAT evidence visibility, and journal/handshake data is not a complete activity history.
- Suggested commit message: `feat(diagnostics): add read-only WireGuard health checks`
- Next milestone: M8
## 2026-10-13 — M8 — DONE
- Implemented: structured secret-safe audit events for mutations, apply, backup, and rollback outcomes; bounded newest-first `log`; serialized explicit `backup`; validated `backups` listing; and confirmed, interface-bound `restore BACKUP [--yes]` with pinned source identity, current-state backup, transactional apply, and disk/runtime rollback.
- Coordinator: `gpt-5.6-sol` (`high`)
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
- Repair cycles: 11
- Sol review: replaced shallow aliased M8 tests; corrected literal and false audit outcomes; added truthful transaction/backup/apply/rollback events; hardened concurrent audit creation, locking, identity checks, filtering, and limits; fixed backup-list pipeline, ownership, link, containment, malformed-entry, and manifest-swap handling; added manual-backup post-lock revalidation, retention, failure audit, and replacement detection; and made restore identifiers strict, source snapshots identity-pinned, confirmations explicit, and apply/rollback failures fully covered.
- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/mocks/bin/flock`, `bash tests/run.sh audit`, `bash tests/run.sh audit-no-secrets`, `bash tests/run.sh backup-list`, `bash tests/run.sh restore`, `bash tests/run.sh restore-invalid`, `bash tests/run.sh restore-rollback`, `bash tests/run.sh backup-path-safety`, `bash tests/run.sh runtime-file-modes`, `bash tests/run.sh`, `git diff --check`, permission, scope, opened-file identity, retention, concurrency, rollback, and secret-pattern review.
- Result: all required checks passed; audit/audit-no-secrets/backup-list/restore/restore-invalid/restore-rollback/backup-path-safety/runtime-file-modes passed 17/5/11/5/8/5/7/14 assertions, and the full regression suite passed 706 assertions. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
- Decisions: none
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, real terminal confirmation, and deployment-server behavior were not accessed or tested; M8 verification is fixture- and mock-based as required. Restore intentionally covers only the server configuration, not archived client artifacts.
- Suggested commit message: `feat(backup): add audited backup listing and restoration`
- Next milestone: M9
# 8. Final completion rule
The MVP is complete only when:
+14
View File
@@ -84,3 +84,17 @@ for an operational read/export/QR failure. `config NAME --show` is the only
inspection form that writes the complete client configuration to stdout;
ordinary `show`, `config`, and `qr` output remains secret-safe.
## M6 удаление клиента
`remove NAME [--yes]` удаляет только подтверждённый managed-peer после повторной проверки точного публичного ключа, адресов и блока под lock. В интерактивном терминале без `--yes` показываются имя, адреса и только последние 8 символов публичного ключа; ответ, отличный от `y`, отменяет операцию без изменений. В неинтерактивном режиме `--yes` обязателен.
Изменение серверной конфигурации выполняется внутри транзакции: сначала конфигурация заменяется и проверяется в WireGuard, а затем финализатор архивирует связанные `.conf`, `.meta`, `.key`, `.pub` и `.psk` (если существуют) в уникальный каталог `ARCHIVE_DIR` с режимами `0700`/`0600`. Ошибка архивирования после применения приводит к откату и оставляет исходные файлы доступными для восстановления; архивы не удаляются автоматически и служат восстановительным материалом. Журнал содержит operation, target, UTC timestamp, outcome и короткий суффикс публичного ключа; ключи и содержимое конфигураций в него не записываются.
`remove` возвращает `64` для ошибки аргументов, `65` для неподтверждённой или небезопасной identity/path, `69` для отсутствующей обязательной зависимости и `70` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния.
`diagnose [--quiet]` выполняет только read-only проверки конфигурации и прав, наличия интерфейса, `wg` dump, forwarding, адресов/маршрутов IPv4 и IPv6, UDP-порта, firewall, systemd, runtime-vs-disk peer set и журнала. Отдельные записи `config-parse`, `duplicate-keys`, `allowedips-overlap` и `key-permissions` не скрывают независимые причины сбоя. Каждая запись имеет один из статусов `OK`, `WARN`, `FAIL` или `SKIP`; `--quiet` выводит только стабильные строки `id=STATUS`. Обязательны `wg`, `wg-quick` и `ip`; `systemctl`, `journalctl`, `nft`, `iptables` и `ss` являются optional и при отсутствии дают собственный `SKIP`. На хосте без активного systemd проверки systemd и журнала пропускаются. `firewall=OK` означает только видимое ожидаемое NAT-свидетельство, а не полную проверку политики firewall. Ненулевой код (`70`) возвращается при любом `FAIL`, `64` — при неверных аргументах. Команда не изменяет конфигурацию, runtime, маршруты, firewall или sysctl; handshake и журнал имеют ограниченную историческую видимость. Тесты используют только изолированные fixtures/mocks и не доказывают состояние реального сервера.
## M8 резервные копии, восстановление и аудит
`backup` создаёт ручную резервную копию текущей серверной конфигурации под lock; `backups` показывает только проверенные прямые дочерние каталоги, в порядке от новых к старым. Повреждённые или неполные записи не исполняются и не читаются как shell-код. `restore BACKUP [--yes]` восстанавливает только серверную конфигурацию: перед заменой текущая версия также резервируется, а ошибки применения проверяют и откатывают disk/runtime. Восстановление другой WireGuard-интерфейсной конфигурации отклоняется. Без `--yes` требуется подтверждение в терминале; в неинтерактивном режиме команда возвращает ошибку использования. Клиентские metadata/key-файлы восстановлением не изменяются.
`log [LIMIT]` выводит только корректные структурированные audit-события в newest-first порядке; `LIMIT` находится в диапазоне 1..1000. При отсутствии журнала выводится безопасное сообщение. Записи содержат UTC timestamp, actor UID, operation, target, outcome и короткую категорию ошибки; секреты, команды и полные конфигурации не записываются. Ошибки аудита после успешной мутации сообщаются как частичный операционный результат. Основные коды: `64` — аргументы, `65` — небезопасная/невалидная backup identity, `70` — ошибка операции; критический неудачный rollback сохраняет отдельный rollback status.
+36 -3
View File
@@ -5,11 +5,44 @@ command_name=${0##*/}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
printf '\n' >>"$TEST_MOCK_LOG"
[[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]] || exit 99
if [[ -n ${MOCK_FLOCK_CHANGE_TO:-} && -z ${MOCK_FLOCK_CHANGED_MARKER:-} && -f $MOCK_FLOCK_CHANGE_TO && -n ${WG_MANAGER_CONFIG:-} ]]; then
cp -- "$MOCK_FLOCK_CHANGE_TO" "$WG_MANAGER_CONFIG"
export MOCK_FLOCK_CHANGED_MARKER=1
fi
if [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]]; then exit 1; fi
if [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]]; then exit 97; fi
if [[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]]; then
[[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1
[[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97
exit 0
fi
if [[ $# -ge 3 && $1 == -n && $2 =~ ^[0-9]+$ && ( $3 == printf || $3 == cat ) ]]; then
[[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1
[[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97
shift 2
command "$@"
exit $?
fi
if [[ $# == 1 && $1 =~ ^[0-9]+$ || $# == 2 && $1 == -s && $2 =~ ^[0-9]+$ ]]; then
[[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1
[[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97
real_flock=${REAL_FLOCK:-}
if [[ -z $real_flock ]]; then
for candidate in /usr/bin/flock /bin/flock; do
if [[ -x $candidate ]]; then real_flock=$candidate; break; fi
done
fi
[[ -n $real_flock ]] || exit 98
exec "$real_flock" "$@"
fi
if [[ $# -ge 2 && ( $1 == -s || $1 != -* ) && ${1#-} =~ ^[0-9]+$ || $1 == -s && ${2:-} =~ ^[0-9]+$ ]]; then
[[ ${MOCK_FLOCK_BUSY:-0} == 1 ]] && exit 1
[[ ${MOCK_FLOCK_FAIL:-0} == 1 ]] && exit 97
real_flock=${REAL_FLOCK:-}
if [[ -z $real_flock ]]; then
for candidate in /usr/bin/flock /bin/flock; do
if [[ -x $candidate ]]; then real_flock=$candidate; break; fi
done
fi
[[ -n $real_flock ]] || exit 98
exec "$real_flock" "$@"
fi
exit 99
+15
View File
@@ -11,5 +11,20 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 4 && $1 == link && $2 == show && $3 == dev ]]; then
[[ $4 == ${WG_MOCK_INTERFACE:-wg0} ]] || exit 99
[[ ${DIAG_IP_LINK_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IP_LINK_OUTPUT:-$4: <UP> mtu 1420}"; exit 0
fi
if [[ $# == 5 && $1 == -o && $2 == addr && $3 == show && $4 == dev ]]; then
[[ $5 == ${WG_MOCK_INTERFACE:-wg0} ]] || exit 99
[[ ${DIAG_IP_ADDR_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IP_ADDR_OUTPUT:-}"; exit 0
fi
if [[ $# == 6 && $1 == -o && $2 == route && $3 == show && $5 == dev ]]; then
[[ $6 == ${WG_MOCK_INTERFACE:-wg0} ]] || exit 99
[[ ${DIAG_IP_ROUTE_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IP_ROUTE_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 3 && $1 == -t && $2 == nat && $3 == -S ]]; then
[[ ${DIAG_IPTABLES_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IPTABLES_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 5 && $1 == -u && $2 == wg-quick@${WG_MOCK_INTERFACE:-wg0} && $3 == --since && $4 == '24 hours ago' && $5 == --no-pager ]]; then
[[ ${DIAG_JOURNAL_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_JOURNAL_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 2 && $1 == list && $2 == ruleset ]]; then
[[ ${DIAG_NFT_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_NFT_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 1 && $1 == -lun ]]; then
[[ ${DIAG_SS_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_SS_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+12
View File
@@ -11,5 +11,17 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 3 && $1 == is-active && $2 == -- && $3 == wg-quick@${WG_MOCK_INTERFACE:-wg0} ]]; then
case ${SYSTEMCTL_STATE:-missing} in
active|inactive|failed|unknown) printf '%s\n' "$SYSTEMCTL_STATE"; [[ $SYSTEMCTL_STATE == active || $SYSTEMCTL_STATE == unknown ]] && exit 0 || exit 3 ;;
esac
fi
if [[ $# == 1 && $1 == is-system-running ]]; then
case ${SYSTEMCTL_INIT:-missing} in
running) printf '%s\n' "$SYSTEMCTL_INIT"; exit 0 ;;
degraded) printf '%s\n' "$SYSTEMCTL_INIT"; exit 1 ;;
*) printf '%s\n' "${SYSTEMCTL_INIT:-offline}"; exit 3 ;;
esac
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+992 -5
View File
File diff suppressed because it is too large Load Diff
+778 -20
View File
@@ -54,7 +54,7 @@ print_usage() {
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove|diagnose|logs|backups|restore|tui}
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui}
Команды управления WireGuard будут добавлены в следующих версиях.
USAGE
@@ -261,6 +261,8 @@ load_config() { set_runtime_paths || return $?; parse_config_file "$CONFIG_PATH"
readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
PARSE_DUPLICATE_KEYS=0
PARSE_ALLOWEDIPS_OVERLAP=0
DISK_LISTEN_PORT='-'
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED META_FILE
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
@@ -379,11 +381,11 @@ commit_disk_peer() {
local key=$1 allowed=$2
[[ -n "$key" && -n "$allowed" ]] || { die "$EXIT_VALIDATION" 'Peer не содержит PublicKey или AllowedIPs'; return $?; }
is_wireguard_key "$key" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в конфигурации'; return $?; }
[[ -z ${DISK_ALLOWED[$key]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в конфигурации'; return $?; }
[[ -z ${DISK_ALLOWED[$key]+x} ]] || { PARSE_DUPLICATE_KEYS=1; die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в конфигурации'; return $?; }
local normalized existing_key
normalized=$(normalize_allowed_ips "$allowed") || { die "$EXIT_VALIDATION" 'Некорректные или пересекающиеся AllowedIPs'; return $?; }
normalized=$(normalize_allowed_ips "$allowed") || { PARSE_ALLOWEDIPS_OVERLAP=1; die "$EXIT_VALIDATION" 'Некорректные или пересекающиеся AllowedIPs'; return $?; }
for existing_key in "${!DISK_ALLOWED[@]}"; do
allowed_sets_overlap "$normalized" "${DISK_ALLOWED[$existing_key]}" && { die "$EXIT_VALIDATION" 'Пересекающиеся AllowedIPs между peers'; return $?; }
allowed_sets_overlap "$normalized" "${DISK_ALLOWED[$existing_key]}" && { PARSE_ALLOWEDIPS_OVERLAP=1; die "$EXIT_VALIDATION" 'Пересекающиеся AllowedIPs между peers'; return $?; }
done
DISK_ALLOWED["$key"]=$normalized
local addresses; addresses=$(extract_client_addresses "${DISK_ALLOWED[$key]}")
@@ -397,7 +399,7 @@ commit_disk_peer() {
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 -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=(); PARSE_DUPLICATE_KEYS=0; PARSE_ALLOWEDIPS_OVERLAP=0
DISK_LISTEN_PORT='-'
[[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; }
while IFS= read -r line || [[ -n "$line" ]]; do
@@ -575,6 +577,7 @@ TRANSACTION_PENDING_SIGNAL=''
TRANSACTION_ROLLBACK_ACTIVE=0
TRANSACTION_ROLLBACK_INTERRUPTED=0
TRANSACTION_ROLLBACK_SIGNAL=''
TRANSACTION_OUTCOME='failed-before-apply'
transaction_test_hooks_enabled() {
local path config_path
@@ -583,7 +586,7 @@ transaction_test_hooks_enabled() {
config_path=${CONFIG_PATH:-${WG_MANAGER_CONFIG:-}}
[[ -n $config_path && $config_path == "${TEST_ROOT%/}"/* ]] || return 1
path_components_safe "$config_path" || return 1
for path in "${WG_CONF:-}" "${CLIENT_DIR:-}" "${METADATA_DIR:-}" "${BACKUP_DIR:-}" "${LOCK_FILE:-}"; do
for path in "${WG_CONF:-}" "${CLIENT_DIR:-}" "${METADATA_DIR:-}" "${BACKUP_DIR:-}" "${LOCK_FILE:-}" "${LOG_DIR:-}" "${ARCHIVE_DIR:-}"; do
[[ -n $path && $path == "${TEST_ROOT%/}"/* ]] && path_components_safe "$path" || return 1
done
for path in "${ADD_PUBLISH_READY_FIFO:-}" "${ADD_PUBLISH_RELEASE_FIFO:-}" "${ADD_PUBLISH_PID_FILE:-}"; do
@@ -725,10 +728,12 @@ create_backup() {
}
validate_backup_manifest() {
local backup=$1 line key value manifest_interface='' manifest_timestamp='' manifest_operation='' manifest_target='' manifest_config='' metadata_name backup_name backup_prefix backup_counter
local backup=$1 line key value manifest_interface='' manifest_timestamp='' manifest_operation='' manifest_target='' manifest_config='' metadata_name backup_name backup_prefix backup_counter server_owner
local -A seen=() metadata_seen=()
[[ -d "$backup" && ! -L "$backup" && -f "$backup/manifest" && ! -L "$backup/manifest" && -f "$backup/wg.conf" && ! -L "$backup/wg.conf" ]] || return 1
[[ $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%a' -- "$backup/manifest") == 600 && $(stat -c '%a' -- "$backup/wg.conf") == 600 ]] || return 1
[[ $backup == "${BACKUP_DIR%/}"/* && ${backup%/*} == "${BACKUP_DIR%/}" ]] || return 1
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return 1
[[ $(stat -c '%a:%u:%g' -- "$backup") == "700:$server_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/manifest") == "600:1:$server_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/wg.conf") == "600:1:$server_owner" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
[[ $line == *=* ]] || return 1
key=${line%%=*}; value=${line#*=}
@@ -750,14 +755,14 @@ validate_backup_manifest() {
esac
done <"$backup/manifest"
[[ ${seen[INTERFACE]+x} && ${seen[TIMESTAMP]+x} && ${seen[OPERATION]+x} && ${seen[TARGET]+x} && ${seen[SOURCE_MODE]+x} && ${seen[SOURCE_UID]+x} && ${seen[SOURCE_GID]+x} && ${seen[CONFIG]+x} ]] || return 1
[[ $manifest_interface == "$WG_INTERFACE" && $manifest_timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $manifest_operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $manifest_target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ && $manifest_config == wg.conf ]] || return 1
[[ $manifest_interface == "$WG_INTERFACE" && $manifest_timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $manifest_operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $manifest_target =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ && $manifest_config == wg.conf ]] || return 1
backup_name=${backup##*/}
backup_prefix="${manifest_timestamp}-${WG_INTERFACE}-"
[[ $backup_name == "$backup_prefix"* ]] || return 1
backup_counter=${backup_name#"$backup_prefix"}
[[ $backup_counter =~ ^[0-9]{3}$ ]] || return 1
for metadata_name in "${!metadata_seen[@]}"; do
[[ -f "$backup/$metadata_name" && ! -L "$backup/$metadata_name" && $(stat -c '%a' -- "$backup/$metadata_name") == 600 ]] || return 1
[[ -f "$backup/$metadata_name" && ! -L "$backup/$metadata_name" && $(stat -c '%a:%h:%u:%g' -- "$backup/$metadata_name") == "600:1:$server_owner" ]] || return 1
done
for line in "$backup"/*; do
[[ -e "$line" || -L "$line" ]] || continue
@@ -831,7 +836,7 @@ transaction_subshell_exit() {
local status=$?
transaction_cleanup
if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then
printf 'STATUS=%s\nBACKUP=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" >"$TRANSACTION_RESULT_FILE"
printf 'STATUS=%s\nBACKUP=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE"
if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then
printf 'Предупреждение: не удалось защитить transaction result\n' >&2
fi
@@ -841,12 +846,12 @@ transaction_subshell_exit() {
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 source_mode source_uid source_gid backup_path finalizer_status=0
local transaction_path
shift 3
TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''
TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''; TRANSACTION_OUTCOME='failed-before-apply'
TRANSACTION_OWNED_PATHS=(); TRANSACTION_PENDING_SIGNAL=''; TRANSACTION_ROLLBACK_ACTIVE=0; TRANSACTION_ROLLBACK_INTERRUPTED=0; TRANSACTION_ROLLBACK_SIGNAL=''
[[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
[[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
is_control_free "$operation" && is_control_free "$target" || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; }
[[ $builder =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный builder транзакции'; return $?; }
declare -F "$builder" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Builder транзакции не является функцией'; return $?; }
@@ -904,12 +909,18 @@ run_transaction_core() {
if ((status == 0)); then
backup_path=$(create_backup "$WG_CONF" "$operation" "$target" "$@") || status=$?
TRANSACTION_BACKUP_PATH=${backup_path:-}
if [[ $status == 0 ]]; then
if ! write_audit_event backup "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит резервной копии'; fi
else
if ! write_audit_event backup "$target" failed backup-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит ошибки резервной копии'; fi
fi
if [[ $status == 0 ]]; then
if [[ ${TRANSACTION_ATOMIC_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then status=$EXIT_OPERATION
elif ! chmod 600 -- "$stage" || ! mv -- "$stage" "$WG_CONF"; then status=$EXIT_OPERATION; fi
fi
fi
if ((status == 0)); then
TRANSACTION_OUTCOME='apply-attempted'
restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid" || status=$EXIT_OPERATION
fi
if ((status == 0)) && ! run_syncconf "$WG_INTERFACE" "$new_stripped"; then status=$EXIT_OPERATION; fi
@@ -928,10 +939,12 @@ run_transaction_core() {
TRANSACTION_OWNED_PATHS=()
fi
else
status=$EXIT_OPERATION
finalizer_status=$?
printf 'Ошибка: не удалось завершить финальную операцию транзакции\n' >&2
status=$finalizer_status
fi
fi
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} && $status != $EXIT_ROLLBACK ]]; then
printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2
status=$EXIT_OPERATION
fi
@@ -952,9 +965,11 @@ run_transaction_core() {
printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2
status=$EXIT_ROLLBACK
else
status=$EXIT_OPERATION
if (( finalizer_status == EXIT_ROLLBACK )); then status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi
TRANSACTION_OUTCOME='rolled-back'
fi
fi
if ((status == EXIT_ROLLBACK)); then TRANSACTION_OUTCOME='rollback-failed'; fi
TRANSACTION_ROLLBACK_ACTIVE=0
fi
if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then
@@ -962,6 +977,7 @@ run_transaction_core() {
printf 'Предупреждение: не удалось ограничить число резервных копий\n' >&2
fi
fi
(( status == 0 )) && TRANSACTION_OUTCOME='success'
transaction_cleanup; exec {lock_fd}>&-; return "$status"
}
@@ -970,7 +986,7 @@ run_transaction() {
die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции'
return $?
fi
local operation=$1 target=$2 builder=$3 result_file result_status result_backup had_errexit=0
local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_outcome had_errexit=0
shift 3
[[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; }
set_runtime_paths || return $?
@@ -989,11 +1005,21 @@ run_transaction() {
(( had_errexit == 1 )) && set -e || set +e
if [[ -r $result_file ]]; then
while IFS='=' read -r key value; do
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; esac
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; OUTCOME) result_outcome=$value ;; esac
done <"$result_file"
fi
rm -f -- "$result_file"
TRANSACTION_BACKUP_PATH=${result_backup:-}
TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply}
if [[ $result_outcome == success ]]; then
if ! write_audit_event apply "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит применения'; fi
elif [[ $result_outcome == rolled-back ]]; then
if ! write_audit_event rollback "$target" success rolled-back >/dev/null 2>&1; then warn 'Не удалось записать аудит отката'; fi
elif [[ $result_outcome == rollback-failed ]]; then
if ! write_audit_event rollback "$target" failed rollback-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит критического отката'; fi
elif [[ $result_outcome == failed-before-apply ]]; then
if ! write_audit_event transaction "$target" failed operation-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит неуспешной транзакции'; fi
fi
unset TRANSACTION_RESULT_FILE TRANSACTION_PRE_LOCK_FILE TRANSACTION_PRE_WG_CONF TRANSACTION_PRE_BACKUP_DIR TRANSACTION_PRE_METADATA_DIR TRANSACTION_PRE_INTERFACE TRANSACTION_FINALIZER
return "$result_status"
}
@@ -1012,6 +1038,14 @@ require_add_privilege() {
}
}
require_remove_privilege() {
if (( EUID == 0 )); then return 0; fi
transaction_test_hooks_enabled || {
die "$EXIT_PERMISSION" 'Для удаления клиента требуются права root'
return $?
}
}
client_paths_available() {
local name=$1 path
for path in "${METADATA_DIR}/${name}.meta" "${CLIENT_DIR}/${name}.conf" "${CLIENT_DIR}/${name}.key" "${CLIENT_DIR}/${name}.pub" "${CLIENT_DIR}/${name}.psk"; do
@@ -1342,10 +1376,16 @@ add_client_impl() {
status=$?
fi
if (( status != 0 )); then
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi
clear_add_transaction_state
return "$status"
fi
status=0
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" success success >/dev/null 2>&1; then
clear_add_transaction_state
die "$EXIT_OPERATION" 'Добавление выполнено, но аудит не записан (частичный результат)'
return "$EXIT_OPERATION"
fi
clear_add_transaction_state
return "$status"
}
@@ -1395,6 +1435,411 @@ resolve_gum() {
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
manual_backup() {
local lock_fd path status parent pre_conf pre_backup pre_meta pre_lock pre_iface live_owner lock_owner lock_created=0 lock_identity opened_identity replacement_backup config_identity config_identity_after backup_identity_after
load_config || return $?
require_add_privilege || return $?
path_components_safe "$LOCK_FILE" || { die "$EXIT_VALIDATION" 'Некорректный путь блокировки'; return $?; }
path_components_safe "$WG_CONF" || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации'; return $?; }
parent=${LOCK_FILE%/*}; [[ -d $parent && ! -L $parent ]] || { die "$EXIT_VALIDATION" 'Каталог блокировки недоступен'; return $?; }
pre_conf=$WG_CONF; pre_backup=$BACKUP_DIR; pre_meta=$METADATA_DIR; pre_lock=$LOCK_FILE; pre_iface=$WG_INTERFACE
if [[ -e $LOCK_FILE ]]; then
[[ -f $LOCK_FILE && ! -L $LOCK_FILE && $(stat -c '%a:%h' -- "$LOCK_FILE") == 600:1 ]] || { die "$EXIT_VALIDATION" 'Файл блокировки небезопасен'; return $?; }
else
(umask 077; : >"$LOCK_FILE") || { die "$EXIT_OPERATION" 'Не удалось создать файл блокировки'; return $?; }
chmod 600 -- "$LOCK_FILE" || { rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось защитить блокировку'; return $?; }
lock_created=1
fi
lock_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$LOCK_FILE") || { ((lock_created == 1)) && rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось проверить блокировку'; return $?; }
if [[ -n ${MANUAL_BACKUP_PREOPEN_REPLACEMENT:-} ]]; then
if ! transaction_test_hooks_enabled || ! path_components_safe "$MANUAL_BACKUP_PREOPEN_REPLACEMENT" || [[ ! -f $MANUAL_BACKUP_PREOPEN_REPLACEMENT || -L $MANUAL_BACKUP_PREOPEN_REPLACEMENT || $MANUAL_BACKUP_PREOPEN_REPLACEMENT == "$LOCK_FILE" ]]; then
((lock_created == 1)) && rm -f -- "$LOCK_FILE"
die "$EXIT_VALIDATION" 'Некорректный тестовый файл блокировки'
return $?
fi
replacement_backup=${LOCK_FILE}.manual-preopen-old
rm -f -- "$replacement_backup"
mv -- "$LOCK_FILE" "$replacement_backup" || return "$EXIT_OPERATION"
cp -- "$MANUAL_BACKUP_PREOPEN_REPLACEMENT" "$LOCK_FILE" || { mv -- "$replacement_backup" "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось подготовить тестовую блокировку'; return $?; }
fi
exec {lock_fd}<>"$LOCK_FILE" || { ((lock_created == 1)) && rm -f -- "$LOCK_FILE"; die "$EXIT_OPERATION" 'Не удалось открыть блокировку'; return $?; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${lock_fd}") || { exec {lock_fd}>&-; die "$EXIT_OPERATION" 'Не удалось проверить открытую блокировку'; return $?; }
if [[ $opened_identity != "$lock_identity" ]]; then
exec {lock_fd}>&-
if [[ -n ${replacement_backup:-} && -e $replacement_backup ]]; then rm -f -- "$LOCK_FILE"; mv -- "$replacement_backup" "$LOCK_FILE"; fi
die "$EXIT_VALIDATION" 'Файл блокировки изменился перед открытием'
return $?
fi
flock -n "$lock_fd" || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$WG_INTERFACE" failed lock-contended >/dev/null 2>&1; then warn 'Не удалось записать аудит блокировки'; fi; die "$EXIT_OPERATION" 'Другая операция уже выполняется'; return $?; }
load_config || { status=$?; exec {lock_fd}>&-; return "$status"; }
if [[ $WG_CONF != "$pre_conf" || $BACKUP_DIR != "$pre_backup" || $METADATA_DIR != "$pre_meta" || $LOCK_FILE != "$pre_lock" || $WG_INTERFACE != "$pre_iface" ]]; then
exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed resources-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения ресурсов'; fi; die "$EXIT_VALIDATION" 'Ресурсы изменились после блокировки'; return $?
fi
[[ -f $WG_CONF && ! -L $WG_CONF ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-unavailable >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; die "$EXIT_VALIDATION" 'Серверная конфигурация недоступна'; return $?; }
live_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-stat >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; return "$EXIT_OPERATION"; }
[[ $(stat -c '%a:%h' -- "$WG_CONF") == 400:1 || $(stat -c '%a:%h' -- "$WG_CONF") == 600:1 ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-permissions >/dev/null 2>&1; then warn 'Не удалось записать аудит прав'; fi; die "$EXIT_VALIDATION" 'Небезопасные права серверной конфигурации'; return $?; }
[[ $(stat -c '%u:%g' -- "$WG_CONF") == "$live_owner" ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-owner >/dev/null 2>&1; then warn 'Не удалось записать аудит владельца'; fi; die "$EXIT_VALIDATION" 'Недоверенный владелец серверной конфигурации'; return $?; }
lock_owner=$(stat -c '%u:%g' -- "$LOCK_FILE") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
[[ $lock_owner == "$live_owner" ]] || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed lock-owner >/dev/null 2>&1; then warn 'Не удалось записать аудит владельца блокировки'; fi; die "$EXIT_VALIDATION" 'Недоверенный владелец блокировки'; return $?; }
parse_wireguard_config "$WG_CONF" >/dev/null 2>&1 || { exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-parse >/dev/null 2>&1; then warn 'Не удалось записать аудит конфигурации'; fi; die "$EXIT_VALIDATION" 'Серверная конфигурация не прошла проверку'; return $?; }
config_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
if [[ -n ${MANUAL_BACKUP_PRECOPY_REPLACEMENT:-} ]]; then
if ! transaction_test_hooks_enabled || ! path_components_safe "$MANUAL_BACKUP_PRECOPY_REPLACEMENT" || [[ ! -f $MANUAL_BACKUP_PRECOPY_REPLACEMENT || -L $MANUAL_BACKUP_PRECOPY_REPLACEMENT || $MANUAL_BACKUP_PRECOPY_REPLACEMENT == "$WG_CONF" ]]; then
exec {lock_fd}>&-; die "$EXIT_VALIDATION" 'Некорректный тестовый источник конфигурации'; return $?
fi
replacement_backup=${WG_CONF}.manual-precopy-old
rm -f -- "$replacement_backup"
mv -- "$WG_CONF" "$replacement_backup" || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
cp -- "$MANUAL_BACKUP_PRECOPY_REPLACEMENT" "$WG_CONF" || { mv -- "$replacement_backup" "$WG_CONF"; exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
fi
config_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
if [[ $config_identity_after != "$config_identity" ]]; then
[[ -n ${replacement_backup:-} && -e $replacement_backup ]] && { rm -f -- "$WG_CONF"; mv -- "$replacement_backup" "$WG_CONF"; }
exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения конфигурации'; fi; die "$EXIT_VALIDATION" 'Конфигурация изменилась перед резервированием'; return $?
fi
path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE") || { status=$?; exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed backup-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит резервной копии'; fi; return "$status"; }
backup_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$WG_CONF") || { rm -rf -- "$path"; exec {lock_fd}>&-; return "$EXIT_OPERATION"; }
if [[ $backup_identity_after != "$config_identity" ]]; then
rm -rf -- "$path"
[[ -n ${replacement_backup:-} && -e $replacement_backup ]] && { rm -f -- "$WG_CONF"; mv -- "$replacement_backup" "$WG_CONF"; }
exec {lock_fd}>&-; if ! write_audit_event manual-backup "$pre_iface" failed config-changed >/dev/null 2>&1; then warn 'Не удалось записать аудит изменения конфигурации'; fi; die "$EXIT_VALIDATION" 'Конфигурация изменилась во время резервирования'; return $?
fi
if ! prune_backups; then warn 'Не удалось ограничить число резервных копий'; fi
exec {lock_fd}>&-
if ! AUDIT_OPERATION=manual-backup AUDIT_TARGET="$WG_INTERFACE" AUDIT_ERROR_CATEGORY=success write_audit_event manual-backup "$WG_INTERFACE" success success; then
die "$EXIT_OPERATION" 'Резервная копия создана, но аудит не записан (частичный результат)'
return "$EXIT_OPERATION"
fi
printf '%s\n' "$path"
}
list_backups() {
local path name line timestamp operation target value invalid_count=0 root_mode root_owner server_owner manifest_identity_before manifest_identity_after manifest_fd manifest_opened_identity
local -a entries=()
load_config || return $?
if [[ ! -e $BACKUP_DIR ]]; then printf 'Резервные копии отсутствуют\n'; return 0; fi
[[ -d $BACKUP_DIR && ! -L $BACKUP_DIR ]] && path_components_safe "$BACKUP_DIR" || { die "$EXIT_VALIDATION" 'Каталог резервных копий небезопасен'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION"
root_mode=$(stat -c '%a' -- "$BACKUP_DIR") || return "$EXIT_OPERATION"
root_owner=$(stat -c '%u:%g' -- "$BACKUP_DIR") || return "$EXIT_OPERATION"
[[ $root_mode == 700 && $root_owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Каталог резервных копий небезопасен'; return $?; }
for path in "$BACKUP_DIR"/*; do
[[ -d $path && ! -L $path ]] || { [[ -e $path ]] && ((invalid_count += 1)); continue; }
manifest_identity_before=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path/manifest" 2>/dev/null) || { ((invalid_count += 1)); continue; }
exec {manifest_fd}<"$path/manifest" || { ((invalid_count += 1)); continue; }
manifest_opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${manifest_fd}" 2>/dev/null) || { exec {manifest_fd}>&-; ((invalid_count += 1)); continue; }
if [[ $manifest_opened_identity != "$manifest_identity_before" ]]; then exec {manifest_fd}>&-; ((invalid_count += 1)); continue; fi
if [[ -n ${LIST_BACKUPS_REPLACE_MANIFEST:-} && -z ${LIST_BACKUPS_REPLACE_DONE:-} && $path == "${BACKUP_DIR%/}"/* ]]; then
if transaction_test_hooks_enabled && path_components_safe "$LIST_BACKUPS_REPLACE_MANIFEST" && [[ -f $LIST_BACKUPS_REPLACE_MANIFEST && ! -L $LIST_BACKUPS_REPLACE_MANIFEST ]]; then
local list_manifest_old=${path}/.manifest-list-old
rm -f -- "$list_manifest_old"
mv -- "$path/manifest" "$list_manifest_old" && cp -- "$LIST_BACKUPS_REPLACE_MANIFEST" "$path/manifest" && chmod 600 -- "$path/manifest"
LIST_BACKUPS_REPLACE_DONE=1
fi
fi
if ! validate_backup_manifest "$path"; then
[[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; }
exec {manifest_fd}>&-
((invalid_count += 1)); continue
fi
manifest_identity_after=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$path/manifest" 2>/dev/null) || {
[[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; }
exec {manifest_fd}>&-
((invalid_count += 1)); continue
}
if [[ $manifest_identity_before != "$manifest_identity_after" ]]; then
[[ -e ${path}/.manifest-list-old ]] && { rm -f -- "$path/manifest"; mv -- "$path/.manifest-list-old" "$path/manifest"; }
exec {manifest_fd}>&-
((invalid_count += 1)); continue
fi
name=${path##*/}; timestamp=''; operation=''; target=''
while IFS= read -r line; do
value=${line#*=}
case $line in TIMESTAMP=*) timestamp=$value;; OPERATION=*) operation=$value;; TARGET=*) target=$value;; esac
done <&$manifest_fd
exec {manifest_fd}>&-
entries+=("${name}"$'\t'"${timestamp}"$'\t'"${operation}"$'\t'"${target}")
done
if ((${#entries[@]} == 0)); then printf 'Резервные копии отсутствуют\n'; ((invalid_count > 0)) && printf 'Предупреждение: пропущено некорректных резервных копий: %d\n' "$invalid_count" >&2; return 0; fi
printf '%s\n' "${entries[@]}" | LC_ALL=C sort -r
((invalid_count > 0)) && printf 'Предупреждение: пропущено некорректных резервных копий: %d\n' "$invalid_count" >&2
return 0
}
list_audit() {
local limit=0 line count=0 mode owner server_owner audit_file fd expected_identity opened_identity locked_identity
local -a records=() audit_lines=()
(($# <= 1)) || { die "$EXIT_USAGE" 'Лишние аргументы log'; return $?; }
(($# == 1)) && { [[ $1 =~ ^[1-9][0-9]{0,3}$ ]] && (( 10#$1 <= 1000 )) || { die "$EXIT_USAGE" 'Некорректный лимит log'; return $?; }; limit=$1; }
load_config || return $?
audit_file=$LOG_DIR/wg-manager.log
path_components_safe "$LOG_DIR" || { die "$EXIT_VALIDATION" 'Каталог журнала небезопасен'; return $?; }
[[ -d $LOG_DIR && ! -L $LOG_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог журнала недоступен'; return $?; }
[[ -e $audit_file ]] || { printf 'Журнал отсутствует\n'; return 0; }
[[ -f $audit_file && ! -L $audit_file ]] || { die "$EXIT_VALIDATION" 'Файл журнала небезопасен'; return $?; }
mode=$(stat -c '%a' -- "$LOG_DIR") || return "$EXIT_OPERATION"; [[ $mode == 700 ]] || { die "$EXIT_VALIDATION" 'Небезопасные права каталога журнала'; return $?; }
mode=$(stat -c '%a' -- "$audit_file") || return "$EXIT_OPERATION"; [[ $mode == 600 ]] || { die "$EXIT_VALIDATION" 'Небезопасные права журнала'; return $?; }
[[ $(stat -c '%h' -- "$audit_file") == 1 ]] || { die "$EXIT_VALIDATION" 'Журнал имеет небезопасные ссылки'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION"
[[ $(stat -c '%u:%g' -- "$LOG_DIR") == "$server_owner" && $(stat -c '%u:%g' -- "$audit_file") == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Недоверенный владелец журнала'; return $?; }
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION"
exec {fd}<"$audit_file" || return "$EXIT_OPERATION"
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; die "$EXIT_VALIDATION" 'Файл журнала изменился при чтении'; return $?; }
if ! flock -s "$fd"; then
exec {fd}>&-
return "$EXIT_OPERATION"
fi
locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $locked_identity == "$opened_identity" ]] || { exec {fd}>&-; die "$EXIT_VALIDATION" 'Файл журнала изменился при чтении'; return $?; }
mapfile -t audit_lines <&$fd
for line in "${audit_lines[@]}"; do
[[ $line =~ ^timestamp=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\ actor_uid=[0-9]+\ operation=[a-z][a-z0-9-]{0,31}\ target=[A-Za-z0-9._-]{1,96}\ client=[A-Za-z0-9._-]{1,96}\ key_suffix=[A-Za-z0-9+/=-]{0,16}\ outcome=[a-z][a-z0-9-]{0,31}\ error=[a-z][a-z0-9-]{0,31}$ ]] || continue
records+=("$line")
done
exec {fd}>&-
((${#records[@]} > 0)) || { printf 'В журнале нет корректных записей\n'; return 0; }
count=0
for ((count=${#records[@]}-1; count>=0; count--)); do
printf '%s\n' "${records[count]}"
((limit > 0 && ${#records[@]} - count >= limit)) && break
done
}
restore_builder() {
local stage=$1
[[ -n ${RESTORE_SNAPSHOT:-} && -f $RESTORE_SNAPSHOT && ! -L $RESTORE_SNAPSHOT ]] || return "$EXIT_VALIDATION"
cp -- "$RESTORE_SNAPSHOT" "$stage" && chmod 600 -- "$stage"
}
cleanup_restore_snapshot() {
if [[ -n ${RESTORE_SNAPSHOT:-} ]]; then rm -f -- "$RESTORE_SNAPSHOT"; fi
unset RESTORE_SNAPSHOT RESTORE_BACKUP RESTORE_BACKUP_DIGEST
}
restore_backup() {
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается идентификатор резервной копии'; return $?; }
local name=$1 flag='' backup status answer source backup_name timestamp counter reconstructed snapshot_fd expected_identity opened_identity live_owner replacement_backup=''
(($# >= 2)) && flag=$2
(($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы restore'; return $?; }
load_config || return $?
backup_name=$name; counter=${backup_name##*-}; timestamp=${backup_name%-"$counter"}; timestamp=${timestamp%-"$WG_INTERFACE"}
[[ $timestamp =~ ^[0-9]{8}T[0-9]{6}Z$ && $counter =~ ^[0-9]{3}$ ]] || { die "$EXIT_VALIDATION" 'Некорректный идентификатор резервной копии'; return $?; }
reconstructed="${timestamp}-${WG_INTERFACE}-${counter}"
[[ $reconstructed == "$name" ]] || { die "$EXIT_VALIDATION" 'Некорректный идентификатор резервной копии'; return $?; }
[[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг restore --yes'; return $?; }
require_add_privilege || return $?
backup=$BACKUP_DIR/$name
source=$backup/wg.conf
live_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца конфигурации'; return $?; }
[[ -d $backup && ! -L $backup && -f $source && ! -L $source ]] || { die "$EXIT_VALIDATION" 'Резервная копия не найдена'; return $?; }
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$source") || { die "$EXIT_OPERATION" 'Не удалось проверить резервную копию'; return $?; }
validate_backup_manifest "$backup" || { die "$EXIT_VALIDATION" 'Резервная копия не прошла проверку'; return $?; }
[[ $backup == "${BACKUP_DIR%/}"/* && ${backup%/*} == "${BACKUP_DIR%/}" && ! -L $source && $(stat -c '%a' -- "$backup") == 700 && $(stat -c '%u:%g' -- "$backup") == "$live_owner" && $(stat -c '%a:%h:%u:%g' -- "$backup/manifest") == "600:1:$live_owner" && $(stat -c '%a:%h:%u:%g' -- "$source") == "600:1:$live_owner" ]] || { die "$EXIT_VALIDATION" 'Некорректная безопасность резервной копии'; return $?; }
parse_wireguard_config "$source" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Конфигурация резервной копии не прошла проверку'; return $?; }
printf 'Резервная копия: %s; интерфейс: %s\n' "$timestamp" "$WG_INTERFACE" >&2
[[ -n $flag || ( -t 0 && -t 1 ) ]] || { die "$EXIT_USAGE" 'В неинтерактивном режиме требуется --yes'; return $?; }
if [[ -z $flag ]]; then printf 'Восстановить резервную копию %s? [y/N] ' "$name" >&2; IFS= read -r answer || answer=n; if [[ $answer != y && $answer != Y ]]; then if ! write_audit_event restore "$name" cancelled cancelled >/dev/null 2>&1; then warn 'Не удалось записать аудит отмены восстановления'; fi; cleanup_restore_snapshot; return 0; fi; fi
RESTORE_BACKUP=$backup
RESTORE_SNAPSHOT=$(mktemp "${WG_CONF%/*}/.wg-manager.restore.XXXXXX") || { die "$EXIT_OPERATION" 'Не удалось подготовить снимок восстановления'; return $?; }
chmod 600 -- "$RESTORE_SNAPSHOT" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось защитить снимок восстановления'; return $?; }
[[ $(stat -c '%d:%i:%a:%u:%g:%h' -- "$source") == "$expected_identity" ]] || { cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Резервная копия изменилась'; return $?; }
if [[ -n ${RESTORE_PREOPEN_REPLACEMENT:-} ]]; then
if ! transaction_test_hooks_enabled || ! path_components_safe "$RESTORE_PREOPEN_REPLACEMENT" || [[ ! -f $RESTORE_PREOPEN_REPLACEMENT || -L $RESTORE_PREOPEN_REPLACEMENT || $RESTORE_PREOPEN_REPLACEMENT == "$source" ]]; then
cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Некорректный тестовый источник восстановления'; return $?
fi
replacement_backup=${source}.restore-preopen-old
rm -f -- "$replacement_backup"
mv -- "$source" "$replacement_backup" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось подготовить тестовый источник восстановления'; return $?; }
cp -- "$RESTORE_PREOPEN_REPLACEMENT" "$source" || { mv -- "$replacement_backup" "$source"; cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось подготовить тестовый источник восстановления'; return $?; }
fi
exec {snapshot_fd}<"$source" || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Не удалось открыть резервную копию'; return $?; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${snapshot_fd}") || { exec {snapshot_fd}>&-; cleanup_restore_snapshot; return "$EXIT_OPERATION"; }
if [[ $opened_identity != "$expected_identity" ]]; then
exec {snapshot_fd}>&-
if [[ -n ${replacement_backup:-} && -e $replacement_backup ]]; then
rm -f -- "$source"
mv -- "$replacement_backup" "$source"
fi
cleanup_restore_snapshot
die "$EXIT_VALIDATION" 'Резервная копия изменилась'
return $?
fi
cat <&$snapshot_fd >"$RESTORE_SNAPSHOT" || { exec {snapshot_fd}>&-; cleanup_restore_snapshot; return "$EXIT_OPERATION"; }
exec {snapshot_fd}>&-
parse_wireguard_config "$RESTORE_SNAPSHOT" >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_VALIDATION" 'Снимок восстановления не прошёл проверку'; return $?; }
if run_transaction restore "$name" restore_builder; then status=0; else status=$?; fi
if (( status == 0 )); then
write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; }
else
if ! write_audit_event restore "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi
fi
cleanup_restore_snapshot
return "$status"
}
DIAG_FAILS=0
DIAG_QUIET=0
declare -A DIAG_IDS=()
diag_result() {
local ident=$1 status=$2 summary=$3 guidance=''
(( $# >= 4 )) && guidance=$4
[[ $ident =~ ^[a-z0-9-]+$ ]] || { ((DIAG_FAILS += 1)); return 1; }
[[ $status == OK || $status == WARN || $status == FAIL || $status == SKIP ]] || { ((DIAG_FAILS += 1)); return 1; }
if [[ -n ${DIAG_IDS[$ident]+x} ]]; then ((DIAG_FAILS += 1)); return 1; fi
DIAG_IDS[$ident]=1
if ((DIAG_QUIET == 1)); then printf '%s=%s\n' "$ident" "$status"; else printf '%s [%s] %s' "$ident" "$status" "$summary"; [[ -n $guidance ]] && printf ' — %s' "$guidance"; printf '\n'; fi
[[ $status == FAIL ]] && ((DIAG_FAILS += 1)); return 0
}
diag_command_available() {
local command_name=$1 disabled
local -a disabled_commands=()
if transaction_test_hooks_enabled; then
IFS=, read -r -a disabled_commands <<< "${DIAG_DISABLE_COMMANDS:-}"
for disabled in "${disabled_commands[@]:-}"; do [[ $disabled == "$command_name" ]] && return 1; done
fi
command -v -- "$command_name" >/dev/null 2>&1
}
diag_read_value() { local value; [[ -r $1 && ! -L $1 ]] || return 1; IFS= read -r value <"$1" || return 1; printf '%s' "$value"; }
diagnose() {
local manager_config_ok=0 output path value command_name dump_ok=0
local disk_peer_count=0 runtime_peer_count=0 route_output systemd_state runtime_match=1 disk_parse_ok=0 systemd_usable=0
local interface_name=wg0 server_vpn_ip=0.0.0.0 vpn_prefix=0 server_port=0 use_ipv6=0 ipv6_prefix='' ipv4_network=''
local lower_output='' line nat_ok=0 key token key_owner index
local address_ok=0 route_ok=0 ipv6_address_ok=0 ipv6_route_ok=0
local line_network=0 line_interface=0 line_nat=0
local -a tokens=()
DIAG_FAILS=0; DIAG_QUIET=0; DIAG_IDS=()
(($# <= 1)) || { die "$EXIT_USAGE" 'Лишние аргументы diagnose'; return $?; }
if (($# == 1)); then [[ $1 == --quiet ]] || { die "$EXIT_USAGE" 'Разрешён только флаг diagnose --quiet'; return $?; }; DIAG_QUIET=1; fi
set_runtime_paths || { diag_result paths FAIL 'Некорректные пути состояния' 'Проверьте WG_MANAGER_*'; return "$EXIT_VALIDATION"; }
if [[ -r $CONFIG_PATH && -f $CONFIG_PATH && ! -L $CONFIG_PATH ]] && path_components_safe "$CONFIG_PATH"; then
diag_result config-readable OK 'Конфигурация доступна'
if load_config >/dev/null 2>&1; then manager_config_ok=1; interface_name=$WG_INTERFACE; server_vpn_ip=$SERVER_VPN_IP; vpn_prefix=$VPN_SUBNET_PREFIX; server_port=$SERVER_PORT; use_ipv6=$USE_IPV6; ipv6_prefix=$IPV6_PREFIX; ipv4_network="${vpn_prefix}.0/24"; fi
else
diag_result config-readable FAIL 'Конфигурация недоступна'
fi
if ((manager_config_ok == 1)); then
local config_mode wg_mode config_owner wg_owner wg_config_safe=0
config_mode=$(stat -c '%a' -- "$CONFIG_PATH" 2>/dev/null) || config_mode=''
config_owner=$(stat -c '%u:%g' -- "$CONFIG_PATH" 2>/dev/null) || config_owner=''
if [[ -r $WG_CONF && -f $WG_CONF && ! -L $WG_CONF ]] && path_components_safe "$WG_CONF"; then wg_config_safe=1; fi
if ((wg_config_safe == 1)); then
wg_mode=$(stat -c '%a' -- "$WG_CONF" 2>/dev/null) || wg_mode=''
wg_owner=$(stat -c '%u:%g' -- "$WG_CONF" 2>/dev/null) || wg_owner=''
else
wg_mode=''; wg_owner=''
fi
if [[ -f $CONFIG_PATH && ! -L $CONFIG_PATH && ( $config_mode == 400 || $config_mode == 600 ) && ( $wg_mode == 400 || $wg_mode == 600 ) && $(stat -c '%h' -- "$CONFIG_PATH") == 1 && $wg_config_safe == 1 && $(stat -c '%h' -- "$WG_CONF") == 1 && $config_owner == "$wg_owner" ]]; then diag_result config-permissions OK 'Права конфигураций безопасны'; else diag_result config-permissions FAIL 'Небезопасные права конфигураций'; fi
if ((wg_config_safe == 1)) && parse_wireguard_config "$WG_CONF" >/dev/null 2>&1; then disk_parse_ok=1; diag_result config-parse OK 'Конфигурация WireGuard корректна'; else diag_result config-parse FAIL 'Конфигурация WireGuard не прошла проверку'; fi
if ((disk_parse_ok == 1)); then
(( PARSE_DUPLICATE_KEYS == 1 )) && diag_result duplicate-keys FAIL 'Обнаружены повторяющиеся поля' || diag_result duplicate-keys OK 'Повторяющихся ключей не найдено'
(( PARSE_ALLOWEDIPS_OVERLAP == 1 )) && diag_result allowedips-overlap FAIL 'Обнаружены дублирующиеся или пересекающиеся AllowedIPs' || diag_result allowedips-overlap OK 'AllowedIPs не пересекаются'
else
(( PARSE_DUPLICATE_KEYS == 1 )) && diag_result duplicate-keys FAIL 'Обнаружены повторяющиеся поля' || diag_result duplicate-keys SKIP 'Полный разбор конфигурации невозможен'
(( PARSE_ALLOWEDIPS_OVERLAP == 1 )) && diag_result allowedips-overlap FAIL 'Обнаружены дублирующиеся или пересекающиеся AllowedIPs' || diag_result allowedips-overlap SKIP 'Полный разбор конфигурации невозможен'
fi
local key_file key_mode key_links key_found=0 key_bad=0 client_dir_mode client_dir_owner
client_dir_mode=$(stat -c '%a' -- "$CLIENT_DIR" 2>/dev/null) || client_dir_mode=''; client_dir_owner=$(stat -c '%u:%g' -- "$CLIENT_DIR" 2>/dev/null) || client_dir_owner=''
if [[ -d $CLIENT_DIR && ! -L $CLIENT_DIR && $client_dir_mode == 700 && $client_dir_owner == "$wg_owner" ]] && path_components_safe "$CLIENT_DIR"; then
for key_file in "${CLIENT_DIR%/}"/*.key "${CLIENT_DIR%/}"/*.psk; do
[[ -e $key_file || -L $key_file ]] || continue
key_found=1
key_mode=$(stat -c '%a' -- "$key_file" 2>/dev/null) || key_mode=''; key_links=$(stat -c '%h' -- "$key_file" 2>/dev/null) || key_links=''
key_owner=$(stat -c '%u:%g' -- "$key_file" 2>/dev/null) || key_owner=''
if [[ ! -f $key_file || -L $key_file || ( $key_mode != 400 && $key_mode != 600 ) || $key_links != 1 || $key_owner != "$wg_owner" ]]; then key_bad=1; fi
done
if ((key_found == 0)); then diag_result key-permissions SKIP 'Отдельные ключевые файлы не найдены'; elif ((key_bad == 0)); then diag_result key-permissions OK 'Права ключевых файлов безопасны'; else diag_result key-permissions FAIL 'Небезопасные права ключевых файлов'; fi
else diag_result key-permissions FAIL 'Каталог клиентских ключей небезопасен'; fi
else diag_result config-permissions FAIL 'Параметры конфигурации недействительны'; diag_result config-parse FAIL 'Конфигурация менеджера не загружена'; diag_result duplicate-keys SKIP 'Конфигурация не загружена'; diag_result allowedips-overlap SKIP 'Конфигурация не загружена'; diag_result key-permissions SKIP 'Конфигурация не загружена'; fi
for command_name in wg wg-quick ip; do diag_command_available "$command_name" && diag_result "command-$command_name" OK "Команда $command_name доступна" || diag_result "command-$command_name" FAIL "Не найдена обязательная команда $command_name"; done
if ((manager_config_ok == 1)) && diag_command_available ip && ip link show dev "$interface_name" >/dev/null 2>&1; then diag_result interface-exists OK 'Интерфейс существует'; elif ((manager_config_ok == 1)); then diag_result interface-exists FAIL 'Интерфейс не найден'; else diag_result interface-exists SKIP 'Конфигурация менеджера не загружена'; fi
if ((manager_config_ok == 1)) && diag_command_available wg && output=$(wg show "$interface_name" dump 2>/dev/null) && parse_runtime_dump "$output" >/dev/null 2>&1; then dump_ok=1; diag_result interface-runtime OK 'Дамп WireGuard корректен'; elif ((manager_config_ok == 1)); then diag_result interface-runtime FAIL 'Дамп WireGuard недоступен или неверен'; else diag_result interface-runtime SKIP 'Конфигурация менеджера не загружена'; fi
if diag_command_available systemctl; then
if ! systemd_state=$(systemctl is-system-running 2>/dev/null); then :; fi
if [[ $systemd_state == running || $systemd_state == degraded ]]; then
systemd_usable=1
if systemd_state=$(systemctl is-active -- "wg-quick@$interface_name" 2>/dev/null); then
case $systemd_state in active) diag_result systemd OK 'Сервис WireGuard активен';; inactive|failed|dead) diag_result systemd FAIL 'Сервис WireGuard неактивен';; *) diag_result systemd WARN 'Состояние systemd неясно';; esac
else
case $systemd_state in inactive|failed|dead) diag_result systemd FAIL 'Сервис WireGuard неактивен';; *) diag_result systemd WARN 'Не удалось получить состояние systemd';; esac
fi
else diag_result systemd SKIP 'systemd не является активным init'; fi
else diag_result systemd SKIP 'systemctl не найден'; fi
path=${WG_MANAGER_IPV4_FORWARD_FILE:-/proc/sys/net/ipv4/ip_forward}; value=$(diag_read_value "$path" 2>/dev/null) || value=''; if [[ $value == 1 ]]; then diag_result forwarding-ipv4 OK 'IPv4 forwarding включён'; elif [[ -n $value ]]; then diag_result forwarding-ipv4 FAIL 'IPv4 forwarding имеет небезопасное значение'; else diag_result forwarding-ipv4 SKIP 'Нет доступа к IPv4 forwarding'; fi
if [[ $use_ipv6 == 1 ]]; then path=${WG_MANAGER_IPV6_FORWARD_FILE:-/proc/sys/net/ipv6/conf/all/forwarding}; value=$(diag_read_value "$path" 2>/dev/null) || value=''; [[ $value == 1 ]] && diag_result forwarding-ipv6 OK 'IPv6 forwarding включён' || [[ -n $value ]] && diag_result forwarding-ipv6 FAIL 'IPv6 forwarding имеет небезопасное значение' || diag_result forwarding-ipv6 SKIP 'Нет доступа к IPv6 forwarding'; else diag_result forwarding-ipv6 SKIP 'IPv6 отключён'; fi
if ((manager_config_ok == 1)) && diag_command_available ip; then
output=$(ip -o addr show dev "$interface_name" 2>/dev/null); address_ok=0
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do token=${token%%/*}; [[ $token == "$server_vpn_ip" ]] && address_ok=1; done
done <<<"$output"
((address_ok == 1)) && diag_result interface-address OK 'Адрес интерфейса подтверждён' || diag_result interface-address WARN 'Адрес интерфейса не подтверждён'
route_output=$(ip -o route show "$ipv4_network" dev "$interface_name" 2>/dev/null); tokens=(); read -r -a tokens <<<"$route_output"; route_ok=0
for ((index=0; index<${#tokens[@]}; index++)); do [[ ${tokens[index]} == "$ipv4_network" && ${tokens[index+1]:-} == dev && ${tokens[index+2]:-} == "$interface_name" ]] && route_ok=1; done
((route_ok == 1)) && diag_result routes OK 'Маршрут VPN подтверждён' || diag_result routes WARN 'Маршрут VPN не подтверждён'
else diag_result interface-address SKIP 'ip не найден'; diag_result routes SKIP 'ip не найден'; fi
if [[ $use_ipv6 == 1 ]] && diag_command_available ip; then
local expected_ipv6="${ipv6_prefix}1" expected_ipv6_route="${ipv6_prefix}/64"
output=$(ip -o addr show dev "$interface_name" 2>/dev/null); ipv6_address_ok=0
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do token=${token%%/*}; [[ $token == "$expected_ipv6" ]] && ipv6_address_ok=1; done
done <<<"$output"
((ipv6_address_ok == 1)) && diag_result interface-address-ipv6 OK 'IPv6 адрес интерфейса подтверждён' || diag_result interface-address-ipv6 WARN 'IPv6 адрес интерфейса не подтверждён'
route_output=$(ip -o route show "$expected_ipv6_route" dev "$interface_name" 2>/dev/null); tokens=(); read -r -a tokens <<<"$route_output"; ipv6_route_ok=0
for ((index=0; index<${#tokens[@]}; index++)); do [[ ${tokens[index]} == "$expected_ipv6_route" && ${tokens[index+1]:-} == dev && ${tokens[index+2]:-} == "$interface_name" ]] && ipv6_route_ok=1; done
((ipv6_route_ok == 1)) && diag_result routes-ipv6 OK 'IPv6 маршрут VPN подтверждён' || diag_result routes-ipv6 WARN 'IPv6 маршрут VPN не подтверждён'
else diag_result interface-address-ipv6 SKIP 'IPv6 отключён'; diag_result routes-ipv6 SKIP 'IPv6 отключён'; fi
if diag_command_available ss; then
local udp_ok=0 endpoint
if output=$(ss -lun 2>/dev/null); then
tokens=()
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do
token=${token//\[/}; token=${token//\]/}
[[ $token == *":$server_port" ]] || continue
endpoint=${token%:"$server_port"}
if [[ $endpoint == '*' || $endpoint == '::' || $endpoint =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then udp_ok=1; fi
done
done <<<"$output"
fi
((udp_ok == 1)) && diag_result udp-listener OK 'UDP-порт подтверждён' || diag_result udp-listener WARN 'UDP-порт не подтверждён'
else diag_result udp-listener SKIP 'ss не найден'; fi
local firewall_checked=0
if diag_command_available nft; then
if output=$(nft list ruleset 2>/dev/null); then
firewall_checked=1; nat_ok=0; lower_output=${output,,}
while IFS= read -r line; do
line=${line,,}
tokens=(); read -r -a tokens <<<"$line"; line_network=0; line_interface=0; line_nat=0
for token in "${tokens[@]}"; do token=${token//\"/}; [[ $token == "$ipv4_network" ]] && line_network=1; [[ $token == "$interface_name" ]] && line_interface=1; [[ $token == masquerade || $token == snat ]] && line_nat=1; done
((line_network == 1 && line_interface == 1 && line_nat == 1)) && nat_ok=1
((nat_ok == 1)) && break
done <<<"$lower_output"
((nat_ok == 1)) && diag_result firewall OK 'Видно ожидаемое NAT-свидетельство' || diag_result firewall WARN 'Не найдено убедительное NAT-свидетельство'
fi
fi
if ((firewall_checked == 0)) && diag_command_available iptables; then
if output=$(iptables -t nat -S 2>/dev/null); then
firewall_checked=1; nat_ok=0; lower_output=${output,,}
while IFS= read -r line; do
line=${line,,}
tokens=(); read -r -a tokens <<<"$line"; line_network=0; line_interface=0; line_nat=0
for token in "${tokens[@]}"; do token=${token//\"/}; [[ $token == "$ipv4_network" ]] && line_network=1; [[ $token == "$interface_name" ]] && line_interface=1; [[ $token == masquerade || $token == snat ]] && line_nat=1; done
((line_network == 1 && line_interface == 1 && line_nat == 1)) && nat_ok=1
((nat_ok == 1)) && break
done <<<"$lower_output"
((nat_ok == 1)) && diag_result firewall OK 'Видно ожидаемое NAT-свидетельство' || diag_result firewall WARN 'Не найдено убедительное NAT-свидетельство'
fi
fi
((firewall_checked == 1)) || { if diag_command_available nft || diag_command_available iptables; then diag_result firewall WARN 'Команда firewall недоступна'; else diag_result firewall SKIP 'Нет nftables или iptables'; fi; }
if ((manager_config_ok == 1 && disk_parse_ok == 1 && dump_ok == 1)); then
disk_peer_count=${#DISK_ALLOWED[@]}; runtime_peer_count=${#RUNTIME_ALLOWED[@]};
((disk_peer_count == runtime_peer_count)) || runtime_match=0
for key in "${!DISK_ALLOWED[@]}"; do [[ -n ${RUNTIME_ALLOWED[$key]+x} && ${RUNTIME_ALLOWED[$key]} == "${DISK_ALLOWED[$key]}" ]] || runtime_match=0; done
for key in "${!RUNTIME_ALLOWED[@]}"; do [[ -n ${DISK_ALLOWED[$key]+x} ]] || runtime_match=0; done
((runtime_match == 1)) && diag_result runtime-disk OK 'Наборы peers совпадают' || diag_result runtime-disk WARN 'Наборы peers имеют различия';
else diag_result runtime-disk SKIP 'Сравнение peers невозможно'; fi
if ((systemd_usable == 1)) && diag_command_available journalctl; then if output=$(journalctl -u "wg-quick@$interface_name" --since '24 hours ago' --no-pager 2>/dev/null); then lower_output=${output,,}; [[ $lower_output == *error* || $lower_output == *failed* ]] && diag_result journal WARN 'Журнал содержит ошибки' || diag_result journal OK 'Свежих ошибок в журнале не найдено'; else diag_result journal WARN 'Журнал недоступен'; fi; else diag_result journal SKIP 'Журнал недоступен без systemd'; fi
((DIAG_FAILS == 0)) || return "$EXIT_OPERATION"
}
resolve_named_client() {
local name=$1 index count=0 found=-1
@@ -1523,6 +1968,297 @@ qr_client() {
(( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; }
}
short_public_key() { local key=$1; printf '%s' "${key: -8}"; }
remove_peer_builder() {
local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?}
local -a lines=() kept=() block=()
local line index peer_start block_start block_end cursor=0 key marker allowed normalized matched=0 metadata_allowed
[[ ${META_NAME[$target_key]:-} == "$target_name" && ${META_MANAGED[$target_key]:-0} == 1 ]] || { die "$EXIT_VALIDATION" 'Identity клиента изменилась после lock'; return $?; }
[[ ${META_FILE[$target_key]:-} == "${REMOVE_META_FILE:-}" && ${META_CONFIG[$target_key]:-} == "${REMOVE_CONFIG_PATH:-}" ]] || { die "$EXIT_VALIDATION" 'Путь клиента изменился после lock'; return $?; }
normalized=$(normalize_allowed_ips "${DISK_ALLOWED[$target_key]:-}") || { die "$EXIT_VALIDATION" 'Адреса клиента изменились после lock'; return $?; }
[[ $normalized == "${REMOVE_ALLOWED:-}" ]] || { die "$EXIT_VALIDATION" 'Адреса клиента изменились после lock'; return $?; }
metadata_allowed=${META_IPV4[$target_key]:-}
[[ -n $metadata_allowed ]] && metadata_allowed+='/32'
[[ -n ${META_IPV6[$target_key]:-} ]] && { [[ -n $metadata_allowed ]] && metadata_allowed+=','; metadata_allowed+=${META_IPV6[$target_key]}/128; }
normalized=$(normalize_allowed_ips "$metadata_allowed") || { die "$EXIT_VALIDATION" 'Адреса metadata изменились после lock'; return $?; }
[[ $normalized == "${REMOVE_ALLOWED:-}" ]] || { die "$EXIT_VALIDATION" 'Адреса metadata изменились после lock'; return $?; }
mapfile -t lines <"$WG_CONF" || return "$EXIT_OPERATION"
while :; do
peer_start=-1
for index in "${!lines[@]}"; do
(( index >= cursor )) || continue
if [[ ${lines[$index]} =~ ^[[:space:]]*\[Peer\][[:space:]]*$ ]]; then peer_start=$index; break; fi
done
if (( peer_start < 0 )); then
kept+=("${lines[@]:$cursor}")
break
fi
block_start=$peer_start
if (( peer_start > 0 )) && [[ ${lines[$((peer_start - 1))]} == "# wg-manager-client: ${target_name}" ]]; then
block_start=$((peer_start - 1))
if (( block_start < cursor )) && ((${#kept[@]} > 0)) && [[ ${kept[$((${#kept[@]} - 1))]} == "# wg-manager-client: ${target_name}" ]]; then
unset 'kept[${#kept[@]}-1]'
fi
fi
if (( block_start > cursor )); then kept+=("${lines[@]:$cursor:$((block_start - cursor))}"); fi
block_end=${#lines[@]}
for index in "${!lines[@]}"; do
(( index > peer_start )) || continue
if [[ ${lines[$index]} =~ ^[[:space:]]*\[Peer\][[:space:]]*$ || ${lines[$index]} =~ ^[[:space:]]*\[Interface\][[:space:]]*$ ]]; then block_end=$index; break; fi
done
block=("${lines[@]:$block_start:$((block_end - block_start))}")
key=''; marker=''; allowed=''
for line in "${block[@]}"; do
[[ $line =~ ^[[:space:]]*PublicKey[[:space:]]*=[[:space:]]*(.*)$ ]] && key=$(trim_space "${BASH_REMATCH[1]}")
[[ $line =~ ^[[:space:]]*AllowedIPs[[:space:]]*=[[:space:]]*(.*)$ ]] && allowed=$(trim_space "${BASH_REMATCH[1]}")
if [[ ${block[0]:-} == "# wg-manager-client: ${target_name}" ]]; then marker=1; fi
done
normalized=$(normalize_allowed_ips "$allowed") || return "$EXIT_VALIDATION"
if [[ $key == "$target_key" && $marker == 1 && $normalized == "${REMOVE_ALLOWED:-}" ]]; then
# A conventional marker immediately before the next [Peer] belongs to
# that next peer and must survive removal of this block.
local trailing_index=$(( ${#block[@]} - 1 ))
while (( trailing_index >= 0 )) && [[ -z ${block[$trailing_index]} ]]; do ((trailing_index -= 1)); done
if (( trailing_index >= 0 )) && [[ ${block[$trailing_index]} =~ ^#\ (wg-manager-client:|###\ Client\ ) ]] && [[ ${block[$trailing_index]} != "# wg-manager-client: ${target_name}" ]]; then
kept+=("${block[$trailing_index]}")
fi
else
kept+=("${block[@]}")
fi
if [[ $key == "$target_key" && $marker == 1 && $normalized == "${REMOVE_ALLOWED:-}" ]]; then ((matched += 1)); fi
cursor=$block_end
done
(( matched == 1 )) || { die "$EXIT_VALIDATION" 'Не найден единственный точный managed-блок клиента'; return $?; }
: >"$stage" || return "$EXIT_OPERATION"
printf '%s\n' "${kept[@]}" >"$stage"
}
remove_archive_sources() {
local path base mode owner server_owner
REMOVE_ARCHIVE_SOURCES=("$REMOVE_META_FILE" "$REMOVE_CONFIG_PATH")
for base in key pub psk; do
path="${CLIENT_DIR}/${REMOVE_NAME}.${base}"
if [[ -e $path || -L $path ]]; then REMOVE_ARCHIVE_SOURCES+=("$path"); fi
done
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do
[[ -f $path && ! -L $path ]] || { die "$EXIT_VALIDATION" 'Небезопасный файл архивации'; return $?; }
mode=$(stat -c '%a' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить режим файла архивации'; return $?; }
owner=$(stat -c '%u:%g' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца файла архивации'; return $?; }
[[ $mode == 600 && $owner == "$server_owner" && $(stat -c '%h' -- "$path") == 1 ]] || { die "$EXIT_VALIDATION" 'Файл архивации имеет небезопасные атрибуты'; return $?; }
done
}
allocate_remove_archive() {
local now stamp base candidate suffix
now=$(current_epoch) || { die "$EXIT_OPERATION" 'Не удалось получить время архивации'; return $?; }
stamp=$(date -u -d "@${now}" +%Y%m%dT%H%M%SZ) || { die "$EXIT_OPERATION" 'Не удалось получить UTC-время архивации'; return $?; }
base="${ARCHIVE_DIR}/${stamp}-${WG_INTERFACE}-${REMOVE_NAME}"
for ((suffix = 0; suffix <= 999; suffix++)); do
if (( suffix == 0 )); then candidate=$base; else candidate="${base}-${suffix}"; fi
if mkdir -- "$candidate" 2>/dev/null; then
if ! chmod 700 -- "$candidate"; then
rm -rf -- "$candidate"
die "$EXIT_OPERATION" 'Не удалось защитить каталог архива'
return $?
fi
REMOVE_ARCHIVE_PATH=$candidate
return 0
fi
done
die "$EXIT_OPERATION" 'Не удалось создать уникальный архив'
return $?
}
archive_remove_artifacts() {
local path base mode owner server_owner remove_index=0 copy_index=0
local -a archived=()
set_runtime_paths || return $?
[[ ${REMOVE_NAME:?} =~ ^[A-Za-z0-9._-]+$ ]] || return "$EXIT_VALIDATION"
path_components_safe "$ARCHIVE_DIR" && path_components_safe "$CLIENT_DIR" && path_components_safe "$METADATA_DIR" || { die "$EXIT_VALIDATION" 'Каталоги архивации содержат небезопасный путь'; return $?; }
[[ ! -L $ARCHIVE_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог архива является ссылкой'; return $?; }
[[ $REMOVE_META_FILE == "${METADATA_DIR}/${REMOVE_NAME}.meta" && $REMOVE_CONFIG_PATH == "${CLIENT_DIR}/${REMOVE_NAME}.conf" ]] || { die "$EXIT_VALIDATION" 'Файлы клиента не соответствуют его имени'; return $?; }
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; }
if [[ -e $ARCHIVE_DIR ]]; then
[[ -d $ARCHIVE_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог архива недоступен'; return $?; }
mode=$(stat -c '%a' -- "$ARCHIVE_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить режим каталога архива'; return $?; }
owner=$(stat -c '%u:%g' -- "$ARCHIVE_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога архива'; return $?; }
[[ $mode == 700 && $owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Каталог архива имеет небезопасные атрибуты'; return $?; }
else
mkdir -- "$ARCHIVE_DIR" || { die "$EXIT_OPERATION" 'Не удалось создать каталог архива'; return $?; }
if ! chmod 700 -- "$ARCHIVE_DIR"; then
if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi
die "$EXIT_OPERATION" 'Не удалось защитить каталог архива'
return $?
fi
if ! owner=$(stat -c '%u:%g' -- "$ARCHIVE_DIR"); then
if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi
die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога архива'
return $?
fi
if [[ $owner != "$server_owner" ]]; then
if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi
die "$EXIT_VALIDATION" 'Владелец каталога архива не совпадает'
return $?
fi
fi
remove_archive_sources || return $?
allocate_remove_archive || return $?
for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do
base=${path##*/}
if [[ ${M6_ARCHIVE_COPY_FAIL_INDEX:-} == "$copy_index" ]] && transaction_test_hooks_enabled; then
rm -rf -- "$REMOVE_ARCHIVE_PATH"
die "$EXIT_OPERATION" 'Не удалось сохранить файл клиента в архив'
return $?
fi
if ! copy_restricted "$path" "${REMOVE_ARCHIVE_PATH}/${base}"; then
rm -rf -- "$REMOVE_ARCHIVE_PATH"
die "$EXIT_OPERATION" 'Не удалось сохранить файл клиента в архив'
return $?
fi
archived+=("$path")
((copy_index += 1))
done
for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do
if [[ ${M6_ARCHIVE_REMOVE_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then
for base in "${archived[@]}"; do
[[ -e $base ]] && continue
if [[ ${M6_ARCHIVE_RECOVERY_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then
die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'
return $?
fi
cp -- "${REMOVE_ARCHIVE_PATH}/${base##*/}" "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'; return $?; }
chmod 600 -- "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось защитить восстановленный файл'; return $?; }
done
die "$EXIT_OPERATION" 'Не удалось удалить исходный файл клиента после архивации'
return $?
fi
if ! rm -f -- "$path"; then
for base in "${archived[@]}"; do
[[ -e $base ]] && continue
if [[ ${M6_ARCHIVE_RECOVERY_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then
die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'
return $?
fi
cp -- "${REMOVE_ARCHIVE_PATH}/${base##*/}" "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'; return $?; }
chmod 600 -- "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось защитить восстановленный файл'; return $?; }
done
die "$EXIT_OPERATION" 'Не удалось завершить архивацию файлов клиента'
return $?
fi
((remove_index += 1))
done
}
write_audit_event() {
local operation=$1 target=$2 outcome=$3 error_category=$4
local audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity locked_identity write_status
[[ $operation =~ ^[a-z][a-z0-9-]{0,31}$ && $target =~ ^[A-Za-z0-9._-]{1,96}$ && $outcome =~ ^[a-z][a-z0-9-]{0,31}$ && $error_category =~ ^[a-z][a-z0-9-]{0,31}$ ]] || return "$EXIT_VALIDATION"
path_components_safe "$LOG_DIR" || return "$EXIT_VALIDATION"
[[ ! -L $LOG_DIR ]] || return "$EXIT_VALIDATION"
if [[ ! -e $LOG_DIR ]]; then
mkdir -- "$LOG_DIR" || return "$EXIT_OPERATION"
chmod 700 -- "$LOG_DIR" || return "$EXIT_OPERATION"
fi
[[ -d $LOG_DIR ]] || return "$EXIT_OPERATION"
mode=$(stat -c '%a' -- "$LOG_DIR") || return "$EXIT_OPERATION"
[[ $mode == 700 ]] || return "$EXIT_VALIDATION"
server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION"
owner=$(stat -c '%u:%g' -- "$LOG_DIR") || return "$EXIT_OPERATION"
[[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION"
if [[ ${M6_AUDIT_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then
return "$EXIT_OPERATION"
fi
if [[ -e $audit_file || -L $audit_file ]]; then
[[ -f $audit_file && ! -L $audit_file && $(stat -c '%h' -- "$audit_file") == 1 ]] || return "$EXIT_VALIDATION"
mode=$(stat -c '%a' -- "$audit_file") || return "$EXIT_OPERATION"
[[ $mode == 600 ]] || return "$EXIT_VALIDATION"
owner=$(stat -c '%u:%g' -- "$audit_file") || return "$EXIT_OPERATION"
[[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION"
else
if ! (umask 077; set -C; : >"$audit_file") 2>/dev/null; then
[[ -f $audit_file && ! -L $audit_file ]] || return "$EXIT_OPERATION"
else
chmod 600 -- "$audit_file" || return "$EXIT_OPERATION"
fi
fi
timestamp=$(current_epoch) || return "$EXIT_OPERATION"
timestamp=$(date -u -d "@${timestamp}" +%Y-%m-%dT%H:%M:%SZ) || return "$EXIT_OPERATION"
expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION"
exec {fd}>>"$audit_file" || return "$EXIT_OPERATION"
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; }
if ! flock "$fd"; then exec {fd}>&-; return "$EXIT_OPERATION"; fi
locked_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; }
[[ $locked_identity == "$opened_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; }
if printf 'timestamp=%s actor_uid=%s operation=%s target=%s client=%s key_suffix=%s outcome=%s error=%s\n' "$timestamp" "$EUID" "$operation" "$target" "${AUDIT_CLIENT:-$target}" "${AUDIT_KEY_SUFFIX:-}" "$outcome" "$error_category" >&$fd; then write_status=0; else write_status=$EXIT_OPERATION; fi
exec {fd}>&-
return "$write_status"
}
remove_audit() {
local outcome=$1
AUDIT_CLIENT=${REMOVE_NAME:-remove} AUDIT_KEY_SUFFIX=$(short_public_key "${REMOVE_KEY:-}") write_audit_event remove "${REMOVE_NAME:-remove}" "$outcome" "${REMOVE_ERROR_CATEGORY:-$outcome}"
}
confirm_remove() {
local name=$1 key=$2 addresses=$3 answer
printf 'Удалить клиента %s (ключ *%s, адреса %s)? [y/N] ' "$name" "$(short_public_key "$key")" "$addresses" >&2
IFS= read -r answer || answer=n
[[ $answer == y || $answer == Y ]]
}
remove_is_interactive() { [[ -t 0 && -t 1 ]]; }
remove_client_impl() {
local name=$1 flag=${2:-} index key path status answer
(($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы remove'; return $?; }
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
[[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг remove --yes'; return $?; }
load_client_model || return $?
path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; }
require_remove_privilege || return $?
index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; }
key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $?
REMOVE_NAME=$name REMOVE_KEY=$key REMOVE_CONFIG_PATH=$path REMOVE_META_FILE=${META_FILE[$key]}
REMOVE_ALLOWED=${DISK_ALLOWED[$key]}
if [[ -z $flag ]] && ! remove_is_interactive; then
REMOVE_ERROR_CATEGORY=confirmation_required
remove_audit cancelled
die "$EXIT_USAGE" 'В неинтерактивном режиме требуется флаг --yes'
return $?
fi
if [[ -n $flag ]]; then answer=y; else if confirm_remove "$name" "$key" "${CLIENT_ADDRESSES[$index]}"; then answer=y; else answer=n; fi; fi
if [[ $answer != y && $answer != Y ]]; then
REMOVE_ERROR_CATEGORY=cancelled
if ! remove_audit cancelled; then printf 'Предупреждение: отмена выполнена, но аудит не записан\n' >&2; fi
printf 'Операция отменена\n' >&2
return 0
fi
TRANSACTION_FINALIZER=archive_remove_artifacts
if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi
if (( status == 0 )); then
REMOVE_ERROR_CATEGORY=success
if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi
elif { REMOVE_ERROR_CATEGORY=transaction_failed; ! remove_audit failed; }; then
printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2
fi
unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER
return "$status"
}
remove_client() {
local xtrace=0 status
[[ $- == *x* ]] && xtrace=1; set +x
if remove_client_impl "$@"; then status=0; else status=$?; fi
unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH REMOVE_ERROR_CATEGORY TRANSACTION_FINALIZER REMOVE_ARCHIVE_SOURCES
(( xtrace == 1 )) && set -x
return "$status"
}
main() {
local status
if (( $# == 0 )); then
@@ -1555,6 +2291,22 @@ main() {
case $command in show) show_client "$name" ;; qr) qr_client "$name" ;; esac
return $?
fi
if [[ $1 == diagnose ]]; then
shift
diagnose "$@"
return $?
fi
if [[ $1 == restore ]]; then
(($# >= 2 && $# <= 3)) || { die "$EXIT_USAGE" 'Ожидается идентификатор резервной копии'; return $?; }
shift
restore_backup "$@"
return $?
fi
if [[ $1 == log ]]; then
shift
list_audit "$@"
return $?
fi
if (( $# != 1 )); then
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2
@@ -1565,7 +2317,13 @@ main() {
version) print_version ;;
status) load_client_model || return $?; print_status ;;
list) load_client_model || return $?; print_list ;;
remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
remove)
shift
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
remove_client "$@" ;;
backup) manual_backup ;;
backups) list_backups ;;
logs|tui) run_placeholder "$1" ;;
*)
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2