322f67f69a
Two defects that together made the previous ownership model unsafe, and reverse an earlier deviation that the follow-up audit correctly rejected. 1. The sidecar marker identified a path, not a file. Ownership was `<swap.path>.awg2-harness-owned` sitting next to the swapfile, and the check was whether that sidecar existed and started with our marker. Nothing tied it to the file it was supposed to describe. The sequence that loses data: our swapfile is removed by hand, the sidecar survives, an operator puts an unrelated file at the same path - and the next reconcile calls it managed and, if the size differs, deletes it. The claim in the previous commit that a wrong path "can now only fail, never destroy" was wrong. Ownership now lives in /var/lib/awg2-harness/swap-ownership.conf, a root-only directory created 0700, and records version, path, device and inode. A path is not an identity: a file can be deleted and another created in its place, but (device, inode) cannot be reused while our file exists. Classification gained StaleOwnership for "right path, different file", which refuses exactly like Foreign but says which situation the operator is actually in. Only ManagedValid and ManagedWrongSize ever authorise a delete, and that is asserted directly. The record is written after the file exists, from its real metadata, so until it lands the swapfile is not considered managed. The old sidecar is now only ever removed, never read. classify_ownership is a pure function taking the recorded state and the observed identity, so the cases that matter - stale record, record for another path, no record - are unit-testable rather than requiring a Linux filesystem. 2. An arbitrary swap.path was still exploitable. The previous commit kept arbitrary absolute paths and argued the ownership marker was sufficient protection. It is not, and the audit is right to reject it. Between assert_swap_path_is_safe and fallocate/dd/mkswap running, a user who can write the parent directory replaces the final component with a symlink and redirects a root-owned operation. That is a TOCTOU; stat-ing before use cannot close it, because the check and the use are separate syscalls. Paths like /tmp/awg.swap or /home/user/awg.swap made this reachable by any local user. swap.path is now restricted to /swapfile or a file directly under /var/lib/awg2-harness/. Both are root-owned, so the window does not exist rather than being detected. /swapfile is kept so existing deployments need no config change. The deny-list it replaces only ruled out the paths someone had thought of; what actually matters is who can write the parent. Eleven tests: the five classification states, a stale record, a record for another path, the invariant that no refusal state is managed, record round-trip, malformed and unsupported-version records, the newly allowed and newly rejected path shapes. cargo test --workspace: 176 + 4 passed. clippy -D warnings clean.
221 lines
17 KiB
Markdown
221 lines
17 KiB
Markdown
# Конфигурация AWG2 Harness
|
||
|
||
AWG2 Harness разрабатывался для self-hosted AmneziaWG/AWG 2.0 и совместимого legacy-сценария AmneziaWG/AWG 1.5. Конфиг живёт в `/etc/awg2-harness/config.toml`. Он общий для всего пакета и должен содержать только эксплуатационно значимые параметры.
|
||
|
||
## Создание runtime config
|
||
|
||
Пакет намеренно **не** создаёт `/etc/awg2-harness/config.toml` автоматически. Это снижает риск случайно применить firewall с неверным SSH-портом или неверным Amnezia-портом.
|
||
|
||
После установки `.deb` создайте runtime config вручную:
|
||
|
||
```bash
|
||
install -d -m 0755 /etc/awg2-harness
|
||
cp /usr/share/doc/awg2-harness/repo/config.example.toml /etc/awg2-harness/config.toml
|
||
nano /etc/awg2-harness/config.toml
|
||
```
|
||
|
||
Перед первым `apply-firewall`/`apply-limits` оператор обязан сверить:
|
||
|
||
- `host_firewall.ssh_port` — должен совпадать с реально слушающим `sshd` портом;
|
||
- `host_firewall.amnezia_port` — должен совпадать с published Docker/Amnezia UDP port;
|
||
- `amnezia.peer_ipv4_cidr` — должен совпадать с реальной peer-подсетью;
|
||
- `anti_torrent.peer_ipv4_cidr` — обычно тот же CIDR.
|
||
|
||
Discovery-команды:
|
||
|
||
```bash
|
||
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-awg3|amnezia-awg)$' | head -n1)"
|
||
docker exec "$C" ip -4 addr
|
||
docker exec "$C" awg show
|
||
```
|
||
|
||
## Рекомендуемый `config.toml`
|
||
|
||
```toml
|
||
[amnezia]
|
||
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]
|
||
enabled = true
|
||
peer_limit = "50mbit"
|
||
root_rate = "500mbit"
|
||
reconcile_interval_secs = 15
|
||
container_ready_timeout_secs = 120
|
||
class_start_id = 10
|
||
ifb_name = "ifb0"
|
||
|
||
[sysctl_tuning]
|
||
enabled = true
|
||
apply_on_daemon_start = true
|
||
ip_forward = true
|
||
tcp_bbr = "auto" # off | auto | require
|
||
default_qdisc = "fq" # none | fq | fq_codel
|
||
rmem_max = 67108864
|
||
wmem_max = 67108864
|
||
netdev_backlog_enabled = true
|
||
netdev_max_backlog = 50000
|
||
udp_rmem_min_enabled = true
|
||
udp_rmem_min = 8192
|
||
|
||
[host_firewall]
|
||
enabled = true
|
||
ssh_port = 2323
|
||
amnezia_port = 443
|
||
amnezia_protocols = ["udp"]
|
||
allow_icmp = true
|
||
default_input_policy = "drop"
|
||
|
||
[anti_torrent]
|
||
enabled = true
|
||
max_connections_per_peer = 150
|
||
peer_ipv4_cidr = "10.8.1.0/24"
|
||
apply_in_amnezia_netns = true
|
||
|
||
[reboot]
|
||
enabled = true
|
||
window_start_utc = "01:00"
|
||
window_end_utc = "02:00"
|
||
|
||
[swap]
|
||
enabled = true
|
||
path = "/swapfile"
|
||
size_mb = 2048
|
||
priority = 1
|
||
swappiness = 1
|
||
```
|
||
|
||
## Пояснения
|
||
|
||
`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен, сопоставление точное. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`.
|
||
|
||
`amnezia-awg3` перечислен на будущее и **не является подтверждённым официальным именем**: в проверенной версии AmneziaVPN `5.0.0.5` поддержка AWG 3.0 добавлена, но варианты контейнера по-прежнему представлены как `Awg` и `Awg2`, отдельного типа `Awg3` нет. Имя оставлено в примере потому, что несовпавшее имя не стоит ничего — поиск просто идёт дальше по списку, — тогда как отсутствующее в списке имя даст молчаливое «контейнер не найден». Если Amnezia введёт другое имя, его нужно будет дописать сюда.
|
||
|
||
`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`.
|
||
|
||
`traffic.root_rate` — верхний HTB-класс, в текущем production default — `500mbit`.
|
||
|
||
`traffic.reconcile_interval_secs` — период фоновой проверки. Он нужен не вместо Docker events, а вместе с ними.
|
||
|
||
`traffic.container_ready_timeout_secs` — сколько ждать появления интерфейса после старта контейнера.
|
||
|
||
`traffic.class_start_id` — первый class id. Production default — `10`, что даёт классы `1:10`, `2:10` и дальше.
|
||
|
||
`traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. Production default — `ifb0`. Этот параметр можно оставить в конфиге, потому что бинарь реально создаёт/удаляет этот интерфейс.
|
||
|
||
`sysctl_tuning` — отдельный host-level модуль управляемого сетевого baseline через `sysctl`:
|
||
|
||
- persistent-файл: `/etc/sysctl.d/90-awg2-harness-vpn.conf`;
|
||
- модуль `swap` отдельно управляет `/etc/sysctl.d/90-awg2-harness-swap.conf` (только `vm.swappiness`); прежний путь `/etc/sysctl.d/99-awg2-harness.conf` удаляется при первом запуске новой версии, если несёт старый маркер;
|
||
- дополнительный managed-файл `/etc/modules-load.d/90-awg2-harness-bbr.conf` создаётся только при фактически активном BBR и удаляется, когда BBR неактивен;
|
||
- оба managed-файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`; файл без маркера не перезаписывается и не удаляется, вместо этого возвращается ошибка;
|
||
- `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` управляются только при активном BBR;
|
||
- runtime apply: только `sysctl -w` и только для drifted keys;
|
||
- на установке `.deb` модуль не применяется;
|
||
- на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`;
|
||
- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит;
|
||
- для `sysctl_tuning`: `modprobe` обязателен только для `tcp_bbr = "require"`.
|
||
|
||
Отдельно для всего пакета:
|
||
|
||
- `traffic`-модуль требует `modprobe` для IFB при `traffic.enabled = true`.
|
||
|
||
Совместимость обновлений:
|
||
|
||
- если в существующем runtime-конфиге отсутствует блок `[sysctl_tuning]`, он не включается автоматически (`enabled=false` по default для missing block);
|
||
- для новых установок рекомендуется явный блок `[sysctl_tuning]` из примера, где `enabled=true`.
|
||
|
||
Ограничения модуля:
|
||
|
||
- не использует `/etc/sysctl.conf`;
|
||
- не запускает `sysctl --system`;
|
||
- не меняет `nftables`, Docker bridge и container-local sysctl;
|
||
- не управляет `net.ipv4.udp_wmem_min` (в Linux документации параметр указан как не имеющий эффекта).
|
||
|
||
При drift orchestrator всегда делает controlled rebuild owned topology.
|
||
|
||
`anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это отдельный AWG2 Harness module и часть текущего production-контракта.
|
||
|
||
`host_firewall.enabled` — включает host-level INPUT firewall в отдельной AWG2-owned таблице `inet awg2_host_firewall`.
|
||
|
||
`host_firewall.ssh_port` — SSH порт хоста, который обязательно должен быть открыт. Production default/example: `2323`. Значение обязано совпадать с реально слушающим sshd портом до применения `apply-firewall`.
|
||
|
||
`host_firewall.amnezia_port` — внешний UDP/TCP порт Amnezia, который AWG2 открывает в host `INPUT` (в зависимости от `host_firewall.amnezia_protocols`). В текущей целевой конфигурации default — `443`; значение должно совпадать с реально опубликованным Amnezia UDP/TCP port.
|
||
|
||
Важно: `host_firewall.amnezia_port` — это transport-порт AmneziaWG/AWG (например, `443/udp`). Это не порт AmneziaDNS.
|
||
|
||
- Не задавайте `amnezia_port = 53`.
|
||
- Не добавляйте DNS/53 в `amnezia_protocols`.
|
||
- Не открывайте `53/tcp` или `53/udp` в AWG2 host firewall для ожидаемого production-профиля.
|
||
|
||
В expected production layout AmneziaDNS работает как внутренний Docker/VPN сервис и не публикуется на public host interface.
|
||
|
||
Для диагностического блока `Docker/Amnezia port path` в `status` приоритет сопоставления такой: сначала ищется published UDP binding с `HostPort == host_firewall.amnezia_port`, затем fallback на ключ `"<amnezia_port>/udp"` как container port, затем (если UDP binding ровно один) берётся он. Если UDP bindings несколько и однозначного совпадения нет, статус показывает `published_udp_port: unknown`.
|
||
|
||
Диагностика `FORWARD` в этом блоке является best-effort: сначала используется `iptables -S FORWARD`, затем fallback через `nft list ruleset`; результаты только отображаются в `status` и не изменяют host `FORWARD`/Docker NAT.
|
||
|
||
`host_firewall.amnezia_protocols` — протоколы для порта Amnezia (`udp`, `tcp` или оба).
|
||
|
||
Важно: `awg2-harness` не управляет host `FORWARD`/Docker NAT-цепочками. Проект сознательно не добавляет универсальные правила в `FORWARD` и не меняет Docker-managed forwarding path.
|
||
|
||
`host_firewall.allow_icmp` — разрешать ICMP/ICMPv6 на входе.
|
||
|
||
`host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`.
|
||
|
||
`anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Текущий пример использует `150`: это мягче, чем строгий `100`, и обычно безопаснее как первый production baseline. `100` можно использовать как более жёсткий режим; `200/250` — более permissive варианты.
|
||
|
||
`anti_torrent.peer_ipv4_cidr` — подсеть, на которую распространяется правило. Обычно должна совпадать с `amnezia.peer_ipv4_cidr`.
|
||
|
||
`anti_torrent.apply_in_amnezia_netns` — выбор namespace для применения anti-torrent правил:
|
||
|
||
- `true` (default): применять в network namespace контейнера Amnezia;
|
||
- `false`: применять в host namespace.
|
||
|
||
Для production рекомендуется `true`, если peer source IP (`10.8.1.x`) видны именно внутри netns контейнера.
|
||
|
||
`apply-firewall` в текущей модели делает **оба** reconcile: host firewall (host namespace) и anti-torrent firewall (configured namespace).
|
||
|
||
`reboot.enabled` — включает плановую перезагрузку.
|
||
|
||
`reboot.window_start_utc` / `reboot.window_end_utc` — UTC-окно плановой перезагрузки. Production default/example — `01:00`–`02:00` UTC.
|
||
|
||
`swap.enabled` — включает setup-задачу swap.
|
||
|
||
`swap.path`, `swap.size_mb`, `swap.priority`, `swap.swappiness` — параметры managed swap. При `swap.enabled=true` пакет приводит swap-state к exclusive состоянию: активен ровно один swap (`swap.path`), остальные выключаются и swap-entries в `/etc/fstab` пересобираются в одну managed строку. Рекомендуемый low priority — `1`, `swappiness = 1`.
|
||
|
||
Ограничения `swap.path`: путь должен быть либо `/swapfile`, либо файлом непосредственно в `/var/lib/awg2-harness/`. Дополнительно: абсолютный, без компонентов `.` и `..`, без пробелов, управляющих символов и обратных слэшей; на этапе применения проверяется, что ни один родительский каталог не является symlink.
|
||
|
||
Ограничение корней обязательно, а не косметика. Если родительский каталог доступен на запись кому-то кроме root, между проверкой пути и запуском `fallocate`/`mkswap` этот пользователь успевает подменить файл на symlink и перенаправить операцию, выполняемую от root. Оба разрешённых корня принадлежат root, поэтому окна не возникает. `/swapfile` оставлен для совместимости с уже развёрнутыми конфигурациями.
|
||
|
||
Владение фиксируется в `/var/lib/awg2-harness/swap-ownership.conf` по `device` + `inode` + пути, а не по маркеру рядом с файлом. Файл, которого нет в этой записи, или файл с тем же путём, но другими `device`/`inode` (то есть подменённый), считается чужим: он никогда не пересоздаётся и не удаляется, вместо этого возвращается ошибка. Чужие активные swap только выключаются — их файлы пакет не удаляет.
|
||
|
||
## Что не выносить в конфиг
|
||
|
||
Не нужно выносить:
|
||
|
||
- handles `1:`, `2:`, `ffff:`;
|
||
- имена внутренних Rust-модулей;
|
||
- формат команд `tc`;
|
||
- retry micro-tuning;
|
||
- формат логов;
|
||
- путь lock-файла, если нет эксплуатационной причины;
|
||
- hardening systemd;
|
||
- nftables hook/priority/table names, если нет осознанной причины.
|
||
|
||
Handles и структура `HTB + fq_codel + ingress + ifb` — это contract traffic module, а не настройка оператора.
|