Ansible: управляем фаерволом на nftables с помощью роли firewall
Приветствую!

Покажу, как я использую свою Ansible роль firewall для управления nftables: общая политика хранится в group_vars, отличия конкретных серверов - в host_vars. Заодно разберём, что роль делает, чтобы не отрезать SSH при применении правил 🔥.

Введение

Пока серверов один-два, фаервол настраивается руками или самописными скриптами: несколько правил в /etc/nftables.conf, systemctl enable nftables и готово. Но когда хостов становится десятки, а то и сотни, рутину необходимо автоматизировать. Иначе следующих проблем не избежать:

Решение - описывать фаервол декларативно и раскатывать через Ansible. Именно для этого я подготовил роль firewall.

От команд nftables к переменным роли

Для начала сравним одну и ту же задачу в двух форматах: разрешить из внешней сети HTTP и HTTPS по TCP, а также QUIC по UDP/443.

Вручную это можно сделать командами nft:

BASH
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"'
Нажмите, чтобы развернуть и увидеть больше

А в конфигурационном файле те же правила выглядят как блок:

NFT
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:

YAML
firewall_enabled: true
nft_allowed_ports:
  - "80/tcp"
  - "443/tcp"
  - "443/udp"
Нажмите, чтобы развернуть и увидеть больше

При наличии базовой политики, которую разберём ниже, роль сама сформирует правила в input_wan, поставит их перед запретом и сохранит результат в /etc/nftables.conf.

Если нужен не короткий список портов, а явное описание правил, вместо nft_allowed_ports можно использовать YAML-формат роли:

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
nftables1.1.x
AnsibleCore 2.17+

Подготовка

Окружение для Ansible (опционально)

Если Ansible у вас ещё не настроен, проще всего взять готовый рабочий каталог - r4ven-me/ansible-workspace. В нём уже есть ansible.cfg, пример inventory, плейбук и requirements.yml для установки роли.

BASH
git clone --branch raven https://github.com/r4ven-me/ansible-workspace.git

cd ./ansible-workspace/
Нажмите, чтобы развернуть и увидеть больше

Структура каталога:

Вывод
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:

BASH
sudo apt update && sudo apt install pipx ssh-askpass

pipx install ansible-core

pipx ensurepath
Нажмите, чтобы развернуть и увидеть больше

Перед запуском роли замените адрес 10.10.10.10 и пользователя в inventory/hosts.yml на свои, например, с помощью редактора vim или neovim.

Проверяем доступность:

BASH
ansible --become --module-name ping crow.r4ven.me

ansible --become --module-name ping birds
Нажмите, чтобы развернуть и увидеть больше

На управляемом сервере обязательно ставим пакет python3-apt:

BASH
ansible --become --module-name shell --args 'apt install --yes python3-apt' crow.r4ven.me
Нажмите, чтобы развернуть и увидеть больше

Установка роли

Роль ставится из Git-репозитория через ansible-galaxy. Самый наглядный способ - указать ссылку на репозиторий и тег прямо в команде:

BASH
ansible-galaxy role install git+https://github.com/r4ven-ansible/firewall.git,v1.0.0
Нажмите, чтобы развернуть и увидеть больше

Формат: git+<url>,<тег или ветка>. Версию лучше закреплять тегом, чтобы обновление роли не происходило неожиданно.

Для воспроизводимости ссылки на роли фиксируют в requirements.yml (в workspace он уже есть):

YAML
roles:
  - name: firewall
    src: https://github.com/r4ven-ansible/firewall.git
    scm: git
    version: v1.0.0
collections: []
Нажмите, чтобы развернуть и увидеть больше

и ставят командой:

BASH
ansible-galaxy role install -r ./requirements.yml
Нажмите, чтобы развернуть и увидеть больше

Проверим, что роль на месте:

BASH
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.

В ином случае, просто создайте/обновите файл (./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) много места под свои правила, поэтому добавлять их можно не переписывая существующие.

YAML
---

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
Нажмите, чтобы развернуть и увидеть больше

Подробнее правила разберём чуть ниже, а пока…

Применение базовой конфигурации

Плейбук для запуска минимален. Создадим его с помощью механизмов перенаправления и here doc:

BASH
cat > ./playbooks/firewall.yml <<EOF
---

- name: Setup firewall
  hosts: all
  gather_facts: true
  become: true
  roles:
    - role: firewall
EOF
Нажмите, чтобы развернуть и увидеть больше

Допустим, у нас в инвентаре есть веб-сервер crow.r4ven.me. Включаем для него роль фаервола хост переменными:

BASH
cat > ./inventory/host_vars/crow.r4ven.me.yml <<EOF
---

nft_remove_other_firewall: true
firewall_enabled: true
EOF
Нажмите, чтобы развернуть и увидеть больше

Здесь:

Сначала посмотрим, что изменится на нём, не применяя ничего (diff + check mode):

BASH
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:

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff
Нажмите, чтобы развернуть и увидеть больше

Или сразу на группу хостов:

BASH
ansible-playbook ./playbooks/firewall.yml --limit birds --diff
Нажмите, чтобы развернуть и увидеть больше

По завершению сразу же проверяем доступность хоста:

BASH
ansible --become --module-name ping crow.r4ven.me
Нажмите, чтобы развернуть и увидеть больше

Как быстро открыть новые порты

Теперь всё, что нужно - это добавить в inventory/host_vars/crow.r4ven.me.yml специальную переменную nft_allowed_ports:

BASH
cat >> ./inventory/host_vars/crow.r4ven.me.yml <<EOF
nft_allowed_ports:
  - "80/tcp"
  - "443/tcp"
  - "443/udp"
EOF
Нажмите, чтобы развернуть и увидеть больше

Смотрим изменения:

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff --check
Нажмите, чтобы развернуть и увидеть больше

Если всё ок, применяем:

BASH
ansible-playbook ./playbooks/firewall.yml --limit crow.r4ven.me --diff
Нажмите, чтобы развернуть и увидеть больше

Разбор базовой политики в group_vars

Как уже ранее говорилось, общую политику задаём один раз для группы хостов. Ниже её разбор по частям.

Таблицы и наборы

YAML
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). Ни одного правила править не нужно.

Цепочки

YAML
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)

Фрагмент базовой политики:

YAML
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"
Нажмите, чтобы развернуть и увидеть больше

Логика получается такая:

  1. Самым первым правилом (order: 1) роль сама разрешает текущее управляющее соединение - о нём отдельный раздел ниже;
  2. Пропускаем то, что обязано работать вне conntrack: IPv6 neighbour discovery (order: 5) и ответы DHCP-клиента (6, 7);
  3. Отбрасываем “битые” пакеты (invalid, order: 10) и ICMP-редиректы (11, 12);
  4. Пропускаем ответы на уже установленные соединения (established, order: 15);
  5. Отбрасываем приватные источники на WAN: правила защиты от подделки адреса с order: 20/21 (в коротком фрагменте выше он опущен);
  6. Пропускаем loopback и доверенные адреса из наборов trusted/trusted6;
  7. Разделяем трафик по источнику: из частных сетей - в input_lan, из остальных - в input_wan;
  8. Из LAN разрешено всё, из WAN - только то, что явно открыто правилами с order от 100 до 197;
  9. Всё остальное в конце input_wan отправляется в log_drop - логируется с ограничением частоты и сбрасывается.

Обратите внимание на порядок: числа order - это “слоты”. Между 110 (SSH) и 198 (запрет по умолчанию) остаётся много места, куда можно добавлять свои правила, не переписывая существующие. Именно поэтому порты из nft_allowed_ports попадают в слоты 111 (TCP) и 112 (UDP).

Защита от спуфинга и перебора SSH

В политику уже включены две полезные вещи, которые обычно забывают сделать вручную.

Защита от подделки адресов. Новые пакеты из внешнего интерфейса, пришедшие якобы с приватного адреса, отбрасываются:

YAML
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 (его отдельные адреса, не подсети):

YAML
nft_wan_local4_networks: "{{ nft_wan_local4 }}"
nft_wan_local6_networks: []
Нажмите, чтобы развернуть и увидеть больше

Ограничение частоты SSH-подключений на каждый IP. Для этого используется динамический набор с таймаутом:

YAML
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 на сервере из примера выше:

NFT
#!/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
Нажмите, чтобы развернуть и увидеть больше

Пара замечаний:

Индивидуальные настройки хоста

Базовая политика покрывает типовой случай. Для исключений есть переменная nft_rules_extra, которая накладывается поверх nft_rules с рекурсивным слиянием словарей. А переменная nft_rules_host уже поверх nft_rules_extra. Это позволяет не копировать всю политику в host_vars, а описать только отличия. Три основных сценария:

YAML
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 }"
Нажмите, чтобы развернуть и увидеть больше

Итоговый порядок слияния такой:

Вывод
nft_rules  →  mgmt-правило  →  nft_allowed_ports  →  nft_rules_extra  →  nft_rules_host
(база)        (роль, order 1)   (быстрые порты)       (группа/хост)       (уровень хоста)
Нажмите, чтобы развернуть и увидеть больше

Правило управляющего доступа роль подмешивает сразу после базы, поэтому его при необходимости можно переопределить или “занулить” через nft_rules_extra/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/0wildcard в правиле, которое принимает первым, недопустим
ssh_port совпадает с портом живой сессиииначе SSH-правила защищают порт, на который никто не подключается
сумма таймаутов проверок меньше таймаута откатаиначе таймер откатит здоровый хост посреди проверки

Параметры:

YAML
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
Нажмите, чтобы развернуть и увидеть больше

Гибкость: NAT, редирект и “сырой” nftables

Если описанных в DSL конструкций недостаточно, роль не загоняет в рамки.

DNAT: проброс порта на внутренний сервис

YAML
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 ....

Редирект на локальный порт

YAML
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:

YAML
nft_rules_extra:
  anti_spoof:
    order: 70
    family: inet
    table: filter
    chain: input
    expr: "fib saddr . iif oif missing"
    action: drop
Нажмите, чтобы развернуть и увидеть больше

Flowtables для маршрутизаторов

Если хост работает шлюзом, можно включить программное ускорение пересылки трафика (flowtable offload):

YAML
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 - при этом остальные удобства (порядок, слияние, проверка, откат) продолжают работать.

Безопасное применение

Самое важное в удалённом управлении фаерволом - не отрезать себя от сервера. Поэтому применение в роли выполняется по строгому сценарию:

  1. Preflight. Роль определяет живое управляющее соединение, строит для него разрешающее правило и отказывается продолжать, если защитить этот путь нельзя (см. раздел выше). Здесь же проверяется, что ssh_port совпадает с портом реальной сессии, а таймауты проверок укладываются в таймаут отката.
  2. Сравнение и staging. Роль сравнивает отрендеренный конфиг без записи в /etc/nftables.conf. Если есть изменение, создаёт приватный каталог транзакции в nft_state_dir и staging-файл candidate, проверенный nft -c -f до замены рабочего конфига и применения правил. Это не “до попадания на диск”: кандидат уже лежит во временном файле.
  3. Snapshot. Сохраняется текущий runtime ruleset, а не только потенциально устаревший конфиг. Отдельно сохраняются исходный файл (включая symlink) либо факт его отсутствия, состояние службы enabled/runtime-enabled, masked/runtime-masked и то, была ли она активна.
  4. Независимый таймер. До применения создаётся transient systemd-таймер с уникальным именем транзакции - независимо от проверок связи (nft_rollback_enabled). Он восстановит runtime, конфиг (или его отсутствие) и состояние службы без участия Ansible. Точность таймера фиксируется (AccuracySec), иначе systemd объединяет срабатывания и таймаут превращается в пожелание. Дополнительно на время транзакции ставится персистентный OnBootSec-юнит: перезагрузка между применением и коммитом иначе оставила бы хост без правил и с замаскированной службой, а transient-таймеры её не переживают.
  5. История и применение. Только при изменении содержимого предыдущий конфиг сохраняется в /root/nft_backup/ как nftables.conf_<sha256> с правами 0600. Уже существующий hash не копируется повторно и его mtime не обновляется. Кандидат заменяет рабочий файл, служба временно маскируется, правила загружаются через nft -f.
  6. Проверка связи и commit. По умолчанию с nft_ssh_check_delegate проверяется TCP-доступность SSH-порта; опционально (nft_ssh_check_connection) роль заодно переподключается собственным путём Ansible. apply, rollback, arm, disarm и commit используют общий flock и marker состояния: commit разрешён только из pending. Если таймер уже начал откат, даже позднее успешное подключение не выдаст ложный успех - вместо пустого rc=1 вы получите сообщение о том, что управляющее соединение было потеряно и состояние восстановлено таймером.
  7. Завершение. Успешный commit снимает маску, включает nftables, разоружает таймер и OnBootSec-юнит, удаляет каталог транзакции и записывает отпечаток управляемых таблиц. После успешного изменения остаются последние nft_backup_keep уникальных предыдущих конфигов по mtime (по умолчанию 10). Идентичный повтор даёт changed=0.

При ошибке rescue пытается откатить транзакцию немедленно, если хост ещё доступен. Если SSH полностью потерян, этот путь тоже недоступен - тогда остаётся вооружённый независимый таймер. Неудачная транзакция возвращает ошибку с результатом попытки восстановления, оставляет snapshots и таймер для расследования, а не объявляет откат успешным на словах; убрать оставленную подстраховку вручную можно командой <каталог транзакции>/transaction disarm.

Ещё две вещи, которые роль делает, чтобы не навредить сама себе:

Отдельно - контроль дрейфа. Роль ориентируется не только на файл конфигурации: после коммита она сохраняет отпечаток управляемых таблиц, и если живой ruleset ему больше не соответствует (кто-то сделал nft flush ruleset, загрузил правила руками или служба их не подняла), транзакция выполняется снова, даже когда отрендеренный конфиг не изменился. Счётчики, содержимое динамических наборов и чужие таблицы (например, Docker) из сравнения исключены.

Это снижает риск, но не даёт абсолютной гарантии 😉. Проверка TCP-порта не проверяет SSH-аутентификацию и все маршруты управления. Таймер зависит от работающих systemd, nft, самой машины и snapshots. После перезагрузки внутри окна транзакции хост остаётся без фаервола до срабатывания OnBootSec-юнита. Держите под рукой консоль или снапшот ВМ.

Параметры можно подстроить под себя:

YAML
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."}
Нажмите, чтобы развернуть и увидеть больше

Что это значит и что делать по шагам:

  1. Хост уже восстановлен - правила, конфиг и состояние службы вернулись к тому, что было до запуска. Убедитесь сами: nft list ruleset и sha256sum /etc/nftables.conf.
  2. Причина - в stderr первой ошибки. rolled-back значит, что сработал независимый таймер, то есть связь была потеряна после применения правил. Если же задача упала на проверке доступности, в сообщении будет именно таймаут проверки - тогда правила отбросили новые подключения, а установленная сессия выжила.
  3. Снимите оставленную подстраховку. Роль намеренно не удаляет снапшот и не разоружает таймер при неудаче - иначе вы потеряли бы и улики, и защиту. Команда напечатана в ошибке: <каталог транзакции>/transaction disarm.
  4. Пока подстраховка не снята, следующий запуск откажется стартовать - рядом с незавершённой транзакцией новая не создаётся, потому что её таймер отменил бы ваш новый коммит. Это не баг, а защита от двух пересекающихся применений.
  5. Правьте причину, не защиту. Предыдущие конфиги лежат в /root/nft_backup/ как nftables.conf_<sha256>, так что сравнить “что было и что стало” можно без догадок.

Хосты за бастионом

Если до сервера вы добираетесь через ProxyJump, у управляющей машины нет прямого маршрута к SSH-порту цели, и TCP-проба по умолчанию будет проваливаться (а значит, будет происходить откат). Есть два решения.

Делегировать проверку на хост, который видит цель напрямую (он должен быть в инвентори):

YAML
nft_ssh_check_delegate: bastion
Нажмите, чтобы развернуть и увидеть больше

Либо проверять связь собственным путём Ansible - он ходит через тот же ProxyJump:

YAML
nft_ssh_check_connection: true
nft_ssh_check_tcp: false
Нажмите, чтобы развернуть и увидеть больше

Теги и частичный запуск

Все этапы роли помечены тегами, так что можно запускать только нужную часть:

ТегЧто делает
firewallВся роль целиком
nft_packagesТолько установка пакета nftables
nft_rulesТолько сборка словаря правил
nft_configСборка правил и тот же транзакционный apply (без --check применяет изменения)
nft_applyСборка правил и применение с откатом; без установки пакетов

Например, чтобы быстро выкатить изменения в правилах:

BASH
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 на nftablesnft_remove_other_firewall: true
Подключить новый серверfirewall_enabled: true и список портов в host_vars
Откатить неудачное изменениеrescue пытается восстановить snapshot сразу, независимый таймер - страховка при потере связи. История конфигов - в /root/nft_backup/

И всё это хранится в git: у вас есть история изменений фаервола, ревью через merge request и воспроизводимость 👍.

Заключение

В итоге политика хранится в YAML, а на сервере остаётся обычный /etc/nftables.conf. Если что-то пошло не так, можно посмотреть diff, проверить сгенерированные правила и откатить применение. Для меня это удобнее, чем поддерживать отдельный конфиг руками на каждом сервере.

Спасибо, что читаете. Удачи вам в автоматизации рутины 🙂.

Авторские права

Автор: Иван Чёрный

Ссылка: https://r4ven.me/automation/ansible-upravlenie-firewall-nftables-rol-firewall/

Лицензия: CC BY-NC-SA 4.0

Использование материалов блога разрешается при условии: указания авторства/источника, некоммерческого использования и сохранения лицензии.

Начать поиск

Введите ключевые слова для поиска статей

↑↓
↵
ESC
⌘K Горячая клавиша