MAATRIX / Блог / CI/CD для конфигов VPN-сервера: GitOps-подход

CI/CD для конфигов VPN-сервера: GitOps-подход

MAATRIX

Если вы админите VPN-сервер дольше полугода, у вас наверняка уже был случай: правите wg0.conf прямо на проде через nano, забываете сделать бэкап, потом два часа вспоминаете, какой пир держал доступ к бухгалтерии. Или хуже — правки на двух серверах разъезжаются, и через месяц никто не может сказать, какой конфиг актуален. GitOps для VPN-инфраструктуры решает именно это: конфиги живут в git, изменения проходят через pull request, а на сервер попадают только через пайплайн — предсказуемо и с историей.

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

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

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

Почему ручные правки конфигов — плохая практика

Ручное редактирование конфигов VPN на живом сервере работает, пока серверов один-два и админ один. Дальше начинаются проблемы, которые накапливаются незаметно.

Нет единого источника истины. Конфиг на сервере — это то, что там оказалось после последней правки через SSH. Никто не помнит, зачем добавили этот пир полгода назад и работает ли он ещё. Документация, если и есть, отстаёт от реальности через пару недель.

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

Human error на проде. Опечатка в AllowedIPs, забытый wg syncconf после правки, случайно удалённый peer — каждая такая ошибка происходит напрямую на боевом сервере, без промежуточной проверки.

Разъезжаются конфиги между серверами. Если у вас несколько точек (например, сервер в России и в США для балансировки), ручные правки на каждом почти гарантированно разойдутся: где-то забыли применить изменение, где-то применили не то.

GitOps не устраняет ошибки полностью — если в pull request влили неправильный конфиг, пайплайн его так же исправно раскатит. Но он убирает целый класс проблем: конфиг всегда версионируется, применяется одинаково и одним и тем же способом, а откат — это git revert, а не попытка вспомнить, что было час назад.

Структура репозитория для конфигов

Прежде чем писать пайплайн, разложите репозиторий так, чтобы в нём было понятно, что относится к инфраструктуре, а что — к конкретным клиентам.

vpn-configs/
├── inventory/
│   ├── ru-1.yml          # переменные конкретного сервера
│   ├── us-1.yml
│   └── uk-1.yml
├── templates/
│   ├── wg0.conf.j2       # jinja2-шаблон серверного конфига
│   └── client.conf.j2    # шаблон клиентского конфига
├── peers/
│   ├── ru-1/
│   │   ├── alice.yml     # публичный ключ, AllowedIPs, комментарий
│   │   └── bob.yml
│   └── us-1/
│       └── carol.yml
├── playbooks/
│   └── deploy-wireguard.yml
├── .gitlab-ci.yml
└── README.md

Важное решение: приватные ключи в этот репозиторий не попадают вообще — только публичные. Об этом отдельно ниже. Каждый peer описывается отдельным файлом — так diff в pull request сразу показывает, кого добавили или отозвали, без необходимости вычитывать весь конфиг построчно.

Пример peers/ru-1/alice.yml:

name: alice
public_key: "AbCdEf...="
allowed_ips: "10.8.0.2/32"
comment: "ноутбук, отдел разработки"
added: "2026-08-15"

Шаблон templates/wg0.conf.j2 собирает финальный конфиг из списка peers на этапе применения — сервер никогда не редактируется руками, только через сгенерированный из git файл.

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

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

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

Пайплайн: от коммита до применения

Дальше — сам CI/CD. Логика одинакова что для GitLab CI, что для Gitea Actions, что для GitHub Actions: линт → dry-run → применение с подтверждением (или авто, в зависимости от ветки).

Пример .gitlab-ci.yml для self-hosted раннера с SSH-доступом к серверам:

stages:
  - lint
  - plan
  - apply

lint:
  stage: lint
  script:
    - yamllint peers/ inventory/
    - ansible-playbook playbooks/deploy-wireguard.yml --syntax-check

plan:
  stage: plan
  script:
    - ansible-playbook playbooks/deploy-wireguard.yml --check --diff
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

apply:
  stage: apply
  script:
    - ansible-playbook playbooks/deploy-wireguard.yml
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  environment:
    name: production

Ключевой момент — стадия plan на merge request. Она гоняет Ansible с флагом --check --diff, показывает в выводе пайплайна, что именно изменится на сервере, но ничего не применяет. Ревьюер видит реальный diff конфига до мержа, а не только diff YAML-файлов в git. Это ловит опечатки в AllowedIPs или случайно продублированный порт задолго до того, как они попадут на прод.

Если у вас Gitea вместо GitLab, логика та же самая — только вместо .gitlab-ci.yml будет workflow-файл Gitea Actions в .gitea/workflows/, синтаксис почти идентичен GitHub Actions.

Секреты: приватные ключи в CI без утечек

Самая частая ошибка при переходе на GitOps для VPN — попытка положить приватные ключи WireGuard или сертификаты OpenVPN прямо в репозиторий «для удобства». Даже приватный self-hosted git — не место для секретов: доступ к репозиторию обычно шире, чем должен быть доступ к ключам, а история git ничего не забывает — удалённый файл остаётся в старых коммитах.

Практический подход:

  1. Приватные ключи генерируются один раз на сервере и никогда не покидают его. В git попадает только публичный ключ пира — этого достаточно, чтобы собрать серверный wg0.conf.
  2. Клиентские конфиги (где приватный ключ клиента нужен целиком) генерируются пайплайном во временный артефакт и передаются администратору вне git — через защищённый канал, не как коммит.
  3. Секреты пайплайна (SSH-ключ до серверов, токены) хранятся в CI/CD variables раннера (GitLab CI Variables, Gitea Actions Secrets), помеченные как masked и protected — не в файлах репозитория.
  4. Если всё же нужно хранить что-то чувствительное версионированно (например, PSK для site-to-site туннеля) — используйте sops с age или git-crypt: файл в репозитории лежит зашифрованным, ключ расшифровки есть только у CI-раннера и доверенных админов.

Пример шифрования PSK через sops с age-ключом:

# генерация ключа (один раз, хранится вне репозитория)
age-keygen -o keys.txt

# шифрование файла с секретом
sops --encrypt --age $(cat keys.txt | grep public | cut -d: -f2) \
  secrets/psk-site-to-site.yml > secrets/psk-site-to-site.enc.yml

# в пайплайне — расшифровка перед применением
sops --decrypt secrets/psk-site-to-site.enc.yml > /tmp/psk.yml

Приватный ключ для расшифровки (keys.txt) кладётся в переменные CI, а не в репозиторий — так зашифрованный файл можно свободно коммитить и ревьюить в PR (сам факт изменения виден, содержимое — нет).

Применение изменений на сервере

Финальный шаг пайплайна — раскатка сгенерированного конфига без даунтайма для остальных пиров. Для WireGuard это не systemctl restart, а wg syncconf, который применяет разницу без разрыва существующих туннелей:

# playbooks/deploy-wireguard.yml (фрагмент)
- name: Собрать wg0.conf из шаблона
  template:
    src: templates/wg0.conf.j2
    dest: /etc/wireguard/wg0.conf
    owner: root
    mode: "0600"
  notify: reload wireguard

handlers:
  - name: reload wireguard
    shell: |
      wg syncconf wg0 <(wg-quick strip wg0)

wg-quick strip убирает из конфига директивы, непонятные самому wg (Address, DNS), а wg syncconf применяет только разницу в списке пиров — активные сессии остальных клиентов не рвутся. Это принципиально отличается от простого рестарта сервиса, который на секунду-две обрывает вообще всех.

Для OpenVPN логика проще с точки зрения приложения — там достаточно systemctl reload openvpn@server, если сертификаты выпускаются через Easy-RSA отдельным шагом, а сам конфиг сервера меняется редко. Подробности выпуска и отзыва сертификатов — в статье про OpenVPN с несколькими пользователями и Easy-RSA.

Если конфигов и серверов много, ту же логику применения удобно вынести в Ansible-плейбук с отдельным inventory на сервер — как это устроено разбирали в статье про Ansible-плейбук для настройки сервера.

Откат, версионирование и защита от дрейфа

Git-история сама по себе даёт откат: git revert <commit> и повторный запуск пайплайна возвращают сервер к прошлому состоянию. Но у GitOps для инфраструктуры есть тонкость, которой нет у GitOps для Kubernetes-манифестов: между применениями кто-то может зайти на сервер руками (аварийная правка, диагностика) — и тогда фактическое состояние разойдётся с тем, что в git.

Чтобы такой дрейф не жил незамеченным, добавьте в пайплайн отдельный scheduled job, который раз в сутки прогоняет ansible-playbook --check --diff без флага apply и присылает уведомление, если найдено расхождение:

drift-check:
  stage: plan
  script:
    - ansible-playbook playbooks/deploy-wireguard.yml --check --diff | tee drift.log
    - grep -q "changed=0" drift.log || curl -X POST "$ALERT_WEBHOOK" -d "Drift detected on $(hostname)"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

Это не устраняет саму возможность ручной правки в экстренной ситуации — иногда она правда нужна, когда сервер недоступен через пайплайн. Но она перестаёт быть тихой: либо инженер сразу же коммитит изменение обратно в git, либо drift-check на следующий день покажет расхождение и напомнит об этом. Здесь же полезно держать резервную копию конфигов отдельно от git — как описано в статье про бэкап конфигов WireGuard — на случай, если сам git-репозиторий станет недоступен.

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

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

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

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

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

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

Обязательно ли использовать Ansible, или можно обойтись bash-скриптом?

Не обязательно. Для одного-двух серверов вполне хватит скрипта, который через envsubst или jinja2-cli собирает конфиг из шаблона и переменных и кладёт его по SCP. Ansible оправдан, когда серверов больше трёх-четырёх и нужен единообразный --check-режим без написания его вручную.

Что делать, если раннер CI/CD скомпрометируют — он же имеет SSH-доступ ко всем серверам?

Это реальный риск pull-в-push архитектуры, и его стоит держать в уме. Минимизируйте права: отдельный SSH-ключ только для деплоя, AuthorizedKeysCommand или sudo с ограниченным набором команд на сервере, а не root-доступ целиком. Для более закрытой модели можно рассмотреть pull-модель по аналогии с ArgoCD — агент на сервере сам тянет изменения из git по расписанию, credentials наружу не публикуются.

Нужен ли отдельный сервер под CI-раннер или хватит того же VPN-сервера?

Лучше отдельный, пусть даже минимальный VPS. Раннер с доступом к продовым секретам и SSH-ключам — сам по себе критичная точка, размещать его на той же машине, которую он администрирует, не стоит из соображений изоляции.

Как быть с клиентами, которые сами генерируют себе QR-код конфига через какой-нибудь wg-easy?

GitOps и веб-панель для самообслуживания не исключают друг друга полностью, но конфликтуют по источнику истины — если панель пишет прямо на диск сервера, пайплайн будет перезаписывать её изменения при следующем apply. Технически совместимое решение — использовать панель только для просмотра QR уже выпущенных из git пиров, а не для создания новых.

С чего начать миграцию, если сейчас всё настроено руками?

Снимите текущий wg0.conf как есть, разложите список пиров по отдельным YAML-файлам, соберите шаблон, который на выходе даёт побайтово идентичный (или эквивалентный) конфиг, и только после этого подключайте пайплайн — чтобы первый apply не изменил ничего неожиданного.

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

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

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