MAATRIX / Блог / Ansible playbook: массовое развёртывание WireGuard

Ansible playbook: массовое развёртывание WireGuard

MAATRIX

Когда WireGuard стоит на одном сервере, установка занимает пять минут руками. Когда узлов десять — в трёх локациях, с разными ролями (хаб и точки выхода) — ручная настройка превращается в источник ошибок: один сервер получил не тот публичный ключ соседа, на другом забыли открыть 51820/udp, на третьем перепутали IP в подсети туннеля. Ansible playbook снимает эту проблему: ключи генерируются на каждом узле сами, конфиги peer'ов собираются из реального состояния всего парка, а весь парк приводится в нужный вид одной командой.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Топология: hub-and-spoke или full mesh

Прежде чем писать playbook, нужно решить, как узлы будут соединяться — от этого зависит, какие peer-блоки генерировать.

ТопологияКак устроенаКогда имеет смысл
Hub-and-spokeОдин центральный сервер (хаб) видит всех, точки — только хабТочки выхода не должны видеть друг друга; трафик маршрутизируется через хаб
Full meshКаждый узел видит каждого напрямуюНужна низкая задержка между узлами; их немного (обычно до 10–15)

Hub-and-spoke проще в автоматизации — на каждой точке всего один peer (хаб), а конфиг с полным списком peer'ов нужен только на самом хабе. Full mesh требует, чтобы на N серверах каждый содержал N−1 peer-блоков, и при добавлении нового узла нужно перегенерировать конфиг на всех остальных. Playbook ниже написан под hub-and-spoke как более частый сценарий для парка серверов под VPN-выход, но структура шаблона легко адаптируется под mesh — разница только в том, для какой группы хостов собирается список peer'ов.

Inventory: группы и адреса в WG-подсети

IP-адрес внутри туннеля (10.10.0.x) удобнее задавать явно в inventory, а не вычислять по индексу хоста — если сервер уберут из списка, у остальных адреса не «поедут».

# inventory.ini

[wireguard_hub]
hub1 ansible_host=203.0.113.10 wg_ip=10.10.0.1

[wireguard_spokes]
node-ru  ansible_host=198.51.100.20 wg_ip=10.10.0.2
node-uk  ansible_host=198.51.100.21 wg_ip=10.10.0.3
node-us  ansible_host=198.51.100.22 wg_ip=10.10.0.4

[wireguard:children]
wireguard_hub
wireguard_spokes

[wireguard:vars]
ansible_user=root
ansible_ssh_private_key_file=~/.ssh/id_ed25519
wg_port=51820
wg_subnet=10.10.0.0/24

Если Ansible ещё не установлен и SSH-ключи не разложены по серверам — это отдельный подготовительный шаг, разобранный в статье про установку и настройку Ansible на VPS. Дальше предполагается, что ansible all -i inventory.ini -m ping уже отвечает pong со всех узлов.

Арендуйте сервер под свои задачи!

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Роль wireguard: идемпотентная генерация ключей

Ключевой момент безопасности: приватный ключ должен генерироваться на самом узле и никогда не покидать его. Playbook создаёт пару ключей локально на каждом сервере через command с параметром creates — это делает задачу идемпотентной без специального модуля для WireGuard в Ansible (штатного модуля для этого нет).

# roles/wireguard/tasks/main.yml
---
- name: Установить WireGuard
  apt:
    name: wireguard
    state: present
    update_cache: true

- name: Создать директорию для ключей
  file:
    path: /etc/wireguard
    state: directory
    mode: "0700"

- name: Сгенерировать приватный ключ (если ещё не создан)
  shell: umask 077 && wg genkey > /etc/wireguard/privatekey
  args:
    creates: /etc/wireguard/privatekey

- name: Получить публичный ключ из приватного
  shell: wg pubkey < /etc/wireguard/privatekey > /etc/wireguard/publickey
  args:
    creates: /etc/wireguard/publickey

- name: Прочитать публичный ключ узла
  slurp:
    src: /etc/wireguard/publickey
  register: pubkey_raw

- name: Сохранить публичный ключ как факт хоста
  set_fact:
    wg_pubkey: "{{ (pubkey_raw.content | b64decode).strip() }}"

Последний шаг — самый важный для дальнейшей раздачи конфигов: set_fact кладёт публичный ключ в hostvars этого узла, и другие хосты в этом же прогоне playbook смогут обратиться к нему как hostvars['node-ru'].wg_pubkey. Это работает только в пределах одного запуска ansible-playbook — факты не сохраняются между отдельными запусками, если не включено фактовое кеширование (fact_caching = jsonfile в ansible.cfg). Без кеша нужно всегда прогонять роль генерации ключей и роль раздачи конфигов в одном playbook, а не двумя разными командами с интервалом.

Шаблоны конфигов: сервер и peer'ы через Jinja2

Шаблон для хаба перечисляет всех spoke-узлов как peer'ов, для точки — единственный peer (хаб).

{# roles/wireguard/templates/wg0-hub.conf.j2 #}
[Interface]
PrivateKey = {{ lookup('file', '/etc/wireguard/privatekey') }}
Address = {{ wg_ip }}/24
ListenPort = {{ wg_port }}

{% for host in groups['wireguard_spokes'] %}
[Peer]
# {{ host }}
PublicKey = {{ hostvars[host].wg_pubkey }}
AllowedIPs = {{ hostvars[host].wg_ip }}/32
{% endfor %}
{# roles/wireguard/templates/wg0-spoke.conf.j2 #}
[Interface]
PrivateKey = {{ lookup('file', '/etc/wireguard/privatekey') }}
Address = {{ wg_ip }}/24

[Peer]
PublicKey = {{ hostvars[groups['wireguard_hub'][0]].wg_pubkey }}
Endpoint = {{ hostvars[groups['wireguard_hub'][0]].ansible_host }}:{{ wg_port }}
AllowedIPs = {{ wg_subnet }}
PersistentKeepalive = 25

Обратите внимание на lookup('file', ...) — приватный ключ подставляется в шаблон, читаясь прямо с диска того же узла, а не передаваясь между хостами. PersistentKeepalive = 25 на стороне точки обязателен, если точка находится за NAT — без него хаб перестанет видеть узел, как только истечёт таблица трансляции адресов на промежуточном роутере.

Playbook целиком: от установки до перезапуска

# site.yml
---
- name: Развернуть WireGuard на всём парке
  hosts: wireguard
  become: true
  roles:
    - wireguard

- name: Конфиг и маршрутизация на хабе
  hosts: wireguard_hub
  become: true
  tasks:
    - name: Разложить wg0.conf для хаба
      template:
        src: roles/wireguard/templates/wg0-hub.conf.j2
        dest: /etc/wireguard/wg0.conf
        mode: "0600"
      notify: restart wireguard

    - name: Включить форвардинг пакетов
      sysctl:
        name: net.ipv4.ip_forward
        value: "1"
        state: present
        reload: true

    - name: Открыть UDP-порт WireGuard
      ufw:
        rule: allow
        port: "{{ wg_port }}"
        proto: udp

  handlers:
    - name: restart wireguard
      systemd:
        name: wg-quick@wg0
        state: restarted
        enabled: true

- name: Конфиг на точках выхода
  hosts: wireguard_spokes
  become: true
  tasks:
    - name: Разложить wg0.conf для точки
      template:
        src: roles/wireguard/templates/wg0-spoke.conf.j2
        dest: /etc/wireguard/wg0.conf
        mode: "0600"
      notify: restart wireguard

  handlers:
    - name: restart wireguard
      systemd:
        name: wg-quick@wg0
        state: restarted
        enabled: true

Запуск на весь парк одной командой:

ansible-playbook -i inventory.ini site.yml

Первый прогон выполняет всё сразу: устанавливает пакет, генерирует ключи на каждом узле, собирает публичные ключи в факты, раскладывает конфиги с правильными peer-блоками и перезапускает wg-quick@wg0. Повторный прогон на уже настроенном парке пройдёт почти без изменений — задачи с creates и шаблоны, содержимое которых не изменилось, отчитаются ok, а не changed, и сервисы не перезапустятся зря. Если у вас ещё нет ни одного сервера с WireGuard и хочется сначала понять логику вручную на одной машине — в статье про установку WireGuard на VPS разобран этот же процесс без Ansible, шаг за шагом.

Добавление нового узла и ротация ключей

Добавление сервера в парк — это не про --limit на новый хост в одиночку. Порядок такой:

  1. Добавить строку в inventory.ini с новым ansible_host и свободным wg_ip.
  2. Прогнать playbook на весь парк: ansible-playbook -i inventory.ini site.yml.

На первом же прогоне роль wireguard создаст ключи на новом узле и запишет его публичный ключ в факты — но конфиг хаба соберётся из hostvars в рамках того же запуска, поэтому список peer'ов на хабе сразу учтёт новый сервер. Если ограничиться --limit new-node, у нового узла появится конфиг, а хаб про него не узнает, пока playbook не пройдёт по группе wireguard_hub тоже — отсюда и правило запускать на весь парк, а не точечно.

Ротация скомпрометированного ключа делается так же честно, руками:

# на узле, чей ключ нужно сменить
rm /etc/wireguard/privatekey /etc/wireguard/publickey

После удаления файлов задачи с creates перестанут видеть их существующими и сгенерируют новую пару при следующем прогоне site.yml на весь парк — старый публичный ключ пропадёт из шаблонов автоматически, потому что шаблон каждый раз собирается заново из актуальных hostvars. Учтите нюанс: пока playbook не прогонится на всех, peer с новым ключом не сможет установить туннель с хабом, у которого ещё старый ключ в конфиге — то есть ротация в проде требует окна, а не мгновенного переключения.

Арендуйте сервер под свои задачи!

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему не использовать готовую роль wireguard из Ansible Galaxy?

Можно — готовые роли (например, githubixx.wireguard) закрывают тот же сценарий и избавляют от написания шаблонов вручную. Playbook в этой статье полезен, если нужна нестандартная топология или хочется понимать каждый шаг перед тем, как доверить это чужому коду.

Как раздать клиентские конфиги (не для сервера, а для телефона или ноутбука) тем же playbook?

Добавьте отдельную роль, которая генерирует пару ключей клиента на управляющей машине (не на сервере), добавляет клиента как peer в конфиг хаба и модулем fetch забирает готовый .conf на локальную машину для передачи пользователю — это отдельная логика от раздачи конфигов между самими серверами.

Что если между прогонами playbook сервер перезагрузили?

Ничего страшного: wg-quick@wg0 включён через enabled: true, туннель поднимется сам при старте системы. Playbook нужен только для изменения конфигурации, а не для поддержания сервиса в рабочем состоянии между запусками.

Нужен ли Ansible Vault для приватных ключей?

В этой схеме приватные ключи не покидают узел и не попадают ни в inventory, ни в git — Vault не требуется. Он понадобится, если вы храните ключи централизованно (например, для клиентских конфигов на управляющей машине) — тогда файлы с ключами стоит шифровать ansible-vault encrypt перед коммитом в репозиторий.

Playbook упал на середине раздачи конфигов по всему парку — что с уже настроенными узлами?

Ansible останавливает выполнение только для хоста, на котором произошла ошибка, остальные узлы в этом прогоне продолжают выполняться независимо. Исправьте причину сбоя и запустите site.yml заново — идемпотентность задач означает, что уже настроенные серверы просто подтвердят состояние ok.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →