Compare commits

...

4 Commits

Author SHA1 Message Date
sasa e5e3064885 feat(clients): add WireGuard state parsing and client listing 2026-10-12 00:01:06 +03:00
sasa 987c000320 feat(core): add validated configuration and CLI foundation 2026-10-11 23:34:01 +03:00
sasa 216b09474e test: add isolated Bash test harness and project skeleton 2026-10-11 23:14:44 +03:00
sasa e7e728cd23 Add OpenAI specific configuration
- add AGENTS.md and PLANS.md based on MPV
- add subagents configuration
2026-10-11 23:06:05 +03:00
39 changed files with 4086 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
name = "milestone_repairer"
description = "Low-cost repair agent that fixes concrete defects found by the senior milestone reviewer."
model = "gpt-5.6-luna"
model_reasoning_effort = "medium"
sandbox_mode = "workspace-write"
developer_instructions = """
Repair only the concrete review findings supplied by the primary agent for the current milestone.
Read AGENTS.md, the current milestone in PLANS.md, the relevant MVP.md requirements, the current diff, and every referenced file. Reproduce each reported failure before editing when the failure is reproducible in the mock-based development environment.
For every finding, identify the cause, make the smallest safe correction, add or strengthen a regression test, and rerun the failed command plus the complete verification gate for the current milestone. Do not weaken tests, suppress errors without justification, or change acceptance criteria.
The development machine has no WireGuard server. Use only fixtures and mocks. Never touch the host's real WireGuard, networking, firewall, services, journal, or /etc/wireguard state.
Do not mark the milestone DONE, create commits, or declare the review passed. Return a concise finding-by-finding report with changed files, tests added, exact verification results, and anything that remains unresolved. The primary agent must independently review the result.
"""
+19
View File
@@ -0,0 +1,19 @@
name = "milestone_worker"
description = "Low-cost implementation agent for one bounded WireGuard Manager milestone or an explicitly isolated part of it."
model = "gpt-5.6-luna"
model_reasoning_effort = "medium"
sandbox_mode = "workspace-write"
developer_instructions = """
Implement only the milestone or bounded subtask assigned by the primary agent.
Before editing, read the applicable AGENTS.md rules, the assigned section of PLANS.md, and the relevant parts of MVP.md. Inspect the current files and preserve unrelated user changes.
Use fixtures and mocks for all WireGuard, networking, service, firewall, journal, qrencode, and gum behavior. The development machine has no WireGuard server. Never access or modify the host's real /etc/wireguard, interfaces, routes, firewall, services, or sysctl settings.
Keep the change within the files and acceptance criteria named in the assignment. Add or update tests with the implementation. Run every assigned verification command. If a check fails, repair the implementation and rerun it before returning.
Do not mark a milestone DONE, update the final milestone status, create commits, broaden scope, or claim that mock verification proves behavior on a real WireGuard server. The primary agent owns review, status changes, and completion decisions.
Return a concise report containing changed files, behavior implemented, verification commands and results, remaining uncertainties, and any blocker. Never include private keys, preshared keys, or full client configs in the report.
"""
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-luna"
default_subagent_reasoning_effort = "medium"
interrupt_message = true
+19
View File
@@ -1 +1,20 @@
/.idea/
/.obsidian/
# Local administrator configuration and runtime data
/wg-manager.conf
/backups/
/logs/
/state/
/bin/gum
# Generated client material
*.key
*.pub
*.psk
*-client-*.conf
*.conf.tmp
# Isolated test output
/tests/tmp/
/tests/output/
+300
View File
@@ -0,0 +1,300 @@
# AGENTS.md
## Scope
- These rules apply to the entire repository.
- Build a local WireGuard management tool for Linux servers.
- Keep `wg-manager.sh` as the primary entry point.
- The project is copied as a directory and is not installed into `PATH`.
- Do not add installer or uninstaller scripts unless explicitly requested.
- Treat `MVP.md` as the product scope and acceptance reference.
- Treat `PLANS.md` as the required milestone sequence for MVP implementation.
- Complete and verify one milestone before starting the next one.
- Update the `PLANS.md` progress and execution log as milestones are completed.
- Run milestones under the Sol coordinator and Luna worker/reviewer loop defined in `PLANS.md` and `.codex/`.
- The coordinator must independently review Luna's changes and delegate explicit Luna repairs until verification passes or a genuine blocker remains.
- Use the existing `add-user.sh` only as a behavioral reference.
- New code must improve its safety rather than copy its weaknesses.
## General priorities
- Preserve a working WireGuard configuration above all else.
- Prefer correctness and recoverability over cleverness.
- Keep CLI behavior independent from the TUI.
- Make every mutating operation explicit and auditable.
- Keep secrets out of output, logs, tests, and error messages.
- Minimize required dependencies.
- Support operation over SSH and ordinary terminal emulators.
- Write user-facing text in Russian unless the surrounding interface uses another language.
- Keep identifiers, functions, CLI command names, and code comments in English.
- Avoid unrelated refactors while implementing a focused change.
## Bash baseline
- Use Bash, not POSIX `sh`.
- Start executable scripts with `#!/usr/bin/env bash`.
- Enable `set -Eeuo pipefail` in production entry points.
- Understand strict-mode exceptions before suppressing them.
- Use `if command; then` when a nonzero status is expected.
- Do not append `|| true` unless the ignored failure is intentional and documented.
- Set `umask 077` before creating configs, keys, metadata, logs, or backups.
- Require a supported Bash version at startup when using version-specific features.
- Prefer Bash builtins over unnecessary subprocesses.
- Use `printf` rather than `echo -e`.
- Use `[[ ... ]]` for Bash conditionals.
- Use `(( ... ))` for arithmetic conditions.
- Use `case` for command dispatch and finite state handling.
- Use `local` for function-local variables.
- Declare variables close to first use.
- Use `readonly` for constants after configuration is loaded.
- Use uppercase names only for exported variables and true constants.
- Use lowercase snake_case for local variables and functions.
- Give functions verb-based names such as `load_config` or `add_client`.
- Keep functions focused and normally below about 60 lines.
- Return statuses from functions; print data only when output is the contract.
- Do not mix machine-readable results with progress messages.
- Send errors and diagnostics to stderr.
- Use explicit exit codes for expected CLI failure categories.
## Quoting and data handling
- Double-quote parameter expansions unless splitting is deliberately required.
- Use `"${array[@]}"` when forwarding array elements.
- Use arrays to build commands; never construct commands as strings.
- Never use `eval`.
- Never pass user-controlled text to `bash -c`.
- Avoid command substitution for multiline or NUL-sensitive data.
- Use `mapfile -t` for line-oriented command output when appropriate.
- Use `IFS= read -r` to read literal lines.
- Use `read -r -d ''` or NUL delimiters for arbitrary file names.
- Use `--` before user-derived positional arguments where supported.
- Do not rely on locale-sensitive parsing.
- Set `LC_ALL=C` locally for command output that must be parsed predictably.
- Do not parse human-formatted output when a stable dump format exists.
- Prefer `wg show <interface> dump` for WireGuard state parsing.
- Treat names, endpoints, addresses, paths, and config values as untrusted input.
- Validate input with allowlists and semantic checks.
- Reject control characters and embedded newlines in user input.
- Never derive a filesystem path from an unchecked client name.
- Canonicalize or constrain paths before reading or writing them.
## Configuration
- Keep server-specific values in `wg-manager.conf`.
- Resolve the project directory from `${BASH_SOURCE[0]}`.
- Do not depend on the caller's current working directory.
- Do not silently overwrite administrator configuration.
- Validate every required setting at startup.
- Reject unknown enum values such as unsupported tunnel modes.
- Validate numeric ranges before arithmetic use.
- Validate IPv4, IPv6, CIDR, port, and hostname values before use.
- Do not `source` a configuration file that may be writable by untrusted users.
- If sourcing is retained, require root ownership and non-group/world-writable permissions.
- Prefer a restricted `KEY=VALUE` parser for long-term safety.
- Environment overrides must be documented and narrowly scoped.
- Never accept key material through command-line arguments.
- Avoid exporting secrets into the process environment.
## Temporary files and cleanup
- Create temporary files with `mktemp`.
- Create mutation staging files on the same filesystem as the target.
- Never use predictable temporary filenames.
- Register cleanup immediately after creating temporary resources.
- Use a cleanup function with `trap` for `EXIT`, `INT`, and `TERM`.
- Quote trap bodies safely; avoid premature expansion.
- Preserve the original exit status during cleanup.
- Do not remove a path unless this process created it or validated it exactly.
- Apply restrictive permissions before writing secret content.
- Do not leave key material in world-readable temporary directories.
## WireGuard safety
- Require root only for commands that need privileged access or mutation.
- Allow safe read-only commands without root when system permissions permit it.
- Check that the configured interface and config file agree.
- Identify peers by public key, not only by display name.
- Treat comments such as `### Client NAME` as metadata, not identity.
- Detect duplicate public keys and overlapping `AllowedIPs`.
- Detect address collisions before generating any persistent file.
- Distinguish managed peers from unmanaged peers.
- Never modify an unmanaged peer implicitly.
- Do not regenerate server keys automatically.
- Do not rotate or replace client keys without explicit confirmation.
- Never print private or preshared keys in routine output.
- Never include key values in traces or debug logs.
- Disable shell tracing around all secret-handling code.
- Store client files with mode `0600`.
- Keep directories containing client material at mode `0700`.
## Mutating operations
- Serialize mutations with `flock`.
- Acquire the lock before re-reading mutable state.
- Revalidate assumptions after the lock is acquired.
- Create a backup before every persistent change.
- Write the complete proposed config to a staging file.
- Validate the staged config before replacing the live config.
- Preserve the target owner, group, and restrictive mode.
- Replace files atomically with a same-filesystem rename.
- Apply the new configuration only after durable storage succeeds.
- Verify active state after applying the change.
- Roll back both disk and runtime state on failure.
- Report rollback failure as a critical error.
- Never append directly to the live WireGuard config.
- Never use broad text deletion to remove a peer block.
- Match an exact managed block and its public key.
- Default to archiving client files instead of permanent deletion.
- Require explicit confirmation before destructive or restorative actions.
- Make repeated operations fail safely or behave idempotently.
## Backups and audit log
- Timestamp backups in an unambiguous UTC format.
- Include the interface name in backup metadata or filenames.
- Keep a bounded number of automatic backups.
- Never prune the only known-good backup.
- Validate a backup before offering restoration.
- Back up the current state before restoring an older state.
- Record who initiated a mutation when that information is available.
- Log the operation, target, timestamp, outcome, and concise error.
- Do not log private keys, preshared keys, or full client configs.
- Avoid logging full environment dumps or command lines with secrets.
- Make logs readable only by root by default.
- Log failed mutations and rollback outcomes.
## CLI design
- TUI actions must call the same backend functions as CLI commands.
- Keep CLI commands usable when `gum` is unavailable.
- Provide `help` and useful usage errors.
- Reject unknown commands and extra positional arguments.
- Keep command names stable once documented.
- Use stdout for requested data and stderr for warnings or progress.
- Do not add decorative ANSI output when stdout is not a terminal.
- Honor `NO_COLOR` for CLI colors where colors are used.
- Return zero only when the requested operation succeeded.
- Use distinct nonzero statuses for usage, validation, dependency, and runtime errors.
- Avoid interactive prompts when a CLI command is explicitly noninteractive.
- Require a confirmation flag for destructive noninteractive commands.
- Never let localization change machine-parsed values or exit codes.
## TUI with gum
- Use `gum` as the only planned TUI framework.
- Do not add `dialog`, `whiptail`, or a second TUI framework.
- Do not download or install `gum` from the application.
- Resolve `gum` in this order: `WG_MANAGER_GUM`, local `bin/gum`, then `PATH`.
- Validate that the resolved path is executable.
- Check required `gum` subcommands or minimum version at startup.
- Fail with a clear message when TUI is requested without usable `gum`.
- Keep all `gum` invocation details in small UI wrapper functions.
- Backend functions must never call `gum` directly.
- Use `gum choose` for short finite menus.
- Use `gum filter` for searchable client selection.
- Use `gum input` for short validated text fields.
- Use `gum confirm` before deletion, restoration, or key rotation.
- Use `gum table` only for display or selection, not as a data store.
- Use `gum pager` for logs, diagnostics, and long text.
- Use `gum spin` only when the wrapped operation can safely hide routine output.
- Preserve stderr from failed commands behind a spinner.
- Never show secret content inside a spinner command line.
- Keep stable internal values separate from translated display labels.
- Treat cancellation and Escape as normal control flow, not an error.
- Return to the previous screen after cancellation.
- Do not trap users in an endless menu loop after repeated failures.
- Keep the main menu shallow and task-oriented.
- Show interface name and current status on the main screen.
- Use consistent verbs: view, add, remove, diagnose, restore, exit.
- Show the exact target before asking for destructive confirmation.
- Require stronger confirmation for restoring backups or deleting client material.
- Keep colors readable on light and dark terminals.
- Do not communicate status by color alone.
- Keep layouts usable at 80 columns.
- Avoid assumptions about mouse support.
- Do not clear valuable terminal output after an error.
- Detect noninteractive stdin or stdout before starting TUI.
- Never emit TUI control output into pipelines or redirected files.
## Error handling
- Provide one concise user-facing error plus actionable context.
- Preserve the failing command's status where meaningful.
- Do not expose secret variable values in error messages.
- Distinguish validation errors from system failures.
- Distinguish skipped diagnostic checks from successful checks.
- Diagnostic results use `OK`, `WARN`, `FAIL`, or `SKIP`.
- A critical diagnostic failure must produce a nonzero status.
- Include remediation guidance when it is reliable and specific.
- Avoid guessing firewall fixes across distributions.
- Continue independent diagnostic checks after a nonfatal failure.
## Dependencies and portability
- Assume a Linux server with Bash and WireGuard tools.
- Keep required dependencies listed in `MVP.md` and README.
- Treat `qrencode`, systemd, `nft`, and `iptables` as optional capabilities.
- Detect optional commands before using them.
- Do not fail the entire diagnostic run because one optional tool is absent.
- Prefer feature detection over distribution detection.
- Do not assume systemd is PID 1 on every supported server.
- Support at least Linux `x86_64`; document additional tested architectures.
- Do not assume the bundled `gum` binary matches the current architecture.
- Report architecture mismatch clearly.
## Tests
- Add regression tests for every fixed bug.
- Tests must not modify the host's real `/etc/wireguard`.
- Use fixtures for server configs, peer blocks, and metadata.
- Mock external commands through a controlled test `PATH`.
- Cover `wg`, `wg-quick`, `ip`, `systemctl`, `journalctl`, `nft`, and `iptables` as needed.
- Test success, expected failure, rollback, and interruption paths.
- Test client names containing spaces, newlines, glob characters, and option prefixes.
- Test duplicate keys, duplicate IPs, and overlapping networks.
- Test empty peer lists and unmanaged peers.
- Test CLI behavior without `gum`.
- Test local binary, environment override, and `PATH` resolution for `gum`.
- Test that logs do not contain generated secrets.
- Test concurrent mutation attempts.
- Test restoration failure and failed rollback reporting.
- Keep tests deterministic; do not depend on external network access.
- Use temporary test directories and remove them safely.
## Quality gates
- Run `bash -n` on every changed shell script.
- Run ShellCheck on every changed shell script when available.
- Run the relevant automated tests.
- Format consistently; use `shfmt` when the project adopts a fixed style.
- Do not silence ShellCheck globally to hide local problems.
- Add a narrow inline suppression only with an explanatory comment.
- Review diffs for accidental key material before finishing.
- Review file permissions in tests that create secrets.
- Verify that failure paths preserve the original config.
- Verify CLI output with stdout redirected.
- Verify TUI entry and cancellation in an interactive terminal.
## Documentation
- Update `MVP.md` only when product scope or architecture changes.
- Keep operational usage in README once implementation begins.
- Document every CLI command and its exit behavior.
- Document required and optional dependencies separately.
- Include examples that use placeholder keys and addresses only.
- Never commit real endpoints, private keys, preshared keys, or client configs.
- Explain backup and restore behavior before users rely on it.
- Document limitations of handshake history and traffic visibility.
## Definition of done
- The requested behavior is implemented through shared backend functions.
- Mutations are locked, validated, backed up, atomic, and reversible.
- CLI operation remains available independently of the TUI.
- TUI uses the configured `gum` resolution order.
- Errors are actionable and do not reveal secrets.
- Relevant tests pass.
- `bash -n` passes.
- ShellCheck has no unexplained findings.
- Documentation matches observable behavior.
- No unrelated files or user changes are overwritten.
+1608
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -1,2 +1,35 @@
# wireguard-scripts
## M1 runtime foundation
The current entry point provides dependency-free `help` and `version` commands. The
planned operational commands are recognized but remain unavailable until later
milestones.
Runtime locations default to the project directory and can be redirected for an
isolated run with absolute paths using these environment variables:
`WG_MANAGER_CONFIG`, `WG_MANAGER_BACKUP_DIR`, `WG_MANAGER_LOG_DIR`,
`WG_MANAGER_METADATA_DIR`, `WG_MANAGER_ARCHIVE_DIR`, and `WG_MANAGER_LOCK_FILE`.
`WG_MANAGER_GUM` selects an executable `gum` explicitly; otherwise the bundled
`bin/gum` and then `PATH` are checked in that order. Configuration is parsed as
restricted `KEY=VALUE` data and is never executed as shell code. Blank lines and
full-line `#` comments are ignored; spaces around the key, `=`, and value are
trimmed. Values are either one unquoted token or a double-quoted value without
escapes, and only documented keys are accepted.
For deterministic read-only tests, `WG_MANAGER_NOW` may override the current
epoch when set to decimal seconds; invalid values are rejected.
M2 adds read-only `status` and `list`. They parse the configured WireGuard file
and, when available, `wg show <interface> dump`; a stopped or unavailable
runtime is reported as unavailable and disk-only peers remain visible. `list`
uses stable tab-separated output with English machine fields `state` and
`origin` (`managed`, `legacy`, or `unmanaged`) and never prints private or
preshared keys.
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
and configured addresses match a valid metadata record with `MANAGED=1`.
+8
View File
@@ -0,0 +1,8 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32,192.0.2.2/32
+12
View File
@@ -0,0 +1,12 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.3/32
+7
View File
@@ -0,0 +1,7 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
AllowedIPs = 192.0.2.2/32
+10
View File
@@ -0,0 +1,10 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
# wg-manager-client: alice
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
Endpoint = vpn.example.invalid:51820
+16
View File
@@ -0,0 +1,16 @@
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
ListenPort = 51820
### Client first
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
### Client second
[Peer]
PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
AllowedIPs = 192.0.2.3/32
# wg-manager-client: third
[Peer]
PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=
AllowedIPs = 192.0.2.4/32
+12
View File
@@ -0,0 +1,12 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32, 2001:db8::2/128
[Peer]
PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
AllowedIPs = 192.0.2.3/32
+18
View File
@@ -0,0 +1,18 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=
AllowedIPs = 192.0.2.4/32
[Peer]
### Client legacy
PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
AllowedIPs = 192.0.2.3/32,2001:db8::3/128
[Peer]
# wg-manager-client: alice
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
+18
View File
@@ -0,0 +1,18 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
# wg-manager-client: alice
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
[Peer]
### Client legacy
PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
AllowedIPs = 192.0.2.3/32,2001:db8::3/128
[Peer]
PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=
AllowedIPs = 192.0.2.4/32
+4
View File
@@ -0,0 +1,4 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
+12
View File
@@ -0,0 +1,12 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 2001:db8::/64
[Peer]
PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
AllowedIPs = 2001:db8::2/128
+12
View File
@@ -0,0 +1,12 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.0/24
[Peer]
PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
AllowedIPs = 192.0.2.3/32
+9
View File
@@ -0,0 +1,9 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
# wg-manager-client: alice
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32, 2001:db8::2/128
+15
View File
@@ -0,0 +1,15 @@
[Interface]
Address = 192.0.2.1/24
Address = 2001:db8::1/64
DNS = 192.0.2.53
DNS = 2001:db8::53
PreUp = true
PreUp = true
PostUp = true
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
ListenPort = 51820
FwMark = 123
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
+2
View File
@@ -0,0 +1,2 @@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 1700000100 100 200 25
+9
View File
@@ -0,0 +1,9 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
# Non-secret fixture placeholder; not used against a live host.
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
HELPERS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly HELPERS_DIR
PROJECT_DIR="$(cd -- "${HELPERS_DIR}/.." && pwd)"
readonly PROJECT_DIR
TEST_ROOT=''
TEST_MOCK_LOG=''
TEST_FAILURES=0
TEST_ASSERTIONS=0
setup_test_environment() {
TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/wg-manager-tests.XXXXXX")"
TEST_MOCK_LOG="${TEST_ROOT}/mock-invocations.log"
: >"$TEST_MOCK_LOG"
chmod 700 "$TEST_ROOT"
cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf"
export TEST_ROOT TEST_MOCK_LOG
export PATH="${PROJECT_DIR}/tests/mocks/bin:${PATH:-}"
trap cleanup_test_environment EXIT INT TERM
}
cleanup_test_environment() {
local status=$?
trap - EXIT INT TERM
if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" ]]; then
rm -rf -- "$TEST_ROOT"
fi
return "$status"
}
begin_test() {
printf 'TEST %s\n' "$1"
}
record_failure() {
printf ' FAIL: %s\n' "$1" >&2
((TEST_FAILURES += 1))
}
assert_equal() {
local expected=$1 actual=$2 message=${3:-значения равны}
((TEST_ASSERTIONS += 1))
if [[ "$expected" != "$actual" ]]; then
record_failure "${message}: ожидалось [${expected}], получено [${actual}]"
fi
}
assert_status() {
local expected=$1 actual=$2 message=${3:-код завершения}
assert_equal "$expected" "$actual" "$message"
}
assert_contains() {
local needle=$1 haystack=$2 message=${3:-строка содержит ожидаемый текст}
((TEST_ASSERTIONS += 1))
if [[ "$haystack" != *"$needle"* ]]; then
record_failure "${message}: не найдено [${needle}]"
fi
}
assert_file_exists() {
local path=$1 message=${2:-файл существует}
((TEST_ASSERTIONS += 1))
if [[ ! -e "$path" ]]; then
record_failure "${message}: отсутствует [${path}]"
fi
}
assert_file_absent() {
local path=$1 message=${2:-файл отсутствует}
((TEST_ASSERTIONS += 1))
if [[ -e "$path" ]]; then
record_failure "${message}: найден [${path}]"
fi
}
assert_file_mode() {
local expected=$1 path=$2 actual message
actual=$(stat -c '%a' -- "$path")
message=${3:-режим файла}
assert_equal "$expected" "$actual" "$message"
}
assert_success() {
local status=$1 message=${2:-команда успешна}
assert_status 0 "$status" "$message"
}
assert_failure() {
local status=$1 message=${2:-команда завершилась с ошибкой}
((TEST_ASSERTIONS += 1))
if (( status == 0 )); then
record_failure "$message: ожидался ненулевой код"
fi
}
finish_tests() {
if (( TEST_FAILURES == 0 )); then
printf 'PASS: %d проверок\n' "$TEST_ASSERTIONS"
return 0
fi
printf 'FAIL: %d ошибок из %d проверок\n' "$TEST_FAILURES" "$TEST_ASSERTIONS" >&2
return 1
}
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+15
View File
@@ -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}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -Eeuo pipefail
command_name=${0##*/}
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
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
cat -- "$WG_MOCK_DUMP_FILE"
exit 0
fi
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -Eeuo pipefail
command_name=${0##*/}
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}"
allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
printf '\n' >>"$TEST_MOCK_LOG"
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
exit 99
fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=../wg-manager.sh
source "$1"
resolve_gum
Executable
+989
View File
@@ -0,0 +1,989 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
TESTS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly TESTS_DIR
# shellcheck source=helpers.sh
source "${TESTS_DIR}/helpers.sh"
# shellcheck source=../wg-manager.sh
source "${PROJECT_DIR}/wg-manager.sh"
case $# in
0) ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac
test_group=${1-}
setup_test_environment
write_valid_config() {
local output_file=$1 override_key=${2-} override_value=${3-}
local wg_interface=wg0 wg_conf="${TEST_ROOT}/wg0.conf" client_dir="${TEST_ROOT}/clients"
local server_endpoint=vpn.example.invalid server_port=51820 server_vpn_ip=192.0.2.1
local client_dns='192.0.2.53,2001:db8::53' vpn_subnet_prefix=192.0.2 start_host=2 end_host=254
local use_ipv6=0 ipv6_prefix=2001:db8:ffff:: default_client_mode=full-tunnel
local recent_handshake_seconds=180 backup_retention=10
case $override_key in
'') ;;
WG_INTERFACE) wg_interface=$override_value ;;
WG_CONF) wg_conf=$override_value ;;
CLIENT_DIR) client_dir=$override_value ;;
SERVER_ENDPOINT) server_endpoint=$override_value ;;
SERVER_PORT) server_port=$override_value ;;
SERVER_VPN_IP) server_vpn_ip=$override_value ;;
CLIENT_DNS) client_dns=$override_value ;;
VPN_SUBNET_PREFIX) vpn_subnet_prefix=$override_value ;;
START_HOST) start_host=$override_value ;;
END_HOST) end_host=$override_value ;;
USE_IPV6) use_ipv6=$override_value ;;
IPV6_PREFIX) ipv6_prefix=$override_value ;;
DEFAULT_CLIENT_MODE) default_client_mode=$override_value ;;
RECENT_HANDSHAKE_SECONDS) recent_handshake_seconds=$override_value ;;
BACKUP_RETENTION) backup_retention=$override_value ;;
RANGE_REVERSED) start_host=254; end_host=2 ;;
*) printf 'unknown test config key: %s\n' "$override_key" >&2; return 64 ;;
esac
cat >"$output_file" <<EOF
# Safe test configuration
WG_INTERFACE="${wg_interface}"
WG_CONF="${wg_conf}"
CLIENT_DIR="${client_dir}"
SERVER_ENDPOINT="${server_endpoint}"
SERVER_PORT="${server_port}"
SERVER_VPN_IP="${server_vpn_ip}"
CLIENT_DNS="${client_dns}"
VPN_SUBNET_PREFIX="${vpn_subnet_prefix}"
START_HOST="${start_host}"
END_HOST="${end_host}"
USE_IPV6="${use_ipv6}"
IPV6_PREFIX="${ipv6_prefix}"
DEFAULT_CLIENT_MODE="${default_client_mode}"
RECENT_HANDSHAKE_SECONDS="${recent_handshake_seconds}"
BACKUP_RETENTION="${backup_retention}"
EOF
}
remove_config_key() {
local input_file=$1 key=$2 line temp_file
temp_file="${input_file}.tmp"
while IFS= read -r line || [[ -n $line ]]; do
[[ $line == "${key}="* ]] && continue
printf '%s\n' "$line"
done <"$input_file" >"$temp_file"
mv -- "$temp_file" "$input_file"
}
replace_config_line() {
local input_file=$1 prefix=$2 replacement=$3 line temp_file
temp_file="${input_file}.tmp"
while IFS= read -r line || [[ -n $line ]]; do
if [[ $line == "${prefix}"* ]]; then printf '%s\n' "$replacement"; else printf '%s\n' "$line"; fi
done <"$input_file" >"$temp_file"
mv -- "$temp_file" "$input_file"
}
run_config_tests() {
local valid_config="${TEST_ROOT}/valid.conf" status output marker
write_valid_config "$valid_config"
export WG_MANAGER_CONFIG="$valid_config"
export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups"
export WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs"
export WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients"
export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive"
export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock"
begin_test 'valid restricted configuration loads with isolated paths'
load_config
assert_equal wg0 "$WG_INTERFACE" 'имя интерфейса'
assert_equal "$TEST_ROOT/wg0.conf" "$WG_CONF" 'путь WG_CONF'
assert_equal "$TEST_ROOT/clients" "$CLIENT_DIR" 'путь клиентов'
assert_equal "$TEST_ROOT/backups" "$BACKUP_DIR" 'путь резервных копий'
assert_equal "$TEST_ROOT/logs" "$LOG_DIR" 'путь журнала'
assert_equal "$TEST_ROOT/clients" "$METADATA_DIR" 'путь метаданных'
assert_equal "$TEST_ROOT/archive" "$ARCHIVE_DIR" 'путь архива'
assert_equal "$TEST_ROOT/manager.lock" "$LOCK_FILE" 'путь lock-файла'
begin_test 'required keys and parser quoting branches are covered'
write_valid_config "${TEST_ROOT}/missing.conf"
remove_config_key "${TEST_ROOT}/missing.conf" WG_INTERFACE
WG_MANAGER_CONFIG="${TEST_ROOT}/missing.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'отсутствующий параметр'
assert_contains 'Отсутствует параметр' "$output" 'диагностика отсутствующего параметра'
write_valid_config "${TEST_ROOT}/quotes.conf" SERVER_ENDPOINT 'vpn.example.invalid"'
WG_MANAGER_CONFIG="${TEST_ROOT}/quotes.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'встроенные кавычки'
assert_contains 'Некорректные кавычки' "$output" 'диагностика кавычек'
write_valid_config "${TEST_ROOT}/ipv6-on.conf" USE_IPV6 1
WG_MANAGER_CONFIG="${TEST_ROOT}/ipv6-on.conf"
load_config
assert_equal 1 "$USE_IPV6" 'включённый IPv6'
write_valid_config "${TEST_ROOT}/whitespace.conf"
replace_config_line "${TEST_ROOT}/whitespace.conf" 'SERVER_PORT=' ' SERVER_PORT = 51820 '
printf '\n# Full-line comment\n' >>"${TEST_ROOT}/whitespace.conf"
WG_MANAGER_CONFIG="${TEST_ROOT}/whitespace.conf"
load_config
assert_equal 51820 "$SERVER_PORT" 'пробелы и комментарии'
write_valid_config "${TEST_ROOT}/unquoted.conf"
replace_config_line "${TEST_ROOT}/unquoted.conf" 'SERVER_PORT=' 'SERVER_PORT=51820'
WG_MANAGER_CONFIG="${TEST_ROOT}/unquoted.conf"
load_config
assert_equal 51820 "$SERVER_PORT" 'неквотированное значение'
begin_test 'runtime path defaults derive from SCRIPT_DIR'
unset WG_MANAGER_CONFIG WG_MANAGER_BACKUP_DIR WG_MANAGER_LOG_DIR WG_MANAGER_METADATA_DIR WG_MANAGER_ARCHIVE_DIR WG_MANAGER_LOCK_FILE
set_runtime_paths
assert_equal "${SCRIPT_DIR}/wg-manager.conf" "$CONFIG_PATH" 'конфигурация по умолчанию'
assert_equal "${SCRIPT_DIR}/backups" "$BACKUP_DIR" 'резервные копии по умолчанию'
assert_equal "${SCRIPT_DIR}/logs" "$LOG_DIR" 'журнал по умолчанию'
assert_equal "${SCRIPT_DIR}/state/clients" "$METADATA_DIR" 'метаданные по умолчанию'
assert_equal "${SCRIPT_DIR}/state/archive" "$ARCHIVE_DIR" 'архив по умолчанию'
assert_equal "${SCRIPT_DIR}/state/wg-manager.lock" "$LOCK_FILE" 'lock по умолчанию'
export WG_MANAGER_CONFIG="$valid_config"
export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups"
export WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs"
export WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients"
export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive"
export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock"
begin_test 'known command validates config then reports not implemented'
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" add 2>&1)
status=$?
set -e
assert_equal "$EXIT_OPERATION" "$status" 'статус команды-заглушки'
assert_contains 'пока не реализована' "$output" 'сообщение заглушки'
begin_test 'invalid configurations are rejected without execution'
marker="${TEST_ROOT}/marker"
local invalid_file
write_valid_config "${TEST_ROOT}/duplicate.conf"
for invalid_file in unknown malformed; do write_valid_config "${TEST_ROOT}/${invalid_file}.conf"; done
write_valid_config "${TEST_ROOT}/substitution.conf" SERVER_ENDPOINT "\$(touch ${marker})"
write_valid_config "${TEST_ROOT}/backtick.conf" SERVER_ENDPOINT "\`touch ${marker}\`"
write_valid_config "${TEST_ROOT}/semicolon.conf" SERVER_ENDPOINT "vpn.example.invalid;touch ${marker}"
write_valid_config "${TEST_ROOT}/wildcard.conf" SERVER_ENDPOINT 'vpn*.example.invalid'
write_valid_config "${TEST_ROOT}/escape.conf" SERVER_ENDPOINT 'vpn\.example.invalid'
write_valid_config "${TEST_ROOT}/bad_ipv4.conf" SERVER_VPN_IP '192.0.999.1'
write_valid_config "${TEST_ROOT}/bad_port.conf" SERVER_PORT 0
write_valid_config "${TEST_ROOT}/bad_mode.conf" DEFAULT_CLIENT_MODE unsafe
write_valid_config "${TEST_ROOT}/bad_range.conf" RANGE_REVERSED 1
write_valid_config "${TEST_ROOT}/bad_path.conf" WG_CONF relative.conf
printf 'WG_INTERFACE="wg1"\n' >>"${TEST_ROOT}/duplicate.conf"
printf 'NOT_ALLOWED="x"\n' >>"${TEST_ROOT}/unknown.conf"
printf 'BROKEN LINE\n' >>"${TEST_ROOT}/malformed.conf"
for invalid_file in duplicate unknown malformed substitution backtick semicolon wildcard escape bad_ipv4 bad_port bad_mode bad_range bad_path; do
export WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "отклонение ${invalid_file}"
case $invalid_file in
duplicate) assert_contains 'Дублирующийся параметр' "$output" 'ветка duplicate' ;;
unknown) assert_contains 'Неизвестный параметр' "$output" 'ветка unknown' ;;
malformed) assert_contains 'Некорректное присваивание' "$output" 'ветка malformed' ;;
substitution|backtick|semicolon|wildcard|escape) assert_contains 'Недопустимый синтаксис' "$output" "ветка ${invalid_file}" ;;
bad_ipv4) assert_contains 'Некорректный IPv4' "$output" 'ветка IPv4' ;;
bad_port) assert_contains 'Некорректный порт' "$output" 'ветка port' ;;
bad_mode) assert_contains 'Некорректный режим' "$output" 'ветка mode' ;;
bad_range) assert_contains 'Некорректный диапазон' "$output" 'ветка range' ;;
bad_path) assert_contains 'Некорректный путь' "$output" 'ветка path' ;;
esac
done
assert_file_absent "$marker" 'конфигурация не выполняет текст'
begin_test 'semantic validation rejects one invalid value at a time'
local semantic_case semantic_output
begin_test 'IPv6 validator rejects malformed compression and accepts valid forms'
for invalid_value in ::: 2001:db8:::1; do
set +e
validate_ipv6 "$invalid_value"
status=$?
set -e
assert_failure "$status" "отклонён IPv6 ${invalid_value}"
done
for valid_value in :: 2001:db8::1 2001:db8:0:1:2:3:4:5; do
set +e
validate_ipv6 "$valid_value"
status=$?
set -e
assert_success "$status" "принят IPv6 ${valid_value}"
done
declare -a semantic_cases=(
'server_inside|SERVER_VPN_IP|192.0.2.100|Диапазон клиентов содержит'
'empty_dns|CLIENT_DNS||Некорректный список DNS'
'trailing_dns|CLIENT_DNS|192.0.2.53,|Некорректный список DNS'
'dotted_endpoint|SERVER_ENDPOINT|999.999.999.999|Некорректная конечная точка'
'bracketed_hostname|SERVER_ENDPOINT|[vpn.example.invalid]|Некорректная конечная точка'
'invalid_interface|WG_INTERFACE|bad/name|Некорректное имя интерфейса'
'invalid_client_path|CLIENT_DIR|relative|Некорректный путь'
'invalid_dns|CLIENT_DNS|192.0.2.53,,2001:db8::53|Некорректный список DNS'
'invalid_prefix|VPN_SUBNET_PREFIX|192.0|Некорректный префикс VPN'
'mismatched_prefix|VPN_SUBNET_PREFIX|192.0.3|Префикс VPN не совпадает'
'nonnumeric_port|SERVER_PORT|abc|Некорректный порт'
'nonnumeric_range|START_HOST|abc|Некорректный диапазон'
'out_of_range|START_HOST|0|Некорректный диапазон'
'invalid_ipv6|IPV6_PREFIX|gg::1|Некорректный IPv6'
'triple_ipv6_prefix|IPV6_PREFIX|:::|Некорректный IPv6'
'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS'
'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка'
'invalid_ipv6_mode|USE_IPV6|2|USE_IPV6 должен'
'invalid_retention|BACKUP_RETENTION|0|Некорректное число резервных копий'
'invalid_handshake|RECENT_HANDSHAKE_SECONDS|604801|Некорректный срок handshake'
)
for semantic_case in "${semantic_cases[@]}"; do
IFS='|' read -r invalid_file invalid_key invalid_value expected_text <<<"$semantic_case"
write_valid_config "${TEST_ROOT}/${invalid_file}.conf" "$invalid_key" "$invalid_value"
WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf"
set +e
semantic_output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "отклонение ${invalid_file}"
assert_contains "$expected_text" "$semantic_output" "диагностика ${invalid_file}"
done
begin_test 'valid endpoint forms and split tunnel are accepted'
local valid_case valid_output
declare -a valid_cases=(
'documentation_ipv4|SERVER_ENDPOINT|192.0.2.10'
'plain_ipv6|SERVER_ENDPOINT|2001:db8::10'
'bracketed_ipv6|SERVER_ENDPOINT|[2001:db8::10]'
'split_tunnel|DEFAULT_CLIENT_MODE|split-tunnel'
)
for valid_case in "${valid_cases[@]}"; do
IFS='|' read -r invalid_file invalid_key invalid_value <<<"$valid_case"
write_valid_config "${TEST_ROOT}/${invalid_file}.conf" "$invalid_key" "$invalid_value"
WG_MANAGER_CONFIG="${TEST_ROOT}/${invalid_file}.conf"
set +e
valid_output=$(load_config 2>&1)
status=$?
set -e
assert_success "$status" "принимается ${invalid_file}"
assert_equal '' "$valid_output" "нет ошибки ${invalid_file}"
done
begin_test 'runtime path overrides reject unsafe values'
WG_MANAGER_CONFIG='relative.conf'
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'относительный override отклонён'
WG_MANAGER_CONFIG=''
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'пустой override отклонён'
WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/../escape"
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'traversal override отклонён'
WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/[unsafe]"
set +e
set_runtime_paths >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'скобки в override отклонены'
write_valid_config "${TEST_ROOT}/bracket-path.conf" WG_CONF "${TEST_ROOT}/[unsafe]"
export WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups"
WG_MANAGER_CONFIG="${TEST_ROOT}/bracket-path.conf"
set +e
output=$(load_config 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'скобки в пути конфигурации отклонены'
assert_contains 'Некорректный путь' "$output" 'диагностика скобок в пути'
export WG_MANAGER_CONFIG="$valid_config"
}
run_gum_tests() {
local copied_script="${TEST_ROOT}/manager-copy.sh" output status
cp -- "${PROJECT_DIR}/wg-manager.sh" "$copied_script"
chmod 700 "$copied_script"
mkdir -p "${TEST_ROOT}/bin" "${TEST_ROOT}/empty-path"
printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/override-gum"
chmod 700 "${TEST_ROOT}/override-gum"
printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/bin/gum"
chmod 700 "${TEST_ROOT}/bin/gum"
begin_test 'gum environment override has priority'
output=$(WG_MANAGER_GUM="${TEST_ROOT}/override-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script")
assert_equal "${TEST_ROOT}/override-gum" "$output" 'WG_MANAGER_GUM'
begin_test 'local gum binary precedes PATH'
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script")
assert_equal "${TEST_ROOT}/bin/gum" "$output" 'локальный gum'
begin_test 'PATH gum is used when local binary is absent'
rm -- "${TEST_ROOT}/bin/gum"
printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_ROOT}/empty-path/gum"
chmod 700 "${TEST_ROOT}/empty-path/gum"
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script")
assert_equal "${TEST_ROOT}/empty-path/gum" "$output" 'gum из PATH'
rm -- "${TEST_ROOT}/empty-path/gum"
begin_test 'gum completely absent reports dependency failure'
set +e
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'gum отсутствует'
assert_contains 'Не найден gum' "$output" 'диагностика отсутствующего gum'
mkdir -- "${TEST_ROOT}/directory-gum"
begin_test 'gum directory override is rejected'
set +e
output=$(WG_MANAGER_GUM="${TEST_ROOT}/directory-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'каталог WG_MANAGER_GUM'
mkdir -- "${TEST_ROOT}/bin/gum"
begin_test 'local gum directory is rejected'
set +e
output=$(env -u WG_MANAGER_GUM PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'каталог local gum'
begin_test 'unusable gum override fails without fallback'
set +e
output=$(WG_MANAGER_GUM="${TEST_ROOT}/missing-gum" PATH="${TEST_ROOT}/empty-path" /bin/bash "${TESTS_DIR}/resolve-gum.sh" "$copied_script" 2>&1)
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'ошибка WG_MANAGER_GUM'
assert_contains 'WG_MANAGER_GUM' "$output" 'сообщение WG_MANAGER_GUM'
}
run_dependency_tests() {
local output status code
local -a exit_codes=("$EXIT_USAGE" "$EXIT_VALIDATION" "$EXIT_OPERATION" "$EXIT_ROLLBACK" "$EXIT_PERMISSION" "$EXIT_DEPENDENCY")
declare -A seen_codes=()
begin_test 'stable exit categories are distinct'
assert_equal 0 "$EXIT_SUCCESS" 'EXIT_SUCCESS'
for code in "${exit_codes[@]}"; do
assert_failure "$code" "ненулевой код ${code}"
if [[ -v "seen_codes[$code]" ]]; then
record_failure "коды не различаются: ${code}"
else
seen_codes["$code"]=1
((TEST_ASSERTIONS += 1))
fi
done
begin_test 'required and optional dependency checks are controlled'
output=$(require_command wg)
assert_contains '/tests/mocks/bin/wg' "$output" 'обязательная команда через mock PATH'
output=$(optional_command gum)
assert_contains '/tests/mocks/bin/gum' "$output" 'необязательная команда через mock PATH'
set +e
require_command definitely-missing >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'отсутствующая обязательная команда'
set +e
optional_command definitely-missing >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_DEPENDENCY" "$status" 'отсутствующая необязательная команда'
}
run_debug_tests() {
local output status
begin_test 'disabled debug is a successful no-op'
unset WG_MANAGER_DEBUG
set +e
output=$(debug hidden 2>&1)
status=$?
set -e
assert_success "$status" 'отключённая отладка'
assert_equal '' "$output" 'отключённая отладка без вывода'
begin_test 'enabled debug uses stderr only'
: >"${TEST_ROOT}/debug.stdout"
: >"${TEST_ROOT}/debug.stderr"
WG_MANAGER_DEBUG=1 debug visible >"${TEST_ROOT}/debug.stdout" 2>"${TEST_ROOT}/debug.stderr"
assert_equal '' "$(<"${TEST_ROOT}/debug.stdout")" 'debug stdout пуст'
assert_contains 'Отладка: visible' "$(<"${TEST_ROOT}/debug.stderr")" 'debug stderr'
}
write_m2_config() {
local output_file=$1 wireguard_file=$2
write_valid_config "$output_file"
replace_config_line "$output_file" 'WG_CONF=' "WG_CONF=\"${wireguard_file}\""
}
write_alice_metadata() {
mkdir -p "$METADATA_DIR"
cat >"${METADATA_DIR}/alice.meta" <<EOF
NAME=alice
PUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
IPV4=192.0.2.2
CREATED_AT=1700000000
CONFIG_PATH=${CLIENT_DIR}/alice.conf
CLIENT_MODE=full-tunnel
MANAGED=1
EOF
}
write_bob_metadata() {
cat >"${METADATA_DIR}/bob.meta" <<EOF
NAME=bob
PUBLIC_KEY=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=
IPV4=192.0.2.3
CREATED_AT=1700000001
CONFIG_PATH=${CLIENT_DIR}/bob.conf
CLIENT_MODE=split-tunnel
MANAGED=1
EOF
}
snapshot_model_inputs() {
local root path mode
for root in "$@"; do
[[ -e "$root" ]] || continue
find "$root" -print0
done | LC_ALL=C sort -z | while IFS= read -r -d '' path; do
mode=$(stat -c '%a' -- "$path")
if [[ -d "$path" ]]; then
printf 'D:%s:%s\n' "$path" "$mode"
else
printf 'F:%s:%s:' "$path" "$mode"
sha256sum -- "$path"
fi
done
}
run_m2_tests() {
local config_file="${TEST_ROOT}/m2.conf" fixture="${TEST_ROOT}/wg0.conf" output status
local runtime_file="${TEST_ROOT}/runtime.dump"
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
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}/clients" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100
set_runtime_paths
WG_CONF=$fixture
mkdir -p "$METADATA_DIR"
: >"$TEST_MOCK_LOG"
unset WG_MOCK_DUMP_FILE WG_MOCK_INTERFACE
begin_test 'persistent WireGuard parser and trusted metadata identity'
write_alice_metadata
parse_wireguard_config "$fixture"
assert_equal 1 "${#DISK_ALLOWED[@]}" 'один peer на диске'
assert_equal 192.0.2.2/32 "${DISK_IPV4[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'адрес peer'
parse_metadata
assert_equal alice "${META_NAME[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'имя из метаданных'
assert_equal 1 "${META_MANAGED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'доверенная метка managed'
assert_equal "${CLIENT_DIR}/alice.conf" "${META_CONFIG[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'путь клиентского конфига'
assert_equal 1700000000 "${META_CREATED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'время создания метаданных'
begin_test 'two managed peers match exact keys and addresses'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-managed.conf" "$fixture"
write_bob_metadata
printf 'IPV6=2001:db8::2\n' >>"${METADATA_DIR}/alice.meta"
load_client_model
local managed_count=0
for record_index in "${!CLIENT_KEYS[@]}"; do [[ ${CLIENT_ORIGINS[$record_index]} == managed ]] && ((managed_count += 1)); done
assert_equal 2 "$managed_count" 'две управляемые записи'
rm -- "${METADATA_DIR}/bob.meta"
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
begin_test 'pre-peer marker and spaced mixed AllowedIPs are parsed'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/pre-peer-marker.conf" "$fixture"
parse_wireguard_config "$fixture"
assert_equal alice "${DISK_MARKER[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'маркер перед peer'
assert_equal '192.0.2.2/32,2001:db8::2/128' "${DISK_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'нормализация AllowedIPs'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
begin_test 'repeatable interface fields and duplicate peer singleton checks'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/repeatable-interface.conf" "$fixture"
parse_wireguard_config "$fixture"
replace_config_line "$fixture" 'FwMark =' 'FwMark = 0xca6c'
parse_wireguard_config "$fixture"
for bad_fwmark in 0x100000000 0xnope 4294967296; do
replace_config_line "$fixture" 'FwMark =' "FwMark = ${bad_fwmark}"
set +e
parse_wireguard_config "$fixture" >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "некорректный FwMark ${bad_fwmark}"
done
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
printf 'Endpoint = vpn.example.invalid:51820\n' >>"$fixture"
set +e
output=$(parse_wireguard_config "$fixture" 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'дублирующийся Endpoint'
assert_contains 'Дублирующийся Endpoint' "$output" 'диагностика duplicate Endpoint'
for duplicate_field in PresharedKey AllowedIPs PersistentKeepalive; do
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
case $duplicate_field in
PresharedKey) printf 'PresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nPresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n' >>"$fixture" ;;
AllowedIPs) printf 'AllowedIPs = 192.0.2.2/32\nAllowedIPs = 192.0.2.2/32\n' >>"$fixture" ;;
PersistentKeepalive) printf 'PersistentKeepalive = 25\nPersistentKeepalive = 25\n' >>"$fixture" ;;
esac
set +e
output=$(parse_wireguard_config "$fixture" 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "дубликат ${duplicate_field}"
done
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
printf 'Endpoint = vpn.example.invalid\n' >>"$fixture"
set +e
output=$(parse_wireguard_config "$fixture" 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'Endpoint без порта'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
printf 'PresharedKey = (none)\n' >>"$fixture"
set +e
output=$(parse_wireguard_config "$fixture" 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'persistent PSK token отвергнут'
begin_test 'section ordering and marker leakage are rejected safely'
printf '# wg-manager-client: leaked\n[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nListenPort = 51820\n[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\n' >"${TEST_ROOT}/marker-leak.conf"
parse_wireguard_config "${TEST_ROOT}/marker-leak.conf"
assert_equal '' "${DISK_MARKER[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]:-}" 'маркер не протёк через Interface'
for invalid_section in peer-before-interface empty-config repeated-interface; do
case $invalid_section in
peer-before-interface) printf '[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\n' >"${TEST_ROOT}/${invalid_section}.conf" ;;
empty-config) : >"${TEST_ROOT}/${invalid_section}.conf" ;;
repeated-interface) printf '[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n[Interface]\nListenPort = 51820\n' >"${TEST_ROOT}/${invalid_section}.conf" ;;
esac
set +e
parse_wireguard_config "${TEST_ROOT}/${invalid_section}.conf" >/dev/null 2>&1
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "секция ${invalid_section}"
done
begin_test 'adjacent conventional markers attach to the next peer'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/markers-adjacent.conf" "$fixture"
parse_wireguard_config "$fixture"
assert_equal first "${DISK_LEGACY[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'первый marker'
assert_equal second "${DISK_LEGACY[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=]}" 'соседний marker'
assert_equal third "${DISK_MARKER[DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=]}" 'соседний explicit marker'
begin_test 'metadata names remain restricted and cannot create paths'
rm -- "${METADATA_DIR}/alice.meta"
local unsafe_name metadata_output
for unsafe_name in 'bad name' '*glob?' '-name' $'bad\nname'; do
printf 'NAME=%s\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s/bad.conf\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "$unsafe_name" "$CLIENT_DIR" >"${METADATA_DIR}/bad.meta"
set +e
metadata_output=$(parse_metadata 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "unsafe metadata name ${unsafe_name@Q}"
assert_equal '' "$(printf '%s' "$metadata_output" | grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|PrivateKey|PresharedKey' || true)" 'metadata error without key material'
assert_equal 0 "$( [[ ! -e "${CLIENT_DIR}/${unsafe_name}.conf" ]] && printf 0 || printf 1 )" 'unsafe metadata does not create path'
rm -- "${METADATA_DIR}/bad.meta"
done
write_alice_metadata
begin_test 'duplicate metadata identity is rejected'
cp -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/duplicate.meta"
set +e
output=$(parse_metadata 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'дубликат метаданных'
assert_contains 'Дублирующийся' "$output" 'диагностика дубликата метаданных'
rm -- "${METADATA_DIR}/duplicate.meta"
begin_test 'empty peer list remains a valid disk model'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/no-peers.conf" "$fixture"
rm -- "${METADATA_DIR}/alice.meta"
unset WG_MOCK_DUMP_FILE
load_client_model
assert_equal 0 "${#CLIENT_KEYS[@]}" 'нет peers'
write_alice_metadata
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
begin_test 'metadata address mismatch never establishes managed identity'
replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.99'
load_client_model
assert_equal legacy "${CLIENT_ORIGINS[0]}" 'mismatch origin'
write_alice_metadata
begin_test 'legacy and unmanaged origins remain distinct from managed identity'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture"
rm -- "${METADATA_DIR}/alice.meta"
unset WG_MOCK_DUMP_FILE
load_client_model
local record_index
for record_index in "${!CLIENT_KEYS[@]}"; do
case ${CLIENT_KEYS[$record_index]} in
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=) assert_equal legacy "${CLIENT_ORIGINS[$record_index]}" 'peer с явным marker без метаданных'; assert_equal unmanaged "${CLIENT_STATES[$record_index]}" 'marker без доверенной метадаты' ;;
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=) assert_equal legacy "${CLIENT_ORIGINS[$record_index]}" 'legacy peer'; assert_equal unmanaged "${CLIENT_STATES[$record_index]}" 'legacy state' ;;
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=) assert_equal unmanaged "${CLIENT_ORIGINS[$record_index]}" 'unmanaged peer' ;;
esac
done
begin_test 'runtime parser and exact wg dump command'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
write_alice_metadata
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
: >"$TEST_MOCK_LOG"
load_client_model
assert_equal 1 "$RUNTIME_AVAILABLE" 'runtime доступен'
assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'порт runtime'
assert_equal active "${CLIENT_STATES[0]}" 'активный peer на границе threshold'
local runtime_log
runtime_log=$(<"$TEST_MOCK_LOG")
runtime_log+=$'\n'
assert_equal $'wg show wg0 dump\n' "$runtime_log" 'точный argv wg'
assert_equal '' "$(printf '%s' "$runtime_log" | grep -E 'systemctl|ip|gum' || true)" 'runtime без лишних команд'
begin_test 'runtime unavailable tokens normalize safely'
parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t(none)\t(none)\t0\t0\t0\t0'
assert_equal - "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'endpoint none'
assert_equal - "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed none'
parse_runtime_dump $'(none)\t(none)\t51820\t0'
assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'interface none tokens'
set +e
output=$(parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid\t192.0.2.2/32\t0\t0\t0\t0' 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'runtime endpoint without port'
parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t[2001:db8::1]:51820\t192.0.2.2/32\t0\t0\t0\t0'
assert_equal '[2001:db8::1]:51820' "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'runtime IPv6 endpoint'
begin_test 'unavailable runtime command degrades safely'
local saved_path=$PATH
local safe_runtime_path="${TEST_ROOT}/no-wg"
mkdir -p "$safe_runtime_path"
ln -s -- "$(type -P sort)" "${safe_runtime_path}/sort"
ln -s -- "$(type -P paste)" "${safe_runtime_path}/paste"
PATH=$safe_runtime_path
if command -v wg >/dev/null 2>&1; then
record_failure 'контролируемый PATH неожиданно содержит wg'
fi
unset WG_MOCK_DUMP_FILE
output=$(/bin/bash "${PROJECT_DIR}/wg-manager.sh" status)
PATH=$saved_path
assert_contains 'недоступен' "$output" 'wg отсутствует'
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
begin_test 'active threshold, inactive, and never states are deterministic'
replace_config_line "$runtime_file" 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' $'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tvpn.example.invalid:51820\t192.0.2.2/32\t1699999919\t100\t200\t25'
load_client_model
assert_equal inactive "${CLIENT_STATES[0]}" 'peer just beyond threshold'
replace_config_line "$runtime_file" 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' $'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t100\t200\t25'
load_client_model
assert_equal never "${CLIENT_STATES[0]}" 'peer without handshake'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
begin_test 'runtime-only peer is retained as unmanaged'
printf '%s\t%s\t-\t192.0.2.9/32\t1700000100\t1\t2\t0\n' 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' '(none)' >>"$runtime_file"
load_client_model
local runtime_index runtime_origin=''
assert_contains EEEEEEEE "$(printf '%s\n' "${CLIENT_KEYS[@]}")" 'runtime-only key parsed'
for runtime_index in "${!CLIENT_KEYS[@]}"; do
[[ ${CLIENT_KEYS[$runtime_index]} == EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= ]] && runtime_origin=${CLIENT_ORIGINS[$runtime_index]}
done
assert_equal unmanaged "$runtime_origin" 'runtime-only origin'
begin_test 'malformed runtime dump fails without exposing fields'
printf 'malformed runtime line\n' >"$runtime_file"
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'malformed runtime status'
assert_contains 'wg dump' "$output" 'malformed runtime diagnostic'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
begin_test 'status is useful without gum or systemd'
local model_before model_after
model_before=$(snapshot_model_inputs "$config_file" "$fixture" "$METADATA_DIR" "$runtime_file" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" "$LOCK_FILE")
output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1)
assert_contains 'Интерфейс: wg0' "$output" 'status interface'
assert_contains 'Управляемые: 1' "$output" 'status managed count'
assert_contains 'Активные: 1' "$output" 'status active count'
output=$("${PROJECT_DIR}/wg-manager.sh" list)
model_after=$(snapshot_model_inputs "$config_file" "$fixture" "$METADATA_DIR" "$runtime_file" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" "$LOCK_FILE")
assert_equal "$model_before" "$model_after" 'status/list не изменяют состояние'
begin_test 'invalid deterministic clock fails without secrets'
WG_MANAGER_NOW=invalid
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'некорректные часы'
assert_equal '' "$(printf '%s' "$output" | grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' || true)" 'ошибка часов без ключей'
WG_MANAGER_NOW=1700000100
begin_test 'list is deterministic and secret-free'
output=$("${PROJECT_DIR}/wg-manager.sh" list)
assert_contains $'NAME\tADDRESSES\tPUBLIC_KEY' "$output" 'list header'
assert_contains $'alice\t192.0.2.2/32\tBBBBBBBB' "$output" 'list managed row'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' <<<"$output" || true)" 'list не показывает секретные поля'
begin_test 'redirected list remains plain text'
"${PROJECT_DIR}/wg-manager.sh" list >"${TEST_ROOT}/list.out"
assert_contains $'alice\t192.0.2.2/32' "$(<"${TEST_ROOT}/list.out")" 'redirected list row'
assert_equal '' "$(LC_ALL=C grep -nP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]' "${TEST_ROOT}/list.out" || true)" 'redirected list without controls'
begin_test 'list ordering is independent of peer input order'
rm -- "${METADATA_DIR}/alice.meta"
unset WG_MOCK_DUMP_FILE
cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture"
output=$("${PROJECT_DIR}/wg-manager.sh" list)
cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-reversed.conf" "$fixture"
assert_equal "$output" "$("${PROJECT_DIR}/wg-manager.sh" list)" 'стабильная сортировка list'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
write_alice_metadata
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
begin_test 'stopped runtime degrades to disk-only output'
unset WG_MOCK_DUMP_FILE
output=$("${PROJECT_DIR}/wg-manager.sh" status)
assert_contains 'недоступен' "$output" 'runtime unavailable'
assert_contains 'Peers: 1' "$output" 'disk peer count'
begin_test 'malformed persistent config fails safely'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/malformed.conf" "$fixture"
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'malformed config status'
assert_contains 'PublicKey' "$output" 'malformed config diagnostic'
begin_test 'duplicate AllowedIPs are rejected'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/duplicate-allowed.conf" "$fixture"
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'duplicate AllowedIPs status'
assert_contains 'AllowedIPs' "$output" 'duplicate AllowedIPs diagnostic'
begin_test 'duplicate public keys and overlapping networks are rejected'
for invalid_fixture in duplicate-public overlap overlap-v6; do
cp -- "${PROJECT_DIR}/tests/fixtures/m2/${invalid_fixture}.conf" "$fixture"
set +e
output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" "${invalid_fixture} status"
if [[ $invalid_fixture == duplicate-public ]]; then assert_contains 'PublicKey' "$output" "${invalid_fixture} diagnostic"; else assert_contains 'AllowedIPs' "$output" "${invalid_fixture} diagnostic"; fi
done
}
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
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
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}/clients" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100
set_runtime_paths
WG_CONF=$fixture
mkdir -p "$METADATA_DIR"
load_config
write_alice_metadata
case $group in
parser)
begin_test 'focused persistent and runtime parser'
parse_wireguard_config "$fixture"
parse_metadata
cp -- "${PROJECT_DIR}/tests/fixtures/m2/repeatable-interface.conf" "$fixture"
replace_config_line "$fixture" 'FwMark =' 'FwMark = 0xca6c'
parse_wireguard_config "$fixture"
assert_equal 1 "${#DISK_ALLOWED[@]}" 'hex FwMark не ломает parser'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/markers-adjacent.conf" "$fixture"
parse_wireguard_config "$fixture"
assert_equal second "${DISK_LEGACY[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=]}" 'focused marker scoping'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
parse_runtime_dump "$(<"$runtime_file")"
assert_equal 1 "$RUNTIME_AVAILABLE" 'runtime parser доступен'
assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'порт интерфейса runtime'
assert_equal 1700000100 "${RUNTIME_HANDSHAKE[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'handshake peer'
assert_equal 100 "${RUNTIME_RX[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'rx peer'
assert_equal 200 "${RUNTIME_TX[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'tx peer'
assert_equal '192.0.2.2/32' "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed ips peer'
parse_runtime_dump $'(none)\t(none)\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t[2001:db8::1]:51820\t192.0.2.2/32\t0\t0\t0\t0'
assert_equal '[2001:db8::1]:51820' "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'focused runtime endpoint'
set +e
output=$(parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tmalformed\t(none)\t(none)\t0\t0\t0\t0' 2>&1)
status=$?
set -e
assert_equal "$EXIT_VALIDATION" "$status" 'malformed PSK отвергнут'
assert_contains 'ключ' "$output" 'диагностика malformed PSK'
;;
status)
begin_test 'focused status output'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
output=$("${PROJECT_DIR}/wg-manager.sh" status)
assert_contains 'Интерфейс: wg0' "$output" 'status interface'
assert_contains 'Управляемые: 1' "$output" 'status managed'
;;
list)
begin_test 'focused list output'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
output=$("${PROJECT_DIR}/wg-manager.sh" list)
assert_contains $'alice\t192.0.2.2/32' "$output" 'list row'
assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey' || true)" 'list secret-free'
;;
list-redirected)
begin_test 'focused redirected list'
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
"${PROJECT_DIR}/wg-manager.sh" list >"${TEST_ROOT}/focused-list.out" 2>"${TEST_ROOT}/focused-list.err"
assert_contains $'alice\t192.0.2.2/32' "$(<"${TEST_ROOT}/focused-list.out")" 'redirected row'
assert_equal '' "$(<"${TEST_ROOT}/focused-list.err")" 'redirected stderr empty'
;;
list-deterministic)
begin_test 'focused deterministic list ordering'
unset WG_MOCK_DUMP_FILE
cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture"
output=$("${PROJECT_DIR}/wg-manager.sh" list)
cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-reversed.conf" "$fixture"
reversed_output=$("${PROJECT_DIR}/wg-manager.sh" list)
assert_equal "$output" "$reversed_output" 'порядок list стабилен'
;;
esac
}
if [[ -z $test_group ]]; then
begin_test 'sourcing entry point does not execute main'
source_output=''
source_output=$(/bin/bash "${TESTS_DIR}/source-entry.sh" "${PROJECT_DIR}/wg-manager.sh")
assert_equal sourced "$source_output" 'безопасная загрузка'
begin_test 'help succeeds without root or dependencies'
set +e
help_output=$("${PROJECT_DIR}/wg-manager.sh" help 2>&1)
help_status=$?
set -e
assert_success "$help_status" 'команда help'
assert_contains 'Использование:' "$help_output" 'справка'
assert_equal "$help_output" "${help_output//$'\e'/}" 'help без ANSI'
begin_test 'version succeeds without config or dependencies'
set +e
version_output=$(env -u WG_MANAGER_CONFIG -u WG_MANAGER_GUM PATH=/usr/bin:/bin "${PROJECT_DIR}/wg-manager.sh" version 2>&1)
version_status=$?
set -e
assert_success "$version_status" 'команда version'
assert_contains 'WireGuard Manager 0.1.0' "$version_output" 'версия'
assert_equal "$version_output" "${version_output//$'\e'/}" 'version без ANSI'
begin_test 'extra arguments fail with usage status'
set +e
extra_output=$("${PROJECT_DIR}/wg-manager.sh" help extra 2>&1)
extra_status=$?
set -e
assert_equal "$EXIT_USAGE" "$extra_status" 'лишний аргумент'
assert_contains 'Ожидается одна команда' "$extra_output" 'диагностика лишнего аргумента'
begin_test 'unknown command fails'
set +e
unknown_output=$("${PROJECT_DIR}/wg-manager.sh" unknown 2>&1)
unknown_status=$?
set -e
assert_failure "$unknown_status" 'неизвестная команда'
assert_contains 'неизвестная команда' "$unknown_output" 'сообщение об ошибке'
begin_test 'help works outside project directory'
set +e
outside_output=$(cd /tmp && "${PROJECT_DIR}/wg-manager.sh" help 2>&1)
outside_status=$?
set -e
assert_success "$outside_status" 'запуск из /tmp'
assert_contains 'Использование:' "$outside_output" 'справка из /tmp'
begin_test 'mock guard accepts only explicitly supported empty invocation'
set +e
MOCK_ALLOW_EMPTY_INVOCATION=wg wg
expected_mock_status=$?
wg
unapproved_empty_status=$?
wg unexpected
unexpected_mock_status=$?
set -e
assert_success "$expected_mock_status" 'разрешённый вызов мока'
assert_failure "$unapproved_empty_status" 'неразрешённый пустой вызов мока'
assert_failure "$unexpected_mock_status" 'неожиданный вызов мока'
expected_mock_log=$'wg\nwg\nwg unexpected\n'
actual_mock_log=$(<"$TEST_MOCK_LOG")
actual_mock_log+=$'\n'
assert_equal "$expected_mock_log" "$actual_mock_log" 'точный журнал вызовов мока'
begin_test 'test writes stay below isolated root'
assert_file_exists "${TEST_ROOT}/wg0.conf" 'копия фикстуры'
assert_equal "$TEST_ROOT" "${TEST_MOCK_LOG%/*}" 'журнал находится в тестовом корне'
scan_test_sources_for_host_path() {
local forbidden_path='/etc'
forbidden_path+='/wireguard'
scan_path() {
local path line
for path in "$1"/*; do
if [[ -d "$path" ]]; then
scan_path "$path"
elif [[ -f "$path" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == *"$forbidden_path"* ]]; then
record_failure "тестовый файл содержит запрещённый путь: ${path}"
return
fi
done <"$path"
fi
done
}
scan_path "$TESTS_DIR"
}
scan_sources_for_absolute_external_commands() {
local command_name candidate path line
for command_name in wg wg-quick ip systemctl nft iptables journalctl sysctl; do
candidate='/bin'
candidate+="/${command_name}"
for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do
[[ -f "$path" ]] || continue
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"\"$candidate"* || "$line" == *"'$candidate"* || "$line" == *"=$candidate"* ]]; then record_failure "абсолютный внешний вызов в ${path}: ${candidate}"; break; fi
done <"$path"
done
candidate='/usr'
candidate+="/bin/${command_name}"
for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do
[[ -f "$path" ]] || continue
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"\"$candidate"* || "$line" == *"'$candidate"* || "$line" == *"=$candidate"* ]]; then record_failure "абсолютный внешний вызов в ${path}: ${candidate}"; break; fi
done <"$path"
done
done
}
scan_test_sources_for_host_path
scan_sources_for_absolute_external_commands
fi
if [[ $test_group == config ]]; then
run_config_tests
elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then
run_m2_focused "$test_group"
else
run_config_tests
run_gum_tests
run_dependency_tests
run_debug_tests
run_m2_tests
fi
finish_tests
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck source=../wg-manager.sh
source "$1"
printf 'sourced'
+20
View File
@@ -0,0 +1,20 @@
# Safe example configuration. Copy to wg-manager.conf and review before use.
WG_INTERFACE="wg0"
WG_CONF="/etc/wireguard/wg0.conf"
CLIENT_DIR="/etc/wireguard/clients"
SERVER_ENDPOINT="vpn.example.invalid"
SERVER_PORT="51820"
SERVER_VPN_IP="192.0.2.1"
CLIENT_DNS="192.0.2.53"
VPN_SUBNET_PREFIX="192.0.2"
START_HOST="2"
END_HOST="254"
USE_IPV6="0"
IPV6_PREFIX="2001:db8:ffff::"
DEFAULT_CLIENT_MODE="full-tunnel"
RECENT_HANDSHAKE_SECONDS="180"
BACKUP_RETENTION="10"
Executable
+618
View File
@@ -0,0 +1,618 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
resolve_script_dir() {
local source_path=${BASH_SOURCE[0]} source_dir
if [[ $source_path == /* ]]; then
if [[ $source_path == */* ]]; then source_dir=${source_path%/*}; else source_dir=/; fi
else
if [[ $source_path == */* ]]; then source_dir=${PWD}/${source_path%/*}; else source_dir=$PWD; fi
fi
[[ -n $source_dir ]] || source_dir=/
cd -- "$source_dir" && pwd
}
SCRIPT_DIR="$(resolve_script_dir)"
readonly SCRIPT_DIR
readonly WG_MANAGER_VERSION='0.1.0'
readonly EXIT_USAGE=64
readonly EXIT_VALIDATION=65
readonly EXIT_OPERATION=70
# shellcheck disable=SC2034
readonly EXIT_SUCCESS=0
# shellcheck disable=SC2034
readonly EXIT_ROLLBACK=71
# shellcheck disable=SC2034
readonly EXIT_PERMISSION=77
readonly EXIT_DEPENDENCY=69
CONFIG_PATH=''
BACKUP_DIR=''
LOG_DIR=''
METADATA_DIR=''
ARCHIVE_DIR=''
LOCK_FILE=''
WG_INTERFACE=''
WG_CONF=''
CLIENT_DIR=''
SERVER_ENDPOINT=''
SERVER_PORT=''
SERVER_VPN_IP=''
CLIENT_DNS=''
VPN_SUBNET_PREFIX=''
START_HOST=''
END_HOST=''
USE_IPV6=''
IPV6_PREFIX=''
DEFAULT_CLIENT_MODE=''
RECENT_HANDSHAKE_SECONDS=''
BACKUP_RETENTION=''
declare -A CONFIG_VALUES=()
print_usage() {
cat <<'USAGE'
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add|show|config|qr|remove|diagnose|logs|backups|restore|tui}
Команды управления WireGuard будут добавлены в следующих версиях.
USAGE
}
die() {
local status=$1
shift
printf 'Ошибка: %s\n' "$*" >&2
return "$status"
}
warn() { printf 'Предупреждение: %s\n' "$*" >&2; }
info() { printf '%s\n' "$*" >&2; }
debug() {
if [[ "${WG_MANAGER_DEBUG:-0}" == '1' ]]; then
printf 'Отладка: %s\n' "$*" >&2
fi
return 0
}
trim_space() {
local value=$1
value="${value#${value%%[!$' \t']*}}"
value="${value%${value##*[!$' \t']}}"
printf '%s' "$value"
}
is_control_free() { [[ $1 != *[$'\001'-$'\037'$'\177']* ]]; }
is_shell_syntax_free() { [[ $1 != *'$'* && $1 != *'`'* && $1 != *';'* && $1 != *'|'* && $1 != *'&'* && $1 != *'<'* && $1 != *'>'* && $1 != *'('* && $1 != *')'* && $1 != *\\* && $1 != *'*'* && $1 != *'?'* && $1 != *'{'* && $1 != *'}'* ]]; }
set_runtime_paths() {
if [[ ${WG_MANAGER_CONFIG+x} ]]; then CONFIG_PATH=$WG_MANAGER_CONFIG; else CONFIG_PATH=${SCRIPT_DIR}/wg-manager.conf; fi
if [[ ${WG_MANAGER_BACKUP_DIR+x} ]]; then BACKUP_DIR=$WG_MANAGER_BACKUP_DIR; else BACKUP_DIR=${SCRIPT_DIR}/backups; fi
if [[ ${WG_MANAGER_LOG_DIR+x} ]]; then LOG_DIR=$WG_MANAGER_LOG_DIR; else LOG_DIR=${SCRIPT_DIR}/logs; fi
if [[ ${WG_MANAGER_METADATA_DIR+x} ]]; then METADATA_DIR=$WG_MANAGER_METADATA_DIR; else METADATA_DIR=${SCRIPT_DIR}/state/clients; fi
if [[ ${WG_MANAGER_ARCHIVE_DIR+x} ]]; then ARCHIVE_DIR=$WG_MANAGER_ARCHIVE_DIR; else ARCHIVE_DIR=${SCRIPT_DIR}/state/archive; fi
if [[ ${WG_MANAGER_LOCK_FILE+x} ]]; then LOCK_FILE=$WG_MANAGER_LOCK_FILE; else LOCK_FILE=${SCRIPT_DIR}/state/wg-manager.lock; fi
local name value
for name in CONFIG_PATH BACKUP_DIR LOG_DIR METADATA_DIR ARCHIVE_DIR LOCK_FILE; do
value=${!name}
if [[ -z "$value" ]] || ! is_control_free "$value" || ! is_shell_syntax_free "$value" || ! validate_path "$value"; then
die "$EXIT_VALIDATION" "Некорректный абсолютный путь ${name}" || return $?
fi
[[ $value != *'//'* && $value != */./* && $value != */../* && $value != */.. && $value != */. ]] || { die "$EXIT_VALIDATION" "Путь ${name} не нормализован"; return $?; }
done
}
parse_config_file() {
local file=$1 line trimmed key raw value line_number=0
CONFIG_VALUES=()
[[ -r "$file" ]] || { die "$EXIT_VALIDATION" "Не удаётся прочитать конфигурацию: ${file}"; return $?; }
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1))
is_control_free "$line" || { die "$EXIT_VALIDATION" "Недопустимый управляющий символ в строке ${line_number}"; return $?; }
trimmed=$(trim_space "$line")
[[ -z "$trimmed" || ${trimmed:0:1} == '#' ]] && continue
[[ $trimmed =~ ^([A-Z][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректное присваивание в строке ${line_number}"; return $?; }
key=${BASH_REMATCH[1]}
raw=$(trim_space "${BASH_REMATCH[2]}")
case "$key" in
WG_INTERFACE|WG_CONF|CLIENT_DIR|SERVER_ENDPOINT|SERVER_PORT|SERVER_VPN_IP|CLIENT_DNS|VPN_SUBNET_PREFIX|START_HOST|END_HOST|USE_IPV6|IPV6_PREFIX|DEFAULT_CLIENT_MODE|RECENT_HANDSHAKE_SECONDS|BACKUP_RETENTION) ;;
*) die "$EXIT_VALIDATION" "Неизвестный параметр конфигурации: ${key}"; return $? ;;
esac
[[ -v "CONFIG_VALUES[$key]" ]] && { die "$EXIT_VALIDATION" "Дублирующийся параметр конфигурации: ${key}"; return $?; }
is_shell_syntax_free "$raw" || { die "$EXIT_VALIDATION" "Недопустимый синтаксис в параметре ${key}"; return $?; }
if [[ $raw == \"* ]]; then
[[ $raw == *\" && ${#raw} -ge 2 && ${raw:1:${#raw}-2} != *\"* ]] || { die "$EXIT_VALIDATION" "Некорректные кавычки в параметре ${key}"; return $?; }
value=${raw:1:${#raw}-2}
elif [[ $raw == *\"* || $raw == *\'* || $raw == *[[:space:]]* || -z "$raw" ]]; then
die "$EXIT_VALIDATION" "Некорректное значение параметра ${key}"; return $?
else value=$raw
fi
CONFIG_VALUES["$key"]=$value
done <"$file"
}
require_config_keys() {
local key
for key in WG_INTERFACE WG_CONF CLIENT_DIR SERVER_ENDPOINT SERVER_PORT SERVER_VPN_IP CLIENT_DNS VPN_SUBNET_PREFIX START_HOST END_HOST USE_IPV6 IPV6_PREFIX DEFAULT_CLIENT_MODE RECENT_HANDSHAKE_SECONDS BACKUP_RETENTION; do
[[ -v "CONFIG_VALUES[$key]" ]] || { die "$EXIT_VALIDATION" "Отсутствует параметр конфигурации: ${key}"; return $?; }
done
}
is_uint() { [[ $1 =~ ^[0-9]+$ ]]; }
validate_ipv4() {
local value=$1 part
local -a parts=()
IFS=. read -r -a parts <<<"$value"
(( ${#parts[@]} == 4 )) || return 1
for part in "${parts[@]}"; do is_uint "$part" && (( 10#$part <= 255 )) || return 1; done
}
validate_ipv6() {
local value=$1 part
local -a parts=()
[[ $value == *:* && $value != *[^0123456789abcdefABCDEF:]* && $value != *:::* ]] || return 1
if [[ $value == *'::'* ]]; then
[[ $value != *'::'*'::'* ]] || return 1
value=${value//::/:}; value=${value#:}; value=${value%:}
IFS=: read -r -a parts <<<"$value"
(( ${#parts[@]} < 8 )) || return 1
for part in "${parts[@]}"; do [[ -z "$part" || ${#part} -gt 4 ]] && return 1; done
return 0
fi
IFS=: read -r -a parts <<<"$value"
(( ${#parts[@]} == 8 )) || return 1
for part in "${parts[@]}"; do [[ -n "$part" && ${#part} -le 4 ]] || return 1; done
}
validate_ip() { validate_ipv4 "$1" || validate_ipv6 "$1"; }
validate_endpoint() {
local endpoint=$1 host=$1 label
local -a endpoint_labels=()
[[ $endpoint != *[[:space:]]* && $endpoint != *[/\\]* ]] || return 1
if [[ $endpoint == \[*\] ]]; then
host=${endpoint:1:${#endpoint}-2}
validate_ipv6 "$host"
return
elif [[ $endpoint == *'['* || $endpoint == *']'* ]]; then return 1; fi
if [[ $host =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
validate_ipv4 "$host"
return
fi
if validate_ipv6 "$host"; then return 0; fi
[[ ${#host} -le 253 && $host =~ ^([A-Za-z0-9-]+\.)*[A-Za-z0-9-]+$ ]] || return 1
IFS=. read -r -a endpoint_labels <<<"$host"
for label in "${endpoint_labels[@]}"; do
[[ ${#label} -le 63 && $label != -* && $label != *- ]] || return 1
done
}
validate_endpoint_value() {
local value=$1 host port
if [[ $value == \[*\]:* ]]; then
host=${value%%\]:*}; host=${host#\[}; port=${value##*:}
validate_ipv6 "$host" && is_uint "$port" && (( 10#$port >= 1 && 10#$port <= 65535 ))
elif [[ $value == *:* && $value != *::* ]]; then
host=${value%:*}; port=${value##*:}
validate_endpoint "$host" && is_uint "$port" && (( 10#$port >= 1 && 10#$port <= 65535 ))
else
validate_endpoint "$value"
fi
}
validate_peer_endpoint_value() {
local value=$1
if [[ $value == \[*\]:* ]]; then
validate_endpoint_value "$value"
elif [[ $value != *:*:* && $value == *:* ]]; then
validate_endpoint_value "$value"
else
return 1
fi
}
is_fwmark() {
local value=$1 number digits
if [[ $value =~ ^[0-9]+$ ]]; then
digits=$value
while [[ ${#digits} -gt 1 && ${digits:0:1} == 0 ]]; do digits=${digits:1}; done
(( ${#digits} <= 10 )) || return 1
(( 10#$digits <= 4294967295 ))
return
fi
[[ $value =~ ^0[xX][0-9A-Fa-f]+$ ]] || return 1
digits=${value#0x}; digits=${digits#0X}
(( ${#digits} <= 8 )) || return 1
number=$((value))
(( number <= 4294967295 ))
}
validate_path() { [[ $1 == /* && $1 != *[[:space:]]* && $1 != *[$'\001'-$'\037'$'\177']* && $1 != *'['* && $1 != *']'* && $1 != *//* && $1 != */./* && $1 != */../* && $1 != */.. && $1 != */. ]]; }
validate_config_values() {
local value server_host
WG_INTERFACE=${CONFIG_VALUES[WG_INTERFACE]}; WG_CONF=${CONFIG_VALUES[WG_CONF]}; CLIENT_DIR=${CONFIG_VALUES[CLIENT_DIR]}; SERVER_ENDPOINT=${CONFIG_VALUES[SERVER_ENDPOINT]}; SERVER_PORT=${CONFIG_VALUES[SERVER_PORT]}; SERVER_VPN_IP=${CONFIG_VALUES[SERVER_VPN_IP]}; CLIENT_DNS=${CONFIG_VALUES[CLIENT_DNS]}; VPN_SUBNET_PREFIX=${CONFIG_VALUES[VPN_SUBNET_PREFIX]}; START_HOST=${CONFIG_VALUES[START_HOST]}; END_HOST=${CONFIG_VALUES[END_HOST]}; USE_IPV6=${CONFIG_VALUES[USE_IPV6]}; IPV6_PREFIX=${CONFIG_VALUES[IPV6_PREFIX]}; DEFAULT_CLIENT_MODE=${CONFIG_VALUES[DEFAULT_CLIENT_MODE]}; RECENT_HANDSHAKE_SECONDS=${CONFIG_VALUES[RECENT_HANDSHAKE_SECONDS]}; BACKUP_RETENTION=${CONFIG_VALUES[BACKUP_RETENTION]}
[[ $WG_INTERFACE =~ ^[A-Za-z0-9_.-]{1,15}$ && $WG_INTERFACE != .* && $WG_INTERFACE != *..* ]] || { die "$EXIT_VALIDATION" 'Некорректное имя интерфейса'; return $?; }
validate_path "$WG_CONF" && validate_path "$CLIENT_DIR" || { die "$EXIT_VALIDATION" 'Некорректный путь конфигурации или клиентов'; return $?; }
validate_endpoint "$SERVER_ENDPOINT" || { die "$EXIT_VALIDATION" 'Некорректная конечная точка сервера'; return $?; }
is_uint "$SERVER_PORT" && (( 10#$SERVER_PORT >= 1 && 10#$SERVER_PORT <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный порт сервера'; return $?; }
validate_ipv4 "$SERVER_VPN_IP" || { die "$EXIT_VALIDATION" 'Некорректный IPv4 сервера'; return $?; }
[[ -n $CLIENT_DNS && $CLIENT_DNS != *[[:space:]]* && $CLIENT_DNS != ,* && $CLIENT_DNS != *, && $CLIENT_DNS != *,,* ]] || { die "$EXIT_VALIDATION" 'Некорректный список DNS'; return $?; }
local dns; local -a dns_values=(); IFS=, read -r -a dns_values <<<"$CLIENT_DNS"; for dns in "${dns_values[@]}"; do [[ -n $dns ]] && validate_ip "$dns" || { die "$EXIT_VALIDATION" 'Некорректный адрес DNS'; return $?; }; done
[[ $VPN_SUBNET_PREFIX =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { die "$EXIT_VALIDATION" 'Некорректный префикс VPN'; return $?; }
local -a prefix_values=(); IFS=. read -r -a prefix_values <<<"$VPN_SUBNET_PREFIX"; for value in "${prefix_values[@]}"; do is_uint "$value" && (( 10#$value <= 255 )) || { die "$EXIT_VALIDATION" 'Некорректный префикс VPN'; return $?; }; done
[[ ${SERVER_VPN_IP%.*} == "$VPN_SUBNET_PREFIX" ]] || { die "$EXIT_VALIDATION" 'Префикс VPN не совпадает с адресом сервера'; return $?; }
is_uint "$START_HOST" && is_uint "$END_HOST" && (( 10#$START_HOST >= 1 && 10#$START_HOST <= 254 && 10#$END_HOST >= 1 && 10#$END_HOST <= 254 && 10#$START_HOST <= 10#$END_HOST )) || { die "$EXIT_VALIDATION" 'Некорректный диапазон адресов клиентов'; return $?; }
server_host=${SERVER_VPN_IP##*.}; (( 10#$START_HOST > 10#$server_host || 10#$server_host > 10#$END_HOST )) || { die "$EXIT_VALIDATION" 'Диапазон клиентов содержит адрес сервера'; return $?; }
[[ $USE_IPV6 == 0 || $USE_IPV6 == 1 ]] || { die "$EXIT_VALIDATION" 'USE_IPV6 должен быть 0 или 1'; return $?; }
validate_ipv6 "$IPV6_PREFIX" || { die "$EXIT_VALIDATION" 'Некорректный IPv6-префикс'; 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 "$RECENT_HANDSHAKE_SECONDS" && (( 10#$RECENT_HANDSHAKE_SECONDS <= 604800 )) || { die "$EXIT_VALIDATION" 'Некорректный срок handshake'; return $?; }
}
load_config() { set_runtime_paths || return $?; parse_config_file "$CONFIG_PATH" || return $?; require_config_keys || return $?; validate_config_values || return $?; }
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
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
RUNTIME_AVAILABLE=0
RUNTIME_LISTEN_PORT='-'
is_wireguard_key() { [[ $1 =~ ^[A-Za-z0-9+/]{43}=$ ]]; }
ipv4_to_int() {
local -a octets=() octet
IFS=. read -r -a octets <<<"${1%/*}"
printf '%u\n' "$(( 10#${octets[0]} * 16777216 + 10#${octets[1]} * 65536 + 10#${octets[2]} * 256 + 10#${octets[3]} ))"
}
cidr_network_v4() {
local address=${1%/*} prefix=${1#*/} mask
mask=$(( prefix == 0 ? 0 : (0xffffffff << (32 - prefix)) & 0xffffffff ))
printf '%u %u\n' "$(( $(ipv4_to_int "$address") & mask ))" "$prefix"
}
ipv6_groups() {
local value=$1 left right part missing index
local -a left_values=() right_values=() groups=()
if [[ $value == *'::'* ]]; then
left=${value%%::*}; right=${value##*::}
[[ -n $left ]] && IFS=: read -r -a left_values <<<"$left"
[[ -n $right ]] && IFS=: read -r -a right_values <<<"$right"
missing=$(( 8 - ${#left_values[@]} - ${#right_values[@]} )); (( missing >= 1 )) || return 1
groups=("${left_values[@]}"); for ((index = 0; index < missing; index += 1)); do groups+=(0); done; groups+=("${right_values[@]}")
else
IFS=: read -r -a groups <<<"$value"
fi
(( ${#groups[@]} == 8 )) || return 1
for part in "${groups[@]}"; do printf '%04x\n' "$((16#$part))"; done
}
ipv6_prefix_overlap() {
local left=$1 right=$2 left_prefix=${1#*/} right_prefix=${2#*/} prefix bit group_index left_group right_group mask
local -a left_groups=() right_groups=()
prefix=$left_prefix; (( right_prefix < prefix )) && prefix=$right_prefix
mapfile -t left_groups < <(ipv6_groups "${left%/*}")
mapfile -t right_groups < <(ipv6_groups "${right%/*}")
((${#left_groups[@]} == 8 && ${#right_groups[@]} == 8)) || return 1
for ((bit = 0; bit < prefix; bit += 1)); do
group_index=$((bit / 16)); mask=$((0x8000 >> (bit % 16)))
left_group=$((16#${left_groups[$group_index]})); right_group=$((16#${right_groups[$group_index]}))
(( (left_group & mask) == (right_group & mask) )) || return 1
done
}
validate_cidr() {
local cidr=$1 address prefix
[[ $cidr == */* && $cidr != *[[:space:]]* ]] || return 1
address=${cidr%/*}; prefix=${cidr#*/}
is_uint "$prefix" || return 1
if validate_ipv4 "$address"; then (( 10#$prefix <= 32 )); return; fi
validate_ipv6 "$address" && (( 10#$prefix <= 128 ))
}
cidr_family() { validate_ipv4 "${1%/*}" && printf '4' || printf '6'; }
cidrs_overlap() {
local left=$1 right=$2 left_family right_family left_network left_prefix right_network right_prefix
left_family=$(cidr_family "$left"); right_family=$(cidr_family "$right"); [[ $left_family == "$right_family" ]] || return 1
if [[ $left_family == 4 ]]; then
read -r left_network left_prefix <<<"$(cidr_network_v4 "$left")"
read -r right_network right_prefix <<<"$(cidr_network_v4 "$right")"
local common=$left_prefix; (( right_prefix < common )) && common=$right_prefix
local mask=$(( common == 0 ? 0 : (0xffffffff << (32 - common)) & 0xffffffff ))
(( (left_network & mask) == (right_network & mask) ))
return
fi
ipv6_prefix_overlap "$left" "$right"
}
normalize_allowed_ips() {
local raw=$1 item existing index
local -a values=()
local -A seen=()
IFS=, read -r -a values <<<"$raw"
((${#values[@]} > 0)) || return 1
for index in "${!values[@]}"; do
values[$index]=$(trim_space "${values[$index]}")
done
for item in "${values[@]}"; do
[[ -n "$item" ]] && validate_cidr "$item" || return 1
[[ -z ${seen[$item]+x} ]] || return 1
seen["$item"]=1
done
for index in "${!values[@]}"; do
for existing in "${values[@]:index+1}"; do cidrs_overlap "${values[$index]}" "$existing" && return 1; done
done
printf '%s\n' "${values[@]}" | LC_ALL=C sort | paste -sd, -
}
allowed_sets_overlap() {
local left=$1 right=$2 left_item right_item
local -a left_values=() right_values=()
IFS=, read -r -a left_values <<<"$left"; IFS=, read -r -a right_values <<<"$right"
for left_item in "${left_values[@]}"; do for right_item in "${right_values[@]}"; do cidrs_overlap "$left_item" "$right_item" && return 0; done; done
return 1
}
extract_client_addresses() {
local allowed=$1 item address prefix
local -a values=() v4=() v6=()
IFS=, read -r -a values <<<"$allowed"
for item in "${values[@]}"; do
address=${item%/*}; prefix=${item#*/}
if [[ $address == *:* ]]; then v6+=("$item"); elif [[ $prefix == 32 ]]; then v4+=("$item"); fi
done
printf '%s|%s\n' "$(IFS=,; printf '%s' "${v4[*]}")" "$(IFS=,; printf '%s' "${v6[*]}")"
}
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 $?; }
local normalized existing_key
normalized=$(normalize_allowed_ips "$allowed") || { 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 $?; }
done
DISK_ALLOWED["$key"]=$normalized
local addresses; addresses=$(extract_client_addresses "${DISK_ALLOWED[$key]}")
DISK_IPV4["$key"]=${addresses%%|*}; DISK_IPV6["$key"]=${addresses#*|}
DISK_ENDPOINT["$key"]=${peer_endpoint:-'-'}
DISK_MARKER["$key"]=${peer_marker:-}
DISK_LEGACY["$key"]=${peer_legacy:-}
peer_key=''; peer_allowed=''; peer_endpoint='-'; peer_marker=''; peer_legacy=''
}
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=()
[[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; }
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1)); is_control_free "$line" || { die "$EXIT_VALIDATION" "Управляющий символ в конфигурации WireGuard, строка ${line_number}"; return $?; }
trimmed=$(trim_space "$line"); [[ -z "$trimmed" ]] && continue
if [[ ${trimmed:0:1} == '#' ]]; then
if [[ $trimmed =~ ^#[[:space:]]*${MANAGED_MARKER_PREFIX}[[:space:]]*([A-Za-z0-9._-]+)$ ]]; then
if [[ $section == Peer && ${#peer_fields[@]} -eq 0 ]]; then peer_marker=${BASH_REMATCH[1]}; else pending_marker=${BASH_REMATCH[1]}; fi
fi
if [[ $trimmed =~ ^###[[:space:]]Client[[:space:]]([A-Za-z0-9._-]+)$ ]]; then
if [[ $section == Peer && ${#peer_fields[@]} -eq 0 ]]; then peer_legacy=${BASH_REMATCH[1]}; else pending_legacy=${BASH_REMATCH[1]}; fi
fi
continue
fi
if [[ $trimmed == '[Interface]' ]]; then
[[ -z $section ]] || { die "$EXIT_VALIDATION" 'Повторная секция Interface'; return $?; }
pending_marker=''; pending_legacy=''; section=Interface; continue
elif [[ $trimmed == '[Peer]' ]]; then
[[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" 'Peer до секции Interface'; return $?; }
[[ $section != Peer ]] || { commit_disk_peer "$peer_key" "$peer_allowed" || return $?; }
section=Peer; peer_key=''; peer_allowed=''; peer_endpoint='-'; peer_marker=$pending_marker; peer_legacy=$pending_legacy; pending_marker=''; pending_legacy=''; peer_fields=(); continue
elif [[ $trimmed == \[*\] ]]; then
die "$EXIT_VALIDATION" "Неизвестная секция WireGuard, строка ${line_number}"; return $?
fi
[[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" "Параметр вне секции, строка ${line_number}"; return $?; }
[[ $trimmed =~ ^([A-Za-z][A-Za-z0-9]*)[[:space:]]*=[[:space:]]*(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректный параметр WireGuard, строка ${line_number}"; return $?; }
key=${BASH_REMATCH[1]}; value=$(trim_space "${BASH_REMATCH[2]}"); [[ -n "$value" ]] || { die "$EXIT_VALIDATION" 'Пустой параметр WireGuard'; return $?; }
[[ $section != Interface || -z $pending_marker$pending_legacy ]] || { pending_marker=''; pending_legacy=''; }
if [[ $section == Interface ]]; then
case "$key" in
PrivateKey|ListenPort|MTU|Table|SaveConfig|FwMark) ;;
Address|DNS|PostUp|PostDown|PreUp|PreDown) continue ;;
*) die "$EXIT_VALIDATION" "Неизвестный параметр Interface: ${key}"; return $? ;;
esac
[[ -z ${interface_fields[$key]+x} ]] || { die "$EXIT_VALIDATION" "Дублирующийся параметр Interface: ${key}"; return $?; }
interface_fields["$key"]=1
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 == FwMark ]]; then is_fwmark "$value" || { die "$EXIT_VALIDATION" 'Некорректный FwMark'; return $?; }; fi
continue
fi
case "$key" in
PublicKey) [[ -z ${peer_fields[PublicKey]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в peer'; return $?; }; is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в peer'; return $?; }; peer_key=$value; peer_fields[PublicKey]=1 ;;
PresharedKey) [[ -z ${peer_fields[PresharedKey]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PresharedKey в peer'; return $?; }; is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный PresharedKey'; return $?; }; peer_fields[PresharedKey]=1 ;;
AllowedIPs) [[ -z ${peer_fields[AllowedIPs]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся AllowedIPs в peer'; return $?; }; peer_allowed=$value; peer_fields[AllowedIPs]=1 ;;
Endpoint) [[ -z ${peer_fields[Endpoint]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся Endpoint в peer'; return $?; }; validate_peer_endpoint_value "$value" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint'; return $?; }; peer_endpoint=$value; peer_fields[Endpoint]=1 ;;
PersistentKeepalive) [[ -z ${peer_fields[PersistentKeepalive]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PersistentKeepalive'; return $?; }; is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный PersistentKeepalive'; return $?; }; peer_fields[PersistentKeepalive]=1 ;;
*) die "$EXIT_VALIDATION" "Неизвестный параметр Peer: ${key}"; return $? ;;
esac
done <"$file"
[[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" 'Отсутствует секция Interface'; return $?; }
[[ $section != Peer ]] || commit_disk_peer "$peer_key" "$peer_allowed"
}
parse_metadata_file() {
local file=$1 line key value line_number=0 existing_key existing_name
local name='' public_key='' ipv4='' ipv6='' created='' config='' mode='' managed=''
[[ -f "$file" && ! -L "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1)); line=$(trim_space "$line"); [[ -z "$line" ]] && continue
[[ $line =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректные метаданные: ${file}"; return $?; }
key=${BASH_REMATCH[1]}; value=${BASH_REMATCH[2]}; [[ -n "$value" ]] || { die "$EXIT_VALIDATION" 'Пустое поле метаданных'; return $?; }
case "$key" in
NAME) [[ -z $name ]] || { die "$EXIT_VALIDATION" 'Дубликат NAME в метаданных'; return $?; }; name=$value ;;
PUBLIC_KEY) [[ -z $public_key ]] || { die "$EXIT_VALIDATION" 'Дубликат PUBLIC_KEY в метаданных'; return $?; }; public_key=$value ;;
IPV4) [[ -z $ipv4 ]] || { die "$EXIT_VALIDATION" 'Дубликат IPV4 в метаданных'; return $?; }; ipv4=$value ;;
IPV6) [[ -z $ipv6 ]] || { die "$EXIT_VALIDATION" 'Дубликат IPV6 в метаданных'; return $?; }; ipv6=$value ;;
CREATED_AT) [[ -z $created ]] || { die "$EXIT_VALIDATION" 'Дубликат CREATED_AT в метаданных'; return $?; }; created=$value ;;
CONFIG_PATH) [[ -z $config ]] || { die "$EXIT_VALIDATION" 'Дубликат CONFIG_PATH в метаданных'; return $?; }; config=$value ;;
CLIENT_MODE) [[ -z $mode ]] || { die "$EXIT_VALIDATION" 'Дубликат CLIENT_MODE в метаданных'; return $?; }; mode=$value ;;
MANAGED) [[ -z $managed ]] || { die "$EXIT_VALIDATION" 'Дубликат MANAGED в метаданных'; return $?; }; managed=$value ;;
*) die "$EXIT_VALIDATION" "Неизвестное поле метаданных: ${key}"; return $? ;;
esac
done <"$file"
[[ $name =~ ^[A-Za-z0-9._-]+$ && $name != -* && -n $public_key && -n $ipv4 && $created =~ ^[0-9]+$ && $config == /* ]] || { die "$EXIT_VALIDATION" "Некорректные метаданные: ${file}"; return $?; }
[[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" "Некорректный режим в метаданных: ${file}"; return $?; }
[[ $managed == 0 || $managed == 1 ]] || { die "$EXIT_VALIDATION" "Некорректный MANAGED в метаданных: ${file}"; return $?; }
validate_path "$config" && [[ $config == "${CLIENT_DIR%/}"/* ]] && { [[ ! -e "$config" || ! -L "$config" ]]; } || { die "$EXIT_VALIDATION" "Некорректный путь клиента в метаданных: ${file}"; return $?; }
is_wireguard_key "$public_key" && validate_ipv4 "$ipv4" || { die "$EXIT_VALIDATION" "Некорректная идентичность в метаданных: ${file}"; return $?; }
if [[ -n $ipv6 ]] && ! validate_ipv6 "$ipv6"; then die "$EXIT_VALIDATION" "Некорректный IPV6 в метаданных: ${file}"; return $?; fi
for existing_key in "${!META_NAME[@]}"; do [[ $existing_key == "$public_key" ]] && { die "$EXIT_VALIDATION" 'Дублирующийся PUBLIC_KEY в метаданных'; return $?; }; done
for existing_name in "${META_NAMES[@]}"; do [[ $existing_name == "$name" ]] && { die "$EXIT_VALIDATION" 'Дублирующееся NAME в метаданных'; return $?; }; done
META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed
}
parse_metadata() {
local file
META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=()
[[ -d "$METADATA_DIR" ]] || return 0
for file in "$METADATA_DIR"/*; do [[ -e "$file" ]] || continue; parse_metadata_file "$file" || return $?; done
}
parse_runtime_dump() {
local line fields_count line_number=0 key endpoint allowed handshake rx tx keepalive
local output=$1
RUNTIME_AVAILABLE=1; RUNTIME_LISTEN_PORT='-'; RUNTIME_ENDPOINT=(); RUNTIME_HANDSHAKE=(); RUNTIME_RX=(); RUNTIME_TX=(); RUNTIME_ALLOWED=()
while IFS= read -r line || [[ -n "$line" ]]; do
((line_number += 1)); IFS=$'\t' read -r -a fields <<<"$line"; fields_count=${#fields[@]}
if (( line_number == 1 )); then
(( fields_count == 4 )) || { die "$EXIT_VALIDATION" 'Некорректная строка интерфейса wg dump'; return $?; }
if [[ ${fields[0]} != '(none)' ]] && ! is_wireguard_key "${fields[0]}"; then die "$EXIT_VALIDATION" 'Некорректный ключ интерфейса wg dump'; return $?; fi
if [[ ${fields[1]} != '(none)' ]] && ! is_wireguard_key "${fields[1]}"; then die "$EXIT_VALIDATION" 'Некорректный ключ интерфейса wg dump'; return $?; fi
is_uint "${fields[2]}" && (( 10#${fields[2]} <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный порт в wg dump'; return $?; }; RUNTIME_LISTEN_PORT=${fields[2]}; continue
fi
(( fields_count == 8 )) || { die "$EXIT_VALIDATION" 'Некорректная строка peer в wg dump'; return $?; }
is_wireguard_key "${fields[0]}" && { [[ ${fields[1]} == '(none)' ]] || is_wireguard_key "${fields[1]}"; } || { die "$EXIT_VALIDATION" 'Некорректный ключ в wg dump'; return $?; }
key=${fields[0]}; endpoint=${fields[2]}; allowed=${fields[3]}; handshake=${fields[4]}; rx=${fields[5]}; tx=${fields[6]}; keepalive=${fields[7]}
[[ $endpoint == '(none)' ]] && endpoint='-'
[[ $allowed == '(none)' ]] && allowed='-'
is_wireguard_key "$key" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в wg dump'; return $?; }
[[ $endpoint == '-' || -z $endpoint ]] || validate_peer_endpoint_value "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint в wg dump'; return $?; }
[[ -z ${RUNTIME_HANDSHAKE[$key]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в wg dump'; return $?; }
if [[ $allowed != '-' ]]; then normalize_allowed_ips "$allowed" >/dev/null || { die "$EXIT_VALIDATION" 'Некорректные AllowedIPs в wg dump'; return $?; }; fi
is_uint "$handshake" && is_uint "$rx" && is_uint "$tx" && is_uint "$keepalive" || { die "$EXIT_VALIDATION" 'Некорректные числа в wg dump'; return $?; }
RUNTIME_ENDPOINT["$key"]=${endpoint:--}; if [[ $allowed == '-' ]]; then RUNTIME_ALLOWED["$key"]='-'; else RUNTIME_ALLOWED["$key"]=$(normalize_allowed_ips "$allowed"); fi; RUNTIME_HANDSHAKE["$key"]=$handshake; RUNTIME_RX["$key"]=$rx; RUNTIME_TX["$key"]=$tx
done <<<"$output"
}
load_runtime_state() {
local output command_status
RUNTIME_AVAILABLE=0; RUNTIME_LISTEN_PORT='-'
if ! command -v wg >/dev/null 2>&1; then return 0; fi
set +e
output=$(wg show "$WG_INTERFACE" dump 2>/dev/null); command_status=$?
set -e
(( command_status == 0 )) || return 0
[[ -n "$output" ]] || return 0
parse_runtime_dump "$output"
}
current_epoch() {
if [[ ${WG_MANAGER_NOW+x} ]]; then
[[ $WG_MANAGER_NOW =~ ^[0-9]+$ ]] || return "$EXIT_VALIDATION"
printf '%s\n' "$WG_MANAGER_NOW"
else
date +%s
fi
}
build_client_records() {
local key addresses name origin handshake now state disk_ipv4 disk_ipv6 expected_allowed
local -a keys=()
local -A seen_keys=()
for key in "${!DISK_ALLOWED[@]}" "${!RUNTIME_HANDSHAKE[@]}"; do [[ -n ${seen_keys[$key]+x} ]] || { seen_keys[$key]=1; keys+=("$key"); }; done
CLIENT_KEYS=(); CLIENT_NAMES=(); CLIENT_ADDRESSES=(); CLIENT_ENDPOINTS=(); CLIENT_HANDSHAKES=(); CLIENT_RX=(); CLIENT_TX=(); CLIENT_STATES=(); CLIENT_ORIGINS=()
now=$(current_epoch) || return $?
for key in "${keys[@]}"; do
name='-'; origin=unmanaged; addresses=${DISK_ALLOWED[$key]:-${RUNTIME_ALLOWED[$key]:--}}; handshake=${RUNTIME_HANDSHAKE[$key]:-0}
disk_ipv4=${DISK_IPV4[$key]:-}; disk_ipv4=${disk_ipv4%%/*}; disk_ipv6=${DISK_IPV6[$key]:-}; disk_ipv6=${disk_ipv6%%/*}
if [[ ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then
expected_allowed="${META_IPV4[$key]}/32"; [[ -n ${META_IPV6[$key]:-} ]] && expected_allowed+=",${META_IPV6[$key]}/128"
if [[ ${META_IPV4[$key]} == "$disk_ipv4" && ${META_IPV6[$key]:-} == "$disk_ipv6" && ${DISK_ALLOWED[$key]:-} == "$(normalize_allowed_ips "$expected_allowed")" ]]; then name=${META_NAME[$key]}; origin=managed; fi
fi
if [[ $origin != managed && -n ${DISK_MARKER[$key]:-}${DISK_LEGACY[$key]:-} ]]; then origin=legacy; name=${DISK_MARKER[$key]:-${DISK_LEGACY[$key]}}; fi
if [[ $origin == managed ]]; then
if [[ $handshake == 0 ]]; then state=never; elif (( now >= handshake && now - handshake <= RECENT_HANDSHAKE_SECONDS )); then state=active; else state=inactive; fi
else state=unmanaged; fi
CLIENT_KEYS+=("$key"); CLIENT_NAMES+=("$name"); CLIENT_ADDRESSES+=("$addresses"); CLIENT_ENDPOINTS+=("${RUNTIME_ENDPOINT[$key]:-${DISK_ENDPOINT[$key]:--}}"); CLIENT_HANDSHAKES+=("$handshake"); CLIENT_RX+=("${RUNTIME_RX[$key]:-0}"); CLIENT_TX+=("${RUNTIME_TX[$key]:-0}"); CLIENT_STATES+=("$state"); CLIENT_ORIGINS+=("$origin")
done
}
load_client_model() {
load_config || return $?; parse_wireguard_config "$WG_CONF" || return $?; parse_metadata || return $?; load_runtime_state || return $?; build_client_records
}
print_status() {
local managed=0 unmanaged=0 active=0 index
for index in "${!CLIENT_KEYS[@]}"; do
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then ((managed += 1)); else ((unmanaged += 1)); fi
if [[ ${CLIENT_STATES[$index]} == active ]]; then ((active += 1)); fi
done
printf 'Интерфейс: %s\nСостояние runtime: %s\nПорт: %s\nPeers: %d\nУправляемые: %d\nНеуправляемые: %d\nАктивные: %d\n' "$WG_INTERFACE" "$([[ $RUNTIME_AVAILABLE == 1 ]] && printf 'доступен' || printf 'недоступен')" "${RUNTIME_LISTEN_PORT:--}" "${#CLIENT_KEYS[@]}" "$managed" "$unmanaged" "$active"
}
print_list() {
local index
printf 'NAME\tADDRESSES\tPUBLIC_KEY\tENDPOINT\tHANDSHAKE\tRX\tTX\tSTATE\tORIGIN\n'
for index in "${!CLIENT_KEYS[@]}"; do printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "${CLIENT_NAMES[$index]}" "${CLIENT_ADDRESSES[$index]}" "${CLIENT_KEYS[$index]:0:8}" "${CLIENT_ENDPOINTS[$index]}" "${CLIENT_HANDSHAKES[$index]}" "${CLIENT_RX[$index]}" "${CLIENT_TX[$index]}" "${CLIENT_STATES[$index]}" "${CLIENT_ORIGINS[$index]}"; done | LC_ALL=C sort -t $'\t' -k1,1 -k2,2 -k3,3
}
require_command() {
local command_name=$1 command_path
command_path=$(command -v -- "$command_name" 2>/dev/null) || { die "$EXIT_DEPENDENCY" "Не найдена обязательная команда: ${command_name}"; return $?; }
[[ -x "$command_path" ]] || { die "$EXIT_DEPENDENCY" "Команда недоступна для запуска: ${command_name}"; return $?; }
printf '%s\n' "$command_path"
}
optional_command() { local command_path; command_path=$(command -v -- "$1" 2>/dev/null) || return "$EXIT_DEPENDENCY"; [[ -f "$command_path" && -x "$command_path" ]] && printf '%s\n' "$command_path" || return "$EXIT_DEPENDENCY"; }
resolve_gum() {
local candidate
if [[ ${WG_MANAGER_GUM+x} ]]; then
[[ -f ${WG_MANAGER_GUM} && -x ${WG_MANAGER_GUM} ]] || { die "$EXIT_DEPENDENCY" 'WG_MANAGER_GUM задан, но путь недоступен'; return $?; }
printf '%s\n' "$WG_MANAGER_GUM"; return 0
fi
candidate=${SCRIPT_DIR}/bin/gum
if [[ -f "$candidate" && -x "$candidate" ]]; then printf '%s\n' "$candidate"; return 0; fi
optional_command gum || { die "$EXIT_DEPENDENCY" 'Не найден gum для TUI'; return $?; }
}
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
main() {
if (( $# != 1 )); then
local status
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2
return "$status"
fi
case "$1" in
help) print_usage ;;
version) print_version ;;
status) load_client_model || return $?; print_status ;;
list) load_client_model || return $?; print_list ;;
add|show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
*)
local status
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2
return "$status"
;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi