Нужно было связать несколько серверов на разных площадках и дать им постоянные внутренние адреса. При отказе одного туннеля связь должна сохраняться без изменения DNS и ручной замены маршрута.
Ниже показана обезличенная версия решения, которое я внедрил и описал в эксплуатационном ранбуке. Адреса, имена узлов и параметры конкретного контура заменены документационными примерами; устройство сети и порядок переключения сохранены.
Several servers in different locations needed stable internal addresses. If one tunnel failed, connectivity had to continue without a DNS change or a manual route replacement.
The diagram below is an anonymised version of the design I implemented and documented in an operational runbook. Host names, addresses and environment-specific values use documentation examples; the network design and failover sequence remain unchanged.
Адрес узла отдельно от транспорта
Ключевое решение — не считать адрес WireGuard адресом сервера. Каждый узел получил собственный постоянный /32 на локальном интерфейсе mesh0. Именно этот адрес публикуется во внутреннем DNS и используется приложениями.
У WireGuard и IPsec отдельные транзитные сети. WireGuard работает с Table = off, StrongSwan не устанавливает прикладные маршруты самостоятельно. Маршрут выбирает только FRR: к одному удалённому /32 он видит два BGP-маршрута.
- 01Адрес узлапостоянный /32 на mesh0
- 02Основной путьполносвязный WireGuard
- 03Резервный путьIKEv2/IPsec через XFRM
- 04Выбор путиFRR, BGP и BFD
Почему здесь BGP, а не два статических маршрута
Статические маршруты просты, пока отказ можно определить состоянием интерфейса. Для туннеля этого недостаточно: интерфейс способен оставаться поднятым, когда удалённый конец или промежуточный путь уже недоступен. BGP даёт явное состояние соседства и политику, а BFD связывает выбор маршрута с доступностью конкретного транспорта.
Соседство строится дважды для каждой пары: через WireGuard и через XFRM. Маршрут, полученный по WireGuard, получает local-preference 200; резервный IPsec остаётся со значением 100. Входные списки разрешают от соседа только его служебный /32, исходный список — только собственный. Ошибка в конфигурации одного узла не должна превратить небольшой контур в транзитную сеть.
ip prefix-list PEER-A-IN seq 10 permit 192.0.2.20/32
ip prefix-list SELF-OUT seq 10 permit 192.0.2.10/32
route-map WG-PREFER permit 10
set local-preference 200
router bgp 64512
bgp router-id 192.0.2.10
neighbor 198.51.100.2 remote-as 64513
neighbor 198.51.100.2 bfd
neighbor 203.0.113.2 remote-as 64513
neighbor 203.0.113.2 bfd
address-family ipv4 unicast
network 192.0.2.10/32
neighbor 198.51.100.2 route-map WG-PREFER in
neighbor 198.51.100.2 prefix-list PEER-A-IN in
neighbor 203.0.113.2 prefix-list PEER-A-IN in
neighbor 198.51.100.2 prefix-list SELF-OUT out
neighbor 203.0.113.2 prefix-list SELF-OUT out
BFD без гонки за миллисекундами
В этом контуре не требовалась скорость переключения магистрального маршрутизатора. BFD отправляет проверку раз в минуту, а три пропущенных ответа подряд снимают соседство. После обрыва маршрут меняется за две-три минуты: короткой потери пакетов или занятой виртуальной машины для этого недостаточно.
Две-три минуты здесь — осознанный запас от кратких потерь пакетов, а не попытка получить самое быстрое переключение.
В штатном состоянии оба транспорта подняты заранее. Переключение не ждёт нового согласования IKE: BGP удаляет основной маршрут и сразу использует уже существующий путь через XFRM. После восстановления соседства более высокий local-preference возвращает трафик в WireGuard.
Как проверялось реальное переключение
Остановка всего WireGuard-интерфейса — плохой тест: она одновременно задевает все связи узла. В ранбуке тест ограничен одной парой. Временные правила блокируют BFD и BGP только от выбранного соседа, после чего скрипт ждёт маршрут через XFRM, проверяет ICMP и прикладной запрос, снимает блокировку и подтверждает возврат основного пути.
Этот тест я добавил в регламент обслуживания и периодически повторяю для выбранной пары. Сначала фиксирую исходный маршрут, затем работу через резерв и возврат в WireGuard; в конце сверяю соседства, туннели и прикладной запрос.
#!/bin/bash
set -euo pipefail
peer=198.51.100.2
target=192.0.2.20
bfd=(-i wg-mesh -s "$peer" -p udp --dport 3784 -j DROP)
bgp=(-i wg-mesh -s "$peer" -p tcp --dport 179 -j DROP)
blocked=0
cleanup() {
if test "$blocked" -eq 1; then
iptables -D INPUT "${bfd[@]}" 2>/dev/null || true
iptables -D INPUT "${bgp[@]}" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
iptables -I INPUT 1 "${bfd[@]}"
iptables -I INPUT 1 "${bgp[@]}"
blocked=1
fallback=0
for attempt in $(seq 1 12); do
route=$(ip route get "$target" | head -n 1)
case "$route" in
*" dev xfrm"*) fallback=1; break ;;
esac
sleep 15
done
test "$fallback" -eq 1
ping -c 3 -W 2 "$target"
curl --resolve "service.example:443:$target" \
--fail --max-time 10 https://service.example/health
cleanup
blocked=0
recovered=0
for attempt in $(seq 1 12); do
route=$(ip route get "$target" | head -n 1)
case "$route" in
*" dev wg-mesh "*) recovered=1; break ;;
esac
sleep 15
done
test "$recovered" -eq 1
trap здесь — только страховка. Ранбук отдельно требует убедиться, что временных правил не осталось, маршрут снова проходит через WireGuard, оба BGP-соседства установлены, BFD поднят, а прикладной запрос проходит в обоих направлениях.
Наблюдаемость без лавины одинаковых событий
Zabbix получает состояние выбранного транспорта, свежесть рукопожатий WireGuard и наличие IPsec SA. Для каждого удалённого узла формируется один снимок: основной путь, резерв и фактически выбранный маршрут. Зависимые показатели строятся из этого снимка без повторного запуска системных команд.
route=$(ip -4 route get "$target" 2>/dev/null | head -n 1 || true)
case "$route" in
*" dev wg-mesh "*) transport=1 ;; # основной
*" dev xfrm"*) transport=2 ;; # резервный
*) transport=0 ;; # маршрута нет
esac
printf '{"peer":"%s","transport":%s,"wg":%s,"ipsec":%s}\n' \
"$peer" "$transport" "$wg_available" "$ipsec_available"
Состояния собираются на всех узлах, но события о сетевой связности создаёт один наблюдатель. Иначе отказ одной машины породил бы зеркальные уведомления со всех остальных. Общая авария также сворачивается в одно событие, а не в набор одинаковых проблем по каждому соседу.
Новый узел меняет всю матрицу связей
В небольшом контуре полносвязная схема остаётся управляемой, но каждый новый сервер образует по одной паре WireGuard и IPsec с каждым действующим узлом. Перед генерацией конфигурации ему выделяются постоянный адрес /32, транзитный адрес WireGuard, номер автономной системы, публичный и локальный адреса для IKE; затем адреса проверяются на пересечения на всех серверах.
Оба закрытых ключа создаются на новом узле. Наружу выходят только открытый ключ WireGuard и публичная часть ключа IPsec. После выпуска узлового сертификата новый сервер сначала подключается ко всем действующим, затем обновлённые открытые конфигурации раскладываются по остальным узлам последовательно. Между шагами сеть проверяется, поэтому ошибка затрагивает один узел, а не вызывает одновременный перезапуск всего контура.
- Инвентарь. Зафиксировать идентификатор, имя, постоянный и транзитный адреса, ASN, NAT и конечные точки.
- Ключи. Создать закрытые ключи локально; в рабочий каталог передать только открытые части.
- Генерация. Получить XFRM-интерфейсы, StrongSwan и FRR для каждого узла и проверить изменения.
- Развёртывание. Сначала новый сервер, затем по одному действующему узлу с проверкой между шагами.
- Завершение. Обновить фильтры сети, внутренний DNS, мониторинг и полные матрицы тестов.
Что делает генератор конфигурации
В генераторе хранится только открытый инвентарь. Для каждой пары он назначает отдельный if_id XFRM и пару транзитных адресов, определяет инициатора IKE с учётом NAT, создаёт два BGP-соседства и ограничивающие списки префиксов. На выходе для каждого узла получаются скрипт создания mesh0 и XFRM, unit systemd, конфигурации StrongSwan и FRR, а также два сценария перехода от старой адресации к маршрутам BGP.
Генератор не перенумеровывает существующие пары: для нового узла они добавляются в конец, а прежние if_id сохраняются. После генерации проверяются синтаксис Python и разница файлов; неожиданное изменение номера XFRM останавливает развёртывание.
from itertools import combinations
node_ids = [1, 2, 3, 4, 5] # узел 5 добавлен последним
legacy_ids = [node_id for node_id in node_ids if node_id <= 4]
pair_order = list(combinations(legacy_ids, 2))
pair_order += [
pair for pair in combinations(node_ids, 2)
if pair not in pair_order
]
for pair_no, (left, right) in enumerate(pair_order, start=1):
pairs[(left, right)] = {
"if_id": 1000 + pair_no,
"ifname": f"xfrm{left}{right}",
}
Выпуск, перевыпуск и отзыв доступа
Узловой сертификат выпускается собственной CA по публичной части ключа. В нём проверяются имя узла в subject и SAN, издатель, срок и назначения serverAuth/clientAuth. Закрытый ключ CA остаётся только на выделенном административном узле; на сервер передаются его собственный сертификат и открытый сертификат CA.
# На новом узле: закрытый ключ не покидает сервер
umask 077
pki --gen --type ecdsa --size 256 > node-key.pem
pki --pub --type ecdsa --in node-key.pem > node-public.pem
# На узле CA: выпуск по переданной публичной части
pki --issue --type pub --in node-public.pem \
--cacert mesh-ca.pem --cakey mesh-ca-key.pem \
--dn "CN=node-e.mesh.example, O=Mesh" \
--san node-e.mesh.example \
--flag serverAuth --flag clientAuth --lifetime 825 \
> node-certificate.pem
pki --print --in node-certificate.pem
Перевыпуск выполняется как поэтапная замена, а не как перезапись действующего файла. На узле создаются ключ и сертификат с суффиксом .next, текущая пара сохраняется с временной меткой, затем StrongSwan перечитывает реквизиты и соединения. После этого по одной переустанавливаются и проверяются IPsec SA; только успешная полная проверка позволяет убрать резервную копию. Для смены самой CA нужен период перекрывающегося доверия: новый корень сначала получают все узлы, затем меняются узловые сертификаты, и лишь после проверки удаляется старый корень.
При исключении или компрометации узла доступ прекращается сразу на уровне всех механизмов доверия: удаляются WireGuard peer, соединение StrongSwan, BGP-соседство и его префикс, правила разрешения публичного адреса и запись DNS. Закрытые ключи удаляются только на исключаемом сервере после проверки оставшейся сети.
Само архивирование старого сертификата не отзывает его. Формальный CRL имеет смысл только тогда, когда он опубликован, доставлен на каждый узел и StrongSwan действительно отклоняет сертификат по этому списку. Текущий ранбук не полагается на доступность CRL или OCSP: немедленный отзыв доступа обеспечивается удалением личности из конфигураций и сетевых списков. Этот сценарий проверяется отдельно от плановой ротации.
Ранбук как часть реализации
После внедрения я оформил отдельный эксплуатационный ранбук. В нём зафиксированы:
- описание нормального состояния и ожидаемые числа соседей, туннелей и маршрутов;
- порядок запуска после перезагрузки и проверка зависимостей systemd;
- плановое обслуживание WireGuard, IPsec, FRR и внутреннего DNS;
- контролируемое переключение одной пары с обязательной прикладной проверкой;
- добавление и исключение узла без перенумерации действующих XFRM-интерфейсов;
- ротация ключей и сертификатов, диагностика и явный порядок отката;
- проверка мониторинга через временные правила с гарантированной очисткой.
Отдельно описан неприятный класс ошибок запуска: локальные интерфейсы должны появиться до WireGuard, StrongSwan, FRR и Docker, но не образовывать цикл через network-online.target. Без этого IPsec SA может выглядеть установленной, хотя XFRM-интерфейса для передачи трафика нет.
Службы обращаются к постоянным именам и адресам. Оба зашифрованных канала подняты заранее, путь выбирает FRR, переключение проверяется на уровне приложения, а порядок восстановления и отката записан в ранбуке.
Separating node identity from transport
The key design choice was not to treat a WireGuard address as the server address. Every node received a stable /32 on a local mesh0 interface. That address is published in internal DNS and used by applications.
WireGuard and IPsec use separate transit networks. WireGuard runs with Table = off, while StrongSwan does not install application routes. FRR is the only component allowed to decide the path and receives two BGP routes to each remote service address.
- 01Node identitystable /32 on mesh0
- 02Primary pathfull-mesh WireGuard
- 03Backup pathIKEv2/IPsec over XFRM
- 04Path selectionFRR, BGP and BFD
Why BGP instead of two static routes
Static routes are simple when failure can be inferred from interface state. A tunnel may remain up while its remote endpoint or the path between sites is no longer usable. BGP provides explicit neighbour state and routing policy; BFD ties that state to the health of each transport.
Each node pair establishes two adjacencies, one over WireGuard and one over XFRM. Routes learned over WireGuard receive local-preference 200; the IPsec route keeps the default 100. Inbound prefix lists accept only the peer's stable /32, while the outbound list permits only the local one. A configuration mistake on one node must not turn a small private network into an unintended transit domain.
ip prefix-list PEER-A-IN seq 10 permit 192.0.2.20/32
ip prefix-list SELF-OUT seq 10 permit 192.0.2.10/32
route-map WG-PREFER permit 10
set local-preference 200
router bgp 64512
bgp router-id 192.0.2.10
neighbor 198.51.100.2 remote-as 64513
neighbor 198.51.100.2 bfd
neighbor 203.0.113.2 remote-as 64513
neighbor 203.0.113.2 bfd
address-family ipv4 unicast
network 192.0.2.10/32
neighbor 198.51.100.2 route-map WG-PREFER in
neighbor 198.51.100.2 prefix-list PEER-A-IN in
neighbor 203.0.113.2 prefix-list PEER-A-IN in
neighbor 198.51.100.2 prefix-list SELF-OUT out
neighbor 203.0.113.2 prefix-list SELF-OUT out
BFD without chasing milliseconds
This network did not need carrier-grade failover times. BFD sends one probe per minute and drops the adjacency after three consecutive misses. A failed path therefore takes two to three minutes to withdraw; a short burst of packet loss or a busy virtual machine is not enough to trigger it.
The two-to-three-minute window is deliberate headroom for brief packet loss, not an attempt to produce the fastest possible failover.
Both transports are established in advance. Failover does not wait for a new IKE exchange: BGP removes the primary route and immediately uses the existing XFRM path. Once the adjacency recovers, the higher local preference returns traffic to WireGuard.
Testing a real failover
Stopping the whole WireGuard interface is a poor test because it affects every peer at once. The runbook limits the test to a single pair. Temporary rules block only BFD and BGP from the chosen neighbour; the script then waits for the XFRM route, checks ICMP and an application request, removes the rules and confirms recovery of the primary path.
I added this test to the maintenance runbook and repeat it periodically for a selected peer pair. I record the initial route, operation over the backup path and the return to WireGuard, then verify the adjacencies, tunnels and application request.
#!/bin/bash
set -euo pipefail
peer=198.51.100.2
target=192.0.2.20
bfd=(-i wg-mesh -s "$peer" -p udp --dport 3784 -j DROP)
bgp=(-i wg-mesh -s "$peer" -p tcp --dport 179 -j DROP)
blocked=0
cleanup() {
if test "$blocked" -eq 1; then
iptables -D INPUT "${bfd[@]}" 2>/dev/null || true
iptables -D INPUT "${bgp[@]}" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
iptables -I INPUT 1 "${bfd[@]}"
iptables -I INPUT 1 "${bgp[@]}"
blocked=1
fallback=0
for attempt in $(seq 1 12); do
route=$(ip route get "$target" | head -n 1)
case "$route" in
*" dev xfrm"*) fallback=1; break ;;
esac
sleep 15
done
test "$fallback" -eq 1
ping -c 3 -W 2 "$target"
curl --resolve "service.example:443:$target" \
--fail --max-time 10 https://service.example/health
cleanup
blocked=0
recovered=0
for attempt in $(seq 1 12); do
route=$(ip route get "$target" | head -n 1)
case "$route" in
*" dev wg-mesh "*) recovered=1; break ;;
esac
sleep 15
done
test "$recovered" -eq 1
The trap is only a safety net. The runbook separately requires checking that no temporary rules remain, the route has returned to WireGuard, both BGP adjacencies are established, BFD is up and the application path works in both directions.
Observability without an alert storm
Zabbix receives the selected transport, WireGuard handshake freshness and the presence of IPsec security associations. One snapshot per remote node records the primary transport, backup transport and actual route. Dependent metrics are extracted from that snapshot without repeatedly executing system commands.
route=$(ip -4 route get "$target" 2>/dev/null | head -n 1 || true)
case "$route" in
*" dev wg-mesh "*) transport=1 ;; # primary
*" dev xfrm"*) transport=2 ;; # backup
*) transport=0 ;; # no route
esac
printf '{"peer":"%s","transport":%s,"wg":%s,"ipsec":%s}\n' \
"$peer" "$transport" "$wg_available" "$ipsec_available"
Every node collects state, but a single observer creates connectivity incidents. Otherwise one failed server would produce mirror alerts from every remaining node. A network-wide failure is also collapsed into one event rather than one problem per neighbour.
A new node changes the whole link matrix
A full mesh remains manageable at this scale, but every new server creates one WireGuard and one IPsec pair with every existing node. Before configuration is generated, it receives a stable /32, a WireGuard transit address, an autonomous-system number and public and local IKE endpoints; those addresses are then checked for conflicts on every server.
Both private keys are created on the new node. Only the WireGuard public key and the public part of the IPsec key leave it. Once the host certificate has been issued, the new node is connected to every existing peer first; updated public configuration is then applied to the remaining nodes one at a time. The network is checked between steps, so a mistake affects one node instead of restarting the whole mesh at once.
- Inventory. Record the ID, name, stable and transit addresses, ASN, NAT state and endpoints.
- Keys. Generate private keys locally and move only public material into the operating directory.
- Generation. Build XFRM, StrongSwan and FRR configuration for every node and review the diff.
- Deployment. Configure the new server first, then update one existing node at a time with checks between steps.
- Closure. Update firewall policy, internal DNS, monitoring and the complete test matrices.
What the configuration generator does
The generator contains public inventory only. For every node pair it allocates a dedicated XFRM if_id and transit pair, chooses the IKE initiator with NAT in mind, creates both BGP adjacencies and emits restrictive prefix lists. Each host receives a mesh0/XFRM setup script, a systemd unit, StrongSwan and FRR configuration, plus two migration helpers for moving the stable address to BGP-controlled routing.
The generator does not renumber existing pairs: pairs for a new node are appended and existing XFRM IDs remain unchanged. Validation checks both Python syntax and the generated diff; an unexpected XFRM number stops the rollout.
from itertools import combinations
node_ids = [1, 2, 3, 4, 5] # node 5 was appended
legacy_ids = [node_id for node_id in node_ids if node_id <= 4]
pair_order = list(combinations(legacy_ids, 2))
pair_order += [
pair for pair in combinations(node_ids, 2)
if pair not in pair_order
]
for pair_no, (left, right) in enumerate(pair_order, start=1):
pairs[(left, right)] = {
"if_id": 1000 + pair_no,
"ifname": f"xfrm{left}{right}",
}
Issuance, renewal and access revocation
A host certificate is issued by the private CA from the public part of the node key. Its subject and SAN, issuer, lifetime and serverAuth/clientAuth usages are checked before deployment. The CA private key remains on the designated administrative node; only the host certificate and public CA certificate are delivered to the server.
# On the new node: the private key never leaves the server
umask 077
pki --gen --type ecdsa --size 256 > node-key.pem
pki --pub --type ecdsa --in node-key.pem > node-public.pem
# On the CA node: issue from the transferred public key
pki --issue --type pub --in node-public.pem \
--cacert mesh-ca.pem --cakey mesh-ca-key.pem \
--dn "CN=node-e.mesh.example, O=Mesh" \
--san node-e.mesh.example \
--flag serverAuth --flag clientAuth --lifetime 825 \
> node-certificate.pem
pki --print --in node-certificate.pem
Renewal is staged rather than overwriting the active files. The node creates a .next key and certificate, saves timestamped copies of the current pair, and asks StrongSwan to reload credentials and connections. IPsec SAs are then re-established and checked one at a time. A CA rotation needs an overlap period: every node receives the new root first, host certificates follow, and the old root is removed only after the full mesh has been verified.
When a node is removed or compromised, access is denied across every trust mechanism: its WireGuard peer, StrongSwan connection, BGP neighbour and prefix, public-address allowlist rules and DNS record are removed. Private keys are destroyed only on the removed host after the remaining network is healthy.
Archiving an old certificate does not revoke it. A formal CRL matters only after it is published, distributed to every node and enforced by StrongSwan. The current runbook does not depend on CRL or OCSP availability: immediate access revocation is achieved by removing the identity from configuration and network allowlists. This scenario is tested separately from routine certificate rotation.
The runbook was part of the implementation
After deployment I wrote a separate operational runbook. It covers:
- the expected healthy state and counts of neighbours, tunnels and routes;
- boot ordering and verification of systemd dependencies;
- planned maintenance for WireGuard, IPsec, FRR and internal DNS;
- a controlled single-pair failover with a mandatory application check;
- adding and removing nodes without renumbering existing XFRM interfaces;
- key and certificate rotation, diagnostics and an explicit rollback order;
- monitoring tests built from temporary firewall rules with guaranteed cleanup.
A particularly important boot failure mode is documented separately. Local stable and XFRM interfaces must exist before WireGuard, StrongSwan, FRR and Docker, but their units must not create an ordering cycle through network-online.target. Otherwise an IPsec SA may look established while there is no XFRM interface capable of carrying traffic.
Services use stable names and addresses. Both encrypted paths are established in advance, FRR selects the route, failover is verified at application level, and the recovery and rollback procedure is recorded in the runbook.