Перейти к основному содержанию

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. Все адреса, имена и домены в статье вымышлены. В конфигурации оставлен один условный сервер, чтобы пример не раскрывал состав рабочей сети:

УзелIPv4IPv6Роль
server110.42.17.1fd42:17:23::1lighthouse, relay, DNS
private ingress10.42.17.10fd42:17:23::10Caddy ingress
Android10.42.17.30fd42:17:23::30Mobile Nebula

В рабочей сети можно держать несколько lighthouse и relay. Клиент знает их публичные адреса и может пережить потерю одного пути. В примере Nebula работает на UDP-порту 4244.

IPv6 здесь относится к overlay. Серверу не нужен публичный IPv6: Nebula переносит пакеты с ULA-адресами внутри IPv4 UDP underlay. Для dual-stack нужны Nebula 1.10+ и сертификаты v2 с обеими сетями.

flowchart LR phone["Android
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.


  1. Using Lighthouse DNS with Nebula описывает ограничения встроенного resolver: он знает только имена Nebula hosts, не поддерживает aliases и upstream DNS. ↩︎

  2. dnsmasq man page описывает локальные ответы, forwarding, listen-address и bind-interfaces. ↩︎

  3. ctrld configuration содержит формат listeners и upstreams. ↩︎

  4. Nebula listen configuration рекомендует фиксированный порт для lighthouse и динамический порт для roaming nodes. ↩︎