Nebula рядом с Tailscale: изолированная сеть и split DNS
В апреле я связал Nebula с Tailscale
через unsafe_routes.
Телефон входил в Nebula, gateway принимал его пакеты, делал MASQUERADE и
пересылал их в Tailscale. Схема работала, но соединяла две сети на уровне
маршрутизации. Ошибка в route, firewall или NAT могла затронуть обе стороны.
В новой схеме Nebula и Tailscale используют разные адресные пространства и
таблицы маршрутизации. Между ними нет forwarding, NAT и маршрута
100.64.0.0/10. Приватные сервисы доступны из обеих сетей через один
caddy-private, а нужный ingress выбирает DNS.
При включённом Tailscale домен grafana.example.com указывает на
100.64.23.17. В Nebula тот же домен получает адрес 10.42.17.10. URL,
TLS-сертификат и Caddyfile остаются прежними.
Задача
Мои приватные сервисы работают за Caddy, который делит network namespace с
Tailscale sidecar. Внутри namespace есть tailscale0 с адресами
100.64.23.17 и fd7a:115c:a1e0::53a7:2a11. Публичные DNS records для
приватных доменов указывают на эти адреса.
Mobile Nebula на Android создаёт свой VPN и передаёт системе список DNS resolver’ов. Если оставить публичный DNS без переопределения, телефон получит Tailscale IP, до которого из изолированной Nebula-сети нет маршрута.
Я зафиксировал четыре ограничения:
- Headscale, его DNS и ACL продолжают работать без изменений;
- Nebula не получает default route и route в
100.64.0.0/10; - Caddyfile и TLS-сертификаты не зависят от выбранной overlay-сети;
- выключение Nebula удаляет только её интерфейс и connected routes.
Топология
Nebula использует отдельные IPv4 и IPv6 overlay. Все адреса, имена и домены в статье вымышлены. В конфигурации оставлен один условный сервер, чтобы пример не раскрывал состав рабочей сети:
| Узел | IPv4 | IPv6 | Роль |
|---|---|---|---|
server1 | 10.42.17.1 | fd42:17:23::1 | lighthouse, relay, DNS |
| private ingress | 10.42.17.10 | fd42:17:23::10 | Caddy ingress |
| Android | 10.42.17.30 | fd42:17:23::30 | Mobile Nebula |
В рабочей сети можно держать несколько lighthouse и relay. Клиент знает их
публичные адреса и может пережить потерю одного пути. В примере Nebula работает
на UDP-порту 4244.
IPv6 здесь относится к overlay. Серверу не нужен публичный IPv6: Nebula переносит пакеты с ULA-адресами внутри IPv4 UDP underlay. Для dual-stack нужны Nebula 1.10+ и сертификаты v2 с обеими сетями.
10.42.17.30"] subgraph nebula["Nebula 10.42.17.0/24"] dns["dnsmasq
10.42.17.1:53"] ingress["nebula0
10.42.17.10"] end subgraph namespace["private ingress namespace"] nebif["nebula0
10.42.17.10"] tsif["tailscale0
100.64.23.17"] caddy["caddy-private
*:443"] end subgraph publicdns["Public DNS"] public["grafana.example.com
100.64.23.17"] end phone -->|"DNS query"| dns dns -->|"10.42.17.10"| phone phone -->|"HTTPS через Nebula"| ingress ingress --- nebif nebif --> caddy public -. "ответ для Tailscale" .-> tsif tsif --> caddy
Почему не Lighthouse DNS
Nebula умеет запускать DNS на lighthouse, но этот resolver обслуживает имена
узлов из сертификатов. Он не поддерживает дополнительные service aliases и не
пересылает запросы во внешний upstream1. Мне нужны обычные FQDN вида
grafana.example.com и public DNS для остальных сайтов, поэтому встроенный
resolver не подходит.
На DNS-сервере работают два процесса:
dnsmasqпринимает запросы только на Nebula IP и переопределяет приватные домены;ctrldслушает127.0.0.1:5354и отправляет остальные запросы в Control D через DoH.
Системный resolver сервера и DNS-конфигурация Headscale при этом не меняются.
Split DNS на сервере
Пример конфига DNS на server1:
listen-address=10.42.17.1
listen-address=fd42:17:23::1
bind-interfaces
no-resolv
no-hosts
domain-needed
bogus-priv
cache-size=1000
server=127.0.0.1#5354
local=/grafana.example.com/
address=/grafana.example.com/10.42.17.10
address=/grafana.example.com/fd42:17:23::10
local=/search.example.com/
address=/search.example.com/10.42.17.10
address=/search.example.com/fd42:17:23::10
local=/prometheus.example.com/
address=/prometheus.example.com/10.42.17.10
address=/prometheus.example.com/fd42:17:23::10
Остальные приватные домены добавляются теми же тремя строками. На других DNS-узлах меняются только адреса, на которых слушает dnsmasq.
listen-address и bind-interfaces закрывают DNS от публичных интерфейсов.
no-resolv запрещает dnsmasq брать upstream из /etc/resolv.conf. Все
неприватные запросы уходят в локальный ctrld. dnsmasq умеет отвечать из
локальной конфигурации и пересылать остальные запросы upstream-серверу2.
ctrld использует отдельный listener на localhost и Control D upstream.
Я храню Resolver ID только в конфиге сервера:
[listener.0]
ip = "127.0.0.1"
port = 5354
restricted = true
[upstream.0]
type = "doh"
endpoint = "https://dns.controld.com/<resolver-id>"
[network.0]
name = "Local dnsmasq"
cidrs = ["127.0.0.0/8"]
[listener.0.policy]
networks = [
{"network.0" = ["upstream.0"]},
]
Формат актуального конфига описан в документации ctrld3. Для каждого
DNS-узла можно завести отдельный resolver Control D, чтобы видеть источник
запроса и не делить один профиль между серверами.
Unit для dnsmasq зависит от Nebula и ctrld:
[Unit]
Description=Nebula split-horizon DNS with Control D upstream
Requires=nebula.service ctrld.service
After=nebula.service ctrld.service
[Service]
Type=simple
DynamicUser=true
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground \
--conf-file=/etc/nebula-dns.conf --pid-file=
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=strict
[Install]
WantedBy=multi-user.target
DynamicUser избавляет от отдельной системной учётки. Capability разрешает
bind к порту 53, а ограничения systemd не дают процессу писать в домашние
каталоги и системные файлы.
Два интерфейса в одном namespace
На сервере уже работали tailscale-sidecar и caddy-private. Caddy делит
network namespace с Tailscale sidecar и слушает *:443. Я добавил туда третий
контейнер, который запускает только Nebula:
[Unit]
Description=Nebula sidecar for existing Tailscale private ingress
After=tailscale-sidecar.service
Requires=tailscale-sidecar.service
[Container]
ContainerName=nebula-ingress
Image=docker.io/nebulaoss/nebula:1.10.3
Network=tailscale-sidecar.container
AddCapability=NET_ADMIN
AddDevice=/dev/net/tun
Volume=%h/volumes/nebula-ingress/config:/config:ro,Z
Exec=-config /config/config.yml
[Service]
Restart=always
RestartSec=5
Параметр Network=tailscale-sidecar.container помещает контейнер Nebula в уже
существующий namespace. После запуска Caddy видит два VPN-интерфейса:
tailscale0 100.64.23.17/32 fd7a:115c:a1e0::53a7:2a11/128
nebula0 10.42.17.10/24 fd42:17:23::10/64
Caddy продолжает слушать *:443, поэтому принимает HTTPS через оба интерфейса.
Upstream’ы, SNI и TLS остаются общими. Порты хоста публиковать не нужно.
В ingress-конфиге Nebula нет unsafe_routes. Sidecar получает только
connected routes для 10.42.17.0/24 и fd42:17:23::/64. Он также
использует listen.port: 0: публичный входящий UDP этому узлу не нужен, потому
что он подключается к lighthouse и relay сам.
Клиентский профиль
В примере Android получает один lighthouse, relay и два DNS endpoints: IPv4 и
IPv6 адрес server1. В рабочую конфигурацию можно добавить резервные серверы.
Существенная часть профиля выглядит так:
static_host_map:
"10.42.17.1": ["<server1-public-ip>:4244"]
"fd42:17:23::1": ["<server1-public-ip>:4244"]
lighthouse:
hosts:
- "10.42.17.1"
- "fd42:17:23::1"
listen:
host: "[::]"
port: 0
relay:
relays:
- "10.42.17.1"
- "fd42:17:23::1"
use_relays: true
mobile_nebula:
dns_resolvers:
- "10.42.17.1"
- "fd42:17:23::1"
match_domains: []
Для roaming-клиента Nebula рекомендует динамический UDP-порт, то есть
listen.port: 04. В профиле нет default route и unsafe_routes. Android
отправляет DNS-запросы в Nebula, а обычный интернет-трафик идёт через текущую
сеть телефона.
Проверка изоляции
Сначала я проверил синтаксис DNS-конфига:
dnsmasq --test --conf-file=/etc/nebula-dns.conf
Затем сравнил private и public ответы:
dig @10.42.17.1 +short A grafana.example.com
# 10.42.17.10
dig @10.42.17.1 +short AAAA grafana.example.com
# fd42:17:23::10
dig @10.42.17.1 +short A example.net
# публичные адреса example.net
HTTPS проверяется с сохранением hostname, чтобы curl отправил правильный SNI:
curl --resolve grafana.example.com:443:10.42.17.10 \
https://grafana.example.com/
curl --resolve grafana.example.com:443:100.64.23.17 \
https://grafana.example.com/
Оба запроса должны попасть в один caddy-private, но через разные интерфейсы.
После этого проверяются routes:
ip route get 10.42.17.30
ip route get 100.64.23.17
ip route get 1.1.1.1
Nebula peer должен идти через nebula0, Tailscale peer через
tailscale0, публичный адрес через прежний default gateway. В таблицах не
должно появиться маршрута, который связывает две overlay-сети.
В этой схеме dnsmasq возвращает 10.42.17.10 и fd42:17:23::10 для
приватных имён, а public-запросы проходят через
ctrld. Tailscale sidecar, caddy-private и Nebula ingress остаются active.
Что дала новая схема
DNS выбирает точку входа без изменения URL. Tailscale-клиенты получают
100.64.23.17, Nebula-клиенты получают 10.42.17.10. Оба адреса ведут в один
Caddy namespace.
Я убрал gateway, MASQUERADE, IP forwarding и unsafe_routes из пути к
приватным сервисам. Остановка Nebula не меняет Tailscale. Сбой Headscale не
ломает маршруты Nebula. Общим компонентом остаётся
caddy-private, потому что именно он обслуживает один набор приватных доменов
и backend’ов.
У схемы есть цена. Нужно поддерживать DNS на нескольких узлах и синхронно
обновлять список приватных доменов. Я предпочёл эту явную конфигурацию мосту
между overlay-сетями: её легко проверить через dig, а rollback сводится к
отключению профиля Mobile Nebula и включению Tailscale.
Using Lighthouse DNS with Nebula описывает ограничения встроенного resolver: он знает только имена Nebula hosts, не поддерживает aliases и upstream DNS. ↩︎
dnsmasq man page описывает локальные ответы, forwarding,
listen-addressиbind-interfaces. ↩︎ctrld configuration содержит формат listeners и upstreams. ↩︎
Nebula listen configuration рекомендует фиксированный порт для lighthouse и динамический порт для roaming nodes. ↩︎