Покажу, как я использую свою Ansible роль firewall для управления nftables: общая политика хранится в group_vars, отличия конкретных серверов - в host_vars. Заодно разберём, что роль делает, чтобы не отрезать SSH при применении правил 🔥.
🖐️Эй!
Подписывайтесь на наш телеграм @r4ven_me📱, чтобы не пропустить новые публикации на сайте😉. А если есть вопросы или желание пообщаться по тематике - заглядывайте в Вороний чат @r4ven_me_chat🧐. Также в блоге теперь доступно соавторство 🐧🐧🐧.
Введение
💡 Прежде всего хочу сразу оговорить один момент: если вы плохо представляете как управлять сетевым экраном в Linux с помощью nftables, то крайне рекомендую сперва прочесть мою предыдущую статью по этой теме: Базовая настройка Nftables в Linux.
Пока серверов один-два, фаервол настраивается руками или самописными скриптами: несколько правил в /etc/nftables.conf, systemctl enable nftables и готово. Но когда хостов становится десятки, а то и сотни, рутину необходимо автоматизировать. Иначе следующих проблем не избежать:
- на каждом сервере конфиг чуть-чуть отличается, и никто не помнит почему;
- чтобы открыть один порт, нужно зайти по SSH на машину и править файл/выполнять команду;
- любая опечатка в правилах, применённая на удалённой машине, может навсегда отрезать вас от неё 🤦;
- часть хостов управляется через
ufw, часть черезfirewalld, часть черезiptables-persistent- тут богатство Linux инструментария может сыграть против вас.

Решение - описывать фаервол декларативно и раскатывать через Ansible. Именно для этого я подготовил роль firewall.
От команд nftables к переменным роли
Для начала сравним одну и ту же задачу в двух форматах: разрешить из внешней сети HTTP и HTTPS по TCP, а также QUIC по UDP/443.
Вручную это можно сделать командами nft:
sudo nft 'add rule inet filter input_wan tcp dport { 80, 443 } accept comment "Allow TCP ports from WAN"'
sudo nft 'add rule inet filter input_wan udp dport 443 accept comment "Allow UDP ports from WAN"'А в конфигурационном файле те же правила выглядят как блок:
table inet filter {
chain input_wan {
tcp dport { 80, 443 } accept comment "Allow TCP ports from WAN"
udp dport 443 accept comment "Allow UDP ports from WAN"
}
}Это фрагменты, а не готовая конфигурация фаервола: таблица inet filter и цепочка input_wan для команд уже должны существовать, а входящий WAN-трафик должен попадать в эту цепочку из базовой input. Разрешающие правила должны стоять до завершающего запрета - add rule добавляет их в конец цепочки. Кроме того, команды меняют только текущие правила в ядре: для сохранения после перезагрузки нужна конфигурация, загружаемая службой nftables.
С ролью для той же задачи достаточно добавить в inventory/host_vars/crow.r4ven.me.yml:
firewall_enabled: true
nft_allowed_ports:
- "80/tcp"
- "443/tcp"
- "443/udp"При наличии базовой политики, которую разберём ниже, роль сама сформирует правила в input_wan, поставит их перед запретом и сохранит результат в /etc/nftables.conf.
Если нужен не короткий список портов, а явное описание правил, вместо nft_allowed_ports можно использовать YAML-формат роли:
firewall_enabled: true
nft_rules_extra:
allow_web_tcp:
order: 111
family: inet
table: filter
chain: input_wan
match:
tcp:
dport:
- 80
- 443
action: accept
comment: "Allow TCP ports from WAN"
allow_quic_udp:
order: 112
family: inet
table: filter
chain: input_wan
match:
udp:
dport: 443
action: accept
comment: "Allow UDP ports from WAN"Здесь family, table и chain задают место правила, match - условие, action - действие, а order - порядок относительно остальных правил. Получается тот же синтаксис nftables, но исходные настройки хранятся в инвентори и применяются одинаково на всех нужных хостах.
ПО, используемое в статье:
| ПО | Версия |
|---|---|
| Debian (управляющий хост) | 13 |
| Debian / Ubuntu (управляемые хосты) | 13 / 26 |
| nftables | 1.1.x |
| Ansible | Core 2.17+ |
📝 Примечание
Роль рассчитана на Deb-based дистрибутивы (Debian, Ubuntu) - установка пакета nftables выполняется через apt, хотя обычно он установлен “из коробки”. Роль лежит в отдельном репозитории: r4ven-ansible/firewall.
❗️ Осторожно
Фаервол - это то, что бывает трудно настроить, и к чему легко потерять 💔 доступ. Особенно становится страшно, если вы делаете это удалённо. Моя роль содержит механизмы защиты (о них ниже), но всё-таки я настаиваю, чтобы вы сперва проверяли все изменения на тестовом стенде, например, в виртуальной машине. А в случае применения конфигурации на продакшн серверах, обязательно предварительно убедитесь, что у вас есть доступ к консоли гипервизора или IPMI железного сервера.
Подготовка
Окружение для Ansible (опционально)
Если Ansible у вас ещё не настроен, проще всего взять готовый рабочий каталог - r4ven-me/ansible-workspace. В нём уже есть ansible.cfg, пример inventory, плейбук и requirements.yml для установки роли.
- На управляющей машине (в моём примере это Linux Mint 22) нужны:
- На управляемом хосте (в моём примере это
crow.r4ven.me):- ОС Debian 13+/Ubuntu 24+
- Сервер OpenSSH (
sshd) - Интерпретатор языка Python версии 3+
- Пакет
python3-apt - Утилита повышения привилегий
sudoилиsu
git clone --branch raven https://github.com/r4ven-me/ansible-workspace.git
cd ./ansible-workspace/💡 Если вы используете свой ansible.cfg, а не из workspace, загляните в конце статьи в подсказку про ssh_args: keepalive для SSH - часть механизма защиты от потери доступа, а не косметика.
Структура каталога:
ansible-workspace/
├── ansible.cfg # настройки (в т.ч. hash_behaviour и keepalive для SSH)
├── requirements.txt # ansible-core
├── requirements.yml # роли для ansible-galaxy
├── inventory/
│ ├── hosts.yml # хосты
│ ├── group_vars/all/firewall.yml # базовая политика
│ └── host_vars/crow.r4ven.me.yml # специфика конкретного хоста
├── playbooks/firewall.yml
└── roles/ # сюда ставятся ролиЕсли на вашей управляющей машине Ansible ещё не установлен, сделаем это с помощью pipx:
sudo apt update && sudo apt install pipx ssh-askpass
pipx install ansible-core
pipx ensurepath💡 Разумеется у меня на сайте есть вводная статья по Ansible, если вдруг вы только начали его изучать: Ansible - система управления конфигурациями: знакомство.
Перед запуском роли замените адрес 10.10.10.10 и пользователя в inventory/hosts.yml на свои, например, с помощью редактора vim или neovim.
Проверяем доступность:
ansible --become --module-name ping crow.r4ven.me
ansible --become --module-name ping birds📝 Где --become параметр для выполнения операции от имени пользователя root, crow.r4ven.me имя моего хоста в inventory, а birds - группа хостов.

💡 Кстати, если вы тоже устали постоянно искать правильные названия хостов в inventory, рекомендую заглянуть сюда: Tab подстановка хостов из Ansible inventory для ansible, ssh, scp, rsync.
На управляемом сервере обязательно ставим пакет python3-apt:
ansible --become --module-name shell --args 'apt install --yes python3-apt' crow.r4ven.meУстановка роли
Роль ставится из Git-репозитория через ansible-galaxy. Самый наглядный способ - указать ссылку на репозиторий и тег прямо в команде:
ansible-galaxy role install git+https://github.com/r4ven-ansible/firewall.git,v1.0.0
Формат: git+<url>,<тег или ветка>. Версию лучше закреплять тегом, чтобы обновление роли не происходило неожиданно.
Для воспроизводимости ссылки на роли фиксируют в requirements.yml (в workspace он уже есть):
roles:
- name: firewall
src: https://github.com/r4ven-ansible/firewall.git
scm: git
version: v1.0.0
collections: []и ставят командой:
ansible-galaxy role install -r ./requirements.ymlПроверим, что роль на месте:
ansible-galaxy role listДля перехода на другую версию поменяйте version и добавьте к команде установки --force.

Как устроена роль firewall
В данной роли таблицы, наборы (sets), цепочки и правила описаны переменными Ansible. Роль собирает их, рендерит через Jinja2 единый /etc/nftables.conf, проверяет конфиг и применяет его.
roles/firewall/
├── defaults/main.yml # значения по умолчанию
├── tasks/
│ ├── main.yml # проверка поддерживаемой ОС
│ ├── firewall.yml # пакеты, сборка правил, подключение apply.yml
│ ├── preflight.yml # защита управляющего доступа и проверки до применения
│ ├── reachability.yml # проверка связи по собственному пути Ansible
│ └── apply.yml # staging, snapshot, транзакция и откат
├── templates/
│ ├── nftables.conf.j2 # шаблон, превращающий YAML-DSL в синтаксис nft
│ └── fingerprint.py.j2 # отпечаток управляемых таблиц для детекта дрейфа
├── meta/main.yml
├── examples/ # пример ansible.cfg, inventory и плейбука
├── tests/ # VM-тесты, результаты и ограничения (README.md)
└── README.mdПеременные делятся на два уровня:
| Где | Что описывает |
|---|---|
./group_vars/all/firewall.yml | Базовая политика для всех хостов: таблицы, цепочки, наборы, общие правила |
./host_vars/<host>.yml | Включение фаервола и отличия конкретного сервера |
Роль выключена по умолчанию (firewall_enabled: false). Пока вы явно не включили её для хоста, она ничего на нём не трогает. Это удобно при постепенном внедрении: подключаете хосты по одному.
Полный пример базовой конфигурации nftables
Если вы работаете в окружении репозитория ansible-workspace, то набор правил у вас уже готов: ./inventory/group_vars/all/firewall.yml.
☝️ Переменные в директории all распространяются на все хосты из ./inventory/hosts.yml.
В ином случае, просто создайте/обновите файл (./inventory/group_vars/all/firewall.yml) с переменными для всех хостов, добавив в него содержимое блока yaml ниже.
Файл большой, и вчитываться в него прямо сейчас не нужно: ниже мы сначала применим его как есть, а потом разберём по частям. Чтобы было понятно, что вы копируете, вот его карта:
| Блок | Что задаёт | Где разбираем |
|---|---|---|
firewall_enabled, nft_* переключатели | общие флаги: включение роли, удаление чужих фаерволов, полный flush, перезапуск Docker | Применение |
nft_tables, nft_sets + # SET CONTENT | таблицы и именованные списки адресов: LAN-сети, доверенные IP, исключения из защиты от спуфинга, динамические наборы для SSH-лимита | Таблицы и наборы |
nft_chains | цепочки с хуками и политиками плюс пользовательские input_wan, input_lan, log_drop | Цепочки |
nft_rules | сами правила, отсортированные по order: обязательные пропуски (1-7), базовые проверки (10-21), разделение LAN/WAN (80-90), LAN allow и WAN-разрешения (100-198), forward (200-206), логирование (300+) | Правила |
закомментированные nat_* в конце (убрал, т.к. файл и так большой) | заготовки NAT и редиректов | Гибкость |
Нумерация order - это “слоты” с запасом: между SSH (110) и запретом по умолчанию (198) много места под свои правила, поэтому добавлять их можно не переписывая существующие.
---
firewall_enabled: false
nft_remove_other_firewall: false
nft_flush_ruleset: false
nft_restart_docker: false
nft_bin: "/usr/sbin/nft"
nft_config_path: "/etc/nftables.conf"
nft_backup_path: "/root/nft_backup"
nft_validate_cmd: "{{ nft_bin }} -c -f %s"
nft_wan_interface: "{{ ansible_facts.get('default_ipv4', {}).get('interface') | default('unknown0', true) }}"
# TABLES
nft_tables:
- family: inet
name: filter
- family: inet
name: nat
# SETS
nft_sets:
- family: inet
table: filter
name: lan4
type: ipv4_addr
flags: interval
elements: "{{ nft_lan4_networks }}"
- family: inet
table: filter
name: lan6
type: ipv6_addr
flags: interval
elements: "{{ nft_lan6_networks }}"
- family: inet
table: filter
name: trusted
type: ipv4_addr
flags: interval
elements: "{{ nft_trusted_ips }}"
- family: inet
table: filter
name: trusted6
type: ipv6_addr
flags: interval
elements: "{{ nft_trusted_ips6 }}"
# исключения из spoof guard, по умолчанию пустые
- family: inet
table: filter
name: wan_local4
type: ipv4_addr
flags: interval
auto_merge: true
elements: "{{ nft_wan_local4_networks }}"
- family: inet
table: filter
name: wan_local6
type: ipv6_addr
flags: interval
auto_merge: true
elements: "{{ nft_wan_local6_networks }}"
# per-source SSH rate limiting
- family: inet
table: filter
name: ssh_flood4
type: ipv4_addr
flags: dynamic
size: 65536
timeout: 10m
- family: inet
table: filter
name: ssh_flood6
type: ipv6_addr
flags: dynamic
size: 65536
timeout: 10m
# SET CONTENT
nft_lan4_networks:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16
nft_lan6_networks:
- fd00::/8
- fe80::/10
nft_trusted_ips:
- 203.0.113.10
# No IPv6 source is trusted until explicitly added.
nft_trusted_ips6: []
# Opt-in: заполняйте только для сегмента, которому доверяете (см. раздел про
# спуфинг). Готовые значения роль выводит из фактов в nft_wan_local4/6.
nft_wan_local4_networks: []
nft_wan_local6_networks: []
# CHAINS
# https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks
nft_chains:
- family: inet
table: filter
name: input
type: filter
hook: input
priority: -1
policy: drop
# Intentionally permissive: new routed traffic is accepted unless explicitly
# dropped here. Input rules do not protect forwarded/container traffic.
- family: inet
table: filter
name: forward
type: filter
hook: forward
priority: -51
policy: accept
# priority must stay above -200 (conntrack), otherwise ct matches
# in this chain will never work
- family: inet
table: filter
name: output
type: filter
hook: output
priority: -1
policy: accept
- family: inet
table: nat
name: prerouting
type: nat
hook: prerouting
priority: -101
policy: accept
- family: inet
table: nat
name: output
type: nat
hook: output
priority: -101
policy: accept
- family: inet
table: nat
name: postrouting
type: nat
hook: postrouting
priority: 99
policy: accept
# custom chains
- family: inet
table: filter
name: input_wan
- family: inet
table: filter
name: input_lan
- family: inet
table: filter
name: log_drop
# RULES
nft_rules:
# ---- BASE ----
allow_dhcp_client6:
order: 7
family: inet
table: filter
chain: input
match:
iifname: "{{ nft_wan_interface }}"
meta:
l4proto: udp
ip6:
saddr: "fe80::/10"
udp:
sport: 547
dport: 546
action: accept
comment: "Allow DHCPv6 client replies"
# The guards run after allow_established, so a forged ICMP error carrying a
# live flow's tuple would be accepted as 'related'. Redirects are the one such
# message with real consequences (route injection, MITM), and no server needs
# to honour them; pair this with accept_redirects=0 in sysctl.
allow_dhcp_client4:
order: 6
family: inet
table: filter
chain: input
match:
iifname: "{{ nft_wan_interface }}"
meta:
l4proto: udp
udp:
sport: 67
dport: 68
action: accept
comment: "Allow DHCPv4 client replies"
allow_icmp6_nd:
order: 5
family: inet
table: filter
chain: input
match:
meta:
l4proto: ipv6-icmp
icmpv6:
type:
- nd-router-solicit
- nd-router-advert
- nd-neighbor-solicit
- nd-neighbor-advert
action: accept
comment: "Allow IPv6 neighbour discovery"
# A DHCP-addressed host loses its lease without these: a unicast renew reply
# from a private server is dropped by the guard before allow_established, and
# broadcast rebind replies match no conntrack entry at all. Bound to the WAN
# interface; a host that leases on another interface needs its own rule.
drop_invalid:
order: 10
family: inet
table: filter
chain: input
match:
ct:
state: invalid
action: drop
comment: "Drop invalid connections"
drop_icmp_redirect4:
order: 11
family: inet
table: filter
chain: input
match:
meta:
l4proto: icmp
icmp:
type: redirect
action: drop
comment: "Drop ICMP redirects"
drop_icmp_redirect6:
order: 12
family: inet
table: filter
chain: input
match:
meta:
l4proto: ipv6-icmp
icmpv6:
type: nd-redirect
action: drop
comment: "Drop ICMPv6 redirects"
allow_established:
order: 15
family: inet
table: filter
chain: input
match:
ct:
state:
- established
- related
action: accept
comment: "Allow established connections"
# ---- WAN SPOOFING GUARD ----
# Run after established/related so replies from private uplink peers survive.
# New/untracked private-source traffic on WAN still reaches these guards.
drop_wan_spoofed_lan_ipv4:
order: 20
family: inet
table: filter
chain: input
expr: 'iifname "{{ nft_wan_interface }}" ip saddr @lan4 ip saddr != @wan_local4'
action:
jump: log_drop
comment: "Drop spoofed private IPv4 from WAN"
drop_wan_spoofed_lan_ipv6:
order: 21
family: inet
table: filter
chain: input
expr: 'iifname "{{ nft_wan_interface }}" ip6 saddr @lan6 ip6 saddr != @wan_local6'
action:
jump: log_drop
comment: "Drop spoofed private IPv6 from WAN"
allow_loopback:
order: 30
family: inet
table: filter
chain: input
match:
iif: lo
action: accept
comment: "Allow loopback"
allow_trusted:
order: 40
family: inet
table: filter
chain: input
match:
ip:
saddr:
value: "@trusted"
action: accept
comment: "Allow trusted IPs"
allow_trusted6:
order: 41
family: inet
table: filter
chain: input
match:
ip6:
saddr:
value: "@trusted6"
action: accept
comment: "Allow trusted IPv6 addresses"
icmp4:
order: 50
family: inet
table: filter
chain: input
match:
meta:
l4proto: icmp
icmp:
type:
- echo-request
- destination-unreachable
- time-exceeded
limit:
rate: "10/second"
action: accept
comment: "ICMP rate limited"
icmp6:
order: 60
family: inet
table: filter
chain: input
match:
meta:
l4proto: ipv6-icmp
icmpv6:
type:
- destination-unreachable
- packet-too-big
- time-exceeded
- parameter-problem
- nd-router-solicit
- nd-router-advert
- nd-neighbor-solicit
- nd-neighbor-advert
action: accept
comment: "Necessary IPv6 ICMP"
icmp6_ping:
order: 61
family: inet
table: filter
chain: input
match:
meta:
l4proto: ipv6-icmp
icmpv6:
type: echo-request
limit:
rate: "10/second"
action: accept
comment: "ICMPv6 ping rate limited"
# ---- LAN/WAN SPLIT ----
jump_to_lan_ipv4:
order: 80
family: inet
table: filter
chain: input
match:
ip:
saddr:
value: "@lan4"
action:
jump: input_lan
jump_to_lan_ipv6:
order: 81
family: inet
table: filter
chain: input
match:
ip6:
saddr:
value: "@lan6"
action:
jump: input_lan
jump_to_wan_ipv4:
order: 82
family: inet
table: filter
chain: input
match:
ip:
saddr:
op: "!="
value: "@lan4"
action:
jump: input_wan
jump_to_wan_ipv6:
order: 83
family: inet
table: filter
chain: input
match:
ip6:
saddr:
op: "!="
value: "@lan6"
action:
jump: input_wan
default_drop:
order: 90
family: inet
table: filter
chain: input
action:
jump: log_drop
# ---- LAN ----
input_lan_allow_all:
order: 100
family: inet
table: filter
chain: input_lan
action: accept
comment: "Allow all from LAN"
# ---- WAN ----
# per-source limit: a single flooding source cannot exhaust
# the SSH budget for everyone else
wan_ssh_flood_ipv4:
order: 108
family: inet
table: filter
chain: input_wan
match:
meta:
l4proto: tcp
tcp:
dport: "{{ ssh_port | default('22') }}"
ct:
state: new
raw: "add @ssh_flood4 { ip saddr limit rate over 20/minute }"
action:
jump: log_drop
comment: "Drop SSH flood per IPv4 source"
wan_ssh_flood_ipv6:
order: 109
family: inet
table: filter
chain: input_wan
match:
meta:
l4proto: tcp
tcp:
dport: "{{ ssh_port | default('22') }}"
ct:
state: new
raw: "add @ssh_flood6 { ip6 saddr limit rate over 20/minute }"
action:
jump: log_drop
comment: "Drop SSH flood per IPv6 source"
wan_allow_ssh:
order: 110
family: inet
table: filter
chain: input_wan
match:
meta:
l4proto: tcp
tcp:
dport: "{{ ssh_port | default('22') }}"
ct:
state: new
action: accept
comment: "SSH rate limited per source"
wan_default_drop:
order: 198
family: inet
table: filter
chain: input_wan
action:
jump: log_drop
comment: "Default WAN drop"
# ---- FORWARD ----
fwd_drop_invalid:
order: 200
family: inet
table: filter
chain: forward
match:
ct:
state: invalid
action: drop
comment: "Drop invalid forwarded connections"
fwd_allow_established:
order: 203
family: inet
table: filter
chain: forward
match:
ct:
state:
- established
- related
action: accept
comment: "Allow established forwarded connections"
# As in input, accept established/related before checking private WAN sources.
fwd_drop_wan_spoofed_lan_ipv4:
order: 205
family: inet
table: filter
chain: forward
expr: 'iifname "{{ nft_wan_interface }}" ip saddr @lan4 ip saddr != @wan_local4'
action:
jump: log_drop
comment: "Drop spoofed private IPv4 forwarded from WAN"
fwd_drop_wan_spoofed_lan_ipv6:
order: 206
family: inet
table: filter
chain: forward
expr: 'iifname "{{ nft_wan_interface }}" ip6 saddr @lan6 ip6 saddr != @wan_local6'
action:
jump: log_drop
comment: "Drop spoofed private IPv6 forwarded from WAN"
# ---- LOG/DROP ----
logdrop_log:
order: 300
family: inet
table: filter
chain: log_drop
match:
limit:
rate: "5/second"
action:
log:
prefix: "NFT-DROP: "
flags: all
logdrop_drop:
order: 301
family: inet
table: filter
chain: log_drop
action: drop☝️Важно
Цепочка forward намеренно использует policy: accept. Новый маршрутизируемый трафик разрешён, если его не отбросило явное правило; разрешение established, related не запрещает новые соединения. Ограничения в input относятся к самому хосту, а не к транзитному трафику или опубликованным портам контейнеров, проходящим через forward. Если нужна фильтрация этого трафика, добавьте отдельные правила в forward; приведённая базовая политика не является для него политикой “запрещено по умолчанию”.
Подробнее правила разберём чуть ниже, а пока…
Применение базовой конфигурации
Плейбук для запуска минимален. Создадим его с помощью механизмов перенаправления и here doc:
cat > ./playbooks/firewall.yml <<EOF
---
- name: Setup firewall
hosts: all
gather_facts: true
become: true
roles:
- role: firewall
EOFДопустим, у нас в инвентаре есть веб-сервер crow.r4ven.me. Включаем для него роль фаервола хост переменными:
cat > ./inventory/host_vars/crow.r4ven.me.yml <<EOF
---
nft_remove_other_firewall: true
firewall_enabled: true
EOF☝️ Важно чтобы имя хоста в имени файла crow.r4ven.me.yml полностью совпадало с его именем в inventory.
Здесь:
firewall_enabled- включает роль для хоста;- ⚠️
nft_remove_other_firewall- останавливает и отключает службыufw,firewalld,iptables,netfilter-persistent, а пакетыufwиfirewalldудаляет, чтобы они не конфликтовали сnftables.
Сначала посмотрим, что изменится на нём, не применяя ничего (diff + check mode):
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff --check
Роль показывает разницу в конфиге (diff: true), так что вы видите ровно те строки, которые появятся в /etc/nftables.conf. Если nft ещё не установлен, это только сравнение отрендеренного конфига: синтаксис nft, установку пакетов, runtime-правила и поведение службы проверить нельзя. Пакеты в check mode не устанавливаются, файл и runtime не меняются; служба при отсутствии nft пропускается. Даже с установленным nft diff и проверка синтаксиса не доказывают доступность сервера после применения.
Если вы всё еще тут и вас всё устраивает - запускаем без --check:
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diffИли сразу на группу хостов:
ansible-playbook ./playbooks/firewall.yml --limit birds --diffПо завершению сразу же проверяем доступность хоста:
ansible --become --module-name ping crow.r4ven.me
Про SSH порт
SSH-порт открывать не нужно: для него в базовой политике есть отдельное правило с ограничением частоты подключений. Порт берётся из переменной ssh_port (по умолчанию 22). Если вы всё же укажете его в дополнительном списке портов nft_allowed_ports (см. ниже), то этот вариант будет пропущен (переменная nft_allowed_ports_exclude_ssh), чтобы не дублировать и не обходить rate limit. Нестандартный ssh_port должен совпадать с реальным портом sshd и ansible_port; сама роль firewall sshd не перенастраивает. Сверять вручную, впрочем, уже не обязательно: роль сравнивает ssh_port с портом, на котором обслуживается живая сессия, и при расхождении отказывается применять конфигурацию - иначе SSH-правила защищали бы порт, на который никто не подключается. Отключается это проверкой nft_mgmt_assert_ssh_port: false. Сверьте также nft_ssh_check_port. Исключается только точное значение настроенного SSH-порта, а не все возможные SSH-порты и не диапазоны, содержащие его.
Как быстро открыть новые порты
Теперь всё, что нужно - это добавить в inventory/host_vars/crow.r4ven.me.yml специальную переменную nft_allowed_ports:
cat >> ./inventory/host_vars/crow.r4ven.me.yml <<EOF
nft_allowed_ports:
- "80/tcp"
- "443/tcp"
- "443/udp"
EOF📝 В nft_allowed_ports мы перечисляем список портов, разрешенных из внешней сети (WAN). Формат порт/протокол, протокол по умолчанию - tcp.
Смотрим изменения:
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff --checkЕсли всё ок, применяем:
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diffРазбор базовой политики в group_vars
Как уже ранее говорилось, общую политику задаём один раз для группы хостов. Ниже её разбор по частям.
Таблицы и наборы
nft_tables:
- family: inet
name: filter
- family: inet
name: nat
nft_lan4_networks:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16
nft_trusted_ips:
- 203.0.113.10
nft_sets:
- family: inet
table: filter
name: lan4
type: ipv4_addr
flags: interval
elements: "{{ nft_lan4_networks }}"
- family: inet
table: filter
name: trusted
type: ipv4_addr
flags: interval
elements: "{{ nft_trusted_ips }}"Используем семейство inet - одни и те же правила работают и для IPv4, и для IPv6. Наборы (sets) - это именованные списки адресов: добавили IP в nft_trusted_ips, и он получил доступ ко всем портам на всех серверах, если пакет не отброшен более ранним правилом (например, защита от подделки адреса на WAN). Ни одного правила править не нужно.
Цепочки
nft_chains:
- family: inet
table: filter
name: input
type: filter
hook: input
priority: -1
policy: drop # по умолчанию всё запрещено
- family: inet
table: filter
name: forward
type: filter
hook: forward
priority: -51
policy: accept # по умолчанию всё разрешено
# пользовательские цепочки (без hook)
- family: inet
table: filter
name: input_wan
- family: inet
table: filter
name: input_lan
- family: inet
table: filter
name: log_dropПолитика входящего трафика к самому хосту (input) - drop: что явно не разрешено, то запрещено. Кроме базовых цепочек с хуками, создаются пользовательские input_lan, input_wan и log_drop - через них мы разделим трафик на “свой” и “чужой”.
Правила
Каждое правило - это ключ в словаре nft_rules со следующими полями:
| Поле | Назначение |
|---|---|
order | Порядок применения (меньше - раньше) |
family, table, chain | Куда добавить правило |
match | Условия: что за пакет |
action | Что с ним делать: accept, drop, jump, dnat и т.д. |
comment | Комментарий (попадет в конфиг и в nft list ruleset) |
Фрагмент базовой политики:
nft_rules:
drop_invalid:
order: 10
family: inet
table: filter
chain: input
match:
ct:
state: invalid
action: drop
comment: "Drop invalid connections"
allow_established:
order: 15
family: inet
table: filter
chain: input
match:
ct:
state:
- established
- related
action: accept
comment: "Allow established connections"
allow_trusted:
order: 40
family: inet
table: filter
chain: input
match:
ip:
saddr:
value: "@trusted"
action: accept
comment: "Allow trusted IPs"
jump_to_lan_ipv4:
order: 80
family: inet
table: filter
chain: input
match:
ip:
saddr:
value: "@lan4"
action:
jump: input_lan
jump_to_wan_ipv4:
order: 82
family: inet
table: filter
chain: input
match:
ip:
saddr:
op: "!="
value: "@lan4"
action:
jump: input_wan
input_lan_allow_all:
order: 100
family: inet
table: filter
chain: input_lan
action: accept
comment: "Allow all from LAN"
wan_default_drop:
order: 198
family: inet
table: filter
chain: input_wan
action:
jump: log_drop
comment: "Default WAN drop"Логика получается такая:
- Самым первым правилом (
order: 1) роль сама разрешает текущее управляющее соединение - о нём отдельный раздел ниже; - Пропускаем то, что обязано работать вне conntrack: IPv6 neighbour discovery (
order: 5) и ответы DHCP-клиента (6,7); - Отбрасываем “битые” пакеты (
invalid,order: 10) и ICMP-редиректы (11,12); - Пропускаем ответы на уже установленные соединения (
established,order: 15); - Отбрасываем приватные источники на WAN: правила защиты от подделки адреса с
order: 20/21(в коротком фрагменте выше он опущен); - Пропускаем
loopbackи доверенные адреса из наборовtrusted/trusted6; - Разделяем трафик по источнику: из частных сетей - в
input_lan, из остальных - вinput_wan; - Из LAN разрешено всё, из WAN - только то, что явно открыто правилами с
orderот 100 до 197; - Всё остальное в конце
input_wanотправляется вlog_drop- логируется с ограничением частоты и сбрасывается.
📝 Порядок правил защиты от подделки адреса источника относительно разрешения established, related - компромисс. Если проверять адрес источника раньше, можно отбросить легитимные ответы от DNS- или NTP-сервера с приватным адресом, доступного через внешний интерфейс: это особенно актуально для хостов, у которых сам uplink находится в частной сети. Поэтому сначала разрешается трафик уже отслеживаемых соединений и связанных с ними пакетов, а затем выполняется проверка источника для оставшегося трафика. Пакеты, необходимые для работы сети вне отслеживаемых соединений, например IPv6 Neighbor Discovery и ответы DHCP, разрешаются отдельными правилами до этой проверки. Такой порядок не исключает подделку пакетов существующего соединения: conntrack отслеживает состояние, но не подтверждает подлинность отправителя. Для успешной атаки нужны параметры подходящего потока, а для TCP - ещё и допустимые номера последовательности. ICMP-редиректы запрещаются отдельными правилами до разрешения established, related (order: 11, 12). Дополнительно отключите их принятие через net.ipv4.conf.all.accept_redirects=0 и net.ipv6.conf.all.accept_redirects=0, проверив также значения для конкретных интерфейсов: роль настройки sysctl не меняет.
Обратите внимание на порядок: числа order - это “слоты”. Между 110 (SSH) и 198 (запрет по умолчанию) остаётся много места, куда можно добавлять свои правила, не переписывая существующие. Именно поэтому порты из nft_allowed_ports попадают в слоты 111 (TCP) и 112 (UDP).
Защита от спуфинга и перебора SSH
В политику уже включены две полезные вещи, которые обычно забывают сделать вручную.
Защита от подделки адресов. Новые пакеты из внешнего интерфейса, пришедшие якобы с приватного адреса, отбрасываются:
drop_wan_spoofed_lan_ipv4:
order: 20
family: inet
table: filter
chain: input
expr: 'iifname "{{ nft_wan_interface }}" ip saddr @lan4 ip saddr != @wan_local4'
action:
jump: log_drop
comment: "Drop spoofed private IPv4 from WAN"Имя внешнего интерфейса определяется автоматически из фактов Ansible (ansible_facts.get('default_ipv4', {}).get('interface'), с fallback на IPv6); если указанного интерфейса в фактах нет, роль об этом предупреждает - правило, привязанное к несуществующему интерфейсу, не совпадает ни с чем и лишь выглядит как защита.
Если вы сейчас подумали “а меня это не отрежет?” - именно про это раздел Защита управляющего доступа ниже: роль разрешает текущее соединение самым первым правилом и отказывается применять конфиг, если не может этого гарантировать.
Наборы @wan_local4/@wan_local6 - это исключения из защитного механизма, и по умолчанию они пустые. Заполнять их стоит только для сегмента, которому вы действительно доверяете: освобождённый от этой защиты приватный источник попадает в input_lan, где input_lan_allow_all разрешает всё, то есть вы доверяете всей on-link подсети, включая любого, кто способен подменить в ней адрес. Готовые значения роль выводит из фактов - nft_wan_local4 (сети интерфейса) и nft_wan_local6 (его отдельные адреса, не подсети):
nft_wan_local4_networks: "{{ nft_wan_local4 }}"
nft_wan_local6_networks: []Ограничение частоты SSH-подключений на каждый IP. Для этого используется динамический набор с таймаутом:
nft_sets:
- family: inet
table: filter
name: ssh_flood4
type: ipv4_addr
flags: dynamic
size: 65536
timeout: 10m
nft_rules:
wan_ssh_flood_ipv4:
order: 108
family: inet
table: filter
chain: input_wan
match:
meta:
l4proto: tcp
tcp:
dport: "{{ ssh_port | default('22') }}"
ct:
state: new
raw: "add @ssh_flood4 { ip saddr limit rate over 20/minute }"
action:
jump: log_dropДинамический набор хранит состояние лимита для каждого источника: новые SSH-подключения сверх 20/minute отбрасываются. Это не фиксированный бан на 10 минут - timeout относится к записи набора. Реальный IPv4 burst проверен на тестовой виртуальной машине, но точный refill лимита, справедливость между источниками и IPv6 flood отдельно не проверялись.
Что получается на выходе
Роль превращает весь этот YAML в обычный, читаемый конфиг nftables. Вот фрагмент того, что оказывается в /etc/nftables.conf на сервере из примера выше:
#!/usr/sbin/nft -f
add table inet filter
delete table inet filter
add table inet nat
delete table inet nat
add table inet filter
add table inet nat
add set inet filter lan4 {
type ipv4_addr; flags interval; elements = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 };
}
add chain inet filter input {
type filter hook input priority -1;
policy drop;
}
add chain inet filter input_wan
add rule inet filter input iifname "eth0" ip saddr 203.0.113.77 tcp dport 22 ct state { new, established } accept comment "Keep the active management path reachable"
add rule inet filter input meta l4proto ipv6-icmp icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept comment "Allow IPv6 neighbour discovery"
add rule inet filter input meta l4proto udp udp sport 67 udp dport 68 iifname eth0 accept comment "Allow DHCPv4 client replies"
add rule inet filter input meta l4proto udp ip6 saddr fe80::/10 udp sport 547 udp dport 546 iifname eth0 accept comment "Allow DHCPv6 client replies"
add rule inet filter input ct state invalid drop comment "Drop invalid connections"
add rule inet filter input meta l4proto icmp icmp type redirect drop comment "Drop ICMP redirects"
add rule inet filter input meta l4proto ipv6-icmp icmpv6 type nd-redirect drop comment "Drop ICMPv6 redirects"
add rule inet filter input ct state { established, related } accept comment "Allow established connections"
add rule inet filter input iifname "eth0" ip saddr @lan4 ip saddr != @wan_local4 jump log_drop comment "Drop spoofed private IPv4 from WAN"
add rule inet filter input iif lo accept comment "Allow loopback"
add rule inet filter input ip saddr @trusted accept comment "Allow trusted IPs"
add rule inet filter input ip6 saddr @trusted6 accept comment "Allow trusted IPv6 addresses"
add rule inet filter input ip saddr @lan4 jump input_lan
add rule inet filter input ip saddr != @lan4 jump input_wan
add rule inet filter input_lan accept comment "Allow all from LAN"
add rule inet filter input_wan ct state new meta l4proto tcp tcp dport 22 add @ssh_flood4 { ip saddr limit rate over 20/minute } jump log_drop comment "Drop SSH flood per IPv4 source"
add rule inet filter input_wan ct state new meta l4proto tcp tcp dport 22 accept comment "SSH rate limited per source"
add rule inet filter input_wan tcp dport { 80, 443 } accept comment "Allow TCP ports from WAN"
add rule inet filter input_wan jump log_drop comment "Default WAN drop"
add rule inet filter log_drop limit rate 5/second log prefix "NFT-DROP: " flags all
add rule inet filter log_drop dropПара замечаний:
- Конструкция
add table→delete table→add tableделает применение атомарным и идемпотентным: таблицы пересоздаются целиком, при повторном запуске правила не дублируются, а чужие таблицы (например, созданныеDocker) остаются нетронутыми. Исключение -nft_flush_ruleset: true: он начинает конфиг сflush rulesetи сносит в том числе таблицыDocker, поэтому в такой конфигурации нуженnft_restart_docker: true, иначе проброшенные порты контейнеров останутся без правил до перезапуска демона. - Список портов
[80, 443]автоматически свернут в выражение{ 80, 443 }, а полеcommentиз YAML попало в комментарии - удобно при отладке черезnft list ruleset. - Первое правило в цепочке
inputроль сгенерировала сама - это разрешение текущего управляющего соединения, подробности ниже.
Индивидуальные настройки хоста
Базовая политика покрывает типовой случай. Для исключений есть переменная nft_rules_extra, которая накладывается поверх nft_rules с рекурсивным слиянием словарей. А переменная nft_rules_host уже поверх nft_rules_extra. Это позволяет не копировать всю политику в host_vars, а описать только отличия. Три основных сценария:
nft_rules_extra:
# 1. Добавить новое правило
allow_prometheus:
order: 115
family: inet
table: filter
chain: input_wan
match:
ip:
saddr: 10.20.20.5
tcp:
dport: 9100
action: accept
comment: "Prometheus scrape"
# 2. Удалить правило из базовой политики
wan_allow_ssh:
# 3. Изменить отдельное поле существующего правила
wan_ssh_flood_ipv4:
match:
raw: "add @ssh_flood4 { ip saddr limit rate over 5/minute }"- Добавление - просто новый ключ.
- Удаление - присваиваем ключу
null, роль отфильтрует его перед рендерингом. - Изменение - указываем только то поле, которое нужно поправить, остальное сохранится из базового правила.
Итоговый порядок слияния такой:
nft_rules → mgmt-правило → nft_allowed_ports → nft_rules_extra → nft_rules_host
(база) (роль, order 1) (быстрые порты) (группа/хост) (уровень хоста)Правило управляющего доступа роль подмешивает сразу после базы, поэтому его при необходимости можно переопределить или “занулить” через nft_rules_extra/nft_rules_host.
⚠️Предупреждение
По умолчанию Ansible заменяет словарь целиком: nft_rules_extra из host_vars полностью вытеснит nft_rules_extra из group_vars.
Рекомендуемый способ - держать групповые дополнительные правила в nft_rules_extra, а хостовые в отдельной переменной nft_rules_host: роль сливает её последним уровнем, и глобальная настройка не нужна. Если же вам всё-таки нужно задавать один и тот же словарь на двух уровнях инвентори, включите hash_behaviour = merge самостоятельно:
[defaults]
hash_behaviour = mergeНастройка помечена в ansible-core как устаревшая, и в examples/ansible.cfg роли её нет - именно поэтому появился nft_rules_host.
Также стоит помнить, что внутри одного правила списки заменяются, а не дополняются. Например, tcp.dport: [80] в nft_rules_extra для wan_allow_tcp_ports заменит список [80, 443] целиком, а не добавит к нему 80.
Защита управляющего доступа
Перед рендерингом правил роль читает живое управляющее соединение из $SSH_CONNECTION на целевом хосте (не из инвентори: при ProxyJump, NAT или bastion адрес, который видит сервер, не равен ansible_host) и добавляет первым правилом цепочки:
iifname "<интерфейс>" ip saddr <адрес админа> tcp dport <порт сессии> ct state { new, established } acceptПлюс отказывается применять конфигурацию, если не может гарантировать защиту:
| Проверка | Почему |
|---|---|
интерфейс управляющего источника разрешается через ip route get | правило, привязанное к чужому интерфейсу, не сработает |
цепочка nft_mgmt_chain существует в nft_chains | иначе правило просто некуда вставить |
у правила самый низкий order в цепочке | иначе более раннее drop успеет раньше |
источники - корректные адреса/CIDR одной семьи, не 0.0.0.0/0 | wildcard в правиле, которое принимает первым, недопустим |
ssh_port совпадает с портом живой сессии | иначе SSH-правила защищают порт, на который никто не подключается |
| сумма таймаутов проверок меньше таймаута отката | иначе таймер откатит здоровый хост посреди проверки |
Параметры:
nft_mgmt_allow_enabled: true # выключает всю защиту целиком
nft_mgmt_sources: [] # пусто - определять адрес автоматически
nft_mgmt_interface: "" # пусто - определять через "ip route get"
nft_mgmt_match_interface: true # false при асимметричной маршрутизации
nft_mgmt_order: 1
nft_mgmt_assert_ssh_port: true❗️ Осторожно
У этой защиты есть цена. Правило попадает в /etc/nftables.conf как обычное - именно это сохраняет доступ после перезагрузки, - а значит: SSH с этого адреса обходит ограничения политики (rate limit, доступ только из trusted); общий или динамический адрес (NAT-шлюз, бастион, CI-раннер) распространяет этот обход на всех за ним и на следующего владельца IP; а запуски с разных управляющих машин каждый раз перезаписывают правило и дают changed. Если это важнее доступности - зафиксируйте nft_mgmt_sources диапазоном администраторов или выключите правило и опирайтесь на таймер отката.
💡 Совет
Лимит в 20 новых SSH-подключений в минуту на один IP работает и против вас. Ansible, запущенный без SSH-мультиплексирования, открывает много соединений и сам упрётся в лимит. В настройках по умолчанию Ansible использует ControlMaster/ControlPersist, но если вы переопределяете ssh_args, не забудьте их оставить и добавить keepalive:
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=5 -o ServerAliveCountMax=3 -o ConnectTimeout=10Keepalive здесь - часть подстраховки, а не косметика. Без него правило, отрезавшее управляющее соединение, не роняет задачу: Ansible висит на мёртвой TCP-сессии, таймер успевает откатить хост под ним, сессия оживает на поздней ретрансмиссии, и все последующие проверки проходят - на уже откаченном хосте. Транзакция всё равно не закоммитится, но причина в выводе будет куда менее очевидной. Такой ssh_args уже лежит в examples/ansible.cfg роли.
Гибкость: NAT, редирект и “сырой” nftables
Если описанных в DSL конструкций недостаточно, роль не загоняет в рамки.
DNAT: проброс порта на внутренний сервис
nft_rules_extra:
nat_dnat_crow:
order: 400
family: inet
table: nat
chain: prerouting
match:
ip:
daddr: 198.51.100.5
tcp:
dport: 8443
counter: true
action:
dnat:
ip: "192.168.10.20:443"
comment: "DNAT 8443 to internal crow.r4ven.me"Для семейства inet в dnat нужно явно указать версию протокола (ip или ip6), и тогда роль сгенерирует корректное dnat ip to ....
Редирект на локальный порт
nft_rules_extra:
nat_redirect_dns:
order: 404
family: inet
table: nat
chain: prerouting
match:
ip:
daddr: 198.51.100.5
meta:
l4proto:
- tcp
- udp
th:
dport: 53
action:
redirect: ":553"
comment: "Redirect DNS to local service on 553"Произвольные выражения
Для нетривиальных случаев есть три “аварийных выхода”: match.raw, expr на уровне правила и action.raw. Например, защита от подделки через FIB:
nft_rules_extra:
anti_spoof:
order: 70
family: inet
table: filter
chain: input
expr: "fib saddr . iif oif missing"
action: dropFlowtables для маршрутизаторов
Если хост работает шлюзом, можно включить программное ускорение пересылки трафика (flowtable offload):
nft_flowtables:
- family: inet
table: filter
name: ft
devices:
- eth0
- eth1
nft_rules_extra:
offload_established:
order: 204
family: inet
table: filter
chain: forward
match:
ct:
state: established
meta:
l4proto:
- tcp
- udp
action:
flow: ftФактически, любой синтаксис nftables можно выразить либо через DSL, либо через raw - при этом остальные удобства (порядок, слияние, проверка, откат) продолжают работать.
Безопасное применение
Самое важное в удалённом управлении фаерволом - не отрезать себя от сервера. Поэтому применение в роли выполняется по строгому сценарию:
- Preflight. Роль определяет живое управляющее соединение, строит для него разрешающее правило и отказывается продолжать, если защитить этот путь нельзя (см. раздел выше). Здесь же проверяется, что
ssh_portсовпадает с портом реальной сессии, а таймауты проверок укладываются в таймаут отката. - Сравнение и staging. Роль сравнивает отрендеренный конфиг без записи в
/etc/nftables.conf. Если есть изменение, создаёт приватный каталог транзакции вnft_state_dirи staging-файлcandidate, проверенныйnft -c -fдо замены рабочего конфига и применения правил. Это не “до попадания на диск”: кандидат уже лежит во временном файле. - Snapshot. Сохраняется текущий runtime ruleset, а не только потенциально устаревший конфиг. Отдельно сохраняются исходный файл (включая
symlink) либо факт его отсутствия, состояние службыenabled/runtime-enabled,masked/runtime-maskedи то, была ли она активна. - Независимый таймер. До применения создаётся
transientsystemd-таймер с уникальным именем транзакции - независимо от проверок связи (nft_rollback_enabled). Он восстановит runtime, конфиг (или его отсутствие) и состояние службы без участияAnsible. Точность таймера фиксируется (AccuracySec), иначеsystemdобъединяет срабатывания и таймаут превращается в пожелание. Дополнительно на время транзакции ставится персистентныйOnBootSec-юнит: перезагрузка между применением и коммитом иначе оставила бы хост без правил и с замаскированной службой, а transient-таймеры её не переживают. - История и применение. Только при изменении содержимого предыдущий конфиг сохраняется в
/root/nft_backup/какnftables.conf_<sha256>с правами0600. Уже существующий hash не копируется повторно и его mtime не обновляется. Кандидат заменяет рабочий файл, служба временно маскируется, правила загружаются черезnft -f. - Проверка связи и commit. По умолчанию с
nft_ssh_check_delegateпроверяется TCP-доступность SSH-порта; опционально (nft_ssh_check_connection) роль заодно переподключается собственным путёмAnsible.apply,rollback,arm,disarmиcommitиспользуют общийflockи marker состояния:commitразрешён только изpending. Если таймер уже начал откат, даже позднее успешное подключение не выдаст ложный успех - вместо пустогоrc=1вы получите сообщение о том, что управляющее соединение было потеряно и состояние восстановлено таймером. - Завершение. Успешный
commitснимает маску, включаетnftables, разоружает таймер иOnBootSec-юнит, удаляет каталог транзакции и записывает отпечаток управляемых таблиц. После успешного изменения остаются последниеnft_backup_keepуникальных предыдущих конфигов по mtime (по умолчанию 10). Идентичный повтор даётchanged=0.
При ошибке rescue пытается откатить транзакцию немедленно, если хост ещё доступен. Если SSH полностью потерян, этот путь тоже недоступен - тогда остаётся вооружённый независимый таймер. Неудачная транзакция возвращает ошибку с результатом попытки восстановления, оставляет snapshots и таймер для расследования, а не объявляет откат успешным на словах; убрать оставленную подстраховку вручную можно командой <каталог транзакции>/transaction disarm.
Ещё две вещи, которые роль делает, чтобы не навредить сама себе:
- Повторный запуск задачи безопасен.
Ansibleповторяет задачу после обрыва SSH, поэтомуprepareне переснимает снапшот (иначе он записал бы уже применённые правила как “исходные”), аcommitне падает на уже закоммиченной транзакции. - Пересекающиеся транзакции запрещены. Новая транзакция откажется стартовать, пока рядом есть
pendingилиrollback-failed: таймер старой отменил бы коммит новой. Завершённые соседи подбираются автоматически.
Отдельно - контроль дрейфа. Роль ориентируется не только на файл конфигурации: после коммита она сохраняет отпечаток управляемых таблиц, и если живой ruleset ему больше не соответствует (кто-то сделал nft flush ruleset, загрузил правила руками или служба их не подняла), транзакция выполняется снова, даже когда отрендеренный конфиг не изменился. Счётчики, содержимое динамических наборов и чужие таблицы (например, Docker) из сравнения исключены.
Это снижает риск, но не даёт абсолютной гарантии 😉. Проверка TCP-порта не проверяет SSH-аутентификацию и все маршруты управления. Таймер зависит от работающих systemd, nft, самой машины и snapshots. После перезагрузки внутри окна транзакции хост остаётся без фаервола до срабатывания OnBootSec-юнита. Держите под рукой консоль или снапшот ВМ.
Параметры можно подстроить под себя:
nft_rollback_enabled: true # независимая подстраховка (не зависит от проверок)
nft_rollback_timeout: 120 # через сколько секунд откатывать
nft_rollback_on_boot: true # восстановление после перезагрузки в окне
nft_state_dir: /var/lib/nftables-firewall
nft_ssh_check_enabled: true # проверять доступность после применения
nft_ssh_check_tcp: true # TCP-проба с delegate (по умолчанию)
nft_ssh_check_connection: false # переподключение собственным путём Ansible
nft_ssh_check_timeout: 30 # сколько ждать ответа
nft_drift_check_enabled: true # реагировать на дрейф живого ruleset
nft_backup_keep: 10 # сколько уникальных предыдущих конфигов хранитьЕсли откат всё-таки случился
Выглядит это так (вывод с моего тестового прогона, где правило намеренно отрезало SSH):
TASK [firewall : Commit only if rollback has not started] ***
fatal: [crow.r4ven.me]: FAILED! => {"cmd": [".../transaction", "commit"], "rc": 1,
"stderr": "Transaction state is 'rolled-back', not 'pending': the rollback timer
already fired or the transaction was rolled back. The control connection was most
likely lost after the new rules were applied, so the independent timer restored
the previous state."}
TASK [firewall : Report failed transaction without claiming successful restoration] ***
fatal: [crow.r4ven.me]: FAILED! => {"msg": "nftables transaction failed: ...
Rollback rc=0. Transaction snapshots remain in
/var/lib/nftables-firewall/nft-transaction-ir9gi5kj; any armed timer is left
intact; failed restoration requires investigation. Clean up with
\"/var/lib/nftables-firewall/nft-transaction-ir9gi5kj/transaction disarm\"
once the state is understood."}Что это значит и что делать по шагам:
- Хост уже восстановлен - правила, конфиг и состояние службы вернулись к тому, что было до запуска. Убедитесь сами:
nft list rulesetиsha256sum /etc/nftables.conf. - Причина - в
stderrпервой ошибки.rolled-backзначит, что сработал независимый таймер, то есть связь была потеряна после применения правил. Если же задача упала на проверке доступности, в сообщении будет именно таймаут проверки - тогда правила отбросили новые подключения, а установленная сессия выжила. - Снимите оставленную подстраховку. Роль намеренно не удаляет снапшот и не разоружает таймер при неудаче - иначе вы потеряли бы и улики, и защиту. Команда напечатана в ошибке:
<каталог транзакции>/transaction disarm. - Пока подстраховка не снята, следующий запуск откажется стартовать - рядом с незавершённой транзакцией новая не создаётся, потому что её таймер отменил бы ваш новый коммит. Это не баг, а защита от двух пересекающихся применений.
- Правьте причину, не защиту. Предыдущие конфиги лежат в
/root/nft_backup/какnftables.conf_<sha256>, так что сравнить “что было и что стало” можно без догадок.
☝️ Если консоль недоступна, а правила уже применены, самое полезное - ничего не менять до срабатывания таймера: он восстановит состояние сам, в пределах nft_rollback_timeout (плюс точность таймера systemd). Перезагрузка в этот момент тоже не страшна: на время транзакции роль ставит отдельный юнит, который восстановит снапшот после загрузки.
Хосты за бастионом
Если до сервера вы добираетесь через ProxyJump, у управляющей машины нет прямого маршрута к SSH-порту цели, и TCP-проба по умолчанию будет проваливаться (а значит, будет происходить откат). Есть два решения.
Делегировать проверку на хост, который видит цель напрямую (он должен быть в инвентори):
nft_ssh_check_delegate: bastionЛибо проверять связь собственным путём Ansible - он ходит через тот же ProxyJump:
nft_ssh_check_connection: true
nft_ssh_check_tcp: false📝 Примечание
Почему проверка через соединение Ansible не включена по умолчанию: чтобы убедиться, что работает новое подключение, роль сначала разрывает постоянное соединение, а вместе с ним теряется та самая установленная сессия, по которой rescue мог бы откатить правила немедленно. В этом режиме восстановление ложится на таймер. Поэтому по умолчанию используется TCP-проба: она тоже замечает оборванный путь для новых подключений, но оставляет живую сессию в покое.
Отключение проверок: nft_ssh_check_enabled: false.
Теги и частичный запуск
Все этапы роли помечены тегами, так что можно запускать только нужную часть:
| Тег | Что делает |
|---|---|
firewall | Вся роль целиком |
nft_packages | Только установка пакета nftables |
nft_rules | Только сборка словаря правил |
nft_config | Сборка правил и тот же транзакционный apply (без --check применяет изменения) |
nft_apply | Сборка правил и применение с откатом; без установки пакетов |
Например, чтобы быстро выкатить изменения в правилах:
ansible-playbook playbooks/firewall.yml -l crow.r4ven.me -t nft_apply-t nft_apply требует уже установленного nftables: тег пропускает установку пакета. Для первого запуска используйте всю роль.
Типовые задачи за пару строк
Вкратце, какие переменные менять в типовых случаях:
| Задача | Что поменять |
|---|---|
| Открыть порт на одном сервере | Добавить "8080/tcp" в nft_allowed_ports хоста |
| Дать доступ с офисного IP на все серверы | Добавить IP в nft_trusted_ips в group_vars |
| Закрыть SSH из интернета на одном хосте | wan_allow_ssh: null в nft_rules_extra |
| Пробросить порт на внутренний сервис | Правило с dnat в таблице nat |
Перейти с ufw на nftables | nft_remove_other_firewall: true |
| Подключить новый сервер | firewall_enabled: true и список портов в host_vars |
| Откатить неудачное изменение | rescue пытается восстановить snapshot сразу, независимый таймер - страховка при потере связи. История конфигов - в /root/nft_backup/ |
И всё это хранится в git: у вас есть история изменений фаервола, ревью через merge request и воспроизводимость 👍.
Заключение
В итоге политика хранится в YAML, а на сервере остаётся обычный /etc/nftables.conf. Если что-то пошло не так, можно посмотреть diff, проверить сгенерированные правила и откатить применение. Для меня это удобнее, чем поддерживать отдельный конфиг руками на каждом сервере.
Тестирование роли с помощью ИИ: что проверено, а что нет
Роль проверяется “разрушительным” набором сценариев (tests/run_vm.py) с реальным обрывом SSH, откатами и перезагрузками. Последний прогон - на трёх целях, везде 0 FAIL:
- ВМ
Debian 13с--reboot- 24 PASS; - арендованный хост
Debian 13с публичным адресом,--reboot --docker --bastion- 26 PASS; - арендованный хост
Ubuntu 26.04, те же флаги - 26 PASS.
Покрыто: применение и идемпотентность, немедленный откат и откат независимым таймером при полной потере SSH, гонка “таймер успел раньше коммита”, воспроизведение самого локаута, детект ручного вмешательства в правила, пересекающиеся транзакции, повторный запуск задачи после обрыва, доступ через бастион (ProxyJump и делегированная проверка), сосуществование с Docker и его проброшенными портами, перезагрузка внутри незавершённой транзакции и после коммита.
Чего в проверке нет: маршрутизируемого IPv6 (на всех целях был только link-local), хоста с несколькими интерфейсами и маршрутизацией транзита, nftables старше 1.1.3, точного тайминга refill у rate limit и IPv6-flood. Отдельно отмечу: в базовой политике ниже цепочка forward остаётся с политикой accept - для маршрутизатора это осознанно нужно менять. Сценарии, логи и границы - в tests/README.md репозитория роли.
Спасибо, что читаете. Удачи вам в автоматизации рутины 🙂.
👨💻Ну и…
Не забывайте про нашу телегу📱и чат 💬
Или может хотите стать соавтором? Тогда клик сюда🔗
Всех благ✌️
That should be it. If not, check the logs 🙂


