diff --git a/README.md b/README.md index b8f1d75..96b4227 100644 --- a/README.md +++ b/README.md @@ -350,9 +350,9 @@ awg2-harness --config /etc/awg2-harness/config.toml `apply-firewall` делает два reconcile: host INPUT firewall в изолированных `iptables`/`ip6tables` A/B-цепочках и nftables anti-torrent guard в выбранном namespace. Команда не меняет policy встроенного `INPUT`, Docker NAT или host `FORWARD`. -`apply-limits` находит Amnezia container, заходит в его network namespace, читает peers через container-side `awg`/`wg`, строит per-peer HTB/fq_codel классы на AWG/WG интерфейсе и IFB ingress path. Peer filters создаются classifier-ом `flower` с единым `pref 10`; legacy peer-фильтры `u32` считаются drift и мигрируют через controlled rebuild. Перед удалением проверяются qdisc handles и alias IFB `awg2-harness:traffic`; чужой объект с тем же именем является ошибкой. Healthy state не печатает сообщение на каждом цикле daemon. +`apply-limits` находит Amnezia container, заходит в его network namespace, читает peers через container-side `awg`/`wg` и до mutation проверяет строгий server-side контракт: один IPv4 `/32` внутри `amnezia.peer_ipv4_cidr` на peer; `(none)` допустим. IPv6, смешанные или множественные маршруты, другая маска и адрес вне подсети дают явную ошибку unsupported topology. После проверки команда строит per-peer HTB/fq_codel классы на AWG/WG интерфейсе и IFB ingress path. Peer filters создаются classifier-ом `flower` с единым `pref 10`; legacy peer-фильтры `u32` считаются drift и мигрируют через controlled rebuild. Перед удалением проверяются qdisc handles и alias IFB `awg2-harness:traffic`; чужой объект с тем же именем является ошибкой. Healthy state не печатает сообщение на каждом цикле daemon. -`status` печатает фактическую диагностику: сервис, контейнер, netns, интерфейс, peers, WireGuard runtime, traffic desired/actual, host firewall drift, Docker/Amnezia port path, anti-torrent counters, swap state, conntrack, recent events и planned reboot. +`status` печатает фактическую диагностику: сервис, контейнер, netns, интерфейс, peers, WireGuard runtime, traffic desired/actual, host firewall drift, Docker/Amnezia port path со всеми UDP `HostIp:HostPort` bindings, anti-torrent counters, swap state, conntrack, recent events и planned reboot. `daemon` — режим для systemd. На старте делает startup reconcile, затем слушает Docker events и периодически сверяет traffic/swap/firewall. Вручную обычно не запускается, кроме отладки. @@ -364,7 +364,9 @@ Traffic shaping реализован через `tc` в network namespace кон Peer discovery берётся из WireGuard/AmneziaWG allowed IPs, а не из handshake state. Поэтому offline peer с `allowed ips: 10.8.1.1/32` тоже получает лимит заранее. -Host firewall владеет только помеченным переходом из встроенного `INPUT` и двумя A/B-цепочками `AWG2H_A`/`AWG2H_B` отдельно для IPv4 и IPv6. Цепочка разрешает loopback, established/related, SSH-порт из конфига и optionally ICMP/ICMPv6, затем применяет configured verdict. Новая цепочка полностью проверяется до переключения; ошибка IPv6 после переключения IPv4 запускает rollback. Published Amnezia UDP port здесь намеренно не открывается: Docker bridge publish обслуживается через Docker-managed `FORWARD`/NAT path. +Ограничение относится только к адресации peers внутри server-side AWG topology. Клиентский маршрут `::/0` и внешний IPv6 Docker publish не означают IPv6 peer addressing: Docker может одновременно публиковать тот же UDP port на `0.0.0.0` и `[::]`, и status показывает оба binding. + +Host firewall владеет только помеченным переходом из встроенного `INPUT` и двумя A/B-цепочками `AWG2H_A`/`AWG2H_B` отдельно для IPv4 и IPv6. Цепочка разрешает loopback, established/related, SSH-порт из конфига и optionally ICMP/ICMPv6, затем применяет configured verdict. Новая цепочка полностью проверяется до переключения; новый jump вставляется первым, а старый удаляется только после успеха обеих IP-family по точной rule specification. Numeric replace/delete не применяется, поэтому чужое правило не затрагивается при конкурентном изменении `INPUT`. Ошибка IPv6 удаляет только новый IPv4 jump и оставляет прежний рабочим. Published Amnezia UDP port здесь намеренно не открывается: Docker bridge publish обслуживается через Docker-managed `FORWARD`/NAT path. Anti-torrent guard — отдельная AWG2-owned таблица `inet awg2_harness` обычно внутри Amnezia netns. Правило ограничивает число новых conntrack flows per source VPN IP. Это не DPI и не распознавание BitTorrent-протокола; это грубый, дешёвый guard против сотен одновременных P2P-соединений. diff --git a/config.example.toml b/config.example.toml index e135cf1..7ddec55 100644 --- a/config.example.toml +++ b/config.example.toml @@ -45,10 +45,10 @@ ip_forward = true # off | auto | require # auto: использовать BBR, если доступен; иначе пропустить без ошибки. # require: завершить с ошибкой, если BBR недоступен. -tcp_bbr = "auto" +tcp_bbr = "off" -# none | fq | fq_codel -# fq применяется только когда BBR активен. +# none | fq +# fq управляется только когда BBR выбран desired policy. default_qdisc = "fq" # Лимиты socket buffers в байтах. @@ -61,7 +61,7 @@ wmem_max = 67108864 netdev_backlog_enabled = false netdev_max_backlog = 10000 -udp_rmem_min_enabled = true +udp_rmem_min_enabled = false udp_rmem_min = 8192 # net.ipv4.udp_wmem_min intentionally not managed: diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index e87dde6..ab0cce6 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -25,9 +25,11 @@ - [ ] `awg2-harness status` показывает PID/netns контейнера. - [ ] `awg2-harness status` показывает найденный интерфейс. - [ ] Количество peers совпадает с `docker exec "$C" awg show all allowed-ips`. -- [ ] Peer с несколькими allowed IP даёт отдельный класс на каждый адрес (в этой команде адреса разделены пробелом). +- [ ] Каждый peer имеет ровно один server-side IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`; `(none)` допустим и не создаёт класс. +- [ ] IPv4+IPv6, IPv6-only, несколько allowed IP, маска не `/32` и адрес вне configured CIDR дают явную `unsupported topology` до изменения traffic/anti-torrent state. +- [ ] Одинаковый отказ проверен для `show all allowed-ips`, fallback `show` и `show all dump`; AWG 3.1 interface-row не ошибочно принимается за peer. - [ ] Offline peers с allowed IP тоже попадают в список shaping. -- [ ] IP вне `amnezia.peer_ipv4_cidr` не попадают в traffic shaping. +- [ ] IP вне `amnezia.peer_ipv4_cidr` не игнорируется молча: весь topology reconcile завершается ошибкой. - [ ] В секции `WireGuard clients` вывода `status` нет строки, соответствующей самому интерфейсу (столбец `Public key` не содержит приватный ключ устройства). - [ ] `docker exec "$C" readlink -f /usr/bin/wg` возвращает `/usr/bin/awg`. @@ -150,7 +152,7 @@ - [ ] `awg2-harness apply-network-tuning` успешно завершается. - [ ] `awg2-harness inspect-network-tuning` показывает `enabled: true` и `drift: no`. - [ ] Создаётся `/etc/sysctl.d/90-awg2-harness-vpn.conf`. -- [ ] `/etc/modules-load.d/90-awg2-harness-bbr.conf` создан, если BBR активен, и отсутствует, если BBR неактивен. +- [ ] `/etc/modules-load.d/90-awg2-harness-bbr.conf` создан, если BBR желателен по configured policy и доступен, и отсутствует при `tcp_bbr = off`/недоступном `auto`. - [ ] `/etc/sysctl.conf` не изменён. - [ ] В обоих managed-файлах первая строка содержит marker `Managed by awg2-harness sysctl_tuning`. - [ ] Файл без marker по managed-пути не перезаписывается и не удаляется: команда завершается ошибкой. @@ -158,7 +160,10 @@ - [ ] В shipped/default config `netdev_backlog_enabled = false`, `netdev_max_backlog = 10000`, а `net.core.netdev_max_backlog` отсутствует в managed-файле. - [ ] При смене `netdev_backlog_enabled = true` на `false` key удаляется из persistence; оператор учитывает, что текущее runtime-значение до reboot автоматически не сбрасывается. - [ ] До/после ручного включения backlog сняты `/proc/net/softnet_stat`, CPU и latency под реальной нагрузкой. -- [ ] `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` присутствуют в managed-файле только при активном BBR. +- [ ] В shipped/default config `tcp_bbr = off`, `default_qdisc = fq`, `udp_rmem_min_enabled = false`; `default_qdisc = fq_codel` отвергается валидацией. +- [ ] `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` присутствуют в managed-файле только когда BBR желателен по policy. +- [ ] Status отдельно показывает `bbr_available`, `bbr_desired` и фактический `bbr_runtime_active`; при available BBR и runtime `cubic` последнее поле равно `no`. +- [ ] При `tcp_bbr = off` уже активный runtime BBR не выдаётся за desired и не маскируется как неактивный. - [ ] `swap` пишет `vm.swappiness` в `/etc/sysctl.d/90-awg2-harness-swap.conf`, а не в `99-awg2-harness.conf`. - [ ] При обновлении с прежней версии `/etc/sysctl.d/99-awg2-harness.conf` удаляется, если несёт маркер `# Managed by awg2-harness`. - [ ] Файл по пути `99-awg2-harness.conf` без этого маркера остаётся нетронутым и не ломает `ensure-swap`. @@ -181,6 +186,7 @@ - [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`. - [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning. - [ ] При `tcp_bbr = require` и недоступном BBR команда/старт завершается с ошибкой. +- [ ] Sustained per-peer cap проверен в двух конфигурациях, BBR-off и BBR-on; HTB `rate == ceil` даёт одинаковый лимит. - [ ] При `enabled=false` managed-файл удаляется только если содержит managed marker. ## I. Anti-torrent guard на nftables @@ -260,10 +266,14 @@ - [ ] Повторный `apply-firewall` идемпотентен и не вносит лишних изменений в healthy state. - [ ] Legacy fields `host_firewall.amnezia_port`/`amnezia_protocols` отвергаются как `unknown field`. - [ ] Новая конфигурация строится в inactive A/B chain до switch; искусственный сбой `ip6tables` после IPv4 switch возвращает IPv4 на прежнюю цепочку. +- [ ] Managed jumps переключаются только через insert-new + exact-spec delete; numeric `-R INPUT `/`-D INPUT ` не используются. +- [ ] Чужое правило, конкурентно вставленное первым в `INPUT`, не заменяется и не удаляется; rollback удаляет только новый managed jump. - [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой. - [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке. - [ ] Для release-кандидата зафиксирован итог проверки `INPUT` vs `FORWARD` на реальном сервере (команды/логи/вывод) и подтверждено, что текущие правила firewall покрывают реальный путь трафика. - [ ] `status` показывает блок `Docker/Amnezia port path` с полями `expected_host_udp_port`, `docker_userland_proxy`, `published_udp_port`, `awg_listen_udp_port`, `port_contract` и note про Docker-managed `FORWARD`. +- [ ] В том же блоке перечислены все UDP `host_bindings`, включая одновременно `0.0.0.0:` и `[::]:`; IPv6 выводится в квадратных скобках. +- [ ] Docker routed binding с пустым `HostPort` сохраняется в диагностике и не превращается в выдуманный numeric port. - [ ] `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` автоматически. - [ ] Сверено соответствие: `amnezia.expected_host_udp_port` ↔ опубликованный Docker UDP host port ↔ `docker exec awg show listen-port`; `port_contract: ok`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2d1ac12..79b41b0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -55,13 +55,13 @@ ifb_name = "ifb0" enabled = true apply_on_daemon_start = true ip_forward = true -tcp_bbr = "auto" # off | auto | require -default_qdisc = "fq" # none | fq | fq_codel +tcp_bbr = "off" # off | auto | require +default_qdisc = "fq" # none | fq rmem_max = 67108864 wmem_max = 67108864 netdev_backlog_enabled = false netdev_max_backlog = 10000 -udp_rmem_min_enabled = true +udp_rmem_min_enabled = false udp_rmem_min = 8192 [host_firewall] @@ -103,6 +103,8 @@ swappiness = 1 `amnezia.peer_ipv4_cidr` — подсеть VPN peers. Production default — `10.8.1.0/24`; значение конфигурируемое. +Поддерживаемая server-side topology строго соответствует официальной self-hosted схеме Amnezia/AWG 3.1: у каждого peer либо нет маршрута (`(none)`), либо есть ровно один IPv4 host route `/32` внутри `amnezia.peer_ipv4_cidr`. IPv6, смешанные IPv4+IPv6, несколько маршрутов, более широкая маска и адрес вне подсети дают явную ошибку `unsupported topology` до изменения `tc` и anti-torrent state. Клиентские `0.0.0.0/0, ::/0` и внешний IPv6 Docker publish относятся к другим плоскостям и этим ограничением не запрещаются. + `amnezia.expected_host_udp_port` — ожидаемый published UDP host port Docker для AmneziaWG. Поле обязательное и используется диагностикой `status` для контракта `expected host port ↔ Docker binding ↔ awg/wg listen-port`. Оно не создаёт host INPUT allow-rule: трафик к published bridge container проходит через Docker `FORWARD`/NAT path, а не через host `INPUT`. `traffic.peer_limit` — per-peer лимит в обе стороны. Текущий production default — `50mbit`. @@ -121,15 +123,19 @@ swappiness = 1 - 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-файл `/etc/modules-load.d/90-awg2-harness-bbr.conf` создаётся только когда BBR выбран desired policy (`auto`/`require` и BBR доступен), и удаляется при `tcp_bbr = "off"` или недоступном `auto`; - оба managed-файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`; файл без маркера не перезаписывается и не удаляется, вместо этого возвращается ошибка; -- `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` управляются только при активном BBR; +- `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` управляются только когда BBR желателен по policy; `default_qdisc` принимает только `none | fq` и не управляет per-peer `fq_codel`; - 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"`. +Production default — `tcp_bbr = "off"`: BBR влияет на локальные TCP sockets хоста, но не ускоряет UDP transport AWG и не является частью HTB peer limiter. `auto`/`require` остаются осознанным opt-in. Status разделяет три разных факта: `bbr_available`, `bbr_desired` и фактический `bbr_runtime_active`, вычисленный по текущему `net.ipv4.tcp_congestion_control`. + +`udp_rmem_min_enabled = false` — production default, потому что `net.ipv4.udp_rmem_min` является per-network-namespace sysctl: запись на хосте не меняет UDP stack AWG-контейнера. Значение `udp_rmem_min = 8192` сохранено как opt-in для локальных UDP sockets хоста; отдельный container-side reconcile ради него не добавляется. + `netdev_backlog_enabled = false` — безопасный production default: harness не управляет `net.core.netdev_max_backlog`, пока оператор не включит это явно после измерений. Значение `netdev_max_backlog = 10000` используется только при `netdev_backlog_enabled = true`. При обновлении с конфига, где backlog раньше был включён, переключение флага в `false` удаляет key из managed-файла, но не перезаписывает уже изменённое runtime-значение. До следующей перезагрузки его можно вернуть вручную к baseline дистрибутива. Перед осознанным включением backlog снимите pre/post-метрики `/proc/net/softnet_stat` (в том числе dropped/time_squeeze) и CPU/latency под реальной нагрузкой. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index c06b87b..c30b04c 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -127,7 +127,7 @@ awg2-harness validate-runtime При `swap.enabled = false` управляемый файл удаляется. **Остаются на месте**: сам swapfile, его активность в ядре и запись в `/etc/fstab` — то есть после перезагрузки swap снова включится. Удаляется только persistence `vm.swappiness`. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции. -`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR. +`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только когда BBR желателен по policy. Фактическая активность диагностируется отдельно по runtime `net.ipv4.tcp_congestion_control`. ## 3. Структура модулей @@ -152,7 +152,15 @@ traffic_core/src/ │ ├── host.rs owned iptables/ip6tables INPUT chains (host namespace) │ └── antitorrent.rs таблица inet awg2_harness (namespace по конфигу) ├── sysctl_tuning.rs управляемый sysctl baseline -├── swap.rs управляемый swap +├── swap/ +│ ├── mod.rs facade и orchestration ensure_swap +│ ├── model.rs модели desired/runtime/status и replacement state +│ ├── path.rs syntax/path-chain safety и state directory +│ ├── ownership.rs ownership record и legacy sidecar +│ ├── runtime.rs swapon/swapoff/mkswap и parser runtime state +│ ├── replacement.rs создание, resize и rollback транзакции +│ ├── persistence.rs /etc/fstab, swappiness и legacy migration +│ └── status.rs inspect_swap и drift aggregation ├── status.rs сборка и печать статуса ├── lock.rs файловые блокировки └── error.rs типы ошибок @@ -273,11 +281,13 @@ struct PeerTrafficStatus { awg show all allowed-ips ``` -Правила разбора: +Правила разбора и общий `PeerTopology` contract: - строки разбираются структурно на public key peer и его allowed IPs; -- в этой команде allowed ips одного peer разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; нужно потреблять все токены начиная с третьей колонки, иначе peers с несколькими allowed IP получат shaping только по первому адресу; -- оставляются только IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`; +- в этой команде allowed ips разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; parser обязан проверить весь список, а не молча взять первый адрес; +- поддерживается ровно один IPv4 `/32` внутри `amnezia.peer_ipv4_cidr` на peer; `(none)` допустим и не создаёт peer class; +- IPv6-only, IPv4+IPv6, несколько allowed IPs, маска не `/32`, невалидный CIDR или IPv4 вне configured peer CIDR возвращают typed `UnsupportedTopology`; +- topology проверяется один раз на startup/periodic/event cycle и передаётся traffic и anti-torrent. При ошибке ни одна из этих подсистем не начинает mutation и не сообщает успешный reconcile; - offline peers включаются: online-состояние не является требованием для shaping; - перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id. @@ -287,7 +297,7 @@ awg show all allowed-ips awg show ``` -Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат. +Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат, и применяет тот же строгий topology contract. Классификация строк `show all dump`: @@ -365,7 +375,7 @@ apply_firewall(): apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns ``` -Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6, затем переключает помеченный jump; сбой IPv6 после IPv4 switch откатывает IPv4. После apply выполняется обязательная повторная инспекция. +Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией и при включённом guard требует успешно проверенный `PeerTopology`; host firewall от server-side peer topology не зависит. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6. Новый jump всегда вставляется через `-I INPUT 1`; старый остаётся вторым до успешного переключения обеих IP-family и затем удаляется по полной rule specification. Numeric `-R/-D` для managed jumps не используется. Сбой IPv6 удаляет новый IPv4 jump по точной specification, поэтому старый IPv4 jump всё время остаётся рабочим. После apply выполняется обязательная повторная инспекция. Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь. @@ -407,6 +417,8 @@ apply_firewall(): Host firewall не открывает published Amnezia UDP port в `INPUT`: Docker bridge publish проходит через `FORWARD`/NAT. Собственная chain разрешает loopback, established/related, configured SSH и optional ICMP/ICMPv6, затем завершает обработку configured `DROP`/`ACCEPT` verdict. Поле `amnezia.expected_host_udp_port` относится только к диагностике port path. +Жизненный цикл managed jump использует только exact rule spec с comment `awg2-harness:host-firewall` и target `AWG2H_A`/`AWG2H_B`. Чужое правило, вставленное другим firewall manager между inspect и switch, не может быть заменено или удалено по изменившемуся номеру строки. + ## 11. Anti-torrent firewall guard Это часть production-контракта AWG2. Модуль вписывается в мульти-оркестраторную модель, потому что сервер выделенный и бинарю разрешено владеть host/network правилами, нужными VPN-продукту. Правило всё равно обязано быть узко ограниченным и наблюдаемым. @@ -542,7 +554,8 @@ System: interface: Network tuning: enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path, - значения управляемых ключей, bbr_available, bbr_active, drift[, reason][, warning] + значения управляемых ключей, bbr_available, bbr_desired, + bbr_runtime_active, drift[, reason][, warning] Peers: total: N - @@ -560,6 +573,7 @@ Host firewall: enabled, ssh_port, input_policy, drift[, reason] Docker/Amnezia port path: expected_host_udp_port, docker_userland_proxy, published_udp_port, + host_bindings (все HostIp:HostPort, включая routed binding без HostPort), awg_listen_udp_port, port_contract, host_forward_policy_drop, docker_forward_rules_present, note[, warning] Anti-torrent: @@ -693,7 +707,7 @@ inode = 123456 ### Перезапись `/etc/fstab` -Это самая разрушительная операция пакета, поэтому она транзакционная и вынесена в отдельный модуль `fstab.rs`. Контракт: +Это самая разрушительная операция пакета, поэтому она транзакционная и изолирована в `swap/persistence.rs`. Контракт: - ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля; - пустой или состоящий из пробелов кандидат отклоняется; @@ -714,8 +728,8 @@ inode = 123456 - валидация конфига, включая форму `amnezia.wg_binaries`; - разбор `config.example.toml` в актуальную схему с точным default-порядком контейнеров, `awg`/`wg` и anti-torrent baseline `500`; -- парсер `awg|wg show all allowed-ips`, включая несколько allowed ips через пробел; -- fallback-парсер `awg|wg show`; +- парсер `awg|wg show all allowed-ips`: один IPv4 `/32` проходит, IPv4+IPv6, IPv6-only, несколько маршрутов, маска не `/32` и адрес вне configured CIDR дают explicit `UnsupportedTopology`; +- fallback-парсер `awg|wg show` и dump-парсер применяют тот же topology contract; `(none)` остаётся допустимым; - классификация peer/interface строк `awg|wg show all dump` против двух 30-полевых AWG 3.1 fixtures, AWG 3.0 с включённой и выключенной защитой заголовка, AWG 2.x, чистого WireGuard, а также конфигурации AWG 2.0, прочитанной tools AWG 3.0; - разбор dump с несколькими интерфейсами, с интерфейсом без peers и с повреждёнными строками; - порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов; @@ -727,6 +741,9 @@ inode = 123456 - packet statistics из Debian-подобного `tc -s class`; - отказ удалять foreign IFB/root qdisc; - typed Docker readiness retry/timeout/lower-priority event; +- Docker UDP bindings: dual-stack HostIp, routed binding с пустым HostPort и неоднозначные/divergent host ports; +- BBR decision: доступный BBR при runtime `cubic` означает `desired=yes`, `runtime_active=no`; mode `off` не маскирует уже активный runtime BBR; +- host firewall rollback удаляет новый jump по exact specification и не затрагивает чужое правило в начале `INPUT`; - conntrack count из Amnezia namespace и global max с host; - diff drift; - расчёт окна перезагрузки; diff --git a/docs/PRODUCTION_EXAMPLE.md b/docs/PRODUCTION_EXAMPLE.md index 435e162..3f33ad3 100644 --- a/docs/PRODUCTION_EXAMPLE.md +++ b/docs/PRODUCTION_EXAMPLE.md @@ -51,6 +51,8 @@ docker exec "$C" awg show awg0 listen-port docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns ``` +В `status` для AWG publish ожидается полный список фактических bindings, например `0.0.0.0:443` и `[::]:443`. Это внешний Docker publish; он не меняет IPv4-only server peer topology. Каждый server peer обязан иметь ровно один IPv4 `/32` внутри configured CIDR. Смешанный/IPv6/multiple `AllowedIPs` должен останавливать traffic и anti-torrent reconcile с `unsupported topology`. + Проверка host firewall выполняется через Docker-совместимый frontend. В `INPUT` ожидается первый jump с comment `awg2-harness:host-firewall`, но не allow-rule для `443/udp`: ```bash diff --git a/docs/UPSTREAM_COMPATIBILITY.md b/docs/UPSTREAM_COMPATIBILITY.md index 691e2d6..f83c960 100644 --- a/docs/UPSTREAM_COMPATIBILITY.md +++ b/docs/UPSTREAM_COMPATIBILITY.md @@ -55,6 +55,8 @@ Reference image содержит `/usr/bin/awg`, `/usr/bin/awg-quick` и symlink Harness не выбирает реализацию и не требует конкретную из них. Peer state читается через `docker exec` официальным `awg` внутри контейнера. Это единая container-side точка доступа для kernel и userspace вариантов и не создаёт зависимости от версии host-side tools. +Поддерживаемый harness-контракт соответствует официальной self-hosted server topology Amnezia/AWG 3.1: один IPv4 host route `/32` на peer. Generic client config может содержать `0.0.0.0/0` и `::/0`, а Docker — публиковать AWG UDP port на внешних IPv4 и IPv6 адресах; это не server-side peer addressing. IPv6/mixed/multiple server `AllowedIPs` harness отвергает до traffic и anti-torrent mutation. + ## Контракт `show all dump` Исторические widths являются диагностикой: diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 6aa8004..5ad4403 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -69,13 +69,13 @@ impl Default for SysctlTuningConfig { enabled: true, apply_on_daemon_start: true, ip_forward: true, - tcp_bbr: "auto".to_string(), + tcp_bbr: "off".to_string(), default_qdisc: "fq".to_string(), rmem_max: 67_108_864, wmem_max: 67_108_864, netdev_backlog_enabled: false, netdev_max_backlog: 10_000, - udp_rmem_min_enabled: true, + udp_rmem_min_enabled: false, udp_rmem_min: 8192, } } @@ -213,8 +213,8 @@ impl Config { } let default_qdisc = self.sysctl_tuning.default_qdisc.trim().to_ascii_lowercase(); - if default_qdisc != "none" && default_qdisc != "fq" && default_qdisc != "fq_codel" { - anyhow::bail!("sysctl_tuning.default_qdisc must be one of: none, fq, fq_codel"); + if default_qdisc != "none" && default_qdisc != "fq" { + anyhow::bail!("sysctl_tuning.default_qdisc must be one of: none, fq"); } const MIB: u64 = 1024 * 1024; @@ -831,6 +831,8 @@ mod tests { assert!(cfg.validate_config().is_ok()); assert!(!cfg.sysctl_tuning.netdev_backlog_enabled); assert_eq!(cfg.sysctl_tuning.netdev_max_backlog, 10_000); + assert_eq!(cfg.sysctl_tuning.tcp_bbr, "off"); + assert!(!cfg.sysctl_tuning.udp_rmem_min_enabled); } #[test] @@ -847,6 +849,16 @@ mod tests { assert!(cfg.validate_config().is_err()); } + #[test] + fn sysctl_tuning_rejects_fq_codel_as_host_default_qdisc() { + let mut cfg = base_config(); + cfg.sysctl_tuning.default_qdisc = "fq_codel".into(); + let error = cfg + .validate_config() + .expect_err("fq_codel is a peer leaf only"); + assert!(error.to_string().contains("none, fq")); + } + #[test] fn sysctl_tuning_rejects_rmem_max_below_1mib() { let mut cfg = base_config(); @@ -1012,7 +1024,7 @@ swappiness = 1 let cfg = toml::from_str::(raw).expect("config parse"); assert!(!cfg.sysctl_tuning.enabled); assert!(cfg.sysctl_tuning.apply_on_daemon_start); - assert_eq!(cfg.sysctl_tuning.tcp_bbr, "auto"); + assert_eq!(cfg.sysctl_tuning.tcp_bbr, "off"); // Deployed configs predate amnezia.wg_binaries and must keep parsing. assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]); } diff --git a/traffic_core/src/daemon.rs b/traffic_core/src/daemon.rs index c8df3ae..311f869 100644 --- a/traffic_core/src/daemon.rs +++ b/traffic_core/src/daemon.rs @@ -7,7 +7,7 @@ use std::{ time::{Duration, Instant}, }; -use crate::{config::Config, docker, error::AppError, firewall, swap, sysctl_tuning, traffic}; +use crate::{config::Config, docker, error::AppError, firewall, swap, sysctl_tuning, traffic, wg}; use tracing::{error, info}; #[derive(Debug, Clone, Default)] @@ -66,14 +66,19 @@ pub fn run(cfg: Config) -> anyhow::Result<()> { } let startup_target = discover_runtime_target_if_needed(&cfg); - if let Err(e) = reconcile_traffic_for_cycle(&cfg, startup_target.as_ref()) { + let startup_topology = discover_peer_topology_if_needed(&cfg, startup_target.as_ref()); + if let Err(e) = + reconcile_traffic_for_cycle(&cfg, startup_target.as_ref(), startup_topology.as_ref()) + { set_err(&state, "traffic", format!("startup traffic reconcile: {e}")); append_event(&format!("startup_reconcile traffic=error error=\"{e}\"")); } else { set_ok(&state, "traffic", "startup traffic reconcile ok"); append_event("startup_reconcile traffic=ok"); } - if let Err(e) = reconcile_firewall_for_cycle(&cfg, startup_target.as_ref()) { + if let Err(e) = + reconcile_firewall_for_cycle(&cfg, startup_target.as_ref(), startup_topology.as_ref()) + { set_err( &state, "firewall", @@ -119,17 +124,18 @@ pub fn run(cfg: Config) -> anyhow::Result<()> { } let target = discover_runtime_target_if_needed(&cfg); + let topology = discover_peer_topology_if_needed(&cfg, target.as_ref()); report( &state, "traffic", "periodic traffic reconcile", - reconcile_traffic_for_cycle(&cfg, target.as_ref()), + reconcile_traffic_for_cycle(&cfg, target.as_ref(), topology.as_ref()), ); report( &state, "firewall", "periodic firewall reconcile", - reconcile_firewall_for_cycle(&cfg, target.as_ref()), + reconcile_firewall_for_cycle(&cfg, target.as_ref(), topology.as_ref()), ); thread::sleep(Duration::from_secs( @@ -141,19 +147,43 @@ pub fn run(cfg: Config) -> anyhow::Result<()> { fn discover_runtime_target_if_needed( cfg: &Config, ) -> Option> { - (cfg.traffic.enabled || cfg.anti_torrent.apply_in_amnezia_netns) + (cfg.traffic.enabled || cfg.anti_torrent.enabled || cfg.anti_torrent.apply_in_amnezia_netns) .then(|| docker::discover_target(cfg)) } +fn discover_peer_topology_if_needed( + cfg: &Config, + target: Option<&anyhow::Result>, +) -> Option> { + (cfg.traffic.enabled || cfg.anti_torrent.enabled).then(|| match target { + Some(Ok(target)) => wg::discover_peer_topology(cfg, target), + Some(Err(error)) => Err(anyhow::anyhow!( + "runtime target discovery failed: {error:#}" + )), + None => Err(anyhow::anyhow!( + "runtime target was not discovered for peer topology" + )), + }) +} + fn reconcile_traffic_for_cycle( cfg: &Config, target: Option<&anyhow::Result>, + topology: Option<&anyhow::Result>, ) -> anyhow::Result<()> { if !cfg.traffic.enabled { return Ok(()); } match target { - Some(Ok(target)) => traffic::reconcile_traffic_with_target(cfg, target), + Some(Ok(target)) => match topology { + Some(Ok(topology)) => traffic::reconcile_traffic_with_topology(cfg, target, topology), + Some(Err(error)) => Err(anyhow::anyhow!( + "peer topology validation failed: {error:#}" + )), + None => Err(anyhow::anyhow!( + "peer topology was not discovered for enabled traffic" + )), + }, Some(Err(e)) => Err(anyhow::anyhow!("runtime target discovery failed: {e:#}")), None => Err(anyhow::anyhow!( "runtime target was not discovered for enabled traffic" @@ -164,16 +194,9 @@ fn reconcile_traffic_for_cycle( fn reconcile_firewall_for_cycle( cfg: &Config, target: Option<&anyhow::Result>, + topology: Option<&anyhow::Result>, ) -> anyhow::Result<()> { - if !cfg.anti_torrent.apply_in_amnezia_netns { - return firewall::apply_firewall(cfg); - } - match target { - Some(target) => firewall::apply_firewall_with_target_result(cfg, target.as_ref()), - None => Err(anyhow::anyhow!( - "runtime target was not discovered for namespace firewall" - )), - } + firewall::apply_firewall_with_cycle_results(cfg, target, topology) } /// Records the outcome of one reconcile, writing an event only when something changed. @@ -277,7 +300,11 @@ fn consume_docker_events( } }; append_event(&format!("docker_event container={name} action=start")); - if let Err(e) = traffic::reconcile_traffic_with_target(cfg, &target) { + let target_result = Ok(target); + let topology = discover_peer_topology_if_needed(cfg, Some(&target_result)); + if let Err(e) = + reconcile_traffic_for_cycle(cfg, Some(&target_result), topology.as_ref()) + { if set_err(state, "traffic", format!("event traffic reconcile: {e}")) { error!(error = %e, "traffic event reconcile error"); append_event(&format!("event_reconcile traffic=error error=\"{e}\"")); @@ -289,7 +316,9 @@ fn consume_docker_events( } } - if let Err(e) = firewall::apply_firewall_with_target_result(cfg, Ok(&target)) { + if let Err(e) = + reconcile_firewall_for_cycle(cfg, Some(&target_result), topology.as_ref()) + { if set_err(state, "firewall", format!("event firewall reconcile: {e}")) { error!(error = %e, "firewall event reconcile error"); append_event(&format!("event_reconcile firewall=error error=\"{e}\"")); @@ -618,4 +647,22 @@ mod tests { .contains("did not become ready within 3 seconds") ); } + + #[test] + fn unsupported_topology_stops_traffic_before_reconcile() { + let cfg = crate::firewall::tests::test_config(); + let target = Ok(target("amnezia-awg2")); + let topology = Err(AppError::UnsupportedTopology( + "IPv6 AllowedIPs is not supported: fd00::2/128".into(), + ) + .into()); + let error = reconcile_traffic_for_cycle(&cfg, Some(&target), Some(&topology)) + .expect_err("unsupported topology must fail"); + assert!( + error + .to_string() + .contains("peer topology validation failed") + ); + assert!(error.to_string().contains("IPv6 AllowedIPs")); + } } diff --git a/traffic_core/src/docker.rs b/traffic_core/src/docker.rs index 25bb2e4..3bc4d0d 100644 --- a/traffic_core/src/docker.rs +++ b/traffic_core/src/docker.rs @@ -30,9 +30,15 @@ impl TriState { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostUdpBinding { + pub host_ip: String, + pub host_port: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublishedUdpPortPath { - pub host_port: u16, + pub host_bindings: Vec, pub container_ip: String, pub container_port: u16, } @@ -65,8 +71,12 @@ impl PortPathDiagnostics { pub fn port_contract(&self) -> &'static str { match (&self.published_udp_port, self.awg_listen_udp_port) { (Some(published), Some(listen_port)) - if published.host_port == self.expected_host_udp_port - && published.container_port == listen_port => + if published.container_port == listen_port + && !published.host_bindings.is_empty() + && published + .host_bindings + .iter() + .all(|binding| binding.host_port == Some(self.expected_host_udp_port)) => { "ok" } @@ -298,50 +308,58 @@ fn parse_udp_port_path_from_inspect( .and_then(|x| x.as_object()) .ok_or_else(|| anyhow::anyhow!("docker inspect has no NetworkSettings.Ports"))?; - let mut udp_bindings: Vec<(u16, u16)> = Vec::new(); + let mut udp_bindings: Vec<(u16, Vec)> = Vec::new(); for (k, v) in ports { if !k.ends_with("/udp") { continue; } - let Some(host_port) = extract_host_port_from_binding(v) else { - continue; - }; let container_port = k .split('/') .next() .and_then(|x| x.parse::().ok()) .ok_or_else(|| anyhow::anyhow!("invalid udp port key in docker inspect: {k}"))?; - udp_bindings.push((host_port, container_port)); + let bindings = parse_host_bindings(v, k)?; + if !bindings.is_empty() { + udp_bindings.push((container_port, bindings)); + } } - if let Some((host_port, container_port)) = udp_bindings + let expected_host_matches = udp_bindings .iter() - .copied() - .find(|(host_port, _)| *host_port == expected_udp_port) - { - return Ok(PublishedUdpPortPath { - host_port, + .filter(|(_, bindings)| { + bindings + .iter() + .any(|binding| binding.host_port == Some(expected_udp_port)) + }) + .collect::>(); + if expected_host_matches.len() == 1 { + let (container_port, bindings) = expected_host_matches[0]; + return Ok(published_path( container_ip, - container_port, - }); + *container_port, + bindings.clone(), + )); + } + if expected_host_matches.len() > 1 { + anyhow::bail!("ambiguous udp published ports matching expected host port"); } let expected_key = format!("{expected_udp_port}/udp"); - if let Some(host_port) = extract_host_port_for_udp_key(ports, &expected_key) { - return Ok(PublishedUdpPortPath { - host_port, + if let Some((container_port, bindings)) = udp_bindings + .iter() + .find(|(container_port, _)| *container_port == expected_udp_port) + { + debug_assert_eq!(expected_key, format!("{container_port}/udp")); + return Ok(published_path( container_ip, - container_port: expected_udp_port, - }); + *container_port, + bindings.clone(), + )); } if udp_bindings.len() == 1 { - let (host_port, container_port) = udp_bindings[0]; - return Ok(PublishedUdpPortPath { - host_port, - container_ip, - container_port, - }); + let (container_port, bindings) = udp_bindings.remove(0); + return Ok(published_path(container_ip, container_port, bindings)); } if udp_bindings.len() > 1 { @@ -355,18 +373,53 @@ fn parse_udp_port_path_from_inspect( )) } -fn extract_host_port_for_udp_key(ports: &serde_json::Map, key: &str) -> Option { - let v = ports.get(key)?; - extract_host_port_from_binding(v) +fn published_path( + container_ip: String, + container_port: u16, + mut host_bindings: Vec, +) -> PublishedUdpPortPath { + host_bindings.sort_by(|left, right| { + (&left.host_ip, left.host_port).cmp(&(&right.host_ip, right.host_port)) + }); + host_bindings.dedup(); + PublishedUdpPortPath { + host_bindings, + container_ip, + container_port, + } } -fn extract_host_port_from_binding(v: &Value) -> Option { - let arr = v.as_array()?; - let first = arr.first()?.as_object()?; - first - .get("HostPort") - .and_then(|x| x.as_str()) - .and_then(|x| x.parse::().ok()) +fn parse_host_bindings(v: &Value, key: &str) -> anyhow::Result> { + let arr = v + .as_array() + .ok_or_else(|| anyhow::anyhow!("docker inspect binding for {key} is not an array"))?; + let mut out = Vec::with_capacity(arr.len()); + for binding in arr { + let object = binding + .as_object() + .ok_or_else(|| anyhow::anyhow!("docker inspect binding for {key} is not an object"))?; + let host_ip = object + .get("HostIp") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("docker inspect binding for {key} has no HostIp"))?; + let host_port_raw = object + .get("HostPort") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("docker inspect binding for {key} has no HostPort"))?; + let host_port = + if host_port_raw.is_empty() { + None + } else { + Some(host_port_raw.parse::().map_err(|_| { + anyhow::anyhow!("invalid HostPort for {key}: {host_port_raw:?}") + })?) + }; + out.push(HostUdpBinding { + host_ip: host_ip.to_string(), + host_port, + }); + } + Ok(out) } fn parse_forward_policy_and_docker_rules(raw: &str) -> (TriState, TriState) { @@ -423,6 +476,13 @@ mod tests { use super::*; use std::cell::RefCell; + fn host_binding(host_ip: &str, host_port: u16) -> HostUdpBinding { + HostUdpBinding { + host_ip: host_ip.into(), + host_port: Some(host_port), + } + } + #[test] fn target_discovery_uses_one_inspect_per_candidate_in_config_order() { let calls = RefCell::new(Vec::::new()); @@ -511,13 +571,57 @@ mod tests { assert_eq!( got, PublishedUdpPortPath { - host_port: 443, + host_bindings: vec![host_binding("0.0.0.0", 443)], container_ip: "172.29.172.2".to_string(), container_port: 443, } ); } + #[test] + fn parse_udp_port_preserves_all_dual_stack_host_bindings() { + let raw = r#"[ + { + "NetworkSettings": { + "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, + "Ports": { + "443/udp": [ + { "HostIp": "::", "HostPort": "443" }, + { "HostIp": "0.0.0.0", "HostPort": "443" } + ] + } + } + } +]"#; + let got = parse_udp_port_path_from_inspect(raw, 443).expect("parse inspect"); + assert_eq!( + got.host_bindings, + vec![host_binding("0.0.0.0", 443), host_binding("::", 443)] + ); + } + + #[test] + fn parse_udp_port_preserves_ipv6_routed_binding_without_host_port() { + let raw = r#"[ + { + "NetworkSettings": { + "Networks": { "bridge": { "IPAddress": "172.29.172.2" } }, + "Ports": { + "443/udp": [{ "HostIp": "::", "HostPort": "" }] + } + } + } +]"#; + let got = parse_udp_port_path_from_inspect(raw, 443).expect("parse inspect"); + assert_eq!( + got.host_bindings, + vec![HostUdpBinding { + host_ip: "::".into(), + host_port: None, + }] + ); + } + #[test] fn parse_udp_port_prefers_host_port_match() { let raw = r#"[ @@ -537,7 +641,7 @@ mod tests { assert_eq!( got, PublishedUdpPortPath { - host_port: 443, + host_bindings: vec![host_binding("0.0.0.0", 443)], container_ip: "172.29.172.2".to_string(), container_port: 44344, } @@ -563,7 +667,7 @@ mod tests { assert_eq!( got, PublishedUdpPortPath { - host_port: 5443, + host_bindings: vec![host_binding("0.0.0.0", 5443)], container_ip: "172.29.172.2".to_string(), container_port: 443, } @@ -588,7 +692,7 @@ mod tests { assert_eq!( got, PublishedUdpPortPath { - host_port: 44344, + host_bindings: vec![host_binding("0.0.0.0", 44344)], container_ip: "172.29.172.2".to_string(), container_port: 44344, } @@ -702,7 +806,7 @@ table inet filter { expected_host_udp_port: 443, docker_userland_proxy: TriState::Unknown, published_udp_port: Some(PublishedUdpPortPath { - host_port: 443, + host_bindings: vec![host_binding("0.0.0.0", 443)], container_ip: "172.17.0.2".into(), container_port: 443, }), @@ -713,6 +817,17 @@ table inet filter { docker_forward_rules_present: TriState::Unknown, }; assert_eq!(diag.port_contract(), "ok"); + diag.published_udp_port + .as_mut() + .expect("published") + .host_bindings + .push(host_binding("::", 8443)); + assert_eq!(diag.port_contract(), "mismatch"); + diag.published_udp_port + .as_mut() + .expect("published") + .host_bindings + .pop(); diag.awg_listen_udp_port = Some(51820); assert_eq!(diag.port_contract(), "mismatch"); diag.awg_listen_udp_port = None; diff --git a/traffic_core/src/error.rs b/traffic_core/src/error.rs index f3168fb..c5493e8 100644 --- a/traffic_core/src/error.rs +++ b/traffic_core/src/error.rs @@ -6,4 +6,6 @@ pub enum AppError { Command(String), #[error("not found: {0}")] NotFound(String), + #[error("unsupported peer topology: {0}")] + UnsupportedTopology(String), } diff --git a/traffic_core/src/firewall/host.rs b/traffic_core/src/firewall/host.rs index 111ac33..64c4e8b 100644 --- a/traffic_core/src/firewall/host.rs +++ b/traffic_core/src/firewall/host.rs @@ -3,7 +3,8 @@ //! Модуль не меняет policy встроенной цепочки INPUT и не очищает чужие правила. //! Он владеет только двумя цепочками `AWG2H_A`/`AWG2H_B` и помеченными comment //! переходами из INPUT. Новая конфигурация сначала полностью собирается в неактивной -//! цепочке, затем переход в INPUT переключается одной командой. +//! цепочке, затем новый переход вставляется первым. Старый переход остаётся рабочим до +//! успешного переключения обеих IP-family и удаляется по точной спецификации правила. use crate::{config::Config, netns::NsTransport, netns::build_host_transport}; @@ -93,8 +94,11 @@ fn apply_host_firewall_with_transport( return Err(with_two_cleanups(error, cleanup_v4, cleanup_v6)); } - remove_duplicate_jumps(transport, Family::V4, next_v4)?; - remove_duplicate_jumps(transport, Family::V6, next_v6)?; + if let Some(old) = old_v4.as_deref().filter(|old| *old != next_v4) { + remove_jumps_to_chain(transport, Family::V4, old)?; + } + let old_v6 = if next_v6 == CHAIN_A { CHAIN_B } else { CHAIN_A }; + remove_jumps_to_chain(transport, Family::V6, old_v6)?; // Удаляем таблицу прежней реализации только после успешного включения обеих семей. run_allow_notfound( @@ -240,15 +244,7 @@ fn jump_rule(operation: &str, position: Option, chain: &str) -> Vec anyhow::Result<()> { - let first_is_managed = managed_jumps(transport, family)? - .first() - .is_some_and(|jump| jump.position == 1); - let args = if first_is_managed { - jump_rule("-R", Some(1), chain) - } else { - jump_rule("-I", Some(1), chain) - }; - run_family(transport, family, args).map(|_| ()) + run_family(transport, family, jump_rule("-I", Some(1), chain)).map(|_| ()) } fn rollback_switch( @@ -257,23 +253,17 @@ fn rollback_switch( old_chain: Option<&str>, new_chain: &str, ) -> anyhow::Result<()> { - match old_chain { - Some(chain) => run_family(transport, family, jump_rule("-R", Some(1), chain)).map(|_| ()), - None => { - let jumps = managed_jumps(transport, family)?; - if jumps - .first() - .is_some_and(|jump| jump.position == 1 && jump.chain == new_chain) - { - run_family( - transport, - family, - vec!["-D".into(), "INPUT".into(), "1".into()], - )?; - } - Ok(()) + remove_one_exact_jump(transport, family, new_chain)?; + if let Some(old_chain) = old_chain { + let jumps = managed_jumps(transport, family)?; + if !jumps.iter().any(|jump| jump.chain == old_chain) { + anyhow::bail!( + "{} rollback lost the previous managed jump to {old_chain}", + family.program() + ); } } + Ok(()) } fn top_managed_chain( @@ -284,9 +274,6 @@ fn top_managed_chain( let Some(first) = jumps.first() else { return Ok(None); }; - if first.position != 1 { - return Ok(None); - } if first.chain != CHAIN_A && first.chain != CHAIN_B { anyhow::bail!( "{} INPUT contains an AWG2 marker pointing to unexpected chain {}", @@ -331,59 +318,32 @@ fn parse_managed_jumps(output: &str) -> Vec { .collect() } -fn remove_duplicate_jumps( - transport: &dyn NsTransport, - family: Family, - active: &str, -) -> anyhow::Result<()> { - let jumps = managed_jumps(transport, family)?; - if !jumps - .first() - .is_some_and(|jump| jump.position == 1 && jump.chain == active) - { - anyhow::bail!( - "{} INPUT does not start with the expected managed jump", - family.program() - ); - } - for jump in jumps.iter().skip(1).rev() { - run_family( - transport, - family, - vec!["-D".into(), "INPUT".into(), jump.position.to_string()], - )?; - } - Ok(()) -} - fn remove_jumps_to_chain( transport: &dyn NsTransport, family: Family, chain: &str, ) -> anyhow::Result<()> { - for jump in managed_jumps(transport, family)? + let count = managed_jumps(transport, family)? .into_iter() .filter(|jump| jump.chain == chain) - .collect::>() - .into_iter() - .rev() - { - run_family( - transport, - family, - vec!["-D".into(), "INPUT".into(), jump.position.to_string()], - )?; + .count(); + for _ in 0..count { + remove_one_exact_jump(transport, family, chain)?; } Ok(()) } +fn remove_one_exact_jump( + transport: &dyn NsTransport, + family: Family, + chain: &str, +) -> anyhow::Result<()> { + run_family(transport, family, jump_rule("-D", None, chain)).map(|_| ()) +} + fn remove_all_managed_jumps(transport: &dyn NsTransport, family: Family) -> anyhow::Result<()> { - for jump in managed_jumps(transport, family)?.into_iter().rev() { - run_family( - transport, - family, - vec!["-D".into(), "INPUT".into(), jump.position.to_string()], - )?; + for jump in managed_jumps(transport, family)? { + remove_one_exact_jump(transport, family, &jump.chain)?; } Ok(()) } @@ -699,7 +659,7 @@ mod tests { state.chains.remove(&format!("{program}:{chain}")); Ok(String::new()) } - "-I" | "-R" => { + "-I" => { let target = args .windows(2) .find(|pair| pair[0] == "-j") @@ -710,24 +670,17 @@ mod tests { } let rule = format!("-A INPUT {}", args[5..].join(" ")); let input = state.inputs.entry(program.to_string()).or_default(); - if operation == "-R" { - input[0] = rule; - } else { - input.insert(0, rule); - } + input.insert(0, rule); Ok(String::new()) } "-D" => { - let position = args - .get(4) - .expect("position") - .parse::() - .expect("numeric position"); - state - .inputs - .get_mut(program) - .expect("input") - .remove(position - 1); + let expected = format!("-A INPUT {}", args[4..].join(" ")); + let input = state.inputs.get_mut(program).expect("input"); + let position = input + .iter() + .position(|rule| rule == &expected) + .expect("exact rule exists"); + input.remove(position); Ok(String::new()) } _ => anyhow::bail!("unexpected command: {args:?}"), @@ -824,17 +777,45 @@ mod tests { assert!(input[0].ends_with(&format!("-j {CHAIN_A}"))); assert!(!state.chains.contains_key(&format!("{program}:{CHAIN_B}"))); } - let v4_switches = state + let v4_inserts = state .calls .iter() - .filter(|call| call.starts_with("iptables -w -R INPUT 1")) + .filter(|call| call.starts_with("iptables -w -I INPUT 1")) .collect::>(); - assert_eq!( - v4_switches.len(), - 2, - "switch to B followed by rollback to A" - ); - assert!(v4_switches[0].ends_with(&format!("-j {CHAIN_B}"))); - assert!(v4_switches[1].ends_with(&format!("-j {CHAIN_A}"))); + assert_eq!(v4_inserts.len(), 1, "new B jump is inserted once"); + assert!(v4_inserts[0].ends_with(&format!("-j {CHAIN_B}"))); + assert!(state.calls.iter().any(|call| { + call == &format!("iptables -w -D INPUT -m comment --comment {OWNER_MARK} -j {CHAIN_B}") + })); + assert!(!state.calls.iter().any(|call| call.contains(" -R INPUT "))); + } + + #[test] + fn foreign_rule_inserted_before_the_old_jump_is_never_replaced_or_deleted() { + let mut previous = test_config(); + previous.host_firewall.ssh_port = 22; + let transport = StatefulFirewallTransport::with_active_a(&previous); + { + let mut state = transport.state.lock().expect("state"); + state.fail_v6_switch_to_b = false; + for input in state.inputs.values_mut() { + input.insert(0, "-A INPUT -p tcp --dport 9999 -j ACCEPT".into()); + } + } + + apply_host_firewall_with_transport(&test_config(), &transport).expect("apply"); + + let state = transport.state.lock().expect("state"); + for program in ["iptables", "ip6tables"] { + let input = state.inputs.get(program).expect("input"); + assert_eq!(input.len(), 2); + assert!(input[0].ends_with(&format!("-j {CHAIN_B}"))); + assert_eq!(input[1], "-A INPUT -p tcp --dport 9999 -j ACCEPT"); + } + assert!(!state.calls.iter().any(|call| call.contains(" -R INPUT "))); + assert!(!state.calls.iter().any(|call| { + call.starts_with("iptables -w -D INPUT 1") + || call.starts_with("ip6tables -w -D INPUT 1") + })); } } diff --git a/traffic_core/src/firewall/mod.rs b/traffic_core/src/firewall/mod.rs index 6f779de..8bea49d 100644 --- a/traffic_core/src/firewall/mod.rs +++ b/traffic_core/src/firewall/mod.rs @@ -18,6 +18,7 @@ use crate::{ docker, lock::FileLock, netns::{NsTransport, build_host_transport, build_transport}, + wg, }; pub use antitorrent::{antitorrent_drift_reason, antitorrent_drop_counter}; @@ -41,23 +42,54 @@ const ANTITORRENT_TABLE: &str = "awg2_harness"; const LEGACY_HOST_FW_TABLE: &str = "awg2_host_firewall"; pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { - let _lock = - FileLock::acquire_timeout("/run/awg2-harness/firewall.lock", Duration::from_secs(10))?; - - host::apply_host_firewall(cfg)?; - antitorrent::apply_antitorrent_firewall(cfg)?; - Ok(()) + let target = (cfg.anti_torrent.enabled || cfg.anti_torrent.apply_in_amnezia_netns) + .then(|| docker::discover_target(cfg)); + let topology = cfg.anti_torrent.enabled.then(|| match target.as_ref() { + Some(Ok(target)) => wg::discover_peer_topology(cfg, target), + Some(Err(error)) => Err(anyhow::anyhow!( + "runtime target discovery failed: {error:#}" + )), + None => Err(anyhow::anyhow!( + "runtime target was not discovered for enabled anti-torrent" + )), + }); + apply_firewall_with_cycle_results(cfg, target.as_ref(), topology.as_ref()) } -pub(crate) fn apply_firewall_with_target_result( +pub(crate) fn apply_firewall_with_cycle_results( cfg: &Config, - target: Result<&docker::RuntimeTarget, &anyhow::Error>, + target: Option<&anyhow::Result>, + topology: Option<&anyhow::Result>, ) -> anyhow::Result<()> { let _lock = FileLock::acquire_timeout("/run/awg2-harness/firewall.lock", Duration::from_secs(10))?; host::apply_host_firewall(cfg)?; - antitorrent::apply_antitorrent_firewall_with_target_result(cfg, target) + if cfg.anti_torrent.enabled { + let topology = match topology { + Some(Ok(topology)) => topology, + Some(Err(error)) => { + return Err(anyhow::anyhow!( + "peer topology validation failed for anti-torrent: {error:#}" + )); + } + None => anyhow::bail!("peer topology was not discovered for enabled anti-torrent"), + }; + topology.validate_antitorrent_coverage(&cfg.anti_torrent.peer_ipv4_cidr)?; + } + + if cfg.anti_torrent.apply_in_amnezia_netns { + match target { + Some(target) => { + antitorrent::apply_antitorrent_firewall_with_target_result(cfg, target.as_ref()) + } + None => Err(anyhow::anyhow!( + "runtime target was not discovered for namespace firewall" + )), + } + } else { + antitorrent::apply_antitorrent_firewall(cfg) + } } /// Anti-torrent rules go wherever the peer source addresses are actually visible. diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 5be8140..e4cf115 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -65,12 +65,13 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { ); // Peer discovery is one container round-trip, shared by both sections below. - let peers = wg::discover_peers(cfg, &target); + let topology = wg::discover_peer_topology(cfg, &target); + let peers = topology.as_ref().map(|topology| &topology.peers); println!("Peers:"); match &peers { Ok(list) => { println!(" total: {}", list.len()); - for p in list { + for p in *list { println!(" - {}", p.ipv4); } } @@ -90,7 +91,7 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } } - for p in peers_sorted { + for p in *peers_sorted { let ip = p.ipv4.to_string(); if let Some(rp) = by_ip.get(&ip) { let state = handshake_is_online( @@ -211,7 +212,11 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { print_host_firewall_section(cfg); print_docker_amnezia_port_path(&docker_path_diag); - print_antitorrent_section_with_transport(cfg, Some(transport.as_ref())); + print_antitorrent_section_with_transport( + cfg, + Some(transport.as_ref()), + Some(topology.as_ref()), + ); print_swap_status(cfg); print_conntrack_section(cfg, Some(transport.as_ref())); print_last_actions_section(); @@ -291,12 +296,13 @@ fn print_host_firewall_section(cfg: &Config) { } fn print_antitorrent_section(cfg: &Config) { - print_antitorrent_section_with_transport(cfg, None); + print_antitorrent_section_with_transport(cfg, None, None); } fn print_antitorrent_section_with_transport( cfg: &Config, container_transport: Option<&dyn crate::netns::NsTransport>, + topology: Option>, ) { println!("Anti-torrent:"); println!(" enabled: {}", cfg.anti_torrent.enabled); @@ -307,7 +313,18 @@ fn print_antitorrent_section_with_transport( if !cfg.anti_torrent.enabled { return; } - let drift = if cfg.anti_torrent.apply_in_amnezia_netns { + let topology_error = match topology { + Some(Ok(topology)) => topology + .validate_antitorrent_coverage(&cfg.anti_torrent.peer_ipv4_cidr) + .err(), + Some(Err(error)) => Some(anyhow::anyhow!("{error:#}")), + None => Some(anyhow::anyhow!("peer topology is unavailable")), + }; + let drift = if let Some(error) = topology_error { + Err(anyhow::anyhow!( + "peer topology validation failed: {error:#}" + )) + } else if cfg.anti_torrent.apply_in_amnezia_netns { match container_transport { Some(transport) => firewall::antitorrent_drift_reason_with_transport(cfg, transport), None => firewall::antitorrent_drift_reason(cfg), @@ -507,10 +524,25 @@ fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { diag.docker_userland_proxy.as_str() ); match &diag.published_udp_port { - Some(p) => println!( - " published_udp_port: {} -> {}:{}", - p.host_port, p.container_ip, p.container_port - ), + Some(p) => { + println!( + " published_udp_port: {}:{}", + p.container_ip, p.container_port + ); + println!(" host_bindings:"); + for binding in &p.host_bindings { + let host = if binding.host_ip.contains(':') { + format!("[{}]", binding.host_ip) + } else { + binding.host_ip.clone() + }; + let port = binding + .host_port + .map(|port| port.to_string()) + .unwrap_or_else(|| "routed".to_string()); + println!(" - {host}:{port}"); + } + } None => println!( " published_udp_port: unknown ({})", diag.published_udp_port_error @@ -549,6 +581,7 @@ fn print_unknown_docker_amnezia_port_path(expected_host_udp_port: u16) { println!(" expected_host_udp_port: {expected_host_udp_port}"); println!(" docker_userland_proxy: unknown"); println!(" published_udp_port: unknown"); + println!(" host_bindings: unknown"); println!(" awg_listen_udp_port: unknown"); println!(" port_contract: unknown"); println!(" host_forward_policy_drop: unknown"); diff --git a/traffic_core/src/swap.rs b/traffic_core/src/swap.rs deleted file mode 100644 index df583e8..0000000 --- a/traffic_core/src/swap.rs +++ /dev/null @@ -1,2525 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, - process::Command, - time::Duration, -}; - -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; - -use anyhow::Context; - -use crate::{ - config::Config, - fstab::{self, FSTAB_PATH, FstabTransaction, escape_field}, - lock::FileLock, - managed_file::{ManagedFile, Ownership}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveSwap { - pub name: PathBuf, - pub kind: String, - pub size_bytes: u64, - pub priority: i32, -} - -#[derive(Debug, Clone)] -struct DesiredSwap { - path: PathBuf, - size_bytes: u64, - priority: i32, - swappiness: u8, -} - -#[derive(Debug, Clone)] -pub struct SwapStatus { - pub enabled: bool, - pub desired_path: PathBuf, - pub active_entries: Vec, - pub desired_active: bool, - pub exclusive: bool, - pub fstab_swap_entries: Vec, - pub fstab_exclusive: bool, - pub current_swappiness: Option, - pub persisted_swappiness: Option, - pub drift_reason: Option, -} - -/// Root-only directory holding this package's persistent state. -pub const STATE_DIR: &str = "/var/lib/awg2-harness"; -const STATE_DIR_PREFIX: &str = "/var/lib/awg2-harness/"; -/// Historical location, kept so existing deployments do not need a config change. -const DEFAULT_SWAP_PATH: &str = "/swapfile"; -/// Records which swapfile we created, by identity rather than by location. -const SWAP_OWNERSHIP_PATH: &str = "/var/lib/awg2-harness/swap-ownership.conf"; -const SWAP_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap-ownership"; -const SWAP_OWNERSHIP_VERSION: u32 = 1; -/// Sidecar written by earlier versions next to the swapfile. Only ever removed, and only -/// when it carries the marker that version wrote. -const LEGACY_OWNERSHIP_SUFFIX: &str = ".awg2-harness-owned"; -const LEGACY_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap"; -/// Replacement being built while the current swapfile is still active. -const STAGING_SUFFIX: &str = ".awg2-harness.new"; -/// The outgoing swapfile, kept until the replacement is confirmed active. -const PREVIOUS_SUFFIX: &str = ".awg2-harness.previous"; - -/// Identity of a file on disk, independent of its name. -/// -/// Path alone is not identity: a file can be deleted and a different one created at the -/// same path. `(device, inode)` is what actually distinguishes the swapfile we made from -/// whatever now occupies its path. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct FileIdentity { - device: u64, - inode: u64, -} - -/// What we recorded when we created the managed swapfile. -#[derive(Debug, Clone, PartialEq, Eq)] -struct SwapOwnershipRecord { - version: u32, - path: PathBuf, - identity: FileIdentity, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SwapfileOwnership { - /// Nothing at the path: safe to create. - Missing, - /// Ours, confirmed by identity, and already the requested size. - ManagedValid, - /// Ours, confirmed by identity, but the size differs: safe to recreate. - ManagedWrongSize, - /// A file exists and we have no record of creating it. Never touched. - Foreign, - /// We have a record, but the file at the path is not the one we created - it was - /// replaced behind our back. Refused exactly like `Foreign`; kept separate so the - /// error can say which situation the operator is in. - StaleOwnership, -} - -impl SwapfileOwnership { - fn is_managed(self) -> bool { - matches!(self, Self::ManagedValid | Self::ManagedWrongSize) - } -} - -/// Persisted `vm.swappiness`, owned by the swap module. -/// -/// Lives at `90-` like every other file this package manages. It used to be written to -/// `/etc/sysctl.d/99-awg2-harness.conf` with a different marker, no ownership check before -/// overwriting, and no cleanup on disable. -const SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/90-awg2-harness-swap.conf"; -const SWAPPINESS_MARKER: &str = "# Managed by awg2-harness swap"; -/// Written by versions before the unified managed-file handling. -const LEGACY_SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/99-awg2-harness.conf"; -const LEGACY_SWAPPINESS_MARKER: &str = "# Managed by awg2-harness"; - -fn swappiness_file() -> ManagedFile { - ManagedFile::new(SWAPPINESS_CONF_PATH, SWAPPINESS_MARKER) -} - -fn legacy_swappiness_file() -> ManagedFile { - ManagedFile::new(LEGACY_SWAPPINESS_CONF_PATH, LEGACY_SWAPPINESS_MARKER) -} - -fn render_swappiness_conf(v: u8) -> String { - format!("{SWAPPINESS_MARKER}\n# Source: /etc/awg2-harness/config.toml\nvm.swappiness = {v}\n") -} - -/// Drops the pre-rename file, but only when it still carries the old marker. -/// -/// A file an administrator put at that path is left alone, which is why the removal goes -/// through the same ownership check as everything else. -fn migrate_legacy_swappiness_file() -> anyhow::Result<()> { - drop_legacy_managed_file(&legacy_swappiness_file()) -} - -/// Removes a file left by an older version. -/// -/// Only a foreign file is tolerated - that path belongs to someone else now and the -/// current file lives elsewhere, so there is nothing to migrate. Everything else is -/// reported: swallowing `PermissionDenied` or a failed unlink would leave -/// `/etc/sysctl.d/99-awg2-harness.conf` in place, and it sorts *after* the new `90-` file, -/// so the stale value would keep winning while the reconcile reported success. -fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> { - match file.ownership()? { - Ownership::Absent => Ok(()), - Ownership::Foreign => { - tracing::warn!( - target: "swap", - "legacy path {} holds a file we do not own; leaving it alone", - file.path().display() - ); - Ok(()) - } - Ownership::Managed => file.remove().map(|_| ()), - } -} - -/// Brings host swap to the desired state as a transaction. -/// -/// Ordering is the point: the managed swap is created, activated and verified *before* -/// any foreign swap is disabled. The previous order disabled foreign swaps first, so a -/// failure in `mkswap` or `swapon` left the host with no swap at all. -pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { - // Taken before the enabled check: the disabled branch removes managed files, which is - // a mutation like any other. Leaving it outside the lock let a manual CLI run and the - // daemon's reconcile write and delete the same file at the same time. - let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?; - - if !cfg.swap.enabled { - return remove_managed_swap_state(); - } - - // 1. Preflight. The state directory is created and verified first: on a clean host - // /var/lib/awg2-harness does not exist yet, and the path check below requires every - // parent to be present. - ensure_state_dir()?; - let desired = DesiredSwap::from_config(cfg)?; - assert_swap_path_is_safe(&desired.path)?; - let active_before = read_active_swaps()?; - - // 2-3. Prepare and activate the managed swap. Activation belongs to this step so that - // every transition can roll back while it still owns both the old and the new file. - ensure_desired_swapfile(&desired, &active_before)?; - - // 4. Confirm it is really live before touching anything else. - let active_after = read_active_swaps()?; - if !active_after.iter().any(|s| s.name == desired.path) { - anyhow::bail!( - "managed swap {} did not become active; foreign swap left untouched", - desired.path.display() - ); - } - - // 5. Whatever a killed run left behind is safe to drop now that the managed swap is - // confirmed live. - drop_inactive_transition_files(&desired.path, &active_after); - - // 6. Only now is it safe to take the others down. - disable_foreign_swaps(&desired, &active_after)?; - - // 7. Persist and verify. - rewrite_fstab_exclusive(&desired)?; - apply_swappiness(desired.swappiness)?; - verify_exclusive_swap_state(&desired)?; - Ok(()) -} - -/// Undoes the persistent part of the module when `swap.enabled = false`. -/// -/// Only the managed file is removed. The runtime `vm.swappiness` is deliberately left as -/// it is: forcing an arbitrary value would be inventing a policy the operator never asked -/// for. The kernel default applies again after reboot, and `status` reports that the value -/// is no longer managed. Active swap is not disabled either - turning it off would be a -/// destructive action triggered by merely disabling a feature. -fn remove_managed_swap_state() -> anyhow::Result<()> { - migrate_legacy_swappiness_file()?; - swappiness_file().remove()?; - Ok(()) -} - -/// Lexical validation of `swap.path`, with no filesystem access. -/// -/// Kept side-effect free so `validate-config` can run it on a host where the file does not -/// exist yet. Operates on the string rather than [`Path`] because the target is always a -/// Linux path, regardless of where validation runs. -pub fn validate_swap_path_syntax(raw: &str) -> anyhow::Result<()> { - if !raw.starts_with('/') { - anyhow::bail!("swap.path must be absolute: {raw}"); - } - if raw.ends_with('/') { - anyhow::bail!("swap.path must not be a directory path: {raw}"); - } - if let Some(bad) = raw - .chars() - .find(|c| c.is_whitespace() || c.is_control() || *c == '\\') - { - anyhow::bail!( - "swap.path must not contain whitespace or control characters: {raw:?} contains {bad:?}" - ); - } - for component in raw.split('/') { - if component == "." || component == ".." { - anyhow::bail!("swap.path must not contain '.' or '..' components: {raw}"); - } - } - // Allow-list, not deny-list. A deny-list only rules out the paths someone thought of; - // what actually matters is whether the parent directory can be written by anyone but - // root. Between the preflight check and `mkswap --file` running, a - // world-writable parent lets an unprivileged user swap the final component for a - // symlink and redirect a root-owned operation - a TOCTOU that no amount of stat-ing - // before use can close. Restricting the location to directories only root can write - // removes the window instead of trying to detect it. - if raw == DEFAULT_SWAP_PATH { - return Ok(()); - } - if let Some(rest) = raw.strip_prefix(STATE_DIR_PREFIX) - && !rest.is_empty() - && !rest.contains('/') - { - return Ok(()); - } - anyhow::bail!( - "swap.path must be {DEFAULT_SWAP_PATH} or a file directly under {STATE_DIR}; got {raw}. \ - Both are root-owned locations; an arbitrary path can have a parent directory that \ - another user may write, which would let them redirect the swapfile operations" - ); -} - -/// Filesystem-level checks performed just before the path is used. -/// -/// A symlink anywhere in the chain, not only at the final component, can redirect the -/// whole operation at a file we never intended to touch. -fn assert_swap_path_is_safe(path: &Path) -> anyhow::Result<()> { - validate_swap_path_syntax(&path.to_string_lossy())?; - assert_path_chain_is_safe(path) -} - -/// Walks the parent chain and refuses any symlink or missing directory in it. -/// -/// Split from the syntax check so it can be exercised independently of the Linux path -/// grammar. -fn assert_path_chain_is_safe(path: &Path) -> anyhow::Result<()> { - let mut walked = PathBuf::new(); - let components: Vec<_> = path.components().collect(); - for component in components.iter().take(components.len().saturating_sub(1)) { - walked.push(component); - match fs::symlink_metadata(&walked) { - Ok(md) if md.file_type().is_symlink() => { - anyhow::bail!( - "swap.path traverses a symlink at {}; refusing to continue", - walked.display() - ); - } - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - anyhow::bail!( - "swap.path parent directory does not exist: {}", - walked.display() - ); - } - Err(e) => { - anyhow::bail!("failed to stat {}: {e}", walked.display()); - } - } - } - Ok(()) -} - -fn ownership_file() -> ManagedFile { - ManagedFile::new(SWAP_OWNERSHIP_PATH, SWAP_OWNERSHIP_MARKER) -} - -fn render_ownership_record(rec: &SwapOwnershipRecord) -> String { - format!( - "{SWAP_OWNERSHIP_MARKER}\nversion = {}\npath = {}\ndevice = {}\ninode = {}\n", - rec.version, - rec.path.display(), - rec.identity.device, - rec.identity.inode - ) -} - -fn parse_ownership_record(raw: &str) -> anyhow::Result { - let mut version = None; - let mut path = None; - let mut device = None; - let mut inode = None; - for line in raw.lines() { - let t = line.trim(); - if t.is_empty() || t.starts_with('#') { - continue; - } - let Some((k, v)) = t.split_once('=') else { - continue; - }; - let v = v.trim(); - match k.trim() { - "version" => version = v.parse::().ok(), - "path" => path = Some(PathBuf::from(v)), - "device" => device = v.parse::().ok(), - "inode" => inode = v.parse::().ok(), - _ => {} - } - } - let (Some(version), Some(path), Some(device), Some(inode)) = (version, path, device, inode) - else { - anyhow::bail!("swap ownership record is incomplete or malformed"); - }; - if version != SWAP_OWNERSHIP_VERSION { - anyhow::bail!("unsupported swap ownership record version: {version}"); - } - Ok(SwapOwnershipRecord { - version, - path, - identity: FileIdentity { device, inode }, - }) -} - -/// Decides ownership from a recorded state and the file actually on disk. -/// -/// Pure on purpose: the interesting cases - a stale record pointing at a file someone -/// else created, a record for a different path - are the ones that must never authorise a -/// delete, and keeping the decision free of filesystem access makes them directly -/// testable. -fn classify_ownership( - recorded: Option<&SwapOwnershipRecord>, - actual_path: &Path, - actual: Option<(FileIdentity, u64)>, - desired_size: u64, -) -> SwapfileOwnership { - let Some((identity, size)) = actual else { - return SwapfileOwnership::Missing; - }; - let Some(rec) = recorded else { - return SwapfileOwnership::Foreign; - }; - if rec.path != actual_path { - // The record describes a different managed path; this file is not ours. - return SwapfileOwnership::Foreign; - } - if rec.identity != identity { - // Right path, wrong file: ours was replaced. Never a licence to delete. - return SwapfileOwnership::StaleOwnership; - } - if size == desired_size { - SwapfileOwnership::ManagedValid - } else { - SwapfileOwnership::ManagedWrongSize - } -} - -#[cfg(unix)] -fn identity_of(md: &fs::Metadata) -> FileIdentity { - use std::os::unix::fs::MetadataExt; - FileIdentity { - device: md.dev(), - inode: md.ino(), - } -} - -/// Windows has no stable dev/inode pair through `Metadata`; this target exists only so the -/// pure logic above stays testable off Linux. -#[cfg(not(unix))] -fn identity_of(_md: &fs::Metadata) -> FileIdentity { - FileIdentity { - device: 0, - inode: 0, - } -} - -fn read_ownership_record() -> anyhow::Result> { - let Some(raw) = ownership_file().read()? else { - return Ok(None); - }; - parse_ownership_record(&raw).map(Some) -} - -fn inspect_swapfile_ownership(path: &Path, desired_size: u64) -> anyhow::Result { - let actual = match fs::symlink_metadata(path) { - Ok(md) => { - if md.file_type().is_symlink() { - anyhow::bail!("swap.path is a symlink: {}", path.display()); - } - if !md.file_type().is_file() { - anyhow::bail!("swap.path is not a regular file: {}", path.display()); - } - Some((identity_of(&md), md.len())) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => anyhow::bail!("failed to stat {}: {e}", path.display()), - }; - - let recorded = read_ownership_record()?; - Ok(classify_ownership( - recorded.as_ref(), - path, - actual, - desired_size, - )) -} - -/// Records the identity of the swapfile we just created. -/// -/// Written after the file exists, so the recorded `(device, inode)` describes the real -/// file. Until this succeeds the swapfile is not considered managed. -fn record_ownership(path: &Path) -> anyhow::Result<()> { - ensure_state_dir()?; - let md = fs::metadata(path) - .map_err(|e| anyhow::anyhow!("failed to stat {} after create: {e}", path.display()))?; - let rec = SwapOwnershipRecord { - version: SWAP_OWNERSHIP_VERSION, - path: path.to_path_buf(), - identity: identity_of(&md), - }; - ownership_file().write(&render_ownership_record(&rec))?; - Ok(()) -} - -/// Creates the state directory if needed and asserts it is safe to keep state in. -/// -/// Run before `swap.path` is validated, for two reasons. On a clean host -/// `/var/lib/awg2-harness` does not exist yet, so a perfectly legal -/// `/var/lib/awg2-harness/swapfile` would be rejected by the parent-chain check for a -/// directory this package is supposed to create. And an existing directory has to be -/// verified, not just assumed: if it is group- or world-writable, the whole point of -/// restricting `swap.path` to root-owned roots is gone, because another user could swap -/// the swapfile out from under a root-owned operation. -pub fn ensure_state_dir() -> anyhow::Result<()> { - let dir = Path::new(STATE_DIR); - - // The parent must exist and must not be a symlink, or the directory we create could - // land somewhere else entirely. - if let Some(parent) = dir.parent() { - match fs::symlink_metadata(parent) { - Ok(md) if md.file_type().is_symlink() => { - anyhow::bail!("{} is a symlink; refusing to use it", parent.display()) - } - Ok(md) if !md.file_type().is_dir() => { - anyhow::bail!("{} is not a directory", parent.display()) - } - Ok(_) => {} - Err(e) => anyhow::bail!("failed to stat {}: {e}", parent.display()), - } - } - - match fs::symlink_metadata(dir) { - Ok(md) if md.file_type().is_symlink() => { - anyhow::bail!("{STATE_DIR} is a symlink; refusing to use it") - } - Ok(md) if !md.file_type().is_dir() => { - anyhow::bail!("{STATE_DIR} exists and is not a directory") - } - Ok(md) => return assert_state_dir_is_private(dir, &md), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => anyhow::bail!("failed to stat {STATE_DIR}: {e}"), - } - - fs::create_dir(dir).map_err(|e| anyhow::anyhow!("failed to create {STATE_DIR}: {e}"))?; - set_dir_mode(dir)?; - let md = fs::symlink_metadata(dir) - .map_err(|e| anyhow::anyhow!("failed to stat {STATE_DIR} after create: {e}"))?; - assert_state_dir_is_private(dir, &md) -} - -/// Refuses a state directory anyone but root can write into. -#[cfg(unix)] -fn assert_state_dir_is_private(dir: &Path, md: &fs::Metadata) -> anyhow::Result<()> { - use std::os::unix::fs::MetadataExt; - - if md.uid() != 0 { - anyhow::bail!( - "{} must be owned by root, got uid {}", - dir.display(), - md.uid() - ); - } - let mode = md.permissions().mode() & 0o777; - if mode & 0o022 != 0 { - anyhow::bail!( - "{} is writable by group or others (mode {mode:04o}); refusing to keep state there", - dir.display() - ); - } - if mode != 0o700 { - set_dir_mode(dir)?; - } - Ok(()) -} - -#[cfg(not(unix))] -fn assert_state_dir_is_private(_dir: &Path, _md: &fs::Metadata) -> anyhow::Result<()> { - Ok(()) -} - -#[cfg(unix)] -fn set_dir_mode(dir: &Path) -> anyhow::Result<()> { - fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) - .map_err(|e| anyhow::anyhow!("failed to chmod 0700 {}: {e}", dir.display())) -} - -#[cfg(not(unix))] -fn set_dir_mode(_dir: &Path) -> anyhow::Result<()> { - Ok(()) -} - -/// Removes the sidecar written next to the swapfile by earlier versions. -/// -/// Only removed, never trusted: ownership now lives in the state directory. A sidecar at -/// a path we do not own is left alone. -fn drop_legacy_ownership_sidecar(swap_path: &Path) { - let Ok(sidecar) = suffixed_path(swap_path, LEGACY_OWNERSHIP_SUFFIX) else { - return; - }; - // Goes through the same marker check as every other managed file. Removing any regular - // file that happened to match the name would delete an operator's file purely because - // of what it was called. - let file = ManagedFile::new(sidecar.clone(), LEGACY_OWNERSHIP_MARKER); - match file.ownership() { - Ok(Ownership::Absent) => {} - Ok(Ownership::Managed) => { - if let Err(e) = file.remove() { - tracing::warn!( - target: "swap", - "could not remove legacy ownership sidecar {}: {e}", - sidecar.display() - ); - } - } - Ok(Ownership::Foreign) => tracing::warn!( - target: "swap", - "{} exists but is not the legacy ownership sidecar; leaving it alone", - sidecar.display() - ), - Err(e) => tracing::warn!( - target: "swap", - "could not inspect {}: {e}", - sidecar.display() - ), - } -} - -pub fn inspect_swap(cfg: &Config) -> anyhow::Result { - let desired_path = PathBuf::from(cfg.swap.path.clone()); - let active_entries = read_active_swaps()?; - let desired_active = active_entries.iter().any(|s| s.name == desired_path); - let exclusive = active_entries.len() == 1 && desired_active; - let fstab_swap_entries = read_fstab_swap_entries("/etc/fstab")?; - let expected_line = render_fstab_line(&desired_path, cfg.swap.priority); - let fstab_exclusive = fstab_swap_entries.len() == 1 - && normalize_ws(&fstab_swap_entries[0]) == normalize_ws(expected_line.trim_end()); - let current_swappiness = read_current_swappiness().ok(); - let persisted_swappiness = read_persisted_swappiness()?; - - let drift_reason = if !cfg.swap.enabled { - None - } else if !exclusive { - if let Some(foreign) = active_entries.iter().find(|s| s.name != desired_path) { - Some(format!("foreign active swap: {}", foreign.name.display())) - } else { - Some("desired swap is not exclusively active".to_string()) - } - } else if !fstab_exclusive { - Some("/etc/fstab swap entries are not exclusive".to_string()) - } else if current_swappiness != Some(cfg.swap.swappiness) { - Some(format!( - "runtime swappiness mismatch: current={:?} desired={}", - current_swappiness, cfg.swap.swappiness - )) - } else if persisted_swappiness != Some(cfg.swap.swappiness) { - Some(format!( - "persisted swappiness mismatch: persisted={:?} desired={}", - persisted_swappiness, cfg.swap.swappiness - )) - } else { - None - }; - - Ok(SwapStatus { - enabled: cfg.swap.enabled, - desired_path, - active_entries, - desired_active, - exclusive, - fstab_swap_entries, - fstab_exclusive, - current_swappiness, - persisted_swappiness, - drift_reason, - }) -} - -impl DesiredSwap { - fn from_config(cfg: &Config) -> anyhow::Result { - let path = PathBuf::from(cfg.swap.path.clone()); - if !path.is_absolute() { - anyhow::bail!("swap.path must be absolute: {}", path.display()); - } - let size_bytes = cfg - .swap - .size_mb - .checked_mul(1024 * 1024) - .ok_or_else(|| anyhow::anyhow!("swap.size_mb overflow"))?; - Ok(Self { - path, - size_bytes, - priority: cfg.swap.priority, - swappiness: cfg.swap.swappiness, - }) - } -} - -fn read_active_swaps() -> anyhow::Result> { - let out = Command::new("swapon") - .args([ - "--show", - "--bytes", - "--raw", - "--noheadings", - "--output", - "NAME,TYPE,SIZE,PRIO", - ]) - .output()?; - if !out.status.success() { - anyhow::bail!( - "swapon --show failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - parse_swapon_output(&String::from_utf8_lossy(&out.stdout)) -} - -fn parse_swapon_output(raw: &str) -> anyhow::Result> { - let mut out = Vec::new(); - for line in raw.lines() { - let t = line.trim(); - if t.is_empty() { - continue; - } - let parts: Vec<&str> = t.split_whitespace().collect(); - if parts.len() < 4 { - anyhow::bail!("malformed swapon row: {t}"); - } - let size_bytes = parts[2] - .parse::() - .map_err(|_| anyhow::anyhow!("malformed swapon size: {}", parts[2]))?; - - // Debian 13/util-linux may include USED and UUID despite the requested - // output set. Accept both the old 4-column form: - // NAME TYPE SIZE PRIO - // and the observed 5/6-column form: - // NAME TYPE SIZE USED PRIO [UUID] - let priority_index = if parts.len() >= 5 && parts[4].parse::().is_ok() { - 4 - } else { - 3 - }; - - let priority = parts[priority_index] - .parse::() - .map_err(|_| anyhow::anyhow!("malformed swapon priority: {}", parts[priority_index]))?; - out.push(ActiveSwap { - name: PathBuf::from(parts[0]), - kind: parts[1].to_string(), - size_bytes, - priority, - }); - } - Ok(out) -} - -/// Takes foreign swap offline. The file itself is never deleted. -/// -/// Destroying a swapfile an administrator created is not this package's call: `swapoff` -/// plus removal from `/etc/fstab` already achieves the exclusive runtime state, and the -/// file can be removed by hand once the operator agrees. -/// Takes foreign swap offline, restoring what it already disabled if a later one fails. -/// -/// Without the rollback a host with two foreign swaps could end up with the first one -/// disabled and the second still up, because the error aborted the loop and nothing put -/// the first back. That is a state neither the operator nor the next reconcile asked for. -fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { - let mut disabled: Vec<&ActiveSwap> = Vec::new(); - for entry in active { - if entry.name == desired.path { - continue; - } - if let Err(e) = run("swapoff", &[entry.name.to_string_lossy().as_ref()]) { - reenable_swaps(&disabled); - return Err(anyhow::anyhow!( - "failed to disable foreign swap {}: {e}", - entry.name.display() - )); - } - disabled.push(entry); - } - Ok(()) -} - -/// Best-effort restore after a failed teardown: report what could not be brought back -/// rather than masking it, but never turn the rollback itself into the reported error. -fn reenable_swaps(entries: &[&ActiveSwap]) { - for entry in entries.iter().rev() { - if let Err(e) = run( - "swapon", - &[ - "-p", - &entry.priority.to_string(), - entry.name.to_string_lossy().as_ref(), - ], - ) { - tracing::warn!( - target: "swap", - "could not re-enable {} while rolling back: {e}", - entry.name.display() - ); - } - } -} - -/// Creates or validates the managed swapfile without ever touching an unowned one. -/// -/// `active` is needed because a live swapfile cannot be resized in place: if the managed -/// swap is currently active with the wrong geometry it has to be deactivated first. That -/// is our own swap, so the brief gap is acceptable; foreign swap stays up throughout. -fn ensure_desired_swapfile(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { - let p = &desired.path; - let ownership = inspect_swapfile_ownership(p, desired.size_bytes)?; - - match ownership { - SwapfileOwnership::Foreign => anyhow::bail!( - "{} already exists and there is no record of awg2-harness creating it; \ - refusing to touch it. Remove it manually or point swap.path elsewhere", - p.display() - ), - SwapfileOwnership::StaleOwnership => anyhow::bail!("{}", stale_ownership_help(p)?), - _ => {} - } - - let active_entry = active.iter().find(|s| s.name == *p); - let size_ok = ownership == SwapfileOwnership::ManagedValid; - - match (size_ok, active_entry) { - // Right file, right size, already active with the right priority: nothing to do. - (true, Some(entry)) - if active_swap_size_matches(entry.size_bytes, desired.size_bytes) - && entry.priority == desired.priority => - { - set_swapfile_mode(p)?; - drop_legacy_ownership_sidecar(p); - Ok(()) - } - // Right file, right size, but the active priority differs. A reactivation, not a - // rebuild - the previous code fell through to a full recreate, so changing - // swap.priority destroyed and rebuilt a perfectly good multi-gigabyte file. - (true, Some(entry)) => { - set_swapfile_mode(p)?; - drop_legacy_ownership_sidecar(p); - change_swap_priority(desired, entry.priority) - } - // Right file, not active: activate it. - (true, None) => { - set_swapfile_mode(p)?; - drop_legacy_ownership_sidecar(p); - swapon_at(p, desired.priority) - } - // Wrong size or no file at all, and nothing of ours is active: build in place. - (false, None) => { - if ownership.is_managed() { - fs::remove_file(p) - .map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?; - } - create_fresh_swapfile(desired)?; - drop_legacy_ownership_sidecar(p); - Ok(()) - } - // Wrong size while our swap is live: replace it through a staging file. The - // currently active priority is carried in, because that is the value a rollback - // has to restore - the new one may be exactly what failed. - (false, Some(entry)) => { - replace_active_swapfile(desired, entry.priority)?; - drop_legacy_ownership_sidecar(p); - Ok(()) - } - } -} - -/// Explains a stale ownership record, distinguishing the two ways it happens. -/// -/// A leftover `.previous` next to the swapfile is the signature of a replacement that was -/// killed between installing the new file and recording its identity: the path holds the -/// new inode while the record still describes the old one. That is recoverable by hand and -/// deserves the exact commands, not the generic message. -/// -/// The generic advice must also not say "delete the ownership record" on its own - without -/// a record the file becomes `Foreign`, which this refuses just as firmly. -fn stale_ownership_help(p: &Path) -> anyhow::Result { - let previous = suffixed_path(p, PREVIOUS_SUFFIX)?; - let staging = suffixed_path(p, STAGING_SUFFIX)?; - - // Both recipes end in a full rebuild. Anything that leaves an existing file at the - // managed path fails the very next run: a swapfile with no matching ownership record is - // Foreign, and Foreign is refused just as firmly as a stale record. Advice that merely - // moves files around or deletes the record only changes which error is reported. - let rebuild = format!( - "swapoff {} 2>/dev/null; swapoff {} 2>/dev/null; rm -f {} {} {}; rm -f {}; \ - then re-run `awg2-harness --config /etc/awg2-harness/config.toml ensure-swap`", - p.display(), - staging.display(), - p.display(), - previous.display(), - staging.display(), - SWAP_OWNERSHIP_PATH, - ); - - if previous.exists() { - return Ok(format!( - "{} does not match the recorded swapfile and {} is present: a swap replacement \ - was interrupted after the new file was installed but before its identity was \ - recorded. No data is at risk - swap holds no persistent state. Recover by \ - rebuilding: {rebuild}", - p.display(), - previous.display() - )); - } - Ok(format!( - "{} is not the file awg2-harness created - it was replaced since. Refusing to touch \ - it. Recover by rebuilding: {rebuild}", - p.display() - )) -} - -/// Re-activates the managed swap at a new priority, restoring the old one on failure. -/// -/// `swapoff` followed by `swapon` is two steps, and the second can fail - a bad priority, -/// a transient kernel error. Without the restore, changing one integer in the config could -/// leave the host with no swap at all. -fn change_swap_priority(desired: &DesiredSwap, previous_priority: i32) -> anyhow::Result<()> { - let p = &desired.path; - run("swapoff", &[p.to_string_lossy().as_ref()])?; - - let Err(e) = swapon_at(p, desired.priority) else { - return Ok(()); - }; - match swapon_at(p, previous_priority) { - Ok(()) => Err(anyhow::anyhow!( - "failed to activate {} with priority {}: {e}; restored the previous priority {}", - p.display(), - desired.priority, - previous_priority - )), - Err(re) => Err(anyhow::anyhow!( - "failed to activate {} with priority {} ({e}) and could not restore priority {} \ - ({re}); the host may currently have no swap", - p.display(), - desired.priority, - previous_priority - )), - } -} - -/// Builds and activates a swapfile where none of ours is running. -/// -/// The file is removed again if anything after its creation fails. Leaving it behind would -/// produce an unowned regular file at the managed path, which the next run correctly -/// classifies as `Foreign` and then refuses to touch - so a single transient `mkswap` -/// failure would wedge the module until an operator intervened. -fn create_fresh_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> { - let p = &desired.path; - // `mkswap --file` creates the file itself, so a non-zero exit is no guarantee that - // nothing was left behind. Without this cleanup a failed run leaves an unowned regular - // file at the managed path, which the next run correctly calls Foreign and refuses - - // wedging the module until an operator intervenes. - if let Err(e) = build_swapfile(p, desired.size_bytes) { - return Err(match remove_if_present(p) { - Ok(()) => e, - Err(cleanup) => anyhow::anyhow!( - "{e}; additionally failed to clean up {}: {cleanup}", - p.display() - ), - }); - } - - let prepared = record_ownership(p).and_then(|()| swapon_at(p, desired.priority)); - if let Err(e) = prepared { - // Created by this run inside an allowed root-owned location, so removing it is - // safe and leaves the host exactly as it was found. - if let Err(re) = fs::remove_file(p) { - return Err(anyhow::anyhow!( - "{e}; additionally failed to clean up the partially prepared {}: {re}", - p.display() - )); - } - let _ = ownership_file().remove(); - return Err(e); - } - Ok(()) -} - -/// Replaces a live managed swapfile with one of a different size. -/// -/// A live swapfile cannot be resized in place, so it has to be swapped out. Allocation and -/// `mkswap` happen while the old swap is still serving. The old file is then kept aside as -/// `.previous` rather than deleted, so every step after that point can be undone: until -/// the new file is confirmed active, the original is still on disk and can be put back. -/// The previous version deleted the old file before the new one was activated, so a -/// failure at `rename` or `swapon` left the host with no swap and nothing to recover from. -fn replace_active_swapfile(desired: &DesiredSwap, previous_priority: i32) -> anyhow::Result<()> { - let p = &desired.path; - let staging = suffixed_path(p, STAGING_SUFFIX)?; - let previous = suffixed_path(p, PREVIOUS_SUFFIX)?; - - // Leftovers from a killed run may still be *active*, in which case removing them fails - // and would wedge every future reconcile. - clear_stale_transition_files(&staging, &previous)?; - - let mut state = ReplacementState { - previous_priority, - ..ReplacementState::default() - }; - - // Built and activated while the old swap is still up: no gap during the slow part. - if let Err(e) = build_swapfile(&staging, desired.size_bytes) { - // Same reasoning as create_fresh_swapfile: mkswap may have left the file behind. - // The original swap is untouched at this point, so removing the staging file - // restores the host exactly as it was found. - let _ = remove_if_present(&staging); - return Err(e); - } - if let Err(e) = swapon_at(&staging, desired.priority) { - let _ = fs::remove_file(&staging); - return Err(e); - } - state.staging_active = true; - - // From here on every failure goes through the rollback, which knows exactly which of - // these steps completed. - let outcome = (|| -> anyhow::Result<()> { - run("swapoff", &[p.to_string_lossy().as_ref()])?; - state.original_active = false; - - fs::rename(p, &previous).map_err(|e| { - anyhow::anyhow!( - "failed to set aside {} as {}: {e}", - p.display(), - previous.display() - ) - })?; - state.original_moved = true; - - // Short window with no allocation in it. - run("swapoff", &[staging.to_string_lossy().as_ref()])?; - state.staging_active = false; - - fs::rename(&staging, p).map_err(|e| { - anyhow::anyhow!( - "failed to move {} into place at {}: {e}", - staging.display(), - p.display() - ) - })?; - state.new_installed = true; - - record_ownership(p)?; - swapon_at(p, desired.priority)?; - state.new_active = true; - - assert_some_swap_is_active(p) - })(); - - if let Err(cause) = outcome { - return Err(rollback_replacement( - desired, &staging, &previous, &state, cause, - )); - } - - // The replacement is confirmed active; the old file is no longer needed. - if let Err(e) = fs::remove_file(&previous) { - tracing::warn!( - target: "swap", - "swap replaced successfully but the old file {} could not be removed: {e}", - previous.display() - ); - } - Ok(()) -} - -/// How far a replacement got, so the rollback undoes exactly that much. -#[derive(Debug, Clone, Copy)] -struct ReplacementState { - /// The replacement is activated at the staging path. - staging_active: bool, - /// The original swap is still activated at the managed path. - original_active: bool, - /// The original file has been renamed to `.previous`. - original_moved: bool, - /// The replacement has been renamed onto the managed path. - new_installed: bool, - /// The replacement is activated at the managed path. - /// - /// Without this the rollback cannot tell an installed-but-inactive replacement from a - /// live one, and would try to move a file that the kernel is still swapping to. - new_active: bool, - previous_priority: i32, -} - -impl Default for ReplacementState { - fn default() -> Self { - Self { - staging_active: false, - // The caller only reaches a replacement when the managed swap is live. - original_active: true, - original_moved: false, - new_installed: false, - new_active: false, - previous_priority: 0, - } - } -} - -/// Undoes a partial replacement and reports what the host ended up with. -/// -/// Order matters: the original is put back and confirmed active *before* the staging swap -/// is released, so the host is never briefly without swap during the recovery itself. The -/// original priority is used, not the desired one - the desired priority may be exactly -/// what the kernel rejected. -fn rollback_replacement( - desired: &DesiredSwap, - staging: &Path, - previous: &Path, - state: &ReplacementState, - cause: anyhow::Error, -) -> anyhow::Error { - rollback_replacement_with(&SystemSwap, desired, staging, previous, state, cause) -} - -/// The two kernel operations the rollback depends on. -/// -/// Behind a trait so the recovery order can be tested. The invariant that matters - -/// never release the staging swap while the original is not back - is only observable as a -/// sequence of calls, so asserting it needs a seam rather than another pure helper. -trait SwapCommands { - fn swapon(&self, path: &Path, priority: i32) -> anyhow::Result<()>; - fn swapoff(&self, path: &Path) -> anyhow::Result<()>; -} - -struct SystemSwap; - -impl SwapCommands for SystemSwap { - fn swapon(&self, path: &Path, priority: i32) -> anyhow::Result<()> { - swapon_at(path, priority) - } - - fn swapoff(&self, path: &Path) -> anyhow::Result<()> { - run("swapoff", &[path.to_string_lossy().as_ref()]) - } -} - -fn rollback_replacement_with( - ops: &dyn SwapCommands, - desired: &DesiredSwap, - staging: &Path, - previous: &Path, - state: &ReplacementState, - cause: anyhow::Error, -) -> anyhow::Error { - let p = &desired.path; - let mut notes: Vec = Vec::new(); - - // Both swapfiles are tracked by where they currently are rather than by a flag. A - // rollback can be interrupted at a point where neither is at the path it started from, - // and the kernel activates a swap area by path: a file does not have to sit at the - // managed path to carry load. - let mut replacement_home: Option = None; - let mut replacement_active = state.staging_active; - - // Step the replacement back without destroying it. Deleting the new file here would - // throw away the only other swap area on the host before the original is known to be - // usable again - which is what the first version did, so a failed reactivation left - // nothing to fall back to. - if state.new_installed { - if state.new_active - && let Err(e) = ops.swapoff(p) - { - // Still live at the managed path, which means the host does have swap. Moving - // it now would only make /proc/swaps disagree with the filesystem. - return anyhow::anyhow!( - "{cause}; the replacement at {} is active and could not be deactivated \ - ({e}), so it is left in place and serving. The previous swapfile is \ - still at {}", - p.display(), - previous.display() - ); - } - match fs::rename(p, staging) { - Ok(()) => replacement_home = Some(staging.to_path_buf()), - Err(e) => { - notes.push(format!( - "could not move the replacement aside to {}: {e}", - staging.display() - )); - replacement_home = Some(p.clone()); - } - } - } else if state.staging_active || staging.exists() { - replacement_home = Some(staging.to_path_buf()); - } - - // Put the original file back where it belongs. Skipped when the replacement could not - // be moved off the managed path: `rename` would unlink it, and that replacement is one - // of the two swapfiles this rollback still has to fall back on. - let mut original_home = if state.original_moved { - previous.to_path_buf() - } else { - p.clone() - }; - if state.original_moved && replacement_home.as_deref() != Some(p.as_path()) { - match fs::rename(previous, p) { - Ok(()) => { - original_home = p.clone(); - if let Err(e) = record_ownership(p) { - notes.push(format!("ownership record not rewritten: {e}")); - } - } - Err(e) => notes.push(format!( - "could not move {} back to {}: {e}", - previous.display(), - p.display() - )), - } - } - - // Reactivate the original at the priority it had before - from wherever it now is. A - // failed rename back is no reason to leave a healthy swapfile idle: returning here, as - // the first version did, left two intact swapfiles on disk with neither of them running. - let mut original_back = state.original_active; - if !original_back { - match ops.swapon(&original_home, state.previous_priority) { - Ok(()) => original_back = true, - Err(e) => notes.push(format!( - "could not reactivate {} at priority {}: {e}", - original_home.display(), - state.previous_priority - )), - } - } - - if !original_back { - // The original is unusable, so whatever replacement exists is the only swap area - // left and becomes the fallback - activated here if the forward path had already - // switched it off. An extra temporary swap is always a better end state than none, - // and the message must describe what is actually running rather than assume it. - if !replacement_active - && let Some(home) = replacement_home.as_deref() - && home.exists() - { - match ops.swapon(home, state.previous_priority) { - Ok(()) => replacement_active = true, - Err(e) => notes.push(format!( - "could not activate {} as a fallback: {e}", - home.display() - )), - } - } - let detail = format_notes(¬es); - return match replacement_home.filter(|_| replacement_active) { - Some(home) => anyhow::anyhow!( - "{cause}; the previous swap could not be restored{detail}; the replacement \ - at {} is left active so the host is not without swap. Once the host allows \ - it, run `swapon -p {} {}` and then `swapoff {}`", - home.display(), - state.previous_priority, - original_home.display(), - home.display() - ), - None => anyhow::anyhow!( - "{cause}; the previous swap could not be restored and no fallback could be \ - activated{detail}. The host currently has no managed swap" - ), - }; - } - - // The original is serving again, but from `.previous`, because the rename back failed. - // The host has swap, so nothing here is urgent - but the managed path does not hold what - // the ownership record describes, and releasing the replacement would take away the only - // other usable swapfile, so both are left exactly as they are for an operator. - if original_home != *p { - // Naming where the replacement ended up matters: when it could not be moved aside it - // is still sitting on the managed path, and an operator told to "put the previous - // file back" would otherwise overwrite it. - let replacement_note = match replacement_home.as_deref() { - Some(home) => format!(" The replacement is at {}.", home.display()), - None => String::new(), - }; - return anyhow::anyhow!( - "{cause}; the previous swapfile could not be moved back to {} and is serving \ - from {} instead{}.{replacement_note} Both swapfiles are intact: once the host \ - allows it, run `swapoff {}`, put it back at {} and re-run ensure-swap", - p.display(), - original_home.display(), - format_notes(¬es), - original_home.display(), - p.display() - ); - } - - // Original confirmed back at the managed path: now the replacement can go. The file is - // only unlinked once swapoff has actually succeeded - removing an active swapfile is not - // possible, and trying leaves an error that says nothing useful. - if let Some(home) = replacement_home { - if replacement_active { - match ops.swapoff(&home) { - Ok(()) => { - if let Err(e) = remove_if_present(&home) { - notes.push(format!("inactive staging file left behind: {e}")); - } - } - Err(e) => notes.push(format!( - "previous swap is restored, but the staging swap {} is still active: {e}", - home.display() - )), - } - } else if let Err(e) = remove_if_present(&home) { - notes.push(format!("inactive staging file left behind: {e}")); - } - } - - anyhow::anyhow!( - "{cause}; rolled back to the previous swapfile{}", - format_notes(¬es) - ) -} - -fn format_notes(notes: &[String]) -> String { - if notes.is_empty() { - String::new() - } else { - format!(" ({})", notes.join("; ")) - } -} - -/// Drops `.new`/`.previous` left by a killed run, once the managed swap is confirmed live. -/// -/// [`clear_stale_transition_files`] only runs on the way *into* a resize, so a process killed -/// after the replacement was activated left the old file on disk indefinitely: the next run -/// finds a valid, active, correctly owned swapfile at the managed path, takes the fast path -/// and never looks at the leftovers. A swapfile is commonly sized after RAM, so that is not a -/// rounding error on disk. -/// -/// Only inactive leftovers are removed. An active one means the kernel is still swapping to -/// it - the interrupted-rollback case, which needs an operator rather than a guess and is -/// reported as such by the next resize. -fn drop_inactive_transition_files(path: &Path, active: &[ActiveSwap]) { - for suffix in [STAGING_SUFFIX, PREVIOUS_SUFFIX] { - let Ok(leftover) = suffixed_path(path, suffix) else { - continue; - }; - if !leftover.exists() || active.iter().any(|s| s.name == leftover) { - continue; - } - match remove_if_present(&leftover) { - Ok(()) => tracing::info!( - target: "swap", - "removed {}, left behind by an interrupted swap replacement", - leftover.display() - ), - Err(e) => tracing::warn!( - target: "swap", - "leftover {} from an interrupted swap replacement could not be removed: {e}", - leftover.display() - ), - } - } -} - -/// Clears `.new`/`.previous` left by a killed run, refusing to guess when they are active. -/// -/// An active leftover cannot be unlinked, and blindly retrying the removal every reconcile -/// would wedge the module. A `.previous` that is active while the main path is gone is the -/// interrupted-rollback case and needs an operator, not a guess. -fn clear_stale_transition_files(staging: &Path, previous: &Path) -> anyhow::Result<()> { - let active = read_active_swaps()?; - for leftover in [staging, previous] { - if active.iter().any(|s| s.name == *leftover) { - anyhow::bail!( - "{} is left over from an interrupted swap replacement and is still active. \ - Run `swapoff {}` once the host has other swap, then retry", - leftover.display(), - leftover.display() - ); - } - remove_if_present(leftover)?; - } - Ok(()) -} - -/// Refuses to report success while the host has no swap at all. -fn assert_some_swap_is_active(expected: &Path) -> anyhow::Result<()> { - let active = read_active_swaps()?; - if active.iter().any(|s| s.name == expected) { - return Ok(()); - } - anyhow::bail!( - "{} is not present in the active swap list after activation", - expected.display() - ) -} - -fn swapon_at(path: &Path, priority: i32) -> anyhow::Result<()> { - run( - "swapon", - &["-p", &priority.to_string(), path.to_string_lossy().as_ref()], - ) -} - -fn remove_if_present(path: &Path) -> anyhow::Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(anyhow::anyhow!("failed to remove {}: {e}", path.display())), - } -} - -fn suffixed_path(path: &Path, suffix: &str) -> anyhow::Result { - let name = path - .file_name() - .ok_or_else(|| anyhow::anyhow!("invalid swap.path: {}", path.display()))?; - let mut out = name.to_os_string(); - out.push(suffix); - Ok(path.with_file_name(out)) -} - -/// Allocates a swapfile and formats it in one step. -/// -/// `mkswap --file --size` creates the file itself, which replaces the previous -/// `fallocate`, fall back to `dd`, `chmod`, `mkswap` chain: four processes, three failure -/// branches and a window where the file existed but was not yet formatted. util-linux in -/// Debian 13 also handles filesystem-specific requirements - notably Btrfs - that raw -/// `fallocate` does not. -/// -/// The mode is set again afterwards because the file must be 0600 before it is activated, -/// and this way it never sits world-readable while already holding swap structure. -fn build_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> { - let size_mb = size_bytes / (1024 * 1024); - run( - "mkswap", - &[ - "--file", - path.to_string_lossy().as_ref(), - "--size", - &format!("{size_mb}M"), - ], - )?; - set_swapfile_mode(path) -} - -#[cfg(unix)] -fn set_swapfile_mode(path: &Path) -> anyhow::Result<()> { - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .map_err(|e| anyhow::anyhow!("failed to chmod 0600 {}: {e}", path.display())) -} - -#[cfg(not(unix))] -fn set_swapfile_mode(_path: &Path) -> anyhow::Result<()> { - Ok(()) -} - -fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> { - let tx = FstabTransaction::begin(Path::new(FSTAB_PATH))?; - let rewritten = rewrite_fstab_content(tx.original(), &desired.path, desired.priority); - tx.commit(&rewritten)?; - Ok(()) -} - -fn rewrite_fstab_content(content: &str, desired_path: &Path, priority: i32) -> String { - let mut out = Vec::::new(); - for line in content.lines() { - let t = line.trim(); - if t.is_empty() || t.starts_with('#') { - out.push(line.to_string()); - continue; - } - let cols: Vec<&str> = t.split_whitespace().collect(); - if cols.len() >= 3 && cols[2] == "swap" { - continue; - } - out.push(line.to_string()); - } - out.push( - render_fstab_line(desired_path, priority) - .trim_end() - .to_string(), - ); - let mut s = out.join("\n"); - if !s.ends_with('\n') { - s.push('\n'); - } - s -} - -/// Path goes through fstab escaping: fields are whitespace-separated, so an unescaped -/// space in the path would shift every following column. -fn render_fstab_line(path: &Path, priority: i32) -> String { - format!( - "{} none swap sw,pri={} 0 0\n", - escape_field(&path.to_string_lossy()), - priority - ) -} - -fn apply_swappiness(v: u8) -> anyhow::Result<()> { - run("sysctl", &["-w", &format!("vm.swappiness={v}")])?; - let current = read_current_swappiness()?; - if current != v { - anyhow::bail!("failed to apply vm.swappiness: got {current}, expected {v}"); - } - - migrate_legacy_swappiness_file()?; - swappiness_file().write(&render_swappiness_conf(v))?; - - let persisted = read_persisted_swappiness()?; - if persisted != Some(v) { - anyhow::bail!( - "failed to persist vm.swappiness: got {:?}, expected {}", - persisted, - v - ); - } - Ok(()) -} - -pub fn read_current_swappiness() -> anyhow::Result { - let out = Command::new("sysctl") - .args(["-n", "vm.swappiness"]) - .output()?; - if !out.status.success() { - anyhow::bail!("sysctl read vm.swappiness failed"); - } - let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); - s.parse::() - .with_context(|| format!("invalid vm.swappiness value: {s}")) -} - -pub fn read_persisted_swappiness() -> anyhow::Result> { - let Some(content) = swappiness_file().read()? else { - return Ok(None); - }; - for line in content.lines() { - let t = line.trim(); - if t.is_empty() || t.starts_with('#') { - continue; - } - let Some((k, v)) = t.split_once('=') else { - continue; - }; - if k.trim() == "vm.swappiness" { - return Ok(Some(v.trim().parse::()?)); - } - } - Ok(None) -} - -fn verify_exclusive_swap_state(desired: &DesiredSwap) -> anyhow::Result<()> { - let active = read_active_swaps()?; - if active.len() != 1 { - anyhow::bail!( - "swap verify failed: expected exactly one active swap, got {}", - active.len() - ); - } - let only = &active[0]; - if only.name != desired.path { - anyhow::bail!( - "swap verify failed: active swap {} != desired {}", - only.name.display(), - desired.path.display() - ); - } - if !active_swap_size_matches(only.size_bytes, desired.size_bytes) { - anyhow::bail!( - "swap verify failed: size mismatch {} != {}", - only.size_bytes, - desired.size_bytes - ); - } - if only.priority != desired.priority { - anyhow::bail!( - "swap verify failed: priority mismatch {} != {}", - only.priority, - desired.priority - ); - } - - let entries = read_fstab_swap_entries("/etc/fstab")?; - if entries.len() != 1 { - anyhow::bail!( - "swap verify failed: expected one fstab swap entry, got {}", - entries.len() - ); - } - let expected = render_fstab_line(&desired.path, desired.priority); - if normalize_ws(&entries[0]) != normalize_ws(expected.trim_end()) { - anyhow::bail!( - "swap verify failed: fstab entry mismatch: got='{}' expected='{}'", - entries[0], - expected.trim_end() - ); - } - - let current = read_current_swappiness()?; - if current != desired.swappiness { - anyhow::bail!( - "swap verify failed: vm.swappiness current={} expected={}", - current, - desired.swappiness - ); - } - Ok(()) -} - -/// Reads swap entries for inspection. -/// -/// Strict for the same reason the rewrite is: masking a read failure here would report -/// "no swap entries in fstab", which reads as healthy drift-free state on a host whose -/// fstab could not be read at all. -fn read_fstab_swap_entries(path: &str) -> anyhow::Result> { - let content = fstab::read_required(Path::new(path))?; - let mut out = Vec::new(); - for line in content.lines() { - let t = line.trim(); - if t.is_empty() || t.starts_with('#') { - continue; - } - let cols: Vec<&str> = t.split_whitespace().collect(); - if cols.len() >= 3 && cols[2] == "swap" { - out.push(t.to_string()); - } - } - Ok(out) -} - -fn normalize_ws(s: &str) -> String { - s.split_whitespace().collect::>().join(" ") -} - -fn active_swap_size_matches(active_bytes: u64, desired_file_bytes: u64) -> bool { - if active_bytes == desired_file_bytes { - return true; - } - active_bytes < desired_file_bytes && desired_file_bytes - active_bytes <= 64 * 1024 -} - -fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { - let out = Command::new(program).args(args).output()?; - if !out.status.success() { - anyhow::bail!( - "{} {:?} failed: {}", - program, - args, - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn scratch(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("scratch dir"); - dir - } - - #[test] - fn swap_path_syntax_accepts_expected_production_values() { - assert!(validate_swap_path_syntax("/swapfile").is_ok()); - assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swapfile").is_ok()); - assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swap.1").is_ok()); - } - - /// An allow-list, not a deny-list: a path whose parent another user can write lets - /// them redirect root-owned operations between preflight and use, which no amount of - /// stat-ing beforehand can prevent. - #[test] - fn swap_path_syntax_rejects_paths_outside_root_owned_roots() { - for bad in [ - "/tmp/awg.swap", - "/home/user/awg.swap", - "/srv/data/swapfile", - "/var/lib/swapfile", - "/var/lib/awg2-harness", - "/var/lib/awg2-harness/nested/swapfile", - "/var/lib/awg2-harness-other/swapfile", - "/swapfile2", - ] { - assert!( - validate_swap_path_syntax(bad).is_err(), - "must reject path outside the allowed roots: {bad}" - ); - } - } - - /// The blacklist used to compare exact strings only, so a path that is lexically - /// different but resolves to a forbidden file slipped through. - #[test] - fn swap_path_syntax_rejects_dot_dot_traversal() { - for bad in [ - "/etc/../etc/fstab", - "/var/../etc/passwd", - "/./swapfile", - "/var/lib/../../etc/fstab", - ] { - assert!( - validate_swap_path_syntax(bad).is_err(), - "must reject traversal: {bad}" - ); - } - } - - #[test] - fn swap_path_syntax_rejects_dangerous_and_virtual_paths() { - for bad in [ - "/", - "/etc", - "/etc/fstab", - "/etc/passwd", - "/etc/shadow", - "/dev", - "/proc", - "/sys", - "/run", - "/boot", - "/proc/self/mem", - "/sys/kernel", - "/dev/sda", - ] { - assert!( - validate_swap_path_syntax(bad).is_err(), - "must reject dangerous path: {bad}" - ); - } - } - - /// Unescaped whitespace would shift the fstab columns; control characters and - /// backslashes have no legitimate use in a swapfile path. - #[test] - fn swap_path_syntax_rejects_whitespace_and_control_characters() { - for bad in [ - "/var/my swap", - "/var/swap\tfile", - "/var/swap\nfile", - "/var/swap\\file", - ] { - assert!( - validate_swap_path_syntax(bad).is_err(), - "must reject: {bad:?}" - ); - } - } - - #[test] - fn swap_path_syntax_rejects_relative_and_directory_paths() { - assert!(validate_swap_path_syntax("swapfile").is_err()); - assert!(validate_swap_path_syntax("var/lib/swapfile").is_err()); - assert!(validate_swap_path_syntax("/var/lib/").is_err()); - } - - fn ident(device: u64, inode: u64) -> FileIdentity { - FileIdentity { device, inode } - } - - fn record(path: &str, device: u64, inode: u64) -> SwapOwnershipRecord { - SwapOwnershipRecord { - version: SWAP_OWNERSHIP_VERSION, - path: PathBuf::from(path), - identity: ident(device, inode), - } - } - - #[test] - fn ownership_missing_when_file_absent() { - let rec = record("/swapfile", 1, 2); - assert_eq!( - classify_ownership(Some(&rec), Path::new("/swapfile"), None, 1024), - SwapfileOwnership::Missing - ); - } - - /// A pre-existing swapfile we have no record of belongs to someone else and must - /// never be deleted or rewritten, whatever its size. - #[test] - fn ownership_foreign_without_record() { - assert_eq!( - classify_ownership( - None, - Path::new("/swapfile"), - Some((ident(1, 2), 1024)), - 1024 - ), - SwapfileOwnership::Foreign - ); - } - - #[test] - fn ownership_managed_valid_and_wrong_size() { - let rec = record("/swapfile", 1, 2); - assert_eq!( - classify_ownership( - Some(&rec), - Path::new("/swapfile"), - Some((ident(1, 2), 1024)), - 1024 - ), - SwapfileOwnership::ManagedValid - ); - assert_eq!( - classify_ownership( - Some(&rec), - Path::new("/swapfile"), - Some((ident(1, 2), 1024)), - 2048 - ), - SwapfileOwnership::ManagedWrongSize - ); - } - - /// The data-loss scenario the sidecar allowed: our swapfile is deleted by hand, the - /// ownership state survives, and someone puts an unrelated file at the same path. - /// Matching on path alone would have called it managed and deleted it. - #[test] - fn ownership_is_stale_when_the_file_was_replaced() { - let rec = record("/swapfile", 1, 2); - assert_eq!( - classify_ownership( - Some(&rec), - Path::new("/swapfile"), - Some((ident(1, 999), 4096)), - 1024 - ), - SwapfileOwnership::StaleOwnership - ); - } - - /// A record for a different managed path says nothing about this file. - #[test] - fn ownership_record_for_another_path_is_foreign() { - let rec = record("/var/lib/awg2-harness/swapfile", 1, 2); - assert_eq!( - classify_ownership( - Some(&rec), - Path::new("/swapfile"), - Some((ident(1, 2), 1024)), - 1024 - ), - SwapfileOwnership::Foreign - ); - } - - /// Neither refusal state may ever authorise a delete. - #[test] - fn foreign_and_stale_are_never_managed() { - assert!(!SwapfileOwnership::Foreign.is_managed()); - assert!(!SwapfileOwnership::StaleOwnership.is_managed()); - assert!(!SwapfileOwnership::Missing.is_managed()); - assert!(SwapfileOwnership::ManagedValid.is_managed()); - assert!(SwapfileOwnership::ManagedWrongSize.is_managed()); - } - - #[test] - fn ownership_record_round_trips() { - let rec = record("/var/lib/awg2-harness/swapfile", 2049, 123456); - let rendered = render_ownership_record(&rec); - assert!(rendered.starts_with(SWAP_OWNERSHIP_MARKER)); - assert_eq!(parse_ownership_record(&rendered).expect("parse"), rec); - } - - #[test] - fn ownership_record_rejects_malformed_and_unsupported() { - for bad in [ - "", - "# marker only\n", - "version = 1\npath = /swapfile\ndevice = 1\n", - "version = 99\npath = /swapfile\ndevice = 1\ninode = 2\n", - "version = x\npath = /swapfile\ndevice = 1\ninode = 2\n", - ] { - assert!( - parse_ownership_record(bad).is_err(), - "must reject record: {bad:?}" - ); - } - } - - fn active(name: &str, size_bytes: u64, priority: i32) -> ActiveSwap { - ActiveSwap { - name: PathBuf::from(name), - kind: "file".to_string(), - size_bytes, - priority, - } - } - - /// Changing only `swap.priority` used to fall through to a full recreate, destroying - /// and rebuilding a correct multi-gigabyte file. It is a reactivation, nothing more. - #[test] - fn priority_only_change_does_not_need_a_new_file() { - let desired = DesiredSwap { - path: PathBuf::from("/swapfile"), - size_bytes: 2048 * 1024 * 1024, - priority: 5, - swappiness: 1, - }; - let entry = active("/swapfile", desired.size_bytes, 1); - - // Same size, different priority: the file is fine, only the activation is wrong. - assert!(active_swap_size_matches( - entry.size_bytes, - desired.size_bytes - )); - assert_ne!(entry.priority, desired.priority); - } - - #[test] - fn staging_path_is_a_sibling_of_the_swapfile() { - let base = Path::new("/var/lib/awg2-harness/swapfile"); - assert_eq!( - suffixed_path(base, STAGING_SUFFIX).expect("staging"), - PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.new") - ); - assert_eq!( - suffixed_path(base, PREVIOUS_SUFFIX).expect("previous"), - PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.previous") - ); - assert_eq!( - suffixed_path(base, LEGACY_OWNERSHIP_SUFFIX).expect("sidecar"), - PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness-owned") - ); - } - - /// The rollback must use the priority the swap had before, not the one being applied: - /// the new priority may be exactly what the kernel rejected. - #[test] - fn rollback_state_carries_the_previous_priority() { - let state = ReplacementState { - previous_priority: 7, - ..ReplacementState::default() - }; - assert_eq!(state.previous_priority, 7); - // A replacement is only reached while our swap is live, so the default has to - // reflect that or the rollback would skip reactivating it. - assert!(state.original_active); - assert!(!state.staging_active); - assert!(!state.original_moved); - assert!(!state.new_installed); - } - - /// Records the swap calls a rollback makes, so their order and absence can be asserted. - struct FakeSwap { - /// Paths whose `swapon` fails; everything else succeeds. - swapon_fails_for: Vec, - /// Paths whose `swapoff` fails; everything else succeeds. - swapoff_fails_for: Vec, - calls: std::sync::Mutex>, - } - - impl FakeSwap { - fn failing_for(paths: &[&Path]) -> Self { - Self { - swapon_fails_for: paths.iter().map(|p| p.to_path_buf()).collect(), - swapoff_fails_for: Vec::new(), - calls: std::sync::Mutex::new(Vec::new()), - } - } - fn swapoff_failing_for(paths: &[&Path]) -> Self { - Self { - swapon_fails_for: Vec::new(), - swapoff_fails_for: paths.iter().map(|p| p.to_path_buf()).collect(), - calls: std::sync::Mutex::new(Vec::new()), - } - } - fn all_ok() -> Self { - Self::failing_for(&[]) - } - fn calls(&self) -> Vec { - self.calls.lock().expect("lock").clone() - } - } - - impl SwapCommands for FakeSwap { - fn swapon(&self, path: &Path, priority: i32) -> anyhow::Result<()> { - self.calls - .lock() - .expect("lock") - .push(format!("swapon {} -p {priority}", path.display())); - if self.swapon_fails_for.iter().any(|p| p == path) { - anyhow::bail!("cannot allocate swap"); - } - Ok(()) - } - fn swapoff(&self, path: &Path) -> anyhow::Result<()> { - self.calls - .lock() - .expect("lock") - .push(format!("swapoff {}", path.display())); - if self.swapoff_fails_for.iter().any(|p| p == path) { - anyhow::bail!("swapoff: device or resource busy"); - } - Ok(()) - } - } - - /// Uses a scratch directory so the rollback's real renames have files to act on. - fn rollback_fixture(name: &str) -> (DesiredSwap, PathBuf, PathBuf) { - let dir = scratch(name); - let main = dir.join("swapfile"); - let desired = DesiredSwap { - path: main.clone(), - size_bytes: 4096, - priority: 5, - swappiness: 1, - }; - ( - desired, - suffixed_path(&main, STAGING_SUFFIX).expect("staging"), - suffixed_path(&main, PREVIOUS_SUFFIX).expect("previous"), - ) - } - - /// The invariant that matters most: if the original cannot be brought back, whatever - /// replacement exists is the only thing carrying load, so releasing it would turn a - /// failed resize into a host with no swap at all. - #[test] - fn rollback_keeps_staging_active_when_the_original_cannot_be_restored() { - let (desired, staging, previous) = rollback_fixture("rb_keep"); - let ops = FakeSwap::failing_for(&[&desired.path]); - let state = ReplacementState { - staging_active: true, - original_active: false, - previous_priority: 1, - ..ReplacementState::default() - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("resize failed"), - ); - - let calls = ops.calls(); - assert_eq!(calls.len(), 1, "only the restore attempt"); - assert!(calls[0].starts_with("swapon ")); - assert!( - !calls.iter().any(|c| c.starts_with("swapoff")), - "the fallback swap must not be released" - ); - assert!( - err.to_string() - .contains("is left active so the host is not without swap") - ); - } - - /// The branch the previous fix missed: the forward path switches the staging swap off - /// before installing it, so "leaving it active" was false and the host really did end - /// up with nothing. It has to be brought back up as the fallback. - #[test] - fn rollback_reactivates_an_already_stopped_staging_swap_as_fallback() { - let (desired, staging, previous) = rollback_fixture("rb_reactivate"); - fs::write(&staging, b"replacement").expect("seed staging"); - let ops = FakeSwap::failing_for(&[&desired.path]); - let state = ReplacementState { - staging_active: false, - original_active: false, - previous_priority: 1, - ..ReplacementState::default() - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("rename failed"), - ); - - let calls = ops.calls(); - assert_eq!(calls.len(), 2, "restore attempt, then fallback activation"); - assert!(calls[1].starts_with("swapon ")); - assert!(calls[1].contains(&staging.display().to_string())); - assert!(!calls.iter().any(|c| c.starts_with("swapoff"))); - assert!(staging.exists(), "the fallback file must survive"); - assert!( - err.to_string() - .contains("is left active so the host is not without swap") - ); - } - - /// An installed replacement is moved aside, not deleted: until the original is known to - /// activate, it is the only other swap area on the host. - #[test] - fn rollback_preserves_an_installed_replacement_as_fallback() { - let (desired, staging, previous) = rollback_fixture("rb_installed"); - fs::write(&desired.path, b"replacement").expect("seed new"); - fs::write(&previous, b"original").expect("seed previous"); - let ops = FakeSwap::failing_for(&[&desired.path]); - let state = ReplacementState { - staging_active: false, - original_active: false, - original_moved: true, - new_installed: true, - new_active: false, - previous_priority: 1, - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("ownership write failed"), - ); - - assert_eq!( - fs::read(&staging).expect("replacement kept"), - b"replacement".to_vec(), - "the replacement is moved aside, never deleted" - ); - assert_eq!( - fs::read(&desired.path).expect("original restored"), - b"original".to_vec() - ); - assert!(!ops.calls().iter().any(|c| c.starts_with("swapoff"))); - assert!( - err.to_string() - .contains("is left active so the host is not without swap") - ); - } - - /// With the original confirmed back, the replacement is released - and the restore uses - /// the previous priority, not the one being applied, which may be what failed. - #[test] - fn rollback_releases_staging_only_after_the_original_is_back() { - let (desired, staging, previous) = rollback_fixture("rb_release"); - fs::write(&staging, b"replacement").expect("seed staging"); - let ops = FakeSwap::all_ok(); - let state = ReplacementState { - staging_active: true, - original_active: false, - previous_priority: 1, - ..ReplacementState::default() - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("resize failed"), - ); - - let calls = ops.calls(); - assert_eq!(calls.len(), 2, "restore first, release second"); - assert!(calls[0].starts_with("swapon ") && calls[0].ends_with("-p 1")); - assert!(calls[1].starts_with("swapoff ")); - assert!(!staging.exists(), "the released file is removed"); - assert!( - err.to_string() - .contains("rolled back to the previous swapfile") - ); - } - - /// A failed rename back is not a dead end. The old swapfile is intact under its - /// `.previous` name and the kernel activates a swap area by path, so it is brought up - /// where it lies instead of being left idle next to a host with no swap. - #[test] - fn rollback_activates_the_previous_swapfile_in_place_when_it_cannot_be_moved_back() { - let (desired, staging, previous) = rollback_fixture("rb_rename_fail"); - fs::write(&previous, b"original").expect("seed previous"); - // A directory at the managed path makes the rename back fail the way a real EACCES - // or EIO would, without needing to inject a filesystem seam. - fs::create_dir(&desired.path).expect("block the managed path"); - let ops = FakeSwap::all_ok(); - let state = ReplacementState { - staging_active: false, - original_active: false, - original_moved: true, - previous_priority: 1, - ..ReplacementState::default() - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("rename failed"), - ); - - let calls = ops.calls(); - assert_eq!(calls, vec![format!("swapon {} -p 1", previous.display())]); - let msg = err.to_string(); - assert!(msg.contains("is serving from"), "{msg}"); - assert!(!msg.contains("no managed swap"), "{msg}"); - assert!(previous.exists(), "the restored swapfile must survive"); - } - - /// Same failure, with the previous swapfile unusable as well: the replacement is the - /// only candidate left, so it is activated rather than the rollback reporting no swap - /// while two intact swapfiles sit on disk. - #[test] - fn rollback_falls_back_to_the_replacement_when_neither_rename_nor_previous_works() { - let (desired, staging, previous) = rollback_fixture("rb_both_fail"); - fs::write(&previous, b"original").expect("seed previous"); - fs::write(&staging, b"replacement").expect("seed staging"); - fs::create_dir(&desired.path).expect("block the managed path"); - let ops = FakeSwap::failing_for(&[&previous]); - let state = ReplacementState { - staging_active: false, - original_active: false, - original_moved: true, - previous_priority: 1, - ..ReplacementState::default() - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("rename failed"), - ); - - let calls = ops.calls(); - assert_eq!( - calls, - vec![ - format!("swapon {} -p 1", previous.display()), - format!("swapon {} -p 1", staging.display()), - ] - ); - assert!( - err.to_string() - .contains("is left active so the host is not without swap") - ); - assert!(staging.exists() && previous.exists()); - } - - /// An installed replacement the kernel refuses to release is the host's only swap. It - /// stays where it is: moving a file the kernel is swapping to only makes /proc/swaps - /// disagree with the filesystem. - #[test] - fn rollback_leaves_an_undetachable_replacement_in_place() { - let (desired, staging, previous) = rollback_fixture("rb_busy"); - fs::write(&desired.path, b"replacement").expect("seed new"); - fs::write(&previous, b"original").expect("seed previous"); - let ops = FakeSwap::swapoff_failing_for(&[&desired.path]); - let state = ReplacementState { - staging_active: false, - original_active: false, - original_moved: true, - new_installed: true, - new_active: true, - previous_priority: 1, - }; - - let err = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("verification failed"), - ); - - assert_eq!( - ops.calls(), - vec![format!("swapoff {}", desired.path.display())], - "nothing else is attempted once the live replacement cannot be released" - ); - assert!(!staging.exists(), "the replacement is not moved aside"); - assert_eq!( - fs::read(&desired.path).expect("replacement in place"), - b"replacement".to_vec() - ); - assert_eq!( - fs::read(&previous).expect("previous untouched"), - b"original".to_vec() - ); - assert!(err.to_string().contains("is left in place and serving")); - } - - /// Nothing was taken down yet, so there is nothing to restore and nothing to release. - #[test] - fn rollback_is_quiet_when_the_original_was_never_deactivated() { - let (desired, staging, previous) = rollback_fixture("rb_quiet"); - let ops = FakeSwap::all_ok(); - let state = ReplacementState::default(); - - let _ = rollback_replacement_with( - &ops, - &desired, - &staging, - &previous, - &state, - anyhow::anyhow!("resize failed"), - ); - assert!(ops.calls().is_empty()); - } - - /// A run killed after the replacement went live leaves the old file on disk, and the - /// next reconcile takes the fast path straight past it. Since a swapfile is usually - /// sized after RAM, "eventually" is not good enough - it is dropped as soon as the - /// managed swap is confirmed active. - #[test] - fn crash_leftovers_are_dropped_once_the_managed_swap_is_live() { - let dir = scratch("leftovers_dropped"); - let main = dir.join("swapfile"); - let staging = dir.join("swapfile.awg2-harness.new"); - let previous = dir.join("swapfile.awg2-harness.previous"); - fs::write(&main, b"managed").expect("seed main"); - fs::write(&staging, b"leftover").expect("seed staging"); - fs::write(&previous, b"leftover").expect("seed previous"); - - drop_inactive_transition_files(&main, &[active(&main.to_string_lossy(), 1024, 1)]); - - assert!(!staging.exists(), "inactive leftover must be removed"); - assert!(!previous.exists(), "inactive leftover must be removed"); - assert!(main.exists(), "the managed swapfile is never touched"); - } - - /// A leftover that is still active cannot be unlinked, and the kernel is swapping to it. - /// It is left for the interrupted-rollback path to report on the next resize. - #[test] - fn active_leftover_transition_file_is_never_unlinked() { - let dir = scratch("stale_active"); - let main = dir.join("swapfile"); - let staging = dir.join("swapfile.awg2-harness.new"); - let previous = dir.join("swapfile.awg2-harness.previous"); - fs::write(&staging, b"leftover").expect("seed staging"); - fs::write(&previous, b"leftover").expect("seed previous"); - - drop_inactive_transition_files( - &main, - &[ - active(&main.to_string_lossy(), 1024, 1), - active(&previous.to_string_lossy(), 1024, 1), - ], - ); - - assert!(!staging.exists(), "the inactive one still goes"); - assert!(previous.exists(), "an active leftover is never unlinked"); - } - - /// The legacy sidecar is removed only when it carries the marker the old version - /// wrote; any other file with that name belongs to the operator. - #[test] - fn legacy_sidecar_is_only_removed_when_it_carries_the_old_marker() { - let dir = scratch("sidecar"); - let swapfile = dir.join("swapfile"); - let sidecar = dir.join("swapfile.awg2-harness-owned"); - - fs::write(&sidecar, "# something an operator wrote\n").expect("seed foreign"); - drop_legacy_ownership_sidecar(&swapfile); - assert!(sidecar.exists(), "a foreign file must not be removed"); - - fs::write(&sidecar, format!("{LEGACY_OWNERSHIP_MARKER}\n")).expect("seed ours"); - drop_legacy_ownership_sidecar(&swapfile); - assert!(!sidecar.exists(), "our own sidecar must be removed"); - } - - /// Foreign swaps are identified by not being the managed path; the managed one is - /// never included in the teardown set. - #[test] - fn managed_swap_is_never_in_the_foreign_set() { - let desired = DesiredSwap { - path: PathBuf::from("/swapfile"), - size_bytes: 1024, - priority: 1, - swappiness: 1, - }; - let entries = [ - active("/swapfile", 1024, 1), - active("/dev/sda2", 4096, -2), - active("/other/swap", 2048, 0), - ]; - let foreign: Vec<_> = entries - .iter() - .filter(|e| e.name != desired.path) - .map(|e| e.name.display().to_string()) - .collect(); - assert_eq!(foreign, vec!["/dev/sda2", "/other/swap"]); - } - - #[test] - fn ownership_rejects_non_regular_file() { - let dir = scratch("own_dir"); - let p = dir.join("swapfile"); - fs::create_dir(&p).expect("seed dir"); - assert!(inspect_swapfile_ownership(&p, 1024).is_err()); - } - - #[test] - fn safe_path_check_rejects_missing_parent_directory() { - let dir = scratch("no_parent"); - let err = - assert_path_chain_is_safe(&dir.join("nested").join("swapfile")).expect_err("must fail"); - assert!(err.to_string().contains("parent directory does not exist")); - } - - #[test] - fn safe_path_check_accepts_existing_parent_chain() { - let dir = scratch("parent_ok"); - assert!(assert_path_chain_is_safe(&dir.join("swapfile")).is_ok()); - } - - /// A symlink anywhere in the chain, not only at the final component, can redirect the - /// operation at a file that was never configured. - #[cfg(unix)] - #[test] - fn safe_path_check_rejects_symlinked_parent() { - let dir = scratch("symlink_parent"); - let real = dir.join("real"); - fs::create_dir(&real).expect("real dir"); - let link = dir.join("link"); - std::os::unix::fs::symlink(&real, &link).expect("symlink"); - - let err = assert_path_chain_is_safe(&link.join("swapfile")).expect_err("must fail"); - assert!(err.to_string().contains("traverses a symlink")); - } - - #[test] - fn parse_swapon_empty() { - let got = parse_swapon_output("").expect("parse"); - assert!(got.is_empty()); - } - - #[test] - fn parse_swapon_single_file() { - let got = parse_swapon_output("/swapfile file 2147479552 1\n").expect("parse"); - assert_eq!(got.len(), 1); - assert_eq!(got[0].name, PathBuf::from("/swapfile")); - assert_eq!(got[0].kind, "file"); - assert_eq!(got[0].size_bytes, 2147479552); - assert_eq!(got[0].priority, 1); - } - - #[test] - fn parse_swapon_multiple_entries() { - let raw = "/swapfile file 2147479552 1\n/dev/zram0 partition 1073741824 100\n"; - let got = parse_swapon_output(raw).expect("parse"); - assert_eq!(got.len(), 2); - } - - #[test] - fn parse_swapon_debian13_columns_with_used_prio_uuid() { - let got = parse_swapon_output( - "/swapfile file 2147479552 0 1 c8db470e-b8fb-414a-8537-8fa405ebeadf\n", - ) - .expect("parse"); - assert_eq!(got.len(), 1); - assert_eq!(got[0].name, PathBuf::from("/swapfile")); - assert_eq!(got[0].size_bytes, 2147479552); - assert_eq!(got[0].priority, 1); - } - - #[test] - fn active_swap_size_accepts_mkswap_usable_size_delta() { - // Debian 13: swapon may report 4096 bytes less than file size. - assert!(active_swap_size_matches(2147479552, 2147483648)); - assert!(active_swap_size_matches(2147483648, 2147483648)); - assert!(!active_swap_size_matches(2146435072, 2147483648)); - assert!(!active_swap_size_matches(2147487744, 2147483648)); - } - - #[test] - fn parse_swapon_rejects_malformed_size() { - let err = parse_swapon_output("/swapfile file abc 1\n"); - assert!(err.is_err()); - } - - #[test] - fn fstab_rewrite_removes_all_swap_entries() { - let raw = - "UUID=x / ext4 defaults 0 1\n/swap-old none swap sw 0 0\n/dev/zram0 none swap sw 0 0\n"; - let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); - assert!(!out.contains("/swap-old")); - assert!(!out.contains("/dev/zram0")); - assert!(out.contains("/swapfile none swap sw,pri=1 0 0")); - } - - #[test] - fn fstab_rewrite_preserves_comments_and_mounts() { - let raw = "# head\nUUID=x / ext4 defaults 0 1\n\n# tail\n"; - let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); - assert!(out.contains("# head")); - assert!(out.contains("UUID=x / ext4 defaults 0 1")); - assert!(out.contains("# tail")); - } - - #[test] - fn fstab_rewrite_is_idempotent() { - let raw = "UUID=x / ext4 defaults 0 1\n/swapfile none swap sw,pri=1 0 0\n"; - let one = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); - let two = rewrite_fstab_content(&one, Path::new("/swapfile"), 1); - assert_eq!(one, two); - } - - #[test] - fn fstab_rewrite_adds_trailing_newline() { - let raw = "UUID=x / ext4 defaults 0 1"; - let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); - assert!(out.ends_with('\n')); - } - - #[test] - fn swappiness_conf_render_is_stable() { - let rendered = render_swappiness_conf(1); - assert!(rendered.starts_with(SWAPPINESS_MARKER)); - assert!(rendered.contains("vm.swappiness = 1")); - assert!(render_swappiness_conf(60).contains("vm.swappiness = 60")); - } - - /// The rendered file must be recognised as ours by the same marker check that guards - /// overwriting it, otherwise every reconcile would refuse to touch its own output. - #[test] - fn rendered_swappiness_conf_is_recognised_as_managed() { - let dir = scratch("swappiness_marker"); - let f = ManagedFile::new(dir.join("90-awg2-harness-swap.conf"), SWAPPINESS_MARKER); - assert!(f.write(&render_swappiness_conf(1)).expect("write")); - assert_eq!( - f.ownership().expect("ownership"), - crate::managed_file::Ownership::Managed - ); - assert!(!f.write(&render_swappiness_conf(1)).expect("idempotent")); - } - - #[test] - fn legacy_swappiness_file_is_removed_when_it_carries_the_old_marker() { - let dir = scratch("legacy_ours"); - let path = dir.join("99-awg2-harness.conf"); - fs::write( - &path, - format!("{LEGACY_SWAPPINESS_MARKER}\nvm.swappiness = 1\n"), - ) - .expect("seed legacy"); - - let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER); - drop_legacy_managed_file(&legacy).expect("migrate"); - assert!(!path.exists()); - } - - /// An administrator's file that happens to sit at the old path is not ours to delete. - #[test] - fn legacy_migration_leaves_a_foreign_file_alone() { - let dir = scratch("legacy_foreign"); - let path = dir.join("99-awg2-harness.conf"); - fs::write(&path, "# hand written\nvm.swappiness = 30\n").expect("seed foreign"); - - let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER); - drop_legacy_managed_file(&legacy).expect("must not fail"); - assert_eq!( - fs::read_to_string(&path).expect("intact"), - "# hand written\nvm.swappiness = 30\n" - ); - } - - #[test] - fn legacy_migration_is_a_no_op_when_nothing_is_there() { - let dir = scratch("legacy_absent"); - let legacy = ManagedFile::new(dir.join("99-awg2-harness.conf"), LEGACY_SWAPPINESS_MARKER); - assert!(drop_legacy_managed_file(&legacy).is_ok()); - } -} diff --git a/traffic_core/src/swap/mod.rs b/traffic_core/src/swap/mod.rs new file mode 100644 index 0000000..0bdd1d4 --- /dev/null +++ b/traffic_core/src/swap/mod.rs @@ -0,0 +1,279 @@ +//! Управление swap: facade и orchestration поверх специализированных подмодулей. + +mod model; +mod ownership; +mod path; +mod persistence; +mod replacement; +mod runtime; +mod status; + +#[allow(unused_imports)] +pub use model::{ActiveSwap, SwapStatus}; +use model::{DesiredSwap, SwapfileOwnership}; +use ownership::{drop_legacy_ownership_sidecar, inspect_swapfile_ownership}; +use path::assert_swap_path_is_safe; +#[allow(unused_imports)] +pub use path::{STATE_DIR, ensure_state_dir, validate_swap_path_syntax}; +use persistence::{ + apply_swappiness, migrate_legacy_swappiness_file, normalize_ws, read_fstab_swap_entries, + render_fstab_line, rewrite_fstab_exclusive, swappiness_file, +}; +#[allow(unused_imports)] +pub use persistence::{read_current_swappiness, read_persisted_swappiness}; +use replacement::{ + active_swap_size_matches, change_swap_priority, create_fresh_swapfile, + drop_inactive_transition_files, replace_active_swapfile, stale_ownership_help, +}; +use runtime::{read_active_swaps, run, set_swapfile_mode, swapon_at}; + +use std::{fs, time::Duration}; + +use crate::{config::Config, lock::FileLock}; +pub use status::inspect_swap; + +/// Brings host swap to the desired state as a transaction. +/// +/// Ordering is the point: the managed swap is created, activated and verified *before* +/// any foreign swap is disabled. The previous order disabled foreign swaps first, so a +/// failure in `mkswap` or `swapon` left the host with no swap at all. +pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { + // Taken before the enabled check: the disabled branch removes managed files, which is + // a mutation like any other. Leaving it outside the lock let a manual CLI run and the + // daemon's reconcile write and delete the same file at the same time. + let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?; + + if !cfg.swap.enabled { + return remove_managed_swap_state(); + } + + // 1. Preflight. The state directory is created and verified first: on a clean host + // /var/lib/awg2-harness does not exist yet, and the path check below requires every + // parent to be present. + ensure_state_dir()?; + let desired = DesiredSwap::from_config(cfg)?; + assert_swap_path_is_safe(&desired.path)?; + let active_before = read_active_swaps()?; + + // 2-3. Prepare and activate the managed swap. Activation belongs to this step so that + // every transition can roll back while it still owns both the old and the new file. + ensure_desired_swapfile(&desired, &active_before)?; + + // 4. Confirm it is really live before touching anything else. + let active_after = read_active_swaps()?; + if !active_after.iter().any(|s| s.name == desired.path) { + anyhow::bail!( + "managed swap {} did not become active; foreign swap left untouched", + desired.path.display() + ); + } + + // 5. Whatever a killed run left behind is safe to drop now that the managed swap is + // confirmed live. + drop_inactive_transition_files(&desired.path, &active_after); + + // 6. Only now is it safe to take the others down. + disable_foreign_swaps(&desired, &active_after)?; + + // 7. Persist and verify. + rewrite_fstab_exclusive(&desired)?; + apply_swappiness(desired.swappiness)?; + verify_exclusive_swap_state(&desired)?; + Ok(()) +} + +/// Undoes the persistent part of the module when `swap.enabled = false`. +/// +/// Only the managed file is removed. The runtime `vm.swappiness` is deliberately left as +/// it is: forcing an arbitrary value would be inventing a policy the operator never asked +/// for. The kernel default applies again after reboot, and `status` reports that the value +/// is no longer managed. Active swap is not disabled either - turning it off would be a +/// destructive action triggered by merely disabling a feature. +fn remove_managed_swap_state() -> anyhow::Result<()> { + migrate_legacy_swappiness_file()?; + swappiness_file().remove()?; + Ok(()) +} + +/// Lexical validation of `swap.path`, with no filesystem access. +/// +/// Kept side-effect free so `validate-config` can run it on a host where the file does not +/// exist yet. Operates on the string rather than [`Path`] because the target is always a +/// Linux path, regardless of where validation runs. +/// Filesystem-level checks performed just before the path is used. +/// +/// A symlink anywhere in the chain, not only at the final component, can redirect the +/// whole operation at a file we never intended to touch. +/// Takes foreign swap offline. The file itself is never deleted. +/// +/// Destroying a swapfile an administrator created is not this package's call: `swapoff` +/// plus removal from `/etc/fstab` already achieves the exclusive runtime state, and the +/// file can be removed by hand once the operator agrees. +/// Takes foreign swap offline, restoring what it already disabled if a later one fails. +/// +/// Without the rollback a host with two foreign swaps could end up with the first one +/// disabled and the second still up, because the error aborted the loop and nothing put +/// the first back. That is a state neither the operator nor the next reconcile asked for. +fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { + let mut disabled: Vec<&ActiveSwap> = Vec::new(); + for entry in active { + if entry.name == desired.path { + continue; + } + if let Err(e) = run("swapoff", &[entry.name.to_string_lossy().as_ref()]) { + reenable_swaps(&disabled); + return Err(anyhow::anyhow!( + "failed to disable foreign swap {}: {e}", + entry.name.display() + )); + } + disabled.push(entry); + } + Ok(()) +} + +/// Best-effort restore after a failed teardown: report what could not be brought back +/// rather than masking it, but never turn the rollback itself into the reported error. +fn reenable_swaps(entries: &[&ActiveSwap]) { + for entry in entries.iter().rev() { + if let Err(e) = run( + "swapon", + &[ + "-p", + &entry.priority.to_string(), + entry.name.to_string_lossy().as_ref(), + ], + ) { + tracing::warn!( + target: "swap", + "could not re-enable {} while rolling back: {e}", + entry.name.display() + ); + } + } +} + +/// Creates or validates the managed swapfile without ever touching an unowned one. +/// +/// `active` is needed because a live swapfile cannot be resized in place: if the managed +/// swap is currently active with the wrong geometry it has to be deactivated first. That +/// is our own swap, so the brief gap is acceptable; foreign swap stays up throughout. +fn ensure_desired_swapfile(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { + let p = &desired.path; + let ownership = inspect_swapfile_ownership(p, desired.size_bytes)?; + + match ownership { + SwapfileOwnership::Foreign => anyhow::bail!( + "{} already exists and there is no record of awg2-harness creating it; \ + refusing to touch it. Remove it manually or point swap.path elsewhere", + p.display() + ), + SwapfileOwnership::StaleOwnership => anyhow::bail!("{}", stale_ownership_help(p)?), + _ => {} + } + + let active_entry = active.iter().find(|s| s.name == *p); + let size_ok = ownership == SwapfileOwnership::ManagedValid; + + match (size_ok, active_entry) { + // Right file, right size, already active with the right priority: nothing to do. + (true, Some(entry)) + if active_swap_size_matches(entry.size_bytes, desired.size_bytes) + && entry.priority == desired.priority => + { + set_swapfile_mode(p)?; + drop_legacy_ownership_sidecar(p); + Ok(()) + } + // Right file, right size, but the active priority differs. A reactivation, not a + // rebuild - the previous code fell through to a full recreate, so changing + // swap.priority destroyed and rebuilt a perfectly good multi-gigabyte file. + (true, Some(entry)) => { + set_swapfile_mode(p)?; + drop_legacy_ownership_sidecar(p); + change_swap_priority(desired, entry.priority) + } + // Right file, not active: activate it. + (true, None) => { + set_swapfile_mode(p)?; + drop_legacy_ownership_sidecar(p); + swapon_at(p, desired.priority) + } + // Wrong size or no file at all, and nothing of ours is active: build in place. + (false, None) => { + if ownership.is_managed() { + fs::remove_file(p) + .map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?; + } + create_fresh_swapfile(desired)?; + drop_legacy_ownership_sidecar(p); + Ok(()) + } + // Wrong size while our swap is live: replace it through a staging file. The + // currently active priority is carried in, because that is the value a rollback + // has to restore - the new one may be exactly what failed. + (false, Some(entry)) => { + replace_active_swapfile(desired, entry.priority)?; + drop_legacy_ownership_sidecar(p); + Ok(()) + } + } +} + +fn verify_exclusive_swap_state(desired: &DesiredSwap) -> anyhow::Result<()> { + let active = read_active_swaps()?; + if active.len() != 1 { + anyhow::bail!( + "swap verify failed: expected exactly one active swap, got {}", + active.len() + ); + } + let only = &active[0]; + if only.name != desired.path { + anyhow::bail!( + "swap verify failed: active swap {} != desired {}", + only.name.display(), + desired.path.display() + ); + } + if !active_swap_size_matches(only.size_bytes, desired.size_bytes) { + anyhow::bail!( + "swap verify failed: size mismatch {} != {}", + only.size_bytes, + desired.size_bytes + ); + } + if only.priority != desired.priority { + anyhow::bail!( + "swap verify failed: priority mismatch {} != {}", + only.priority, + desired.priority + ); + } + + let entries = read_fstab_swap_entries("/etc/fstab")?; + if entries.len() != 1 { + anyhow::bail!( + "swap verify failed: expected one fstab swap entry, got {}", + entries.len() + ); + } + let expected = render_fstab_line(&desired.path, desired.priority); + if normalize_ws(&entries[0]) != normalize_ws(expected.trim_end()) { + anyhow::bail!( + "swap verify failed: fstab entry mismatch: got='{}' expected='{}'", + entries[0], + expected.trim_end() + ); + } + + let current = read_current_swappiness()?; + if current != desired.swappiness { + anyhow::bail!( + "swap verify failed: vm.swappiness current={} expected={}", + current, + desired.swappiness + ); + } + Ok(()) +} diff --git a/traffic_core/src/swap/model.rs b/traffic_core/src/swap/model.rs new file mode 100644 index 0000000..8ebf6c2 --- /dev/null +++ b/traffic_core/src/swap/model.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use crate::config::Config; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveSwap { + pub name: PathBuf, + pub kind: String, + pub size_bytes: u64, + pub priority: i32, +} + +#[derive(Debug, Clone)] +pub(super) struct DesiredSwap { + pub(super) path: PathBuf, + pub(super) size_bytes: u64, + pub(super) priority: i32, + pub(super) swappiness: u8, +} + +impl DesiredSwap { + pub(super) fn from_config(cfg: &Config) -> anyhow::Result { + let path = PathBuf::from(cfg.swap.path.clone()); + if !path.is_absolute() { + anyhow::bail!("swap.path must be absolute: {}", path.display()); + } + let size_bytes = cfg + .swap + .size_mb + .checked_mul(1024 * 1024) + .ok_or_else(|| anyhow::anyhow!("swap.size_mb overflow"))?; + Ok(Self { + path, + size_bytes, + priority: cfg.swap.priority, + swappiness: cfg.swap.swappiness, + }) + } +} + +#[derive(Debug, Clone)] +pub struct SwapStatus { + pub enabled: bool, + pub desired_path: PathBuf, + pub active_entries: Vec, + pub desired_active: bool, + pub exclusive: bool, + pub fstab_swap_entries: Vec, + pub fstab_exclusive: bool, + pub current_swappiness: Option, + pub persisted_swappiness: Option, + pub drift_reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct FileIdentity { + pub(super) device: u64, + pub(super) inode: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct SwapOwnershipRecord { + pub(super) version: u32, + pub(super) path: PathBuf, + pub(super) identity: FileIdentity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SwapfileOwnership { + Missing, + ManagedValid, + ManagedWrongSize, + Foreign, + StaleOwnership, +} + +impl SwapfileOwnership { + pub(super) fn is_managed(self) -> bool { + matches!(self, Self::ManagedValid | Self::ManagedWrongSize) + } +} + +/// How far a replacement got, so rollback undoes exactly that much. +#[derive(Debug, Clone, Copy)] +pub(super) struct ReplacementState { + pub(super) staging_active: bool, + pub(super) original_active: bool, + pub(super) original_moved: bool, + pub(super) new_installed: bool, + pub(super) new_active: bool, + pub(super) previous_priority: i32, +} + +impl Default for ReplacementState { + fn default() -> Self { + Self { + staging_active: false, + original_active: true, + original_moved: false, + new_installed: false, + new_active: false, + previous_priority: 0, + } + } +} diff --git a/traffic_core/src/swap/ownership.rs b/traffic_core/src/swap/ownership.rs new file mode 100644 index 0000000..632a604 --- /dev/null +++ b/traffic_core/src/swap/ownership.rs @@ -0,0 +1,335 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::managed_file::{ManagedFile, Ownership}; + +use super::{ + model::{FileIdentity, SwapOwnershipRecord, SwapfileOwnership}, + path::{ensure_state_dir, suffixed_path}, +}; + +pub(super) const SWAP_OWNERSHIP_PATH: &str = "/var/lib/awg2-harness/swap-ownership.conf"; +pub(super) const SWAP_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap-ownership"; +pub(super) const SWAP_OWNERSHIP_VERSION: u32 = 1; +pub(super) const LEGACY_OWNERSHIP_SUFFIX: &str = ".awg2-harness-owned"; +pub(super) const LEGACY_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap"; + +pub(super) fn ownership_file() -> ManagedFile { + ManagedFile::new(SWAP_OWNERSHIP_PATH, SWAP_OWNERSHIP_MARKER) +} + +pub(super) fn render_ownership_record(rec: &SwapOwnershipRecord) -> String { + format!( + "{SWAP_OWNERSHIP_MARKER}\nversion = {}\npath = {}\ndevice = {}\ninode = {}\n", + rec.version, + rec.path.display(), + rec.identity.device, + rec.identity.inode + ) +} + +pub(super) fn parse_ownership_record(raw: &str) -> anyhow::Result { + let mut version = None; + let mut path = None; + let mut device = None; + let mut inode = None; + for line in raw.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + let Some((k, v)) = t.split_once('=') else { + continue; + }; + let v = v.trim(); + match k.trim() { + "version" => version = v.parse::().ok(), + "path" => path = Some(PathBuf::from(v)), + "device" => device = v.parse::().ok(), + "inode" => inode = v.parse::().ok(), + _ => {} + } + } + let (Some(version), Some(path), Some(device), Some(inode)) = (version, path, device, inode) + else { + anyhow::bail!("swap ownership record is incomplete or malformed"); + }; + if version != SWAP_OWNERSHIP_VERSION { + anyhow::bail!("unsupported swap ownership record version: {version}"); + } + Ok(SwapOwnershipRecord { + version, + path, + identity: FileIdentity { device, inode }, + }) +} + +pub(super) fn classify_ownership( + recorded: Option<&SwapOwnershipRecord>, + actual_path: &Path, + actual: Option<(FileIdentity, u64)>, + desired_size: u64, +) -> SwapfileOwnership { + let Some((identity, size)) = actual else { + return SwapfileOwnership::Missing; + }; + let Some(rec) = recorded else { + return SwapfileOwnership::Foreign; + }; + if rec.path != actual_path { + return SwapfileOwnership::Foreign; + } + if rec.identity != identity { + return SwapfileOwnership::StaleOwnership; + } + if size == desired_size { + SwapfileOwnership::ManagedValid + } else { + SwapfileOwnership::ManagedWrongSize + } +} + +#[cfg(unix)] +fn identity_of(md: &fs::Metadata) -> FileIdentity { + use std::os::unix::fs::MetadataExt; + FileIdentity { + device: md.dev(), + inode: md.ino(), + } +} + +#[cfg(not(unix))] +fn identity_of(_md: &fs::Metadata) -> FileIdentity { + FileIdentity { + device: 0, + inode: 0, + } +} + +fn read_ownership_record() -> anyhow::Result> { + let Some(raw) = ownership_file().read()? else { + return Ok(None); + }; + parse_ownership_record(&raw).map(Some) +} + +pub(super) fn inspect_swapfile_ownership( + path: &Path, + desired_size: u64, +) -> anyhow::Result { + let actual = match fs::symlink_metadata(path) { + Ok(md) => { + if md.file_type().is_symlink() { + anyhow::bail!("swap.path is a symlink: {}", path.display()); + } + if !md.file_type().is_file() { + anyhow::bail!("swap.path is not a regular file: {}", path.display()); + } + Some((identity_of(&md), md.len())) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => anyhow::bail!("failed to stat {}: {e}", path.display()), + }; + let recorded = read_ownership_record()?; + Ok(classify_ownership( + recorded.as_ref(), + path, + actual, + desired_size, + )) +} + +pub(super) fn record_ownership(path: &Path) -> anyhow::Result<()> { + ensure_state_dir()?; + let md = fs::metadata(path) + .map_err(|e| anyhow::anyhow!("failed to stat {} after create: {e}", path.display()))?; + let rec = SwapOwnershipRecord { + version: SWAP_OWNERSHIP_VERSION, + path: path.to_path_buf(), + identity: identity_of(&md), + }; + ownership_file().write(&render_ownership_record(&rec))?; + Ok(()) +} + +pub(super) fn drop_legacy_ownership_sidecar(swap_path: &Path) { + let Ok(sidecar) = suffixed_path(swap_path, LEGACY_OWNERSHIP_SUFFIX) else { + return; + }; + let file = ManagedFile::new(sidecar.clone(), LEGACY_OWNERSHIP_MARKER); + match file.ownership() { + Ok(Ownership::Absent) => {} + Ok(Ownership::Managed) => { + if let Err(e) = file.remove() { + tracing::warn!(target: "swap", "could not remove legacy ownership sidecar {}: {e}", sidecar.display()); + } + } + Ok(Ownership::Foreign) => { + tracing::warn!(target: "swap", "{} exists but is not the legacy ownership sidecar; leaving it alone", sidecar.display()) + } + Err(e) => tracing::warn!(target: "swap", "could not inspect {}: {e}", sidecar.display()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir + } + + fn ident(device: u64, inode: u64) -> FileIdentity { + FileIdentity { device, inode } + } + + fn record(path: &str, device: u64, inode: u64) -> SwapOwnershipRecord { + SwapOwnershipRecord { + version: SWAP_OWNERSHIP_VERSION, + path: PathBuf::from(path), + identity: ident(device, inode), + } + } + + #[test] + fn ownership_missing_when_file_absent() { + let rec = record("/swapfile", 1, 2); + assert_eq!( + classify_ownership(Some(&rec), Path::new("/swapfile"), None, 1024), + SwapfileOwnership::Missing + ); + } + + /// A pre-existing swapfile we have no record of belongs to someone else and must + /// never be deleted or rewritten, whatever its size. + #[test] + fn ownership_foreign_without_record() { + assert_eq!( + classify_ownership( + None, + Path::new("/swapfile"), + Some((ident(1, 2), 1024)), + 1024 + ), + SwapfileOwnership::Foreign + ); + } + + #[test] + fn ownership_managed_valid_and_wrong_size() { + let rec = record("/swapfile", 1, 2); + assert_eq!( + classify_ownership( + Some(&rec), + Path::new("/swapfile"), + Some((ident(1, 2), 1024)), + 1024 + ), + SwapfileOwnership::ManagedValid + ); + assert_eq!( + classify_ownership( + Some(&rec), + Path::new("/swapfile"), + Some((ident(1, 2), 1024)), + 2048 + ), + SwapfileOwnership::ManagedWrongSize + ); + } + + /// The data-loss scenario the sidecar allowed: our swapfile is deleted by hand, the + /// ownership state survives, and someone puts an unrelated file at the same path. + /// Matching on path alone would have called it managed and deleted it. + #[test] + fn ownership_is_stale_when_the_file_was_replaced() { + let rec = record("/swapfile", 1, 2); + assert_eq!( + classify_ownership( + Some(&rec), + Path::new("/swapfile"), + Some((ident(1, 999), 4096)), + 1024 + ), + SwapfileOwnership::StaleOwnership + ); + } + + /// A record for a different managed path says nothing about this file. + #[test] + fn ownership_record_for_another_path_is_foreign() { + let rec = record("/var/lib/awg2-harness/swapfile", 1, 2); + assert_eq!( + classify_ownership( + Some(&rec), + Path::new("/swapfile"), + Some((ident(1, 2), 1024)), + 1024 + ), + SwapfileOwnership::Foreign + ); + } + + /// Neither refusal state may ever authorise a delete. + #[test] + fn foreign_and_stale_are_never_managed() { + assert!(!SwapfileOwnership::Foreign.is_managed()); + assert!(!SwapfileOwnership::StaleOwnership.is_managed()); + assert!(!SwapfileOwnership::Missing.is_managed()); + assert!(SwapfileOwnership::ManagedValid.is_managed()); + assert!(SwapfileOwnership::ManagedWrongSize.is_managed()); + } + + #[test] + fn ownership_record_round_trips() { + let rec = record("/var/lib/awg2-harness/swapfile", 2049, 123456); + let rendered = render_ownership_record(&rec); + assert!(rendered.starts_with(SWAP_OWNERSHIP_MARKER)); + assert_eq!(parse_ownership_record(&rendered).expect("parse"), rec); + } + + #[test] + fn ownership_record_rejects_malformed_and_unsupported() { + for bad in [ + "", + "# marker only\n", + "version = 1\npath = /swapfile\ndevice = 1\n", + "version = 99\npath = /swapfile\ndevice = 1\ninode = 2\n", + "version = x\npath = /swapfile\ndevice = 1\ninode = 2\n", + ] { + assert!( + parse_ownership_record(bad).is_err(), + "must reject record: {bad:?}" + ); + } + } + + /// The legacy sidecar is removed only when it carries the marker the old version + /// wrote; any other file with that name belongs to the operator. + #[test] + fn legacy_sidecar_is_only_removed_when_it_carries_the_old_marker() { + let dir = scratch("sidecar"); + let swapfile = dir.join("swapfile"); + let sidecar = dir.join("swapfile.awg2-harness-owned"); + + fs::write(&sidecar, "# something an operator wrote\n").expect("seed foreign"); + drop_legacy_ownership_sidecar(&swapfile); + assert!(sidecar.exists(), "a foreign file must not be removed"); + + fs::write(&sidecar, format!("{LEGACY_OWNERSHIP_MARKER}\n")).expect("seed ours"); + drop_legacy_ownership_sidecar(&swapfile); + assert!(!sidecar.exists(), "our own sidecar must be removed"); + } + + #[test] + fn ownership_rejects_non_regular_file() { + let dir = scratch("own_dir"); + let p = dir.join("swapfile"); + fs::create_dir(&p).expect("seed dir"); + assert!(inspect_swapfile_ownership(&p, 1024).is_err()); + } +} diff --git a/traffic_core/src/swap/path.rs b/traffic_core/src/swap/path.rs new file mode 100644 index 0000000..e1c3454 --- /dev/null +++ b/traffic_core/src/swap/path.rs @@ -0,0 +1,291 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +pub const STATE_DIR: &str = "/var/lib/awg2-harness"; +const STATE_DIR_PREFIX: &str = "/var/lib/awg2-harness/"; +const DEFAULT_SWAP_PATH: &str = "/swapfile"; + +pub fn validate_swap_path_syntax(raw: &str) -> anyhow::Result<()> { + if !raw.starts_with('/') { + anyhow::bail!("swap.path must be absolute: {raw}"); + } + if raw.ends_with('/') { + anyhow::bail!("swap.path must not be a directory path: {raw}"); + } + if let Some(bad) = raw + .chars() + .find(|c| c.is_whitespace() || c.is_control() || *c == '\\') + { + anyhow::bail!( + "swap.path must not contain whitespace or control characters: {raw:?} contains {bad:?}" + ); + } + for component in raw.split('/') { + if component == "." || component == ".." { + anyhow::bail!("swap.path must not contain '.' or '..' components: {raw}"); + } + } + if raw == DEFAULT_SWAP_PATH { + return Ok(()); + } + if let Some(rest) = raw.strip_prefix(STATE_DIR_PREFIX) + && !rest.is_empty() + && !rest.contains('/') + { + return Ok(()); + } + anyhow::bail!( + "swap.path must be {DEFAULT_SWAP_PATH} or a file directly under {STATE_DIR}; got {raw}. \ + Both are root-owned locations; an arbitrary path can have a parent directory that \ + another user may write, which would let them redirect the swapfile operations" + ); +} + +pub(super) fn assert_swap_path_is_safe(path: &Path) -> anyhow::Result<()> { + validate_swap_path_syntax(&path.to_string_lossy())?; + assert_path_chain_is_safe(path) +} + +pub(super) fn assert_path_chain_is_safe(path: &Path) -> anyhow::Result<()> { + let mut walked = PathBuf::new(); + let components: Vec<_> = path.components().collect(); + for component in components.iter().take(components.len().saturating_sub(1)) { + walked.push(component); + match fs::symlink_metadata(&walked) { + Ok(md) if md.file_type().is_symlink() => { + anyhow::bail!( + "swap.path traverses a symlink at {}; refusing to continue", + walked.display() + ); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!( + "swap.path parent directory does not exist: {}", + walked.display() + ); + } + Err(e) => anyhow::bail!("failed to stat {}: {e}", walked.display()), + } + } + Ok(()) +} + +pub fn ensure_state_dir() -> anyhow::Result<()> { + let dir = Path::new(STATE_DIR); + if let Some(parent) = dir.parent() { + match fs::symlink_metadata(parent) { + Ok(md) if md.file_type().is_symlink() => { + anyhow::bail!("{} is a symlink; refusing to use it", parent.display()) + } + Ok(md) if !md.file_type().is_dir() => { + anyhow::bail!("{} is not a directory", parent.display()) + } + Ok(_) => {} + Err(e) => anyhow::bail!("failed to stat {}: {e}", parent.display()), + } + } + match fs::symlink_metadata(dir) { + Ok(md) if md.file_type().is_symlink() => { + anyhow::bail!("{STATE_DIR} is a symlink; refusing to use it") + } + Ok(md) if !md.file_type().is_dir() => { + anyhow::bail!("{STATE_DIR} exists and is not a directory") + } + Ok(md) => return assert_state_dir_is_private(dir, &md), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => anyhow::bail!("failed to stat {STATE_DIR}: {e}"), + } + fs::create_dir(dir).map_err(|e| anyhow::anyhow!("failed to create {STATE_DIR}: {e}"))?; + set_dir_mode(dir)?; + let md = fs::symlink_metadata(dir) + .map_err(|e| anyhow::anyhow!("failed to stat {STATE_DIR} after create: {e}"))?; + assert_state_dir_is_private(dir, &md) +} + +#[cfg(unix)] +fn assert_state_dir_is_private(dir: &Path, md: &fs::Metadata) -> anyhow::Result<()> { + use std::os::unix::fs::MetadataExt; + if md.uid() != 0 { + anyhow::bail!( + "{} must be owned by root, got uid {}", + dir.display(), + md.uid() + ); + } + let mode = md.permissions().mode() & 0o777; + if mode & 0o022 != 0 { + anyhow::bail!( + "{} is writable by group or others (mode {mode:04o}); refusing to keep state there", + dir.display() + ); + } + if mode != 0o700 { + set_dir_mode(dir)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn assert_state_dir_is_private(_dir: &Path, _md: &fs::Metadata) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_dir_mode(dir: &Path) -> anyhow::Result<()> { + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| anyhow::anyhow!("failed to chmod 0700 {}: {e}", dir.display())) +} + +#[cfg(not(unix))] +fn set_dir_mode(_dir: &Path) -> anyhow::Result<()> { + Ok(()) +} + +pub(super) fn suffixed_path(path: &Path, suffix: &str) -> anyhow::Result { + let name = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("invalid swap.path: {}", path.display()))?; + let mut out = name.to_os_string(); + out.push(suffix); + Ok(path.with_file_name(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir + } + + #[test] + fn swap_path_syntax_accepts_expected_production_values() { + assert!(validate_swap_path_syntax("/swapfile").is_ok()); + assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swapfile").is_ok()); + assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swap.1").is_ok()); + } + + /// An allow-list, not a deny-list: a path whose parent another user can write lets + /// them redirect root-owned operations between preflight and use, which no amount of + /// stat-ing beforehand can prevent. + #[test] + fn swap_path_syntax_rejects_paths_outside_root_owned_roots() { + for bad in [ + "/tmp/awg.swap", + "/home/user/awg.swap", + "/srv/data/swapfile", + "/var/lib/swapfile", + "/var/lib/awg2-harness", + "/var/lib/awg2-harness/nested/swapfile", + "/var/lib/awg2-harness-other/swapfile", + "/swapfile2", + ] { + assert!( + validate_swap_path_syntax(bad).is_err(), + "must reject path outside the allowed roots: {bad}" + ); + } + } + + /// The blacklist used to compare exact strings only, so a path that is lexically + /// different but resolves to a forbidden file slipped through. + #[test] + fn swap_path_syntax_rejects_dot_dot_traversal() { + for bad in [ + "/etc/../etc/fstab", + "/var/../etc/passwd", + "/./swapfile", + "/var/lib/../../etc/fstab", + ] { + assert!( + validate_swap_path_syntax(bad).is_err(), + "must reject traversal: {bad}" + ); + } + } + + #[test] + fn swap_path_syntax_rejects_dangerous_and_virtual_paths() { + for bad in [ + "/", + "/etc", + "/etc/fstab", + "/etc/passwd", + "/etc/shadow", + "/dev", + "/proc", + "/sys", + "/run", + "/boot", + "/proc/self/mem", + "/sys/kernel", + "/dev/sda", + ] { + assert!( + validate_swap_path_syntax(bad).is_err(), + "must reject dangerous path: {bad}" + ); + } + } + + /// Unescaped whitespace would shift the fstab columns; control characters and + /// backslashes have no legitimate use in a swapfile path. + #[test] + fn swap_path_syntax_rejects_whitespace_and_control_characters() { + for bad in [ + "/var/my swap", + "/var/swap\tfile", + "/var/swap\nfile", + "/var/swap\\file", + ] { + assert!( + validate_swap_path_syntax(bad).is_err(), + "must reject: {bad:?}" + ); + } + } + + #[test] + fn swap_path_syntax_rejects_relative_and_directory_paths() { + assert!(validate_swap_path_syntax("swapfile").is_err()); + assert!(validate_swap_path_syntax("var/lib/swapfile").is_err()); + assert!(validate_swap_path_syntax("/var/lib/").is_err()); + } + + #[test] + fn safe_path_check_rejects_missing_parent_directory() { + let dir = scratch("no_parent"); + let err = + assert_path_chain_is_safe(&dir.join("nested").join("swapfile")).expect_err("must fail"); + assert!(err.to_string().contains("parent directory does not exist")); + } + + #[test] + fn safe_path_check_accepts_existing_parent_chain() { + let dir = scratch("parent_ok"); + assert!(assert_path_chain_is_safe(&dir.join("swapfile")).is_ok()); + } + + /// A symlink anywhere in the chain, not only at the final component, can redirect the + /// operation at a file that was never configured. + #[cfg(unix)] + #[test] + fn safe_path_check_rejects_symlinked_parent() { + let dir = scratch("symlink_parent"); + let real = dir.join("real"); + fs::create_dir(&real).expect("real dir"); + let link = dir.join("link"); + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + + let err = assert_path_chain_is_safe(&link.join("swapfile")).expect_err("must fail"); + assert!(err.to_string().contains("traverses a symlink")); + } +} diff --git a/traffic_core/src/swap/persistence.rs b/traffic_core/src/swap/persistence.rs new file mode 100644 index 0000000..d3e7c9b --- /dev/null +++ b/traffic_core/src/swap/persistence.rs @@ -0,0 +1,260 @@ +use std::{path::Path, process::Command}; + +use anyhow::Context; + +use crate::{ + fstab::{self, FSTAB_PATH, FstabTransaction, escape_field}, + managed_file::{ManagedFile, Ownership}, +}; + +use super::{model::DesiredSwap, runtime::run}; + +pub(super) const SWAPPINESS_MARKER: &str = "# Managed by awg2-harness swap"; +const SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/90-awg2-harness-swap.conf"; +const LEGACY_SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/99-awg2-harness.conf"; +pub(super) const LEGACY_SWAPPINESS_MARKER: &str = "# Managed by awg2-harness"; + +pub(super) fn swappiness_file() -> ManagedFile { + ManagedFile::new(SWAPPINESS_CONF_PATH, SWAPPINESS_MARKER) +} + +pub(super) fn legacy_swappiness_file() -> ManagedFile { + ManagedFile::new(LEGACY_SWAPPINESS_CONF_PATH, LEGACY_SWAPPINESS_MARKER) +} + +pub(super) fn render_swappiness_conf(v: u8) -> String { + format!("{SWAPPINESS_MARKER}\n# Source: /etc/awg2-harness/config.toml\nvm.swappiness = {v}\n") +} + +pub(super) fn migrate_legacy_swappiness_file() -> anyhow::Result<()> { + drop_legacy_managed_file(&legacy_swappiness_file()) +} + +pub(super) fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> { + match file.ownership()? { + Ownership::Absent => Ok(()), + Ownership::Foreign => { + tracing::warn!(target: "swap", "legacy path {} holds a file we do not own; leaving it alone", file.path().display()); + Ok(()) + } + Ownership::Managed => file.remove().map(|_| ()), + } +} + +pub(super) fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> { + let tx = FstabTransaction::begin(Path::new(FSTAB_PATH))?; + let rewritten = rewrite_fstab_content(tx.original(), &desired.path, desired.priority); + tx.commit(&rewritten)?; + Ok(()) +} + +pub(super) fn rewrite_fstab_content(content: &str, desired_path: &Path, priority: i32) -> String { + let mut out = Vec::::new(); + for line in content.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + out.push(line.to_string()); + continue; + } + let cols: Vec<&str> = t.split_whitespace().collect(); + if cols.len() >= 3 && cols[2] == "swap" { + continue; + } + out.push(line.to_string()); + } + out.push( + render_fstab_line(desired_path, priority) + .trim_end() + .to_string(), + ); + let mut rendered = out.join("\n"); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered +} + +pub(super) fn render_fstab_line(path: &Path, priority: i32) -> String { + format!( + "{} none swap sw,pri={} 0 0\n", + escape_field(&path.to_string_lossy()), + priority + ) +} + +pub(super) fn apply_swappiness(v: u8) -> anyhow::Result<()> { + run("sysctl", &["-w", &format!("vm.swappiness={v}")])?; + let current = read_current_swappiness()?; + if current != v { + anyhow::bail!("failed to apply vm.swappiness: got {current}, expected {v}"); + } + migrate_legacy_swappiness_file()?; + swappiness_file().write(&render_swappiness_conf(v))?; + let persisted = read_persisted_swappiness()?; + if persisted != Some(v) { + anyhow::bail!( + "failed to persist vm.swappiness: got {:?}, expected {}", + persisted, + v + ); + } + Ok(()) +} + +pub fn read_current_swappiness() -> anyhow::Result { + let out = Command::new("sysctl") + .args(["-n", "vm.swappiness"]) + .output()?; + if !out.status.success() { + anyhow::bail!("sysctl read vm.swappiness failed"); + } + let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); + value + .parse::() + .with_context(|| format!("invalid vm.swappiness value: {value}")) +} + +pub fn read_persisted_swappiness() -> anyhow::Result> { + let Some(content) = swappiness_file().read()? else { + return Ok(None); + }; + for line in content.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + let Some((key, value)) = t.split_once('=') else { + continue; + }; + if key.trim() == "vm.swappiness" { + return Ok(Some(value.trim().parse::()?)); + } + } + Ok(None) +} + +pub(super) fn read_fstab_swap_entries(path: &str) -> anyhow::Result> { + let content = fstab::read_required(Path::new(path))?; + let mut out = Vec::new(); + for line in content.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + let cols: Vec<&str> = t.split_whitespace().collect(); + if cols.len() >= 3 && cols[2] == "swap" { + out.push(t.to_string()); + } + } + Ok(out) +} + +pub(super) fn normalize_ws(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, path::PathBuf}; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir + } + + #[test] + fn fstab_rewrite_removes_all_swap_entries() { + let raw = + "UUID=x / ext4 defaults 0 1\n/swap-old none swap sw 0 0\n/dev/zram0 none swap sw 0 0\n"; + let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + assert!(!out.contains("/swap-old")); + assert!(!out.contains("/dev/zram0")); + assert!(out.contains("/swapfile none swap sw,pri=1 0 0")); + } + + #[test] + fn fstab_rewrite_preserves_comments_and_mounts() { + let raw = "# head\nUUID=x / ext4 defaults 0 1\n\n# tail\n"; + let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + assert!(out.contains("# head")); + assert!(out.contains("UUID=x / ext4 defaults 0 1")); + assert!(out.contains("# tail")); + } + + #[test] + fn fstab_rewrite_is_idempotent() { + let raw = "UUID=x / ext4 defaults 0 1\n/swapfile none swap sw,pri=1 0 0\n"; + let one = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + let two = rewrite_fstab_content(&one, Path::new("/swapfile"), 1); + assert_eq!(one, two); + } + + #[test] + fn fstab_rewrite_adds_trailing_newline() { + let raw = "UUID=x / ext4 defaults 0 1"; + let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); + assert!(out.ends_with('\n')); + } + + #[test] + fn swappiness_conf_render_is_stable() { + let rendered = render_swappiness_conf(1); + assert!(rendered.starts_with(SWAPPINESS_MARKER)); + assert!(rendered.contains("vm.swappiness = 1")); + assert!(render_swappiness_conf(60).contains("vm.swappiness = 60")); + } + + /// The rendered file must be recognised as ours by the same marker check that guards + /// overwriting it, otherwise every reconcile would refuse to touch its own output. + #[test] + fn rendered_swappiness_conf_is_recognised_as_managed() { + let dir = scratch("swappiness_marker"); + let f = ManagedFile::new(dir.join("90-awg2-harness-swap.conf"), SWAPPINESS_MARKER); + assert!(f.write(&render_swappiness_conf(1)).expect("write")); + assert_eq!( + f.ownership().expect("ownership"), + crate::managed_file::Ownership::Managed + ); + assert!(!f.write(&render_swappiness_conf(1)).expect("idempotent")); + } + + #[test] + fn legacy_swappiness_file_is_removed_when_it_carries_the_old_marker() { + let dir = scratch("legacy_ours"); + let path = dir.join("99-awg2-harness.conf"); + fs::write( + &path, + format!("{LEGACY_SWAPPINESS_MARKER}\nvm.swappiness = 1\n"), + ) + .expect("seed legacy"); + + let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER); + drop_legacy_managed_file(&legacy).expect("migrate"); + assert!(!path.exists()); + } + + /// An administrator's file that happens to sit at the old path is not ours to delete. + #[test] + fn legacy_migration_leaves_a_foreign_file_alone() { + let dir = scratch("legacy_foreign"); + let path = dir.join("99-awg2-harness.conf"); + fs::write(&path, "# hand written\nvm.swappiness = 30\n").expect("seed foreign"); + + let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER); + drop_legacy_managed_file(&legacy).expect("must not fail"); + assert_eq!( + fs::read_to_string(&path).expect("intact"), + "# hand written\nvm.swappiness = 30\n" + ); + } + + #[test] + fn legacy_migration_is_a_no_op_when_nothing_is_there() { + let dir = scratch("legacy_absent"); + let legacy = ManagedFile::new(dir.join("99-awg2-harness.conf"), LEGACY_SWAPPINESS_MARKER); + assert!(drop_legacy_managed_file(&legacy).is_ok()); + } +} diff --git a/traffic_core/src/swap/replacement.rs b/traffic_core/src/swap/replacement.rs new file mode 100644 index 0000000..f60fc50 --- /dev/null +++ b/traffic_core/src/swap/replacement.rs @@ -0,0 +1,1035 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use super::{ + model::{ActiveSwap, DesiredSwap, ReplacementState}, + ownership::{SWAP_OWNERSHIP_PATH, ownership_file, record_ownership}, + path::suffixed_path, + runtime::{build_swapfile, read_active_swaps, run, swapon_at}, +}; + +pub(super) const STAGING_SUFFIX: &str = ".awg2-harness.new"; +pub(super) const PREVIOUS_SUFFIX: &str = ".awg2-harness.previous"; + +/// Explains a stale ownership record, distinguishing the two ways it happens. +/// +/// A leftover `.previous` next to the swapfile is the signature of a replacement that was +/// killed between installing the new file and recording its identity: the path holds the +/// new inode while the record still describes the old one. That is recoverable by hand and +/// deserves the exact commands, not the generic message. +/// +/// The generic advice must also not say "delete the ownership record" on its own - without +/// a record the file becomes `Foreign`, which this refuses just as firmly. +pub(super) fn stale_ownership_help(p: &Path) -> anyhow::Result { + let previous = suffixed_path(p, PREVIOUS_SUFFIX)?; + let staging = suffixed_path(p, STAGING_SUFFIX)?; + + // Both recipes end in a full rebuild. Anything that leaves an existing file at the + // managed path fails the very next run: a swapfile with no matching ownership record is + // Foreign, and Foreign is refused just as firmly as a stale record. Advice that merely + // moves files around or deletes the record only changes which error is reported. + let rebuild = format!( + "swapoff {} 2>/dev/null; swapoff {} 2>/dev/null; rm -f {} {} {}; rm -f {}; \ + then re-run `awg2-harness --config /etc/awg2-harness/config.toml ensure-swap`", + p.display(), + staging.display(), + p.display(), + previous.display(), + staging.display(), + SWAP_OWNERSHIP_PATH, + ); + + if previous.exists() { + return Ok(format!( + "{} does not match the recorded swapfile and {} is present: a swap replacement \ + was interrupted after the new file was installed but before its identity was \ + recorded. No data is at risk - swap holds no persistent state. Recover by \ + rebuilding: {rebuild}", + p.display(), + previous.display() + )); + } + Ok(format!( + "{} is not the file awg2-harness created - it was replaced since. Refusing to touch \ + it. Recover by rebuilding: {rebuild}", + p.display() + )) +} + +/// Re-activates the managed swap at a new priority, restoring the old one on failure. +/// +/// `swapoff` followed by `swapon` is two steps, and the second can fail - a bad priority, +/// a transient kernel error. Without the restore, changing one integer in the config could +/// leave the host with no swap at all. +pub(super) fn change_swap_priority( + desired: &DesiredSwap, + previous_priority: i32, +) -> anyhow::Result<()> { + let p = &desired.path; + run("swapoff", &[p.to_string_lossy().as_ref()])?; + + let Err(e) = swapon_at(p, desired.priority) else { + return Ok(()); + }; + match swapon_at(p, previous_priority) { + Ok(()) => Err(anyhow::anyhow!( + "failed to activate {} with priority {}: {e}; restored the previous priority {}", + p.display(), + desired.priority, + previous_priority + )), + Err(re) => Err(anyhow::anyhow!( + "failed to activate {} with priority {} ({e}) and could not restore priority {} \ + ({re}); the host may currently have no swap", + p.display(), + desired.priority, + previous_priority + )), + } +} + +/// Builds and activates a swapfile where none of ours is running. +/// +/// The file is removed again if anything after its creation fails. Leaving it behind would +/// produce an unowned regular file at the managed path, which the next run correctly +/// classifies as `Foreign` and then refuses to touch - so a single transient `mkswap` +/// failure would wedge the module until an operator intervened. +pub(super) fn create_fresh_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> { + let p = &desired.path; + // `mkswap --file` creates the file itself, so a non-zero exit is no guarantee that + // nothing was left behind. Without this cleanup a failed run leaves an unowned regular + // file at the managed path, which the next run correctly calls Foreign and refuses - + // wedging the module until an operator intervenes. + if let Err(e) = build_swapfile(p, desired.size_bytes) { + return Err(match remove_if_present(p) { + Ok(()) => e, + Err(cleanup) => anyhow::anyhow!( + "{e}; additionally failed to clean up {}: {cleanup}", + p.display() + ), + }); + } + + let prepared = record_ownership(p).and_then(|()| swapon_at(p, desired.priority)); + if let Err(e) = prepared { + // Created by this run inside an allowed root-owned location, so removing it is + // safe and leaves the host exactly as it was found. + if let Err(re) = fs::remove_file(p) { + return Err(anyhow::anyhow!( + "{e}; additionally failed to clean up the partially prepared {}: {re}", + p.display() + )); + } + let _ = ownership_file().remove(); + return Err(e); + } + Ok(()) +} + +/// Replaces a live managed swapfile with one of a different size. +/// +/// A live swapfile cannot be resized in place, so it has to be swapped out. Allocation and +/// `mkswap` happen while the old swap is still serving. The old file is then kept aside as +/// `.previous` rather than deleted, so every step after that point can be undone: until +/// the new file is confirmed active, the original is still on disk and can be put back. +/// The previous version deleted the old file before the new one was activated, so a +/// failure at `rename` or `swapon` left the host with no swap and nothing to recover from. +pub(super) fn replace_active_swapfile( + desired: &DesiredSwap, + previous_priority: i32, +) -> anyhow::Result<()> { + let p = &desired.path; + let staging = suffixed_path(p, STAGING_SUFFIX)?; + let previous = suffixed_path(p, PREVIOUS_SUFFIX)?; + + // Leftovers from a killed run may still be *active*, in which case removing them fails + // and would wedge every future reconcile. + clear_stale_transition_files(&staging, &previous)?; + + let mut state = ReplacementState { + previous_priority, + ..ReplacementState::default() + }; + + // Built and activated while the old swap is still up: no gap during the slow part. + if let Err(e) = build_swapfile(&staging, desired.size_bytes) { + // Same reasoning as create_fresh_swapfile: mkswap may have left the file behind. + // The original swap is untouched at this point, so removing the staging file + // restores the host exactly as it was found. + let _ = remove_if_present(&staging); + return Err(e); + } + if let Err(e) = swapon_at(&staging, desired.priority) { + let _ = fs::remove_file(&staging); + return Err(e); + } + state.staging_active = true; + + // From here on every failure goes through the rollback, which knows exactly which of + // these steps completed. + let outcome = (|| -> anyhow::Result<()> { + run("swapoff", &[p.to_string_lossy().as_ref()])?; + state.original_active = false; + + fs::rename(p, &previous).map_err(|e| { + anyhow::anyhow!( + "failed to set aside {} as {}: {e}", + p.display(), + previous.display() + ) + })?; + state.original_moved = true; + + // Short window with no allocation in it. + run("swapoff", &[staging.to_string_lossy().as_ref()])?; + state.staging_active = false; + + fs::rename(&staging, p).map_err(|e| { + anyhow::anyhow!( + "failed to move {} into place at {}: {e}", + staging.display(), + p.display() + ) + })?; + state.new_installed = true; + + record_ownership(p)?; + swapon_at(p, desired.priority)?; + state.new_active = true; + + assert_some_swap_is_active(p) + })(); + + if let Err(cause) = outcome { + return Err(rollback_replacement( + desired, &staging, &previous, &state, cause, + )); + } + + // The replacement is confirmed active; the old file is no longer needed. + if let Err(e) = fs::remove_file(&previous) { + tracing::warn!( + target: "swap", + "swap replaced successfully but the old file {} could not be removed: {e}", + previous.display() + ); + } + Ok(()) +} + +/// How far a replacement got, so the rollback undoes exactly that much. +/// Undoes a partial replacement and reports what the host ended up with. +/// +/// Order matters: the original is put back and confirmed active *before* the staging swap +/// is released, so the host is never briefly without swap during the recovery itself. The +/// original priority is used, not the desired one - the desired priority may be exactly +/// what the kernel rejected. +fn rollback_replacement( + desired: &DesiredSwap, + staging: &Path, + previous: &Path, + state: &ReplacementState, + cause: anyhow::Error, +) -> anyhow::Error { + rollback_replacement_with(&SystemSwap, desired, staging, previous, state, cause) +} + +/// The two kernel operations the rollback depends on. +/// +/// Behind a trait so the recovery order can be tested. The invariant that matters - +/// never release the staging swap while the original is not back - is only observable as a +/// sequence of calls, so asserting it needs a seam rather than another pure helper. +pub(super) trait SwapCommands { + fn swapon(&self, path: &Path, priority: i32) -> anyhow::Result<()>; + fn swapoff(&self, path: &Path) -> anyhow::Result<()>; +} + +struct SystemSwap; + +impl SwapCommands for SystemSwap { + fn swapon(&self, path: &Path, priority: i32) -> anyhow::Result<()> { + swapon_at(path, priority) + } + + fn swapoff(&self, path: &Path) -> anyhow::Result<()> { + run("swapoff", &[path.to_string_lossy().as_ref()]) + } +} + +pub(super) fn rollback_replacement_with( + ops: &dyn SwapCommands, + desired: &DesiredSwap, + staging: &Path, + previous: &Path, + state: &ReplacementState, + cause: anyhow::Error, +) -> anyhow::Error { + let p = &desired.path; + let mut notes: Vec = Vec::new(); + + // Both swapfiles are tracked by where they currently are rather than by a flag. A + // rollback can be interrupted at a point where neither is at the path it started from, + // and the kernel activates a swap area by path: a file does not have to sit at the + // managed path to carry load. + let mut replacement_home: Option = None; + let mut replacement_active = state.staging_active; + + // Step the replacement back without destroying it. Deleting the new file here would + // throw away the only other swap area on the host before the original is known to be + // usable again - which is what the first version did, so a failed reactivation left + // nothing to fall back to. + if state.new_installed { + if state.new_active + && let Err(e) = ops.swapoff(p) + { + // Still live at the managed path, which means the host does have swap. Moving + // it now would only make /proc/swaps disagree with the filesystem. + return anyhow::anyhow!( + "{cause}; the replacement at {} is active and could not be deactivated \ + ({e}), so it is left in place and serving. The previous swapfile is \ + still at {}", + p.display(), + previous.display() + ); + } + match fs::rename(p, staging) { + Ok(()) => replacement_home = Some(staging.to_path_buf()), + Err(e) => { + notes.push(format!( + "could not move the replacement aside to {}: {e}", + staging.display() + )); + replacement_home = Some(p.clone()); + } + } + } else if state.staging_active || staging.exists() { + replacement_home = Some(staging.to_path_buf()); + } + + // Put the original file back where it belongs. Skipped when the replacement could not + // be moved off the managed path: `rename` would unlink it, and that replacement is one + // of the two swapfiles this rollback still has to fall back on. + let mut original_home = if state.original_moved { + previous.to_path_buf() + } else { + p.clone() + }; + if state.original_moved && replacement_home.as_deref() != Some(p.as_path()) { + match fs::rename(previous, p) { + Ok(()) => { + original_home = p.clone(); + if let Err(e) = record_ownership(p) { + notes.push(format!("ownership record not rewritten: {e}")); + } + } + Err(e) => notes.push(format!( + "could not move {} back to {}: {e}", + previous.display(), + p.display() + )), + } + } + + // Reactivate the original at the priority it had before - from wherever it now is. A + // failed rename back is no reason to leave a healthy swapfile idle: returning here, as + // the first version did, left two intact swapfiles on disk with neither of them running. + let mut original_back = state.original_active; + if !original_back { + match ops.swapon(&original_home, state.previous_priority) { + Ok(()) => original_back = true, + Err(e) => notes.push(format!( + "could not reactivate {} at priority {}: {e}", + original_home.display(), + state.previous_priority + )), + } + } + + if !original_back { + // The original is unusable, so whatever replacement exists is the only swap area + // left and becomes the fallback - activated here if the forward path had already + // switched it off. An extra temporary swap is always a better end state than none, + // and the message must describe what is actually running rather than assume it. + if !replacement_active + && let Some(home) = replacement_home.as_deref() + && home.exists() + { + match ops.swapon(home, state.previous_priority) { + Ok(()) => replacement_active = true, + Err(e) => notes.push(format!( + "could not activate {} as a fallback: {e}", + home.display() + )), + } + } + let detail = format_notes(¬es); + return match replacement_home.filter(|_| replacement_active) { + Some(home) => anyhow::anyhow!( + "{cause}; the previous swap could not be restored{detail}; the replacement \ + at {} is left active so the host is not without swap. Once the host allows \ + it, run `swapon -p {} {}` and then `swapoff {}`", + home.display(), + state.previous_priority, + original_home.display(), + home.display() + ), + None => anyhow::anyhow!( + "{cause}; the previous swap could not be restored and no fallback could be \ + activated{detail}. The host currently has no managed swap" + ), + }; + } + + // The original is serving again, but from `.previous`, because the rename back failed. + // The host has swap, so nothing here is urgent - but the managed path does not hold what + // the ownership record describes, and releasing the replacement would take away the only + // other usable swapfile, so both are left exactly as they are for an operator. + if original_home != *p { + // Naming where the replacement ended up matters: when it could not be moved aside it + // is still sitting on the managed path, and an operator told to "put the previous + // file back" would otherwise overwrite it. + let replacement_note = match replacement_home.as_deref() { + Some(home) => format!(" The replacement is at {}.", home.display()), + None => String::new(), + }; + return anyhow::anyhow!( + "{cause}; the previous swapfile could not be moved back to {} and is serving \ + from {} instead{}.{replacement_note} Both swapfiles are intact: once the host \ + allows it, run `swapoff {}`, put it back at {} and re-run ensure-swap", + p.display(), + original_home.display(), + format_notes(¬es), + original_home.display(), + p.display() + ); + } + + // Original confirmed back at the managed path: now the replacement can go. The file is + // only unlinked once swapoff has actually succeeded - removing an active swapfile is not + // possible, and trying leaves an error that says nothing useful. + if let Some(home) = replacement_home { + if replacement_active { + match ops.swapoff(&home) { + Ok(()) => { + if let Err(e) = remove_if_present(&home) { + notes.push(format!("inactive staging file left behind: {e}")); + } + } + Err(e) => notes.push(format!( + "previous swap is restored, but the staging swap {} is still active: {e}", + home.display() + )), + } + } else if let Err(e) = remove_if_present(&home) { + notes.push(format!("inactive staging file left behind: {e}")); + } + } + + anyhow::anyhow!( + "{cause}; rolled back to the previous swapfile{}", + format_notes(¬es) + ) +} + +fn format_notes(notes: &[String]) -> String { + if notes.is_empty() { + String::new() + } else { + format!(" ({})", notes.join("; ")) + } +} + +/// Drops `.new`/`.previous` left by a killed run, once the managed swap is confirmed live. +/// +/// [`clear_stale_transition_files`] only runs on the way *into* a resize, so a process killed +/// after the replacement was activated left the old file on disk indefinitely: the next run +/// finds a valid, active, correctly owned swapfile at the managed path, takes the fast path +/// and never looks at the leftovers. A swapfile is commonly sized after RAM, so that is not a +/// rounding error on disk. +/// +/// Only inactive leftovers are removed. An active one means the kernel is still swapping to +/// it - the interrupted-rollback case, which needs an operator rather than a guess and is +/// reported as such by the next resize. +pub(super) fn drop_inactive_transition_files(path: &Path, active: &[ActiveSwap]) { + for suffix in [STAGING_SUFFIX, PREVIOUS_SUFFIX] { + let Ok(leftover) = suffixed_path(path, suffix) else { + continue; + }; + if !leftover.exists() || active.iter().any(|s| s.name == leftover) { + continue; + } + match remove_if_present(&leftover) { + Ok(()) => tracing::info!( + target: "swap", + "removed {}, left behind by an interrupted swap replacement", + leftover.display() + ), + Err(e) => tracing::warn!( + target: "swap", + "leftover {} from an interrupted swap replacement could not be removed: {e}", + leftover.display() + ), + } + } +} + +/// Clears `.new`/`.previous` left by a killed run, refusing to guess when they are active. +/// +/// An active leftover cannot be unlinked, and blindly retrying the removal every reconcile +/// would wedge the module. A `.previous` that is active while the main path is gone is the +/// interrupted-rollback case and needs an operator, not a guess. +fn clear_stale_transition_files(staging: &Path, previous: &Path) -> anyhow::Result<()> { + let active = read_active_swaps()?; + for leftover in [staging, previous] { + if active.iter().any(|s| s.name == *leftover) { + anyhow::bail!( + "{} is left over from an interrupted swap replacement and is still active. \ + Run `swapoff {}` once the host has other swap, then retry", + leftover.display(), + leftover.display() + ); + } + remove_if_present(leftover)?; + } + Ok(()) +} + +/// Refuses to report success while the host has no swap at all. +fn assert_some_swap_is_active(expected: &Path) -> anyhow::Result<()> { + let active = read_active_swaps()?; + if active.iter().any(|s| s.name == expected) { + return Ok(()); + } + anyhow::bail!( + "{} is not present in the active swap list after activation", + expected.display() + ) +} + +fn remove_if_present(path: &Path) -> anyhow::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::anyhow!("failed to remove {}: {e}", path.display())), + } +} + +pub(super) fn active_swap_size_matches(active_bytes: u64, desired_file_bytes: u64) -> bool { + if active_bytes == desired_file_bytes { + return true; + } + active_bytes < desired_file_bytes && desired_file_bytes - active_bytes <= 64 * 1024 +} + +#[cfg(test)] +mod tests { + use super::super::ownership::LEGACY_OWNERSHIP_SUFFIX; + use super::*; + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir + } + + fn active(name: &str, size_bytes: u64, priority: i32) -> ActiveSwap { + ActiveSwap { + name: PathBuf::from(name), + kind: "file".to_string(), + size_bytes, + priority, + } + } + + /// Changing only `swap.priority` used to fall through to a full recreate, destroying + /// and rebuilding a correct multi-gigabyte file. It is a reactivation, nothing more. + #[test] + fn priority_only_change_does_not_need_a_new_file() { + let desired = DesiredSwap { + path: PathBuf::from("/swapfile"), + size_bytes: 2048 * 1024 * 1024, + priority: 5, + swappiness: 1, + }; + let entry = active("/swapfile", desired.size_bytes, 1); + + // Same size, different priority: the file is fine, only the activation is wrong. + assert!(active_swap_size_matches( + entry.size_bytes, + desired.size_bytes + )); + assert_ne!(entry.priority, desired.priority); + } + + #[test] + fn staging_path_is_a_sibling_of_the_swapfile() { + let base = Path::new("/var/lib/awg2-harness/swapfile"); + assert_eq!( + suffixed_path(base, STAGING_SUFFIX).expect("staging"), + PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.new") + ); + assert_eq!( + suffixed_path(base, PREVIOUS_SUFFIX).expect("previous"), + PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.previous") + ); + assert_eq!( + suffixed_path(base, LEGACY_OWNERSHIP_SUFFIX).expect("sidecar"), + PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness-owned") + ); + } + + /// The rollback must use the priority the swap had before, not the one being applied: + /// the new priority may be exactly what the kernel rejected. + #[test] + fn rollback_state_carries_the_previous_priority() { + let state = ReplacementState { + previous_priority: 7, + ..ReplacementState::default() + }; + assert_eq!(state.previous_priority, 7); + // A replacement is only reached while our swap is live, so the default has to + // reflect that or the rollback would skip reactivating it. + assert!(state.original_active); + assert!(!state.staging_active); + assert!(!state.original_moved); + assert!(!state.new_installed); + } + + /// Records the swap calls a rollback makes, so their order and absence can be asserted. + struct FakeSwap { + /// Paths whose `swapon` fails; everything else succeeds. + swapon_fails_for: Vec, + /// Paths whose `swapoff` fails; everything else succeeds. + swapoff_fails_for: Vec, + calls: std::sync::Mutex>, + } + + impl FakeSwap { + fn failing_for(paths: &[&Path]) -> Self { + Self { + swapon_fails_for: paths.iter().map(|p| p.to_path_buf()).collect(), + swapoff_fails_for: Vec::new(), + calls: std::sync::Mutex::new(Vec::new()), + } + } + fn swapoff_failing_for(paths: &[&Path]) -> Self { + Self { + swapon_fails_for: Vec::new(), + swapoff_fails_for: paths.iter().map(|p| p.to_path_buf()).collect(), + calls: std::sync::Mutex::new(Vec::new()), + } + } + fn all_ok() -> Self { + Self::failing_for(&[]) + } + fn calls(&self) -> Vec { + self.calls.lock().expect("lock").clone() + } + } + + impl SwapCommands for FakeSwap { + fn swapon(&self, path: &Path, priority: i32) -> anyhow::Result<()> { + self.calls + .lock() + .expect("lock") + .push(format!("swapon {} -p {priority}", path.display())); + if self.swapon_fails_for.iter().any(|p| p == path) { + anyhow::bail!("cannot allocate swap"); + } + Ok(()) + } + fn swapoff(&self, path: &Path) -> anyhow::Result<()> { + self.calls + .lock() + .expect("lock") + .push(format!("swapoff {}", path.display())); + if self.swapoff_fails_for.iter().any(|p| p == path) { + anyhow::bail!("swapoff: device or resource busy"); + } + Ok(()) + } + } + + /// Uses a scratch directory so the rollback's real renames have files to act on. + fn rollback_fixture(name: &str) -> (DesiredSwap, PathBuf, PathBuf) { + let dir = scratch(name); + let main = dir.join("swapfile"); + let desired = DesiredSwap { + path: main.clone(), + size_bytes: 4096, + priority: 5, + swappiness: 1, + }; + ( + desired, + suffixed_path(&main, STAGING_SUFFIX).expect("staging"), + suffixed_path(&main, PREVIOUS_SUFFIX).expect("previous"), + ) + } + + /// The invariant that matters most: if the original cannot be brought back, whatever + /// replacement exists is the only thing carrying load, so releasing it would turn a + /// failed resize into a host with no swap at all. + #[test] + fn rollback_keeps_staging_active_when_the_original_cannot_be_restored() { + let (desired, staging, previous) = rollback_fixture("rb_keep"); + let ops = FakeSwap::failing_for(&[&desired.path]); + let state = ReplacementState { + staging_active: true, + original_active: false, + previous_priority: 1, + ..ReplacementState::default() + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("resize failed"), + ); + + let calls = ops.calls(); + assert_eq!(calls.len(), 1, "only the restore attempt"); + assert!(calls[0].starts_with("swapon ")); + assert!( + !calls.iter().any(|c| c.starts_with("swapoff")), + "the fallback swap must not be released" + ); + assert!( + err.to_string() + .contains("is left active so the host is not without swap") + ); + } + + /// The branch the previous fix missed: the forward path switches the staging swap off + /// before installing it, so "leaving it active" was false and the host really did end + /// up with nothing. It has to be brought back up as the fallback. + #[test] + fn rollback_reactivates_an_already_stopped_staging_swap_as_fallback() { + let (desired, staging, previous) = rollback_fixture("rb_reactivate"); + fs::write(&staging, b"replacement").expect("seed staging"); + let ops = FakeSwap::failing_for(&[&desired.path]); + let state = ReplacementState { + staging_active: false, + original_active: false, + previous_priority: 1, + ..ReplacementState::default() + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("rename failed"), + ); + + let calls = ops.calls(); + assert_eq!(calls.len(), 2, "restore attempt, then fallback activation"); + assert!(calls[1].starts_with("swapon ")); + assert!(calls[1].contains(&staging.display().to_string())); + assert!(!calls.iter().any(|c| c.starts_with("swapoff"))); + assert!(staging.exists(), "the fallback file must survive"); + assert!( + err.to_string() + .contains("is left active so the host is not without swap") + ); + } + + /// An installed replacement is moved aside, not deleted: until the original is known to + /// activate, it is the only other swap area on the host. + #[test] + fn rollback_preserves_an_installed_replacement_as_fallback() { + let (desired, staging, previous) = rollback_fixture("rb_installed"); + fs::write(&desired.path, b"replacement").expect("seed new"); + fs::write(&previous, b"original").expect("seed previous"); + let ops = FakeSwap::failing_for(&[&desired.path]); + let state = ReplacementState { + staging_active: false, + original_active: false, + original_moved: true, + new_installed: true, + new_active: false, + previous_priority: 1, + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("ownership write failed"), + ); + + assert_eq!( + fs::read(&staging).expect("replacement kept"), + b"replacement".to_vec(), + "the replacement is moved aside, never deleted" + ); + assert_eq!( + fs::read(&desired.path).expect("original restored"), + b"original".to_vec() + ); + assert!(!ops.calls().iter().any(|c| c.starts_with("swapoff"))); + assert!( + err.to_string() + .contains("is left active so the host is not without swap") + ); + } + + /// With the original confirmed back, the replacement is released - and the restore uses + /// the previous priority, not the one being applied, which may be what failed. + #[test] + fn rollback_releases_staging_only_after_the_original_is_back() { + let (desired, staging, previous) = rollback_fixture("rb_release"); + fs::write(&staging, b"replacement").expect("seed staging"); + let ops = FakeSwap::all_ok(); + let state = ReplacementState { + staging_active: true, + original_active: false, + previous_priority: 1, + ..ReplacementState::default() + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("resize failed"), + ); + + let calls = ops.calls(); + assert_eq!(calls.len(), 2, "restore first, release second"); + assert!(calls[0].starts_with("swapon ") && calls[0].ends_with("-p 1")); + assert!(calls[1].starts_with("swapoff ")); + assert!(!staging.exists(), "the released file is removed"); + assert!( + err.to_string() + .contains("rolled back to the previous swapfile") + ); + } + + /// A failed rename back is not a dead end. The old swapfile is intact under its + /// `.previous` name and the kernel activates a swap area by path, so it is brought up + /// where it lies instead of being left idle next to a host with no swap. + #[test] + fn rollback_activates_the_previous_swapfile_in_place_when_it_cannot_be_moved_back() { + let (desired, staging, previous) = rollback_fixture("rb_rename_fail"); + fs::write(&previous, b"original").expect("seed previous"); + // A directory at the managed path makes the rename back fail the way a real EACCES + // or EIO would, without needing to inject a filesystem seam. + fs::create_dir(&desired.path).expect("block the managed path"); + let ops = FakeSwap::all_ok(); + let state = ReplacementState { + staging_active: false, + original_active: false, + original_moved: true, + previous_priority: 1, + ..ReplacementState::default() + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("rename failed"), + ); + + let calls = ops.calls(); + assert_eq!(calls, vec![format!("swapon {} -p 1", previous.display())]); + let msg = err.to_string(); + assert!(msg.contains("is serving from"), "{msg}"); + assert!(!msg.contains("no managed swap"), "{msg}"); + assert!(previous.exists(), "the restored swapfile must survive"); + } + + /// Same failure, with the previous swapfile unusable as well: the replacement is the + /// only candidate left, so it is activated rather than the rollback reporting no swap + /// while two intact swapfiles sit on disk. + #[test] + fn rollback_falls_back_to_the_replacement_when_neither_rename_nor_previous_works() { + let (desired, staging, previous) = rollback_fixture("rb_both_fail"); + fs::write(&previous, b"original").expect("seed previous"); + fs::write(&staging, b"replacement").expect("seed staging"); + fs::create_dir(&desired.path).expect("block the managed path"); + let ops = FakeSwap::failing_for(&[&previous]); + let state = ReplacementState { + staging_active: false, + original_active: false, + original_moved: true, + previous_priority: 1, + ..ReplacementState::default() + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("rename failed"), + ); + + let calls = ops.calls(); + assert_eq!( + calls, + vec![ + format!("swapon {} -p 1", previous.display()), + format!("swapon {} -p 1", staging.display()), + ] + ); + assert!( + err.to_string() + .contains("is left active so the host is not without swap") + ); + assert!(staging.exists() && previous.exists()); + } + + /// An installed replacement the kernel refuses to release is the host's only swap. It + /// stays where it is: moving a file the kernel is swapping to only makes /proc/swaps + /// disagree with the filesystem. + #[test] + fn rollback_leaves_an_undetachable_replacement_in_place() { + let (desired, staging, previous) = rollback_fixture("rb_busy"); + fs::write(&desired.path, b"replacement").expect("seed new"); + fs::write(&previous, b"original").expect("seed previous"); + let ops = FakeSwap::swapoff_failing_for(&[&desired.path]); + let state = ReplacementState { + staging_active: false, + original_active: false, + original_moved: true, + new_installed: true, + new_active: true, + previous_priority: 1, + }; + + let err = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("verification failed"), + ); + + assert_eq!( + ops.calls(), + vec![format!("swapoff {}", desired.path.display())], + "nothing else is attempted once the live replacement cannot be released" + ); + assert!(!staging.exists(), "the replacement is not moved aside"); + assert_eq!( + fs::read(&desired.path).expect("replacement in place"), + b"replacement".to_vec() + ); + assert_eq!( + fs::read(&previous).expect("previous untouched"), + b"original".to_vec() + ); + assert!(err.to_string().contains("is left in place and serving")); + } + + /// Nothing was taken down yet, so there is nothing to restore and nothing to release. + #[test] + fn rollback_is_quiet_when_the_original_was_never_deactivated() { + let (desired, staging, previous) = rollback_fixture("rb_quiet"); + let ops = FakeSwap::all_ok(); + let state = ReplacementState::default(); + + let _ = rollback_replacement_with( + &ops, + &desired, + &staging, + &previous, + &state, + anyhow::anyhow!("resize failed"), + ); + assert!(ops.calls().is_empty()); + } + + /// A run killed after the replacement went live leaves the old file on disk, and the + /// next reconcile takes the fast path straight past it. Since a swapfile is usually + /// sized after RAM, "eventually" is not good enough - it is dropped as soon as the + /// managed swap is confirmed active. + #[test] + fn crash_leftovers_are_dropped_once_the_managed_swap_is_live() { + let dir = scratch("leftovers_dropped"); + let main = dir.join("swapfile"); + let staging = dir.join("swapfile.awg2-harness.new"); + let previous = dir.join("swapfile.awg2-harness.previous"); + fs::write(&main, b"managed").expect("seed main"); + fs::write(&staging, b"leftover").expect("seed staging"); + fs::write(&previous, b"leftover").expect("seed previous"); + + drop_inactive_transition_files(&main, &[active(&main.to_string_lossy(), 1024, 1)]); + + assert!(!staging.exists(), "inactive leftover must be removed"); + assert!(!previous.exists(), "inactive leftover must be removed"); + assert!(main.exists(), "the managed swapfile is never touched"); + } + + /// A leftover that is still active cannot be unlinked, and the kernel is swapping to it. + /// It is left for the interrupted-rollback path to report on the next resize. + #[test] + fn active_leftover_transition_file_is_never_unlinked() { + let dir = scratch("stale_active"); + let main = dir.join("swapfile"); + let staging = dir.join("swapfile.awg2-harness.new"); + let previous = dir.join("swapfile.awg2-harness.previous"); + fs::write(&staging, b"leftover").expect("seed staging"); + fs::write(&previous, b"leftover").expect("seed previous"); + + drop_inactive_transition_files( + &main, + &[ + active(&main.to_string_lossy(), 1024, 1), + active(&previous.to_string_lossy(), 1024, 1), + ], + ); + + assert!(!staging.exists(), "the inactive one still goes"); + assert!(previous.exists(), "an active leftover is never unlinked"); + } + + /// Foreign swaps are identified by not being the managed path; the managed one is + /// never included in the teardown set. + #[test] + fn managed_swap_is_never_in_the_foreign_set() { + let desired = DesiredSwap { + path: PathBuf::from("/swapfile"), + size_bytes: 1024, + priority: 1, + swappiness: 1, + }; + let entries = [ + active("/swapfile", 1024, 1), + active("/dev/sda2", 4096, -2), + active("/other/swap", 2048, 0), + ]; + let foreign: Vec<_> = entries + .iter() + .filter(|e| e.name != desired.path) + .map(|e| e.name.display().to_string()) + .collect(); + assert_eq!(foreign, vec!["/dev/sda2", "/other/swap"]); + } + + #[test] + fn active_swap_size_accepts_mkswap_usable_size_delta() { + // Debian 13: swapon may report 4096 bytes less than file size. + assert!(active_swap_size_matches(2147479552, 2147483648)); + assert!(active_swap_size_matches(2147483648, 2147483648)); + assert!(!active_swap_size_matches(2146435072, 2147483648)); + assert!(!active_swap_size_matches(2147487744, 2147483648)); + } +} diff --git a/traffic_core/src/swap/runtime.rs b/traffic_core/src/swap/runtime.rs new file mode 100644 index 0000000..2916dc5 --- /dev/null +++ b/traffic_core/src/swap/runtime.rs @@ -0,0 +1,154 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +#[cfg(unix)] +use std::fs; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use super::model::ActiveSwap; + +pub(super) fn read_active_swaps() -> anyhow::Result> { + let out = Command::new("swapon") + .args([ + "--show", + "--bytes", + "--raw", + "--noheadings", + "--output", + "NAME,TYPE,SIZE,PRIO", + ]) + .output()?; + if !out.status.success() { + anyhow::bail!( + "swapon --show failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + parse_swapon_output(&String::from_utf8_lossy(&out.stdout)) +} + +pub(super) fn parse_swapon_output(raw: &str) -> anyhow::Result> { + let mut out = Vec::new(); + for line in raw.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + let parts: Vec<&str> = t.split_whitespace().collect(); + if parts.len() < 4 { + anyhow::bail!("malformed swapon row: {t}"); + } + let size_bytes = parts[2] + .parse::() + .map_err(|_| anyhow::anyhow!("malformed swapon size: {}", parts[2]))?; + let priority_index = if parts.len() >= 5 && parts[4].parse::().is_ok() { + 4 + } else { + 3 + }; + let priority = parts[priority_index] + .parse::() + .map_err(|_| anyhow::anyhow!("malformed swapon priority: {}", parts[priority_index]))?; + out.push(ActiveSwap { + name: PathBuf::from(parts[0]), + kind: parts[1].to_string(), + size_bytes, + priority, + }); + } + Ok(out) +} + +pub(super) fn swapon_at(path: &Path, priority: i32) -> anyhow::Result<()> { + run( + "swapon", + &["-p", &priority.to_string(), path.to_string_lossy().as_ref()], + ) +} + +pub(super) fn build_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> { + let size_mb = size_bytes / (1024 * 1024); + run( + "mkswap", + &[ + "--file", + path.to_string_lossy().as_ref(), + "--size", + &format!("{size_mb}M"), + ], + )?; + set_swapfile_mode(path) +} + +#[cfg(unix)] +pub(crate) fn set_swapfile_mode(path: &Path) -> anyhow::Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow::anyhow!("failed to chmod 0600 {}: {e}", path.display())) +} + +#[cfg(not(unix))] +pub(crate) fn set_swapfile_mode(_path: &Path) -> anyhow::Result<()> { + Ok(()) +} + +pub(super) fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { + let out = Command::new(program).args(args).output()?; + if !out.status.success() { + anyhow::bail!( + "{} {:?} failed: {}", + program, + args, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parse_swapon_empty() { + let got = parse_swapon_output("").expect("parse"); + assert!(got.is_empty()); + } + + #[test] + fn parse_swapon_single_file() { + let got = parse_swapon_output("/swapfile file 2147479552 1\n").expect("parse"); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, PathBuf::from("/swapfile")); + assert_eq!(got[0].kind, "file"); + assert_eq!(got[0].size_bytes, 2147479552); + assert_eq!(got[0].priority, 1); + } + + #[test] + fn parse_swapon_multiple_entries() { + let raw = "/swapfile file 2147479552 1\n/dev/zram0 partition 1073741824 100\n"; + let got = parse_swapon_output(raw).expect("parse"); + assert_eq!(got.len(), 2); + } + + #[test] + fn parse_swapon_debian13_columns_with_used_prio_uuid() { + let got = parse_swapon_output( + "/swapfile file 2147479552 0 1 c8db470e-b8fb-414a-8537-8fa405ebeadf\n", + ) + .expect("parse"); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, PathBuf::from("/swapfile")); + assert_eq!(got[0].size_bytes, 2147479552); + assert_eq!(got[0].priority, 1); + } + + #[test] + fn parse_swapon_rejects_malformed_size() { + let err = parse_swapon_output("/swapfile file abc 1\n"); + assert!(err.is_err()); + } +} diff --git a/traffic_core/src/swap/status.rs b/traffic_core/src/swap/status.rs new file mode 100644 index 0000000..cc467b9 --- /dev/null +++ b/traffic_core/src/swap/status.rs @@ -0,0 +1,62 @@ +use std::path::PathBuf; + +use crate::config::Config; + +use super::{ + model::SwapStatus, + persistence::{ + normalize_ws, read_current_swappiness, read_fstab_swap_entries, read_persisted_swappiness, + render_fstab_line, + }, + runtime::read_active_swaps, +}; + +pub fn inspect_swap(cfg: &Config) -> anyhow::Result { + let desired_path = PathBuf::from(cfg.swap.path.clone()); + let active_entries = read_active_swaps()?; + let desired_active = active_entries.iter().any(|s| s.name == desired_path); + let exclusive = active_entries.len() == 1 && desired_active; + let fstab_swap_entries = read_fstab_swap_entries("/etc/fstab")?; + let expected_line = render_fstab_line(&desired_path, cfg.swap.priority); + let fstab_exclusive = fstab_swap_entries.len() == 1 + && normalize_ws(&fstab_swap_entries[0]) == normalize_ws(expected_line.trim_end()); + let current_swappiness = read_current_swappiness().ok(); + let persisted_swappiness = read_persisted_swappiness()?; + + let drift_reason = if !cfg.swap.enabled { + None + } else if !exclusive { + if let Some(foreign) = active_entries.iter().find(|s| s.name != desired_path) { + Some(format!("foreign active swap: {}", foreign.name.display())) + } else { + Some("desired swap is not exclusively active".to_string()) + } + } else if !fstab_exclusive { + Some("/etc/fstab swap entries are not exclusive".to_string()) + } else if current_swappiness != Some(cfg.swap.swappiness) { + Some(format!( + "runtime swappiness mismatch: current={current_swappiness:?} desired={}", + cfg.swap.swappiness + )) + } else if persisted_swappiness != Some(cfg.swap.swappiness) { + Some(format!( + "persisted swappiness mismatch: persisted={persisted_swappiness:?} desired={}", + cfg.swap.swappiness + )) + } else { + None + }; + + Ok(SwapStatus { + enabled: cfg.swap.enabled, + desired_path, + active_entries, + desired_active, + exclusive, + fstab_swap_entries, + fstab_exclusive, + current_swappiness, + persisted_swappiness, + drift_reason, + }) +} diff --git a/traffic_core/src/sysctl_tuning.rs b/traffic_core/src/sysctl_tuning.rs index 95d48c7..8661eda 100644 --- a/traffic_core/src/sysctl_tuning.rs +++ b/traffic_core/src/sysctl_tuning.rs @@ -18,7 +18,8 @@ pub struct SysctlTuningStatus { pub modules_load_present: bool, pub modules_load_matches: bool, pub bbr_available: Option, - pub bbr_active: Option, + pub bbr_desired: Option, + pub bbr_runtime_active: Option, /// Each entry carries both the desired and the current value, so a separate desired /// list would be the same data twice. pub current: Vec, @@ -39,10 +40,18 @@ struct RenderPlan { persisted: String, modules_load_persisted: Option, bbr_available: Option, - bbr_active: Option, + bbr_desired: Option, + bbr_runtime_active: Option, warnings: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BbrDecision { + available: Option, + desired: Option, + runtime_active: Option, +} + pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> { // Taken before the enabled check: removing the managed files is a mutation and must // not race a concurrent apply. @@ -108,7 +117,8 @@ fn unreadable_status(cfg: &Config, plan: &RenderPlan, detail: String) -> SysctlT modules_load_present: false, modules_load_matches: false, bbr_available: plan.bbr_available, - bbr_active: plan.bbr_active, + bbr_desired: plan.bbr_desired, + bbr_runtime_active: plan.bbr_runtime_active, current: Vec::new(), drift_reason: Some(format!("managed sysctl file is unreadable: {detail}")), warnings: vec![format!("managed sysctl file is unreadable: {detail}")], @@ -130,7 +140,8 @@ pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result modules_load_present: Path::new(BBR_MODULES_LOAD_PATH).exists(), modules_load_matches: false, bbr_available: None, - bbr_active: None, + bbr_desired: None, + bbr_runtime_active: None, current: Vec::new(), drift_reason: Some(format!("render plan unavailable: {e}")), warnings: vec![format!("render plan error: {e}")], @@ -241,7 +252,8 @@ pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result modules_load_present, modules_load_matches, bbr_available: plan.bbr_available, - bbr_active: plan.bbr_active, + bbr_desired: plan.bbr_desired, + bbr_runtime_active: plan.bbr_runtime_active, current: values, drift_reason, warnings: plan.warnings, @@ -290,8 +302,11 @@ pub fn print_sysctl_tuning_status(status: &SysctlTuningStatus) { if let Some(v) = status.bbr_available { println!(" bbr_available: {}", if v { "yes" } else { "no" }); } - if let Some(v) = status.bbr_active { - println!(" bbr_active: {}", if v { "yes" } else { "no" }); + if let Some(v) = status.bbr_desired { + println!(" bbr_desired: {}", if v { "yes" } else { "no" }); + } + if let Some(v) = status.bbr_runtime_active { + println!(" bbr_runtime_active: {}", if v { "yes" } else { "no" }); } for row in &status.current { @@ -354,14 +369,15 @@ fn build_render_plan(cfg: &Config, allow_modprobe: bool) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result anyhow::Result, + current_congestion_control: Option<&str>, +) -> anyhow::Result { + let runtime_active = current_congestion_control.map(|value| value.trim() == "bbr"); + match mode { + "off" => Ok(BbrDecision { + available: None, + desired: Some(false), + runtime_active, + }), + "auto" => { + let available = available.ok_or_else(|| { + anyhow::anyhow!("BBR availability was not probed for tcp_bbr=auto") + })?; + Ok(BbrDecision { + available: Some(available), + desired: Some(available), + runtime_active, + }) + } + "require" => { + let available = available.ok_or_else(|| { + anyhow::anyhow!("BBR availability was not probed for tcp_bbr=require") + })?; + if !available { + anyhow::bail!("tcp_bbr=require but bbr is unavailable"); + } + Ok(BbrDecision { + available: Some(true), + desired: Some(true), + runtime_active, + }) + } + other => anyhow::bail!("unsupported tcp_bbr mode in render plan: {other}"), + } +} + fn bbr_available() -> anyhow::Result { let v = read_sysctl("net.ipv4.tcp_available_congestion_control")?; Ok(v.split_whitespace().any(|p| p == "bbr")) @@ -634,7 +696,32 @@ mod tests { assert!(!plan.desired.contains_key("net.core.default_qdisc")); assert_eq!(plan.modules_load_persisted, None); assert_eq!(plan.bbr_available, None); - assert_eq!(plan.bbr_active, None); + assert_eq!(plan.bbr_desired, Some(false)); + } + + #[test] + fn bbr_decision_separates_available_desired_and_runtime_active() { + let cubic = decide_bbr("auto", Some(true), Some("cubic")).expect("decision"); + assert_eq!(cubic.available, Some(true)); + assert_eq!(cubic.desired, Some(true)); + assert_eq!(cubic.runtime_active, Some(false)); + + let active = decide_bbr("require", Some(true), Some("bbr")).expect("decision"); + assert_eq!(active.desired, Some(true)); + assert_eq!(active.runtime_active, Some(true)); + + let unavailable = decide_bbr("auto", Some(false), Some("cubic")).expect("decision"); + assert_eq!(unavailable.available, Some(false)); + assert_eq!(unavailable.desired, Some(false)); + assert_eq!(unavailable.runtime_active, Some(false)); + } + + #[test] + fn bbr_off_does_not_hide_an_existing_runtime_bbr() { + let decision = decide_bbr("off", None, Some("bbr")).expect("decision"); + assert_eq!(decision.available, None); + assert_eq!(decision.desired, Some(false)); + assert_eq!(decision.runtime_active, Some(true)); } #[test] diff --git a/traffic_core/src/traffic/mod.rs b/traffic_core/src/traffic/mod.rs index bc04e63..09d103d 100644 --- a/traffic_core/src/traffic/mod.rs +++ b/traffic_core/src/traffic/mod.rs @@ -129,24 +129,30 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> { let _lock = FileLock::acquire_timeout("/run/awg2-harness/traffic.lock", Duration::from_secs(10))?; let target = docker::discover_target(cfg)?; - reconcile_traffic_locked(cfg, &target) + let topology = wg::discover_peer_topology(cfg, &target)?; + reconcile_traffic_locked(cfg, &target, &topology) } -pub(crate) fn reconcile_traffic_with_target( +pub(crate) fn reconcile_traffic_with_topology( cfg: &Config, target: &docker::RuntimeTarget, + topology: &wg::PeerTopology, ) -> anyhow::Result<()> { if !cfg.traffic.enabled { return Ok(()); } let _lock = FileLock::acquire_timeout("/run/awg2-harness/traffic.lock", Duration::from_secs(10))?; - reconcile_traffic_locked(cfg, target) + reconcile_traffic_locked(cfg, target, topology) } -fn reconcile_traffic_locked(cfg: &Config, target: &docker::RuntimeTarget) -> anyhow::Result<()> { +fn reconcile_traffic_locked( + cfg: &Config, + target: &docker::RuntimeTarget, + topology: &wg::PeerTopology, +) -> anyhow::Result<()> { let transport = build_transport(target.container_pid, &target.container_name); - let peers = wg::discover_peers(cfg, target)?; + let peers = &topology.peers; if peers.is_empty() { if let Some(reason) = empty_topology_drift_reason( @@ -173,7 +179,7 @@ fn reconcile_traffic_locked(cfg: &Config, target: &docker::RuntimeTarget) -> any cfg.traffic.class_start_id, &cfg.traffic.root_rate, &cfg.traffic.peer_limit, - &peers, + peers, )?; if !drift.has_drift { @@ -193,7 +199,7 @@ fn reconcile_traffic_locked(cfg: &Config, target: &docker::RuntimeTarget) -> any transport.as_ref(), &target.interface_name, &cfg.traffic.ifb_name, - &peers, + peers, )?; Ok(()) diff --git a/traffic_core/src/wg.rs b/traffic_core/src/wg.rs index 14a34c9..c5cbf1b 100644 --- a/traffic_core/src/wg.rs +++ b/traffic_core/src/wg.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, Ipv4Addr}; use crate::{ config::{self, Config}, docker::RuntimeTarget, + error::AppError, netns::{DockerExecTransport, NsTransport}, }; @@ -19,6 +20,25 @@ pub struct Peer { pub ipv4: Ipv4Addr, } +#[derive(Debug, Clone)] +pub struct PeerTopology { + pub peers: Vec, +} + +impl PeerTopology { + pub fn validate_antitorrent_coverage(&self, peer_cidr: &str) -> anyhow::Result<()> { + for peer in &self.peers { + if !config::ip_in_cidr(peer.ipv4, peer_cidr)? { + return unsupported(format!( + "peer address {} is outside anti_torrent.peer_ipv4_cidr {peer_cidr}", + peer.ipv4 + )); + } + } + Ok(()) + } +} + #[derive(Debug, Clone)] pub struct PeerRuntime { pub public_key: String, @@ -38,32 +58,43 @@ fn container_transport(target: &RuntimeTarget) -> DockerExecTransport { } } -pub fn discover_peers(cfg: &Config, target: &RuntimeTarget) -> anyhow::Result> { - discover_peers_with_transport( +pub fn discover_peer_topology( + cfg: &Config, + target: &RuntimeTarget, +) -> anyhow::Result { + discover_peer_topology_with_transport( &container_transport(target), &cfg.amnezia.wg_binaries, &cfg.amnezia.peer_ipv4_cidr, ) } -pub fn discover_peers_with_transport( +#[cfg(test)] +fn discover_peers_with_transport( transport: &dyn NsTransport, binaries: &[String], peer_cidr: &str, ) -> anyhow::Result> { + Ok(discover_peer_topology_with_transport(transport, binaries, peer_cidr)?.peers) +} + +pub fn discover_peer_topology_with_transport( + transport: &dyn NsTransport, + binaries: &[String], + peer_cidr: &str, +) -> anyhow::Result { let mut peers = match run_wg(transport, binaries, &["show", "all", "allowed-ips"]) { - Ok(primary) => parse_allowed_ips(&primary, peer_cidr)?, - Err(_) => Vec::new(), + Ok(primary) if !primary.trim().is_empty() => parse_allowed_ips(&primary, peer_cidr)?, + Ok(_) | Err(_) => { + let fallback = run_wg(transport, binaries, &["show"]) + .map_err(|e| anyhow::anyhow!("wg show fallback failed: {e}"))?; + parse_fallback_wg_show(&fallback, peer_cidr)? + } }; - if peers.is_empty() { - let fallback = run_wg(transport, binaries, &["show"]) - .map_err(|e| anyhow::anyhow!("wg show fallback failed: {e}"))?; - peers = parse_fallback_wg_show(&fallback, peer_cidr)?; - } peers.sort_by_key(|p| p.ipv4.to_string()); peers.dedup_by_key(|p| p.ipv4); - Ok(peers) + Ok(PeerTopology { peers }) } pub fn discover_peer_runtime( @@ -134,45 +165,84 @@ fn run_wg( Err(last_err.unwrap_or_else(|| anyhow::anyhow!("amnezia.wg_binaries is empty"))) } -/// Extracts peer host addresses inside `peer_cidr` from raw allowed-ips text. +/// Validates and extracts the single server-side host route supported for one peer. /// /// The three wg outputs consumed here use three different separators for a single /// peer's allowed ips: `wg show all allowed-ips` uses spaces, `wg show all dump` uses /// commas and plain `wg show` uses `", "`. Callers pass whitespace-separated tokens and /// each token is additionally split on commas, so all three are handled identically. -fn collect_peer_ipv4_in_cidr<'a>( +fn validate_peer_allowed_ips<'a>( tokens: impl IntoIterator, peer_cidr: &str, -) -> anyhow::Result> { - let mut out = Vec::new(); +) -> anyhow::Result> { + let mut entries = Vec::new(); for token in tokens { for entry in token.split(',') { let entry = entry.trim(); - let Some((addr, prefix)) = entry.split_once('/') else { - continue; - }; - if prefix != HOST_PREFIX { - continue; - } - let Ok(v4) = addr.parse::() else { - continue; - }; - if config::ip_in_cidr(v4, peer_cidr)? { - out.push(v4); + if !entry.is_empty() { + entries.push(entry); } } } - Ok(out) + + if entries.as_slice() == ["(none)"] { + return Ok(None); + } + if entries.is_empty() { + return unsupported("empty AllowedIPs field"); + } + if entries.contains(&"(none)") { + return unsupported("`(none)` cannot be combined with an AllowedIPs route"); + } + + let mut addresses = Vec::with_capacity(entries.len()); + for entry in &entries { + let Some((addr, prefix)) = entry.split_once('/') else { + return unsupported(format!("AllowedIPs entry is not a CIDR: {entry}")); + }; + let ip = addr.parse::().map_err(|_| { + AppError::UnsupportedTopology(format!("invalid AllowedIPs address: {addr}")) + })?; + let IpAddr::V4(v4) = ip else { + return unsupported(format!("IPv6 AllowedIPs is not supported: {entry}")); + }; + if prefix != HOST_PREFIX { + return unsupported(format!( + "only an IPv4 /32 host route is supported, got: {entry}" + )); + } + if !config::ip_in_cidr(v4, peer_cidr)? { + return unsupported(format!( + "peer address {v4} is outside amnezia.peer_ipv4_cidr {peer_cidr}" + )); + } + addresses.push(v4); + } + if addresses.len() != 1 { + return unsupported(format!( + "expected exactly one IPv4 /32 per peer, got {} entries: {}", + addresses.len(), + entries.join(", ") + )); + } + Ok(addresses.into_iter().next()) +} + +fn unsupported(message: impl Into) -> anyhow::Result { + Err(AppError::UnsupportedTopology(message.into()).into()) } fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { - let cols: Vec<&str> = line.split_whitespace().collect(); - if cols.len() < 3 { + if line.trim().is_empty() { continue; } - for ipv4 in collect_peer_ipv4_in_cidr(cols[2..].iter().copied(), peer_cidr)? { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 3 { + return unsupported(format!("malformed `show all allowed-ips` row: {line:?}")); + } + if let Some(ipv4) = validate_peer_allowed_ips(cols[2..].iter().copied(), peer_cidr)? { out.push(Peer { ipv4 }); } } @@ -187,7 +257,7 @@ fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result = - collect_peer_ipv4_in_cidr(std::iter::once(cols[4]), peer_cidr)? - .into_iter() - .map(|v| v.to_string()) - .collect(); - filtered_allowed.sort(); - filtered_allowed.dedup(); - if filtered_allowed.is_empty() { + let Some(ipv4) = validate_peer_allowed_ips(std::iter::once(cols[4]), peer_cidr)? else { continue; - } + }; + let filtered_allowed = vec![ipv4.to_string()]; let endpoint = Some(cols[3].trim()) .filter(|s| !s.is_empty() && *s != "(none)") @@ -367,17 +431,16 @@ mod tests { #[test] fn parse_fallback() { - let raw = "allowed ips: 10.8.1.4/32, 10.8.1.5/32\nallowed ips: 192.168.0.1/32"; + let raw = "allowed ips: 10.8.1.4/32\nallowed ips: 10.8.1.5/32"; let peers = parse_fallback_wg_show(raw, "10.8.1.0/24").expect("parse"); assert_eq!(peers.len(), 2); } #[test] - fn cidr_filtering_keeps_only_target_subnet() { + fn address_outside_configured_cidr_is_unsupported() { let raw = "wg0 key1 10.8.1.10/32\nwg0 key2 10.9.1.2/32"; - let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); - assert_eq!(peers.len(), 1); - assert_eq!(peers[0].ipv4.to_string(), "10.8.1.10"); + let error = parse_allowed_ips(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("outside amnezia.peer_ipv4_cidr")); } #[test] @@ -389,13 +452,11 @@ mod tests { assert_eq!(peers[1].ipv4.to_string(), "10.8.1.2"); } - /// `wg show all allowed-ips` separates a peer's allowed ips with spaces, not commas. #[test] - fn parse_primary_collects_every_space_separated_allowed_ip() { + fn parse_primary_rejects_multiple_allowed_ips_for_one_peer() { let raw = "awg0 key1 10.8.1.10/32 10.8.1.11/32 10.8.1.12/32"; - let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); - let got: Vec = peers.iter().map(|p| p.ipv4.to_string()).collect(); - assert_eq!(got, vec!["10.8.1.10", "10.8.1.11", "10.8.1.12"]); + let error = parse_allowed_ips(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("exactly one IPv4 /32")); } #[test] @@ -407,10 +468,10 @@ mod tests { } #[test] - fn parse_fallback_collects_every_allowed_ip_on_one_line() { + fn parse_fallback_rejects_multiple_allowed_ips_on_one_line() { let raw = " allowed ips: 10.8.1.4/32, 10.8.1.5/32, 10.8.1.6/32"; - let peers = parse_fallback_wg_show(raw, "10.8.1.0/24").expect("parse"); - assert_eq!(peers.len(), 3); + let error = parse_fallback_wg_show(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("exactly one IPv4 /32")); } #[test] @@ -434,7 +495,10 @@ mod tests { fn empty_primary_output_falls_back_to_wg_show() { let transport = TestTransport::new(vec![ ("awg show all allowed-ips", Ok("")), - ("awg show", Ok("allowed ips: 10.8.1.4/32, 10.8.1.5/32")), + ( + "awg show", + Ok("allowed ips: 10.8.1.4/32\nallowed ips: 10.8.1.5/32"), + ), ]); let peers = @@ -447,7 +511,7 @@ mod tests { #[test] fn parses_wg_show_dump_runtime_peers() { let raw = format!( - "{AWG31_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nawg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0" + "{AWG31_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nawg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32\t0\t0\t0\t0" ); let peers = parse_wg_show_dump_runtime_peers(&raw, "10.8.1.0/24").expect("parse"); assert_eq!(peers.len(), 2); @@ -457,11 +521,58 @@ mod tests { } #[test] - fn maps_allowed_ip_to_runtime_peer() { + fn runtime_dump_rejects_multiple_allowed_ips() { let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32,10.8.1.11/32\t1715000000\t123\t456\t0"; - let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse"); - assert_eq!(peers.len(), 1); - assert_eq!(peers[0].allowed_ips.len(), 2); + let error = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("exactly one IPv4 /32")); + } + + #[test] + fn primary_rejects_ipv4_plus_ipv6() { + let raw = "awg0 key1 10.8.1.10/32 fd00::10/128"; + let error = parse_allowed_ips(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("IPv6 AllowedIPs")); + } + + #[test] + fn fallback_rejects_ipv6_only() { + let raw = "allowed ips: fd00::10/128"; + let error = parse_fallback_wg_show(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("IPv6 AllowedIPs")); + } + + #[test] + fn runtime_dump_rejects_ipv6() { + let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\tfd00::10/128\t1715000000\t123\t456\t0"; + let error = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("IPv6 AllowedIPs")); + } + + #[test] + fn primary_rejects_ipv4_subnet_route() { + let raw = "awg0 key1 10.8.1.0/24"; + let error = parse_allowed_ips(raw, "10.8.1.0/24").expect_err("unsupported"); + assert!(error.to_string().contains("only an IPv4 /32")); + } + + #[test] + fn anti_torrent_cidr_must_cover_every_peer() { + let topology = PeerTopology { + peers: vec![Peer { + ipv4: "10.8.1.10".parse().expect("IPv4"), + }], + }; + topology + .validate_antitorrent_coverage("10.8.1.0/24") + .expect("covered"); + let error = topology + .validate_antitorrent_coverage("10.9.0.0/16") + .expect_err("must reject uncovered peer"); + assert!( + error + .to_string() + .contains("outside anti_torrent.peer_ipv4_cidr") + ); } /// AmneziaWG 3.0 prints protocol timers as ranges; the peer keepalive column may be