Привет, Хабр!

Я пилю VantageDNS — рекурсивный DNS-резолвер с фильтрацией. NextDNS-clone, если коротко: юзер настраивает роутер на наш DoH endpoint, а мы рекурсивно резолвим и заодно режем рекламу, трекеры и malware.

Privacy-фокус для такого продукта это не маркетинговая фича, а архитектурное ограничение, которое надо тащить с первого дня. Privacy policy без архитектурных гарантий — это обещание не лезть в твою тумбочку, ключи от которой ты сам отдал.

В этой статье конкретика: что edge-нода не пишет на диск, как устроен кольцевой буфер для query log, что делать с crash dumps, и как юзер может проверить, что мы не врём, через strace.

Что вообще можно слить в DNS-провайдере

Если очень захотеть, то много чего. Перед тем как объяснять, что мы режем, перечислю, какие места обычно текут:

  • Query log на самой edge-ноде. Файл с qname, timestamp, client IP. Самый очевидный артефакт.

  • config_id ↔ email mapping в той же базе, что и query log. Если они в одном Postgres и кто-то получил dump, то privacy всё.

  • HTTP access log перед DoH endpoint. Если перед твоим DNS стоит nginx или caddy с access_log on, у тебя на диске IP юзера и path с config_id. Привет, корреляция.

  • Crash dumps. Процесс упал, Linux, если ему позволить, аккуратно сложит heap в файл рядом. В heap живут активные DNS-сессии.

  • Backups, snapshots, log aggregators. Datadog, Sentry, Grafana Loki. Любой из них может незаметно засосать лишнего.

  • TCP fingerprints, JA3, ALPN дампы. На anycast-резолверах при дебаге это иногда включают и забывают выключить.

Каждое из этого потенциальный leak. Privacy-by-design это когда ты по очереди обходишь весь список и для каждой строки отвечаешь: а у меня этого вообще нет на диске, или это лежит в EU-юрисдикции с TTL и шифрованием at rest, или я честно говорю в transparency report «лежит, доступ у меня одного, retention такой-то».

Есть провайдеры, которые на лендинге пишут «privacy-focused», а в transparency-разделе «мы можем хранить логи до 30 дней для биллинга» и «у нас офис в Калифорнии». Я к ним без претензий, но называть это privacy-focused натяжка.

Что мы делаем на edge

Кольцевой буфер вместо файла

Главный артефакт, который провайдеры обычно сливают, это query log. У нас он есть, но не на диске edge-ноды.

vdns-edge это Go-binary на 10 нодах по миру. Каждая нода держит in-memory кольцевой буфер на 100k событий. Когда событие приходит, оно кладётся в канал. Раз в 5 секунд (или при заполнении до 80%) воркер забирает batch и шлёт в ClickHouse в Хельсинки через gRPC.

type QueryEvent struct { ConfigID string QName string QType uint16 Action Action // resolved | blocked | nxdomain Timestamp int64 // Внимание: client IP здесь нет. // Edge-ноде он не нужен после ответа. }

type Shipper struct { buf chan QueryEvent metrics *Metrics }

func NewShipper(capacity int) *Shipper { return &Shipper{ buf: make(chan QueryEvent, capacity), // 100_000 metrics: NewMetrics(), } }

func (s *Shipper) Submit(ev QueryEvent) { select { case s.buf <- ev: s.metrics.IncSubmitted() default: // Буфер полон. Старое не вытесняем, // новое дропаем. Лучше потерять метрику, // чем создать на диске файл-улику. s.metrics.IncDrops() } }

Ключевой момент: что происходит, если control plane недоступен. Сеть моргнула, gRPC stream порвался, ClickHouse под нагрузкой. У многих систем здесь срабатывает «давайте пока сольём в /var/spool, а как починится, догрузим». У нас нет.

Если control plane unreachable дольше пары минут, буфер заполняется, и старые события дропаются на новых. Сознательный выбор. Метрики посчитаются неточно, дашборд юзера пару минут показывает дырку, зато на диске edge-ноды нет файла, который завтра можно будет занести в материалы дела.

Полный цикл shipper'а с reconnect и backoff

func (s *Shipper) Run(ctx context.Context, controlPlane string) { backoff := time.Second for { if err := s.runOnce(ctx, controlPlane); err != nil { s.metrics.IncShipErrors() select { case <-ctx.Done(): return case <-time.After(backoff): } if backoff < 30*time.Second { backoff *= 2 } continue } backoff = time.Second } }

func (s *Shipper) runOnce(ctx context.Context, addr string) error { conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(tlsCreds())) if err != nil { return err } defer conn.Close()

client := pb.NewQueryLogClient(conn)
stream, err := client.Ship(ctx)
if err != nil {
    return err
}

ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

batch := make([]QueryEvent, 0, 1024)
flush := func() error {
    if len(batch) == 0 {
        return nil
    }
    if err := stream.Send(&pb.Batch{Events: toProto(batch)}); err != nil {
        return err
    }
    batch = batch[:0]
    return nil
}

for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case ev := <-s.buf:
        batch = append(batch, ev)
        if len(batch) >= 1024 {
            if err := flush(); err != nil {
                return err
            }
        }
    case <-ticker.C:
        if err := flush(); err != nil {
            return err
        }
    }
}

}

Никаких WAL, никаких retry-файлов. Не отправилось — не отправилось.

Никакого nginx перед DoH

Классическая ошибка: поставить перед своим DNS-демоном nginx или caddy для TLS-терминации. Удобно: автоматический LE, готовые метрики, access_log на халяву.

Вот этот access_log и есть проблема. Если он включён, у тебя на диске лежит:

1.2.3.4 - - [08/May/2026:12:34:56 +0000] "POST /abc123/dns-query HTTP/2.0" 200 ...

То есть IP юзера, его config_id, timestamp с точностью до секунды. Это полный query log, просто в другом файле.

У нас 443 слушает сам vdns-edge. TLS-терминация, HTTP/2, HTTP/3 всё в Go-процессе. Access log как явление отсутствует. Если бы я ставил nginx, в location-блоке висел бы access_log off; и error_log /dev/null;, но я не ставлю.

TLS handshake не логируется

Go стандартная crypto/tls сама ничего про ClientHello не пишет. Но если кто-то по дороге решит включить tls.Config.KeyLogWriter для дебага и забыть выключить, будет беда. У нас в коде есть unit-тест, который проверяет, что KeyLogWriter == nil для прод-конфига. Параноидально, но дёшево.

JA3 fingerprints не считаем и не храним. ALPN дамп тоже нет.

Crash dumps выключены

Если Go-процесс упадёт с panic, по умолчанию он выплюнет stack trace в stderr и помрёт. Но Linux умеет ещё аккуратно сложить core dump в файл, и если включён systemd-coredump, этот файл будет с heap’ом, в котором живут активные DNS-сессии.

systemd unit:

[Service] Type=notify ExecStart=/usr/local/bin/vdns-edge --config /etc/vdns-edge/config.yaml Restart=on-failure

Никаких core dump’ов на диск.

LimitCORE=0 Environment=GOTRACEBACK=none

Никаких файлов в /tmp от процесса.

PrivateTmp=true

Read-only filesystem кроме явных исключений.

ProtectSystem=strict ReadWritePaths=/etc/letsencrypt/live/edge.vantagedns.com

GOTRACEBACK=none это чтобы при panic Go не печатал в stderr содержимое горутин. Минус: дебажить продакшн больно. Плюс: если кто-то на ноде получил root, у него нет heap-дампа.

ProtectSystem=strict означает корневую ФС read-only для процесса. Писать может только в ReadWritePaths, а там только LE-сертификаты для ротации.

Heap minimisation после batch flush

После каждого крупного flush’а в ClickHouse в shipper’е стоит debug.FreeOSMemory(). Это не магия, Go GC сам решает, когда отдавать память операционке. Но мы хотя бы намекаем, что вот сейчас batch уехал, можно отпустить страницы. Если в этот момент кто-то получил полный root и сделал gcore, heap уже компактнее.

Не идеальная защита, скорее «делаем что можем».

Кеш не помнит, кто запрашивал

Edge кеширует DNS-ответы по (qname, qtype). Не по (qname, qtype, client_ip). Это значит, что если юзер A запросил example.com, а через секунду тот же запрос пришёл от юзера B, юзер B получит cached-ответ, и в логах нет следа, что это был именно он.

Параллельно это TTL-friendly, поэтому полезно и для производительности, не только для privacy.

Чего на edge нет принципиально

Чтобы закрыть тему диска, вот список того, что мы сознательно не ставим:

  • Логов в файле. stderr идёт в systemd journal, и оттуда мы шлём только error/warn агрегаты в Хельсинки, без qname и без config_id. То есть «vdns-edge: upstream timeout, count=12 over 5s» да, «vdns-edge: failed to resolve example.com for config abc123» нет.

  • Backup’ов config-state на диск. Если edge перезапустился, он подсасывает все конфиги заново через gRPC stream от control plane. Никакого state.json или embedded SQLite, который потом можно было бы достать с диска.

  • SQLite или BoltDB. Зачем, если всё помещается в память.

  • Sentry SDK с request body. У нас Sentry нет вообще на edge. На control plane есть, но без request data, только error stack.

Что на control plane

Без вранья: query log где-то живёт. Иначе бы дашборд не работал. Этот «где-то» это ClickHouse в Хельсинки. И вот про него я обязан сказать ровно то, что есть, без героики:

  • Юрисдикция: Финляндия. GDPR, никакого CLOUD Act. Финские LEA-запросы возможны, но процедура публичная и прозрачная.

  • Retention: 24 часа на free, до 30 дней на paid. Enforced через ClickHouse TTL на партициях и продублирован на API-слое: даже если TTL не сработал, API не отдаст ничего старше плана юзера.

  • Без US-backups. S3 в US-region не используется. Snapshot’ы лежат на Hetzner Storage Box, тоже EU.

  • Encryption at rest: LUKS на dedicated server. Снимки шифруются перед отправкой на storage box.

  • Кто имеет доступ: я. Один человек. Solo founder, без сотрудников. Когда сотрудники появятся, transparency report обновится.

Юзер не из вакуума, его данные где-то есть. Вопрос всегда: в какой юрисдикции, какой retention, кто имеет доступ. На эти три вопроса я отвечаю прямо.

Как проверить, что я не вру

Самый дешёвый способ — посмотреть на edge со стороны сети. Если он действительно ничего не пишет про юзера на диск и шипит метаданные только в Helsinki, это видно tcpdump’ом и strace’ом за пять минут. Эндпойнт без регистрации выдаётся на vantagedns.com/try, config_id живёт 24 часа, для проверки достаточно. Дальше:

# Запустили локально sudo systemctl start vdns-edge

Смотрим, какие файлы он открывает на запись

sudo strace -f -e trace=openat -p $(pgrep vdns-edge) 2>&1
| grep -v O_RDONLY
| grep -v ENOENT
| head -50

Что вы должны увидеть:

  • Открытие /etc/letsencrypt/live/.../privkey.pem при ротации сертификата (раз в ~60 дней)

  • Открытие /dev/urandom для рандомизации DNS query ID

  • Сетевые сокеты, это тоже openat, но не на диск

  • /proc/self/... для метрик

Чего быть не должно:

  • /var/lib/... write

  • /var/log/... write (кроме того, что systemd journal сам делает на уровне ОС)

  • /tmp/... или /var/tmp/... write (мы за PrivateTmp=true)

  • /home/... или любой пользовательский путь

Если вы найдёте у нас write на юзерские данные, это баг, и я очень хочу про него услышать. Issue, email, PGP, что удобнее.

Warrant canary

Стандартная практика, которой пользуются Riseup, Mullvad и другие: каждую неделю на vantagedns.com/canary публикуется PGP-signed файл вида:

VantageDNS warrant canary, week of 2026-05-05.

Statement: VantageDNS has not received any National Security Letters,
gag orders, or warrants from any government agency, neither domestic
(Finland, EU) nor foreign, that would require us to keep their
existence secret.

Signed: [PGP signature, key fingerprint published at /press-kit]
Next update: 2026-05-12.

Если на следующей неделе файл не обновился, значит что-то произошло. Прямо сказать «нас прижали» нельзя из-за gag order, но можно перестать говорить «нас не прижали». Юзер делает выводы.

Это не пуленепробиваемый механизм, но это то немногое, что вообще можно сделать в правовых рамках.

Чего я не могу гарантировать

Раздел, который провайдеры обычно стесняются писать. Privacy это набор compromises, и честнее их перечислить.

  • Если Финляндия завтра примет CLOUD Act-аналог, мы это напишем в transparency report и будем думать, что делать. Возможно, переезд юрисдикции. Возможно, закрытие. Молча подчиняться не вариант.

  • Если control plane скомпрометирован zero-day, query log за окно компрометации читаем, потому что в ClickHouse он лежит расшифрованным (encryption at rest, не at runtime). Это плата за то, что дашборд вообще работает.

  • Если Let’s Encrypt скомпрометирован и кто-то делает MITM на DoH endpoint, это не наша вина, но юзер пострадает. Тут единственная защита это DNS-over-HTTPS pinning на уровне клиента, и она вне нашего контроля.

  • Если на edge-ноде получили root, кольцевой буфер в памяти процесса можно дампнуть через gcore. Защита частичная, не полная.

Конец

Я не пишу «exciting times for DNS privacy». Просто констатирую: privacy не product feature, а архитектурное ограничение. Если его не держать с первого коммита, потом не доклеить никаким маркетингом.

Я держу. Кто хочет проверить, код открыт, strace в руки, warrant canary на сайте.

Ссылки:

  • vantagedns.com — лендинг

  • vantagedns.com/try — попробовать

  • vantagedns.com/canary — warrant canary

  • vantagedns.com/press-kit — PGP-ключ и transparency report

Комментарии (0)