AmneziaWG 3.0 readiness: centralize wg CLI contract, drop dead host-side fallback
Peer discovery is now the single owner of how wg is invoked and parsed. wg.rs: - resolve the binary through ordered amnezia.wg_binaries (default [awg, wg]); awg is the real binary in the official image and wg is a symlink to it, so awg is tried first and wg stays for plain WireGuard containers. No probe call: resolution is a property of the command being run anyway. - drop the nsenter fallback. AmneziaWG is userspace and its UAPI socket lives under RUNSTATEDIR/amneziawg/ in the container mount namespace, while host wireguard-tools looks under /var/run/wireguard/. nsenter -n enters only the network namespace, so that path could never return peers. - classify dump lines structurally instead of by column count. The AWG interface line is longer than a peer line (14 columns on AWG 2.0, 28 on AWG 3.0) and its handshake/rx/tx positions hold junk-packet sizes that parse as integers, so only the allowed-ips column discriminates. Column 1 of an interface line is the device private key, which the previous heuristic rejected only incidentally. - fix allowed-ips parsing: `show all allowed-ips` separates a peer's addresses with spaces, not commas, so peers with more than one allowed IP were shaped on their first address only and the rest ran unlimited. - collapse the three duplicated /32-in-CIDR filters into one tokenizer shared by the allowed-ips, dump and pretty-print parsers. config.rs: - add amnezia.wg_binaries with serde default, validated as bare command names (non-empty, no whitespace, no path separators) since they execute inside the container. Existing deployed configs keep parsing unchanged. - remove host wg from the required-tool preflight. It gated daemon startup on wireguard-tools for a code path that produced nothing. status.rs: - discover peers once instead of twice per status call and degrade gracefully like the neighbouring sections instead of aborting the whole output. Tests: real AWG 3.0 / AWG 2.0 / plain WireGuard interface-line fixtures, AWG 3.0 range keepalive, space-separated allowed-ips, binary resolution order, and a test that parses and validates the shipped config.example.toml.
This commit is contained in:
@@ -5,11 +5,11 @@
|
||||
- [ ] Хост — Debian 13 amd64.
|
||||
- [ ] Сервер выделенный, без соседних сервисов.
|
||||
- [ ] AmneziaWG/AWG 2.0 или AmneziaWG/AWG 1.5 установлена официальным способом.
|
||||
- [ ] Контейнер `amnezia-awg2` или `amnezia-awg` запущен.
|
||||
- [ ] Контейнер `amnezia-awg2` или `amnezia-awg` запущен (`amnezia-awg3` — резерв на будущее, в AmneziaVPN 5.0 такого типа контейнера ещё нет).
|
||||
- [ ] `docker ps` видит контейнер.
|
||||
- [ ] Можно получить PID контейнера.
|
||||
- [ ] `/proc/<pid>/ns/net` существует.
|
||||
- [ ] На хосте доступны `ip`, `tc`, `wg`, `nsenter`, `modprobe`.
|
||||
- [ ] На хосте доступны `ip`, `tc`, `nsenter`, `modprobe`. Хостовый `wg` не требуется.
|
||||
- [ ] `awg2-harness validate-config` проходит без ошибок.
|
||||
|
||||
## B. Peer discovery
|
||||
@@ -17,9 +17,12 @@
|
||||
- [ ] `awg2-harness status` показывает найденный контейнер.
|
||||
- [ ] `awg2-harness status` показывает PID/netns контейнера.
|
||||
- [ ] `awg2-harness status` показывает найденный интерфейс.
|
||||
- [ ] Количество peers совпадает с `wg show all allowed-ips`.
|
||||
- [ ] Количество peers совпадает с `docker exec "$C" awg show all allowed-ips`.
|
||||
- [ ] Peer с несколькими allowed IP даёт отдельный класс на каждый адрес (в этой команде адреса разделены пробелом).
|
||||
- [ ] Offline peers с allowed IP тоже попадают в список shaping.
|
||||
- [ ] IP вне `amnezia.peer_ipv4_cidr` не попадают в traffic shaping.
|
||||
- [ ] В секции `WireGuard clients` вывода `status` нет строки, соответствующей самому интерфейсу (столбец `Public key` не содержит приватный ключ устройства).
|
||||
- [ ] `docker exec "$C" readlink -f /usr/bin/wg` возвращает `/usr/bin/awg`.
|
||||
|
||||
## C. Traffic topology contract
|
||||
|
||||
@@ -161,7 +164,7 @@
|
||||
- [ ] AmneziaDNS установлен из приложения AmneziaVPN.
|
||||
- [ ] Контейнер `amnezia-dns` в состоянии healthy.
|
||||
- [ ] `docker inspect amnezia-dns` показывает `"53/tcp":null` и `"53/udp":null`.
|
||||
- [ ] `amnezia-dns` и активный AWG-контейнер (`amnezia-awg2` или `amnezia-awg`) подключены к одной Amnezia DNS Docker-сети.
|
||||
- [ ] `amnezia-dns` и активный AWG-контейнер (`amnezia-awg2`, `amnezia-awg3` или `amnezia-awg`) подключены к одной Amnezia DNS Docker-сети.
|
||||
- [ ] Публичный DNS-запрос к `<server-public-ip>:53` не резолвит домены; допустимы `connection refused`, `timeout` или `no servers could be reached`.
|
||||
- [ ] DNS-резолвинг у VPN-клиента работает при активном подключении.
|
||||
- [ ] AWG2 host firewall не открывает `53/tcp` и `53/udp`.
|
||||
@@ -181,6 +184,6 @@
|
||||
- [ ] `status` показывает блок `Docker/Amnezia port path` с полями `docker_userland_proxy`, `published_udp_port` и note про Docker-managed `FORWARD`.
|
||||
- [ ] `status` показывает поля `host_forward_policy_drop` и `docker_forward_rules_present` в блоке `Docker/Amnezia port path`.
|
||||
- [ ] Если `docker_userland_proxy=false` и `FORWARD policy DROP`, при отсутствии явных Docker accept-признаков `status` выводит warning, но `awg2-harness` не изменяет `FORWARD` автоматически.
|
||||
- [ ] Сверено соответствие: `config host_firewall.amnezia_port` ↔ опубликованный Docker UDP host port ↔ `docker exec amnezia-awg2 wg show` (`listening port`).
|
||||
- [ ] Сверено соответствие: `config host_firewall.amnezia_port` ↔ опубликованный Docker UDP host port ↔ `docker exec amnezia-awg2 awg show` (`listening port`).
|
||||
- [ ] При нескольких UDP bindings без однозначного соответствия `host_firewall.amnezia_port` диагностика `published_udp_port` показывает `unknown` (ambiguous), а не произвольный первый порт.
|
||||
- [ ] Для диагностики FORWARD используется best-effort проверка: `iptables -S FORWARD` и fallback через `nft list ruleset`; `status` не падает при недоступности одной из команд.
|
||||
|
||||
+10
-5
@@ -24,20 +24,21 @@ nano /etc/awg2-harness/config.toml
|
||||
Discovery-команды:
|
||||
|
||||
```bash
|
||||
docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg
|
||||
docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg3 2>/dev/null || docker port amnezia-awg
|
||||
|
||||
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)"
|
||||
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
|
||||
docker exec "$C" ip -4 addr
|
||||
docker exec "$C" wg show
|
||||
docker exec "$C" awg show
|
||||
```
|
||||
|
||||
## Рекомендуемый `config.toml`
|
||||
|
||||
```toml
|
||||
[amnezia]
|
||||
container_names = ["amnezia-awg2", "amnezia-awg"]
|
||||
container_names = ["amnezia-awg2", "amnezia-awg3", "amnezia-awg"]
|
||||
interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"]
|
||||
interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"]
|
||||
wg_binaries = ["awg", "wg"]
|
||||
peer_ipv4_cidr = "10.8.1.0/24"
|
||||
|
||||
[traffic]
|
||||
@@ -91,12 +92,16 @@ swappiness = 1
|
||||
|
||||
## Пояснения
|
||||
|
||||
`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`.
|
||||
`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен, сопоставление точное. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`. `amnezia-awg3` перечислен на будущее: в AmneziaVPN 5.0 отдельного типа контейнера под AWG 3.0 нет, клиент по-прежнему знает только `Awg` и `Awg2`.
|
||||
|
||||
`amnezia.interface_candidates` — точные имена интерфейса, которые пробуются первыми.
|
||||
|
||||
`amnezia.interface_patterns` — fallback-паттерны для устойчивости к обновлениям Amnezia. Они нужны для снижения риска переименования интерфейса между `awg0`, `wg0`, `amnezia0`, `tun0`.
|
||||
|
||||
`amnezia.wg_binaries` — утилита чтения peer state внутри контейнера, пробуется по порядку до первого успеха. Default — `["awg", "wg"]`. В официальном образе AmneziaWG реальный бинарь `/usr/bin/awg`, а `/usr/bin/wg` — symlink на него, поэтому `awg` идёт первым; `wg` остаётся для контейнеров с чистым WireGuard. Значения выполняются внутри контейнера и валидируются как имена команд: непустые, без пробелов и без разделителей пути. Параметр опциональный — конфиги, созданные до его появления, продолжают работать с default.
|
||||
|
||||
Хостовый `wireguard-tools` не нужен и не проверяется в preflight. Peer state читается только через `docker exec`: AmneziaWG — userspace-реализация, её UAPI-сокет находится в `RUNSTATEDIR/amneziawg/` внутри mount namespace контейнера, а `nsenter -n` входит только в network namespace, поэтому хостовый `wg` до него не достаёт.
|
||||
|
||||
`amnezia.peer_ipv4_cidr` — подсеть VPN peers. Production default — `10.8.1.0/24`; значение конфигурируемое.
|
||||
|
||||
`traffic.peer_limit` — per-peer лимит в обе стороны. Текущий production default — `50mbit`.
|
||||
|
||||
@@ -147,9 +147,17 @@ Preferred strategy:
|
||||
|
||||
Implementation options:
|
||||
|
||||
- current path: spawn host tools with namespace entry, for example `nsenter -t <pid> -n tc ...`, `nsenter -t <pid> -n ip ...`, `nsenter -t <pid> -n wg ...`;
|
||||
- current path: spawn host tools with namespace entry, for example `nsenter -t <pid> -n tc ...`, `nsenter -t <pid> -n ip ...`;
|
||||
- later/native: use netlink crates and explicit namespace switching where practical.
|
||||
|
||||
Peer state is the one exception and must never be read with a host-side binary.
|
||||
AmneziaWG is a userspace implementation whose UAPI socket lives under
|
||||
`RUNSTATEDIR/amneziawg/<iface>.sock` inside the container mount namespace, while host
|
||||
`wireguard-tools` looks under `/var/run/wireguard/`. `nsenter -n` enters only the network
|
||||
namespace, so a host `wg` reaches neither the socket nor a kernel device and always
|
||||
returns an empty peer list. Peer discovery therefore uses `docker exec` exclusively, and
|
||||
`wireguard-tools` is not a host dependency.
|
||||
|
||||
`docker exec` should not be the architectural default. It can remain as compatibility fallback if `nsenter`/host tools are unavailable, but the design assumption is that the Rust binary has host authority and controls the relevant namespace.
|
||||
|
||||
This also reduces operational risk: if the Amnezia container drops `tc` or `iproute2`, host-side tools can still apply rules inside the container netns.
|
||||
@@ -174,15 +182,28 @@ Interface detection order:
|
||||
|
||||
## 7. Peer discovery
|
||||
|
||||
Transport is always `docker exec <container>` — see section 5 for why a host-side `wg`
|
||||
cannot work.
|
||||
|
||||
Binary resolution uses ordered `amnezia.wg_binaries`, default `["awg", "wg"]`. The first
|
||||
binary that exits successfully wins. The official AmneziaWG image ships `/usr/bin/awg` and
|
||||
symlinks `/usr/bin/wg` to it, so `awg` is the real dependency; `wg` remains for plain
|
||||
WireGuard containers where `awg` does not exist. There is no probe call: the resolution is
|
||||
a property of the command that is being run anyway.
|
||||
|
||||
Primary command:
|
||||
|
||||
```bash
|
||||
wg show all allowed-ips
|
||||
awg show all allowed-ips
|
||||
```
|
||||
|
||||
Parsing rules:
|
||||
|
||||
- parse lines structurally into peer public key and allowed IPs;
|
||||
- a peer's allowed ips are **space-separated** in this command, comma-separated in
|
||||
`show all dump` and `", "`-separated in plain `show`; all tokens from column 3 onward
|
||||
must be consumed, otherwise peers with more than one allowed IP are shaped only on their
|
||||
first address;
|
||||
- keep IPv4 `/32` addresses within `amnezia.peer_ipv4_cidr`;
|
||||
- include offline peers; online state is not a requirement for shaping;
|
||||
- sort peer IP strings lexicographically before class assignment to preserve the current lexicographic class-id contract.
|
||||
@@ -190,16 +211,31 @@ Parsing rules:
|
||||
Fallback:
|
||||
|
||||
```bash
|
||||
wg show
|
||||
awg show
|
||||
```
|
||||
|
||||
The fallback parser should support standard `wg show` text output by extracting `allowed ips:` lines.
|
||||
|
||||
`show all dump` line classification:
|
||||
|
||||
- a line is a peer line only if it has at least 9 tab-separated columns, column 5
|
||||
(allowed ips) is `(none)` or a comma-separated CIDR list, and columns 6-8 (handshake,
|
||||
rx, tx) all parse as integers;
|
||||
- column count is not a discriminator on its own: the AmneziaWG interface line carries the
|
||||
AWG parameters and is *longer* than a peer line (14 columns for AWG 2.0, 28 for AWG 3.0),
|
||||
and its handshake/rx/tx positions hold junk-packet sizes that parse as integers;
|
||||
- this matters beyond correctness — column 2 of an interface line is the device private
|
||||
key, so a misclassified interface line would surface it as a peer public key;
|
||||
- AWG 3.0 prints protocol timers as ranges (`20-30`). The peer keepalive column is not
|
||||
read, and a range value must not break parsing.
|
||||
|
||||
Execution policy for production:
|
||||
|
||||
- do not use shell wrappers (`sh -c`) for `wg` invocation;
|
||||
- do not suppress stderr via redirection in command strings;
|
||||
- execute `wg` directly and handle non-zero exit codes in Rust.
|
||||
- execute the binary directly and handle non-zero exit codes in Rust;
|
||||
- `amnezia.wg_binaries` values are executed inside the container and are validated as bare
|
||||
command names: non-empty, no whitespace, no path separators.
|
||||
|
||||
## 8. Desired traffic topology
|
||||
|
||||
@@ -463,9 +499,12 @@ The operation must be idempotent.
|
||||
|
||||
Unit tests:
|
||||
|
||||
- config validation;
|
||||
- `wg show all allowed-ips` parser;
|
||||
- `wg show` fallback parser;
|
||||
- config validation, including `amnezia.wg_binaries` shape;
|
||||
- `awg|wg show all allowed-ips` parser, including multiple space-separated allowed ips;
|
||||
- `awg|wg show` fallback parser;
|
||||
- `awg|wg show all dump` peer/interface line classification against AWG 3.0 (28 columns),
|
||||
AWG 2.0 (14 columns) and plain WireGuard (5 columns) interface lines;
|
||||
- wg binary resolution order and the error raised when no binary is available;
|
||||
- CIDR filtering;
|
||||
- deterministic class assignment with lexicographic class-id stability contract;
|
||||
- nftables ruleset rendering and parsing for the AWG2-owned table;
|
||||
|
||||
@@ -127,14 +127,16 @@ Password or SSH private key:
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)"
|
||||
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
|
||||
echo "container=$C"
|
||||
docker port "$C"
|
||||
docker exec "$C" wg show
|
||||
docker exec "$C" awg show
|
||||
```
|
||||
|
||||
Если контейнер называется иначе или интерфейс не `awg0`, отразите это в `/etc/awg2-harness/config.toml`.
|
||||
|
||||
`awg` — реальный бинарь в официальном образе AmneziaWG; `wg` внутри контейнера является symlink на него и тоже сработает. На хосте `wireguard-tools` устанавливать не нужно: обвязка читает peer state только через `docker exec`.
|
||||
|
||||
### B3. Установить AmneziaDNS
|
||||
|
||||
Установите AmneziaDNS из desktop-приложения AmneziaVPN:
|
||||
@@ -164,7 +166,7 @@ amnezia-dns amnezia-dns Up ... (healthy) 53/tcp, 53/udp
|
||||
Проверка публикации портов:
|
||||
|
||||
```bash
|
||||
AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)"
|
||||
AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
|
||||
echo "AWG=$AWG"
|
||||
|
||||
docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns
|
||||
@@ -193,7 +195,7 @@ Optional deep-check DNS path (если нужна углублённая про
|
||||
```bash
|
||||
apt install -y dnsutils
|
||||
|
||||
AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)"
|
||||
AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
|
||||
DNS_IP="$(docker inspect -f '{{range $n,$v := .NetworkSettings.Networks}}{{if $v.IPAddress}}{{println $v.IPAddress}}{{end}}{{end}}' amnezia-dns | head -n1)"
|
||||
DNS_PID="$(docker inspect -f '{{.State.Pid}}' amnezia-dns)"
|
||||
AWG_PID="$(docker inspect -f '{{.State.Pid}}' "$AWG")"
|
||||
|
||||
@@ -43,12 +43,24 @@ awg2-harness --config /etc/awg2-harness/config.toml status
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)"
|
||||
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
|
||||
docker port "$C"
|
||||
docker exec "$C" wg show
|
||||
docker exec "$C" awg show
|
||||
docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns
|
||||
```
|
||||
|
||||
Проверка AWG-контракта внутри контейнера (без раскрытия секретов):
|
||||
|
||||
```bash
|
||||
docker exec "$C" readlink -f /usr/bin/wg # ожидается /usr/bin/awg
|
||||
docker exec "$C" awg --version
|
||||
docker exec "$C" ip -o link show awg0
|
||||
docker exec "$C" awg show all allowed-ips
|
||||
docker exec "$C" awg show all dump | awk -F '\t' '{ print "row=" NR, "fields=" NF, "interface=" $1 }'
|
||||
```
|
||||
|
||||
Interface-строка `awg show all dump` даёт 14 полей на AWG 2.0 и 28 на AWG 3.0, peer-строки — 9. Полный вывод `awg show all dump` передавать нельзя: во второй колонке interface-строки находится приватный ключ устройства.
|
||||
|
||||
Проверка, что runtime config создан вручную (ожидаемое поведение):
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user