From 22d307c3c5f46184493068cd4f9d7d34069ca5b3 Mon Sep 17 00:00:00 2001 From: Daniel Lavrushin Date: Thu, 15 Oct 2026 17:40:45 +0200 Subject: [PATCH] =?UTF-8?q?Implement=20ECH=20key=20stripping=20for=20IPv6?= =?UTF-8?q?=20DNS=20answers=20when=20IPv6=20support=20is=E2=80=A6=20(#424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement ECH key stripping for IPv6 DNS answers when IPv6 support is disabled - Refactor DNS packet processing to utilize a new `replyDomain` function for domain extraction. - Update DNS TCP server to check for IP family support using `QueuesIPFamily` method. - Introduce `handleECHOnlyPacket` method to manage ECH key stripping logic. - Modify NFQueue handling to incorporate ECH key removal based on configuration settings. - Enhance iptables and nftables rule management to queue DNS answers for ECH key removal when IPv6 support is off. - Add tests to validate ECH key stripping behavior for IPv6 DNS answers and ensure proper rule application in iptables and nftables. * Enhance IPv6 Support and DNS Handling - Updated documentation to clarify the behavior of b4 with IPv6 support disabled, specifying that DNS answers over IPv6 are processed in NFQUEUE mode when ECH key removal is enabled. - Modified core settings documentation to reflect changes in IPv6 handling, ensuring clarity on how b4 interacts with IPv6 packets and DNS responses. - Improved Russian translations to align with the updated English documentation regarding IPv6 support and DNS processing. - Adjusted the DNS TCP server implementation to handle IPv6 link-local addresses correctly, ensuring they are not redirected. - Enhanced the ECH key stripping logic to account for unknown devices, ensuring that device-scoped sets do not interfere with IPv6 responses. - Updated tests to verify the correct handling of IPv6 DNS answers and the behavior of the system when IPv6 support is toggled. - Refined the iptables and nftables management to ensure proper rule application and monitoring for IPv6 DNS traffic. * Refactor DNS handling for IPv6: streamline ECH key stripping and enhance device matching logic * Fix issues with manual device handling and regex patterns in DHCP and SNI matching * regex memo, zone fix * Check each family's DNS answer rule in the nft monitor in ECH-only mode With one address family enabled and the other queued only to remove ECH keys, both families' answer rules share the inet chains. The monitor matched only an unqualified sport 53 rule and the ECH-only family's rule, so losing the enabled family's answer rule went unnoticed while the other remained. It now requires both families' answer rules in prerouting and output. * Correct the ECH notes carried into the restructured docs Name the per-set condition for IPv6 ECH removal on the IPv4 / IPv6 field and the --ipv6 flag, state that NFQUEUE mode writes IPv6 rules for DNS while a set removes ECH keys, add Intercept DNS over TCP to the DNSSEC troubleshooting fix, split long sentences in How b4 handles DNS, and fix the Russian wording for TUN and captured interfaces. --- changelog.md | 4 +- changelog_ru.md | 4 +- docs/docs/advanced/cli.md | 2 +- docs/docs/advanced/config.md | 2 +- docs/docs/dns.md | 3 + docs/docs/internals/dns.md | 9 +- docs/docs/settings/core.md | 2 +- .../current/advanced/cli.md | 2 +- .../current/advanced/config.md | 2 +- .../current/dns.md | 3 + .../current/internals/dns.md | 9 +- .../current/settings/core.md | 2 +- src/config/echonly_family_test.go | 72 ++++ src/config/methods.go | 18 + src/dhcp/manager.go | 43 ++- src/dhcp/manager_test.go | 34 +- src/http/ui/src/i18n/en.json | 2 +- src/http/ui/src/i18n/ru.json | 2 +- src/nfq/dns.go | 8 +- src/nfq/dnstcp.go | 16 +- src/nfq/echonly_test.go | 298 +++++++++++++++++ src/nfq/echstrip.go | 66 +++- src/nfq/handler.go | 3 + src/nfq/ipblock.go | 2 +- src/nfq/nfq.go | 12 +- src/nfq/pool.go | 4 +- src/sni/match.go | 121 ++++++- src/sni/match_test.go | 193 +++++++++++ src/tables/dnstcp.go | 73 ++++- src/tables/echonly.go | 152 +++++++++ src/tables/echonly_test.go | 307 ++++++++++++++++++ src/tables/iptables.go | 19 +- src/tables/monitor.go | 36 +- src/tables/nft_dnstcp_test.go | 221 +++++++++++++ src/tables/nftables.go | 9 +- 35 files changed, 1664 insertions(+), 91 deletions(-) create mode 100644 src/config/echonly_family_test.go create mode 100644 src/nfq/echonly_test.go create mode 100644 src/tables/echonly.go create mode 100644 src/tables/echonly_test.go diff --git a/changelog.md b/changelog.md index de16bc75..f1daaaeb 100644 --- a/changelog.md +++ b/changelog.md @@ -2,7 +2,7 @@ ## [1.87.0] - 2026-10-12 -- ADDED: **A set can remove ECH keys from DNS answers, so a browser opens sites such as those on Cloudflare where the network drops connections that use ECH** - the switch is under DNS & Routing, DNS Redirect, Encrypted ClientHello (ECH) in the set editor, and a set that lists cloudflare-ech.com with it on covers every Cloudflare site. Discovery proposes such a set, which changes no packets, for a site that loads only without ECH. +- ADDED: **A set can remove ECH keys from DNS answers, so a browser opens sites such as those on Cloudflare where the network drops connections that use ECH** - the switch is under DNS & Routing, DNS Redirect, Encrypted ClientHello (ECH) in the set editor, a set that lists cloudflare-ech.com with it on covers every Cloudflare site, and in NFQUEUE mode it also acts on DNS answers that travel over IPv6 while IPv6 support is off. Discovery proposes such a set, which changes no packets, for a site that loads only without ECH. - CHANGED: **On Keenetic, and on routers with an older kernel where b4 cannot hold DNS queries ahead of the kernel's connection tracking, DNS queries go on to the resolver the device asked, and b4 takes over that resolver's reply for the names a set blocks, pins or sends to its own resolver** - that resolver and the servers it asks still see those names, also for a set with an encrypted resolver. System Info shows the row DNS queries copied, not held (NFLOG). - FIXED: **On Keenetic, a device that asked for a name's IPv4 and IPv6 addresses at once, and the router itself, still often lost the second answer after 1.85.1, while System Info showed DNS queries queued before conntrack (raw) as available or the log suggested kmod-ipt-raw, an OpenWrt package** - KeeneticOS runs the raw table, where b4 queued DNS queries from 1.85.1 on, only after the kernel's connection tracking has registered them. [#402](https://github.com/DanielLavrushin/b4/issues/402) - FIXED: **On routers with an older kernel, a device occasionally got only one of the two answers it asked for at once, IPv4 or IPv6, for a name in a set with a DNS redirect** - b4 sent both answers at the same moment, and the kernel's connection tracking dropped one of them as a duplicate of the other. @@ -11,6 +11,8 @@ - FIXED: **Discovery reported a site, such as one on Cloudflare, as loading without b4 while Chrome could not open it** - its probes never used ECH, which a browser such as Chrome uses for every site that publishes an ECH key and which some networks block. - FIXED: **Discovery could propose a packet strategy remembered from an earlier run for a site that only needed its DNS answer corrected** - a remembered strategy that also loaded the site won over the DNS fix. - FIXED: **System Info listed tools such as jq, sha256sum and ipset without a version when they were on the USB drive b4 is installed on** - b4 asked a tool for its version only when it sat in a standard system directory such as `/usr/bin` or `/opt/bin`. +- FIXED: **A `regexp:` entry listed in two sets, such as a set and its copy made with Duplicate, worked only in the set higher in the list, so when one of them was limited by Source devices, some devices got the wrong set or none for the names that entry matches** - b4 kept each pattern only for the first set that listed it. +- FIXED: **A device added by hand by its IPv6 address, typed in another form such as in capitals or with leading zeros, was not recognised when b4 matched its traffic to a set's domains, so a set that selects it under Source devices skipped it and one with All devices except selected applied to it** - b4 looked packets up by the standard form of their address, while it kept the device's address as typed. ## [1.86.0] - 2026-10-09 diff --git a/changelog_ru.md b/changelog_ru.md index ff99c54b..fe61cfa7 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -2,7 +2,7 @@ ## [1.87.0] - 2026-10-12 -- ДОБАВЛЕНО: **Сет может убирать ключи ECH из ответов DNS, и браузер открывает сайты, например на Cloudflare, там, где сеть отбрасывает соединения с ECH** - переключатель находится в редакторе сета в разделе DNS & Маршрутизация, Перенаправление DNS, Encrypted ClientHello (ECH), а сет с cloudflare-ech.com и включённым переключателем охватывает все сайты Cloudflare. Дискавери предлагает такой сет, без изменения пакетов, для сайта, который открывается только без ECH. +- ДОБАВЛЕНО: **Сет может убирать ключи ECH из ответов DNS, и браузер открывает сайты, например на Cloudflare, там, где сеть отбрасывает соединения с ECH** - переключатель находится в редакторе сета в разделе DNS & Маршрутизация, Перенаправление DNS, Encrypted ClientHello (ECH), сет с cloudflare-ech.com и включённым переключателем охватывает все сайты Cloudflare, а в режиме NFQUEUE ключи убираются и из ответов DNS, идущих по IPv6, даже при выключенной поддержке IPv6. Дискавери предлагает такой сет, без изменения пакетов, для сайта, который открывается только без ECH. - ИЗМЕНЕНО: **На Keenetic и на роутерах со старым ядром, где b4 не может задержать DNS-запросы до того, как ядро начнёт отслеживать их соединения, DNS-запросы уходят дальше к резолверу, которого спросило устройство, а ответ этого резолвера на имена, которые сет блокирует, закрепляет или отправляет на свой резолвер, перехватывает b4** - этот резолвер и серверы, которые он опрашивает, по-прежнему видят эти имена, в том числе для сета с зашифрованным резолвером. «Информация о системе» показывает строку «DNS-запросы копируются, а не задерживаются (NFLOG)». - ИСПРАВЛЕНО: **На Keenetic устройство, запросившее IPv4- и IPv6-адреса имени одновременно, как и сам роутер, и после 1.85.1 часто теряло второй ответ, а «Информация о системе» при этом показывала возможность «DNS-запросы в очереди раньше conntrack (raw)» доступной или лог предлагал kmod-ipt-raw, пакет для OpenWrt** - KeeneticOS обрабатывает таблицу raw, из которой b4 с версии 1.85.1 ставил DNS-запросы в очередь, только после того, как ядро начало отслеживать соединение запроса. [#402](https://github.com/DanielLavrushin/b4/issues/402) - ИСПРАВЛЕНО: **На роутерах со старым ядром устройство иногда получало только один из двух ответов, IPv4 или IPv6, которые запросило одновременно для имени из сета с DNS-перенаправлением** - b4 отправлял оба ответа в один и тот же момент, и отслеживание соединений ядра отбрасывало один из них как дубликат другого. @@ -11,6 +11,8 @@ - ИСПРАВЛЕНО: **Дискавери сообщал, что сайт, например на Cloudflare, открывается без b4, хотя Chrome открыть его не мог** - его проверки никогда не использовали ECH, который браузер вроде Chrome использует для каждого сайта, публикующего ключ ECH, и который некоторые сети блокируют. - ИСПРАВЛЕНО: **Дискавери мог предложить стратегию пакетов, запомненную в прошлом запуске, для сайта, которому нужно было только исправить DNS-ответ** - запомненная стратегия, которая тоже открывала сайт, побеждала исправление DNS. - ИСПРАВЛЕНО: **Информация о системе показывала утилиты, например jq, sha256sum и ipset, без версии, если они лежали на USB-накопителе, на который установлен b4** - b4 спрашивал версию только у утилиты из стандартного системного каталога, например `/usr/bin` или `/opt/bin`. +- ИСПРАВЛЕНО: **Запись `regexp:`, указанная в двух сетах, например в сете и его копии, созданной кнопкой «Дублировать», работала только в том из них, что выше в списке, поэтому, если один из них был ограничен устройствами-источниками, часть устройств получала для имён, совпадающих с этой записью, не тот сет или никакой** - b4 закреплял каждый шаблон только за первым сетом, в котором он встретился. +- ИСПРАВЛЕНО: **Устройство, добавленное вручную по IPv6-адресу, введённому в другой записи, например заглавными буквами или с ведущими нулями, не распознавалось, когда b4 сопоставлял его трафик с доменами сетов, поэтому сет, в устройствах-источниках которого оно выбрано, его пропускал, а сет с режимом «Все устройства, кроме выбранных» к нему применялся** - b4 искал пакеты по стандартной записи их адреса, а адрес устройства хранил в том виде, в каком его ввели. ## [1.86.0] - 2026-10-09 diff --git a/docs/docs/advanced/cli.md b/docs/docs/advanced/cli.md index 92d718fa..c7fdc2e2 100644 --- a/docs/docs/advanced/cli.md +++ b/docs/docs/advanced/cli.md @@ -27,7 +27,7 @@ A flag overrides the setting for one run only. When b4 saves its configuration, | `--ipv4` | Process IPv4 traffic | `queue.ipv4` | `true` | | `--ipv6` | Process IPv6 traffic | `queue.ipv6` | `false` | -`--ipv6` is the **Enable IPv6 Support** switch under **Settings > Core > Packet Engine**. It decides whether b4 processes IPv6, not whether the router uses it. With it off, IPv6 connections pass the router without any set applying to them, see [The `queue` section](./config.md#the-queue-section). +`--ipv6` is the **Enable IPv6 Support** switch under **Settings > Core > Packet Engine**. It decides whether b4 processes IPv6, not whether the router uses it. With it off, IPv6 connections pass the router without any set applying to them. In NFQUEUE mode, sets with [Remove ECH keys from DNS answers](../dns.md#removing-ech-keys) on still remove ECH keys from IPv6 DNS answers. See [The `queue` section](./config.md#the-queue-section). ## Firewall diff --git a/docs/docs/advanced/config.md b/docs/docs/advanced/config.md index 579442f4..47f99fa0 100644 --- a/docs/docs/advanced/config.md +++ b/docs/docs/advanced/config.md @@ -111,7 +111,7 @@ The sample shows some of the sections and some of their keys. A real file holds | `udp_conn_bytes_limit` | The same for UDP | `8` | | `interfaces` | **Capture Interfaces**, a filter on the interface a packet passes. Empty means all of them | `[]` | -`queue.ipv6` decides whether b4 processes IPv6, not whether the router uses it. Off, the default, b4 binds its queue to IPv4 only and writes no IPv6 firewall rules for any set, so a client that reaches a site over IPv6 bypasses the sets. b4 then removes IPv6 addresses from DNS answers for the domains sets match, see [DNS](../dns.md). The address families are bound when the service starts, so a change takes full effect after a restart. `--ipv6` sets the same thing for one run. +`queue.ipv6` decides whether b4 processes IPv6, not whether the router uses it. Off, the default, b4 processes IPv4 only and writes no IPv6 firewall rules for any set, so a client that reaches a site over IPv6 bypasses the sets. b4 then removes IPv6 addresses from DNS answers for the domains sets match, see [DNS](../dns.md). The exception is NFQUEUE mode while an enabled set has `dns.strip_ech` on. In that mode b4 writes IPv6 rules for DNS answers and DNS over TCP, only to remove their [ECH keys](../dns.md#removing-ech-keys). A change takes full effect after a restart. `--ipv6` sets the same thing for one run. ## The `sets` section diff --git a/docs/docs/dns.md b/docs/docs/dns.md index 7f440faf..1d48c8e0 100644 --- a/docs/docs/dns.md +++ b/docs/docs/dns.md @@ -84,6 +84,8 @@ Sites behind some CDNs publish an Encrypted ClientHello (ECH) key in DNS. A brow - the name asked matches the set, such as `steamdb.info` listed in the set; - the key's public name is a domain the set matches. A set listing `cloudflare-ech.com` removes the key from every Cloudflare site's answers. +While **Enable IPv6 Support** is off, b4 in NFQUEUE mode also removes the key from answers that arrive over IPv6. A set that uses **Source Devices** leaves such answers alone, since b4 cannot tell which device they go to. A device [added by hand](settings/core.md#manual-devices) by its IPv6 address is the exception. + [Discovery](discovery.md#sites-with-an-ech-key) proposes such a set, with no packet changes, for a site that loads without ECH but not with it. A key already in a browser's cache is used until it expires. ## Sending every DNS query to DoH @@ -148,6 +150,7 @@ The [log](logs.md) records the same decisions, as `dns-doh->`, `dns-pin` and so | The site still goes to the old address | The device keeps an answer it already has until the answer expires. | | `servfail`, `fallback-cache` or `fallback-upstream` | The set's resolver is unreachable from this connection. Another resolver, or DoH in place of plain DNS, is the next try. | | The set's names resolve slowly or not at all, with the set's resolver on the router | A resolver on the router, and the router's resolver when a resolver on the network uses it upstream, has to reach its own upstream over DoH or DoT, which b4 does not intercept. | +| A browser still uses ECH, with a resolver on the network that checks DNSSEC, such as BIND or Unbound | The resolver gets the original answer on a path b4 does not see: DoT or DoH to its upstream, an interface left out of **Capture Interfaces**, or TCP while **Intercept DNS over TCP** is off. With **Intercept DNS over TCP** on, plain DNS through a captured interface closes these paths, see [ECH keys](internals/dns.md#ech-keys). | ### On KeeneticOS and older kernels {#copied-requests} diff --git a/docs/docs/internals/dns.md b/docs/docs/internals/dns.md index 3dac0d9a..b689576d 100644 --- a/docs/docs/internals/dns.md +++ b/docs/docs/internals/dns.md @@ -11,10 +11,12 @@ b4 intercepts DNS on port 53 and answers the names its sets target. The options | Transport | How it reaches b4 | When | | --- | --- | --- | | UDP port 53 | Queue rules in `PREROUTING` and `OUTPUT`: requests (`dport 53`) from the raw table, ahead of conntrack (priority -300 on nftables), and replies (`sport 53`) from the mangle table. The exceptions are under [Requests and conntrack](#requests-and-conntrack) | Always, while b4 is running | -| TCP port 53 | A `nat` `REDIRECT` to a local listener, **Listener port** 5453 by default | While an enabled set has a DNS server, a DoH URL or **Remove ECH keys from DNS answers** on, and **Intercept DNS over TCP** is on | +| TCP port 53 | A `nat` `REDIRECT` to a local listener, **Listener port** 5453 by default. TCP to a link-local IPv6 address (`fe80::/10`) is left alone | While an enabled set has a DNS server, a DoH URL or **Remove ECH keys from DNS answers** on, and **Intercept DNS over TCP** is on | Both cover traffic forwarded from the network and the queries the router makes for itself. The queries b4 sends on a client's behalf, and its lookups through a set's resolver, carry the [queue mark](../guides/marks.md#the-queue-mark). The rules skip packets that carry it, so such a query never re-enters the queue. Lookups b4 makes through the router's own resolver carry no mark. +With **Enable IPv6 Support** off, these rules cover IPv4 only. The exception is NFQUEUE mode while an enabled set has **Remove ECH keys from DNS answers** on. DNS replies over IPv6 are then queued as well, and IPv6 DNS over TCP is redirected too. b4 only removes ECH keys from them, see [ECH keys](#ech-keys). + TCP interception needs `REDIRECT` in the `nat` table. Where the kernel does not provide it, b4 logs a warning, and DNS over TCP stays with the client's resolver for that address family. ```mermaid @@ -96,6 +98,11 @@ Only the address the set names is compared: a query the resolver sends from anot - Only the `ech` parameter goes, together with its entry in `mandatory`, and `mandatory` itself when nothing is left in it. The ALPN list, the address hints and the TTL stay as they were. - The RRSIG records over the changed records are dropped and the AD flag is cleared, since the changed records no longer match their signatures. - Listing `cloudflare-ech.com` in a set also makes the set match, by server name, the ECH ClientHellos of clients that kept the key, and its TCP and UDP settings apply to them. A set made only to remove keys changes no packets, so those ClientHellos pass unchanged. +- With **Enable IPv6 Support** off, answers that arrive over IPv6 in NFQUEUE mode lose nothing but their key. No pin, redirect, block, address learning or other rewrite applies to them. b4 cannot tell which device such an answer goes to, so only sets without source devices are matched against it. A device [added by hand](../settings/core.md#manual-devices) by its IPv6 address is the exception. The TUN engine carries no IPv6, and there such answers keep their key. + +In a zone signed with DNSSEC, such as `steamdb.info` (`cloudflare-ech.com` is not signed), an HTTPS answer without its key no longer matches its signature. A resolver that validates DNSSEC rejects it as bogus and asks the zone's other servers. BIND answers the HTTPS query with SERVFAIL once none of them gives an answer that validates. The device then gets no key, and A and AAAA answers are not affected. + +When the resolver reaches one of the zone's servers on a path b4 does not process, it gets the original answer there and validates it. It then hands the key to every device that asks it, until the record expires. Such paths are DNS over TLS or HTTPS to an upstream, an interface left out of **Capture Interfaces**, and TCP while **Intercept DNS over TCP** is off. A resolver on the b4 host itself sends its answers to devices through b4 as well, and b4 removes the key from them after the resolver has validated them. ## Lookups b4 makes itself {#lookups-b4-makes-itself} diff --git a/docs/docs/settings/core.md b/docs/docs/settings/core.md index 5cce8b37..bf21f358 100644 --- a/docs/docs/settings/core.md +++ b/docs/docs/settings/core.md @@ -55,7 +55,7 @@ IP block detection is turned on per set, on the **TCP > General** tab. This card | Field | What it does | Default | | --- | --- | --- | | **Enable IPv4 Support** | b4 handles IPv4 traffic. | On | -| **Enable IPv6 Support** | b4 handles IPv6 packets, DNS answers and firewall rules. | Off | +| **Enable IPv6 Support** | b4 handles IPv6 packets, DNS answers and firewall rules. While it is off, sets with [Remove ECH keys from DNS answers](../dns.md#removing-ech-keys) on still remove ECH keys from IPv6 DNS answers in NFQUEUE mode. | Off | | **Force IPv4 for matched domains** | While IPv6 support is off, b4 removes IPv6 addresses from DNS answers for domains a set matched, so clients reach them over IPv4. | On | :::warning IPv6 support off diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md index 9fc97c36..0017c991 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/cli.md @@ -27,7 +27,7 @@ b4 принимает флаги командной строки, и они им | `--ipv4` | Обрабатывать IPv4-трафик | `queue.ipv4` | `true` | | `--ipv6` | Обрабатывать IPv6-трафик | `queue.ipv6` | `false` | -`--ipv6` - это переключатель **Поддержка IPv6** в **Настройки > Основные > Движок пакетов**. Он определяет, обрабатывает ли IPv6 сам b4, а не пользуется ли им роутер. Когда он выключен, IPv6-соединения проходят через роутер без участия сетов, см. [Раздел `queue`](./config.md#раздел-queue). +`--ipv6` - это переключатель **Поддержка IPv6** в **Настройки > Основные > Движок пакетов**. Он определяет, обрабатывает ли IPv6 сам b4, а не пользуется ли им роутер. Когда он выключен, IPv6-соединения проходят через роутер без участия сетов. В режиме NFQUEUE сеты, у которых включено [Убирать ключи ECH из ответов DNS](../dns.md#удаление-ключей-ech), всё равно убирают ключи ECH из DNS-ответов по IPv6. См. [Раздел `queue`](./config.md#раздел-queue). ## Файрвол diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/config.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/config.md index 97fd3897..ee4b6107 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/config.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/advanced/config.md @@ -111,7 +111,7 @@ b4 хранит конфигурацию в одном JSON-файле, по у | `udp_conn_bytes_limit` | То же для UDP | `8` | | `interfaces` | **Интерфейсы захвата**, фильтр по интерфейсу, через который проходит пакет. Пустой список означает все | `[]` | -`queue.ipv6` определяет, обрабатывает ли IPv6 сам b4, а не пользуется ли им роутер. При выключенном значении, а оно по умолчанию, b4 привязывает очередь только к IPv4 и не пишет IPv6-правил файрвола ни для одного сета, поэтому клиент, открывший сайт по IPv6, проходит мимо сетов. b4 тогда убирает IPv6-адреса из DNS-ответов для доменов, с которыми совпадают сеты, см. [DNS](../dns.md). Семейства адресов привязываются при старте сервиса, поэтому изменение действует в полном объёме после перезапуска. `--ipv6` задаёт то же самое на один запуск. +`queue.ipv6` определяет, обрабатывает ли IPv6 сам b4, а не пользуется ли им роутер. При выключенном значении, а оно по умолчанию, b4 обрабатывает только IPv4 и не пишет IPv6-правил файрвола ни для одного сета, поэтому клиент, открывший сайт по IPv6, проходит мимо сетов. b4 тогда убирает IPv6-адреса из DNS-ответов для доменов, с которыми совпадают сеты, см. [DNS](../dns.md). Исключение - режим NFQUEUE, пока у включённого сета включено `dns.strip_ech`. В этом режиме b4 пишет IPv6-правила для DNS-ответов и DNS поверх TCP, но только чтобы убрать из них [ключи ECH](../dns.md#удаление-ключей-ech). Изменение действует в полном объёме после перезапуска. `--ipv6` задаёт то же самое на один запуск. ## Раздел `sets` diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/dns.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/dns.md index bb737d11..de8aa11d 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/dns.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/dns.md @@ -84,6 +84,8 @@ b4 видит DNS-запросы на порт 53 - от устройств се - запрошенное имя совпадает с сетом, например `steamdb.info` в списке сета; - публичное имя ключа - домен, совпадающий с сетом. Сет с `cloudflare-ech.com` убирает ключ из ответов всех сайтов Cloudflare. +Пока **Поддержка IPv6** выключена, b4 в режиме NFQUEUE убирает ключ и из ответов, которые приходят по IPv6. Сет, у которого заданы **Исходные устройства**, такие ответы не трогает: b4 не знает, к какому устройству они идут. Исключение - устройство, [добавленное вручную](settings/core.md#manual-devices) по IPv6-адресу. + [Дискавери](discovery.md#сайты-с-ключом-ech) предлагает такой сет без изменения пакетов для сайта, который открывается без ECH, но не с ним. Ключ, уже лежащий в кеше браузера, используется, пока не истечёт. ## Весь DNS через DoH @@ -148,6 +150,7 @@ b4 видит DNS-запросы на порт 53 - от устройств се | Сайт по-прежнему открывается по старому адресу | Устройство пользуется уже полученным ответом, пока у него не истечёт срок. | | `servfail`, `fallback-cache` или `fallback-upstream` | Резолвер сета недоступен с этого подключения. Следующий шаг - другой резолвер или DoH вместо обычного DNS. | | Имена сета разрешаются медленно или не разрешаются, а резолвер сета работает на роутере | Резолвер на роутере, а также резолвер роутера, если резолвер в сети использует его как вышестоящий, должен обращаться к своему вышестоящему резолверу по DoH или DoT: их b4 не перехватывает. | +| Браузер по-прежнему использует ECH, а резолвер в сети проверяет DNSSEC, например BIND или Unbound | Резолвер получает исходный ответ путём, которого b4 не видит: DoT или DoH к вышестоящему резолверу, интерфейс, не вошедший в **Интерфейсы захвата**, или TCP, пока **Перехватывать DNS поверх TCP** выключено. При включённом **Перехватывать DNS поверх TCP** обычный DNS через интерфейс, который захватывает b4, закрывает эти пути, см. [Ключи ECH](internals/dns.md#ech-keys). | ### На KeeneticOS и старых ядрах {#copied-requests} diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/internals/dns.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/internals/dns.md index 2d862c2b..ee8e18bf 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/internals/dns.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/internals/dns.md @@ -11,10 +11,12 @@ b4 перехватывает DNS на порту 53 и отвечает на и | Транспорт | Как попадает в b4 | Когда | | --- | --- | --- | | UDP, порт 53 | Правила очереди в `PREROUTING` и `OUTPUT`: запросы (`dport 53`) - из таблицы raw, раньше conntrack (в nftables с приоритетом -300), ответы (`sport 53`) - из таблицы mangle. Исключения описаны в разделе [Запросы и conntrack](#requests-and-conntrack) | Всегда, пока b4 запущен | -| TCP, порт 53 | `REDIRECT` в таблице `nat` на локальный слушатель, по умолчанию **Порт слушателя** 5453 | Пока хотя бы у одного включённого сета задан DNS-сервер или DoH-адрес либо включено **Убирать ключи ECH из ответов DNS**, и включено **Перехватывать DNS поверх TCP** | +| TCP, порт 53 | `REDIRECT` в таблице `nat` на локальный слушатель, по умолчанию **Порт слушателя** 5453. TCP к link-local IPv6-адресам (`fe80::/10`) не перенаправляется | Пока хотя бы у одного включённого сета задан DNS-сервер или DoH-адрес либо включено **Убирать ключи ECH из ответов DNS**, и включено **Перехватывать DNS поверх TCP** | Оба способа охватывают и трафик из сети, и запросы самого роутера. Запросы, которые b4 отправляет за клиента, и его запросы через резолвер сета несут [метку очереди](../guides/marks.md#метка-очереди). Правила пропускают пакеты с этой меткой, поэтому такой запрос не возвращается в очередь. Запросы, которые b4 делает через резолвер самого роутера, метки не несут. +Пока **Поддержка IPv6** выключена, эти правила действуют только для IPv4. Исключение - режим NFQUEUE, пока у включённого сета включено **Убирать ключи ECH из ответов DNS**. Тогда в очередь ставятся и ответы DNS по IPv6, а DNS поверх TCP по IPv6 тоже перенаправляется. Из таких ответов b4 только убирает ключи ECH, см. [Ключи ECH](#ech-keys). + Перехват TCP требует `REDIRECT` в таблице `nat`. Если ядро его не поддерживает, b4 пишет предупреждение в лог, и DNS поверх TCP для этого семейства адресов остаётся на резолвере клиента. ```mermaid @@ -96,6 +98,11 @@ flowchart TB - Убирается только параметр `ech` вместе с его записью в `mandatory`, а сам `mandatory` - если в нём ничего не осталось. Список ALPN, подсказки адресов и TTL остаются как были. - Записи RRSIG над изменёнными записями убираются, а флаг AD сбрасывается: изменённые записи больше не совпадают со своими подписями. - Если в сете есть `cloudflare-ech.com`, сет совпадает по имени сервера и с ClientHello с ECH от клиентов, у которых ключ остался, и к ним применяются его настройки TCP и UDP. Сет, сделанный только для удаления ключей, пакеты не меняет, поэтому такие ClientHello проходят без изменений. +- Пока **Поддержка IPv6** выключена, ответы, пришедшие по IPv6 в режиме NFQUEUE, теряют только ключ. Закрепления, перенаправление, блокировка, запоминание адресов и другие правки ответа к ним не применяются. b4 не знает, к какому устройству идёт такой ответ, поэтому сверяет его только с сетами без исходных устройств. Исключение - устройство, [добавленное вручную](../settings/core.md#manual-devices) по IPv6-адресу. Движок TUN не обрабатывает IPv6, и там такие ответы сохраняют ключ. + +В зоне, подписанной DNSSEC, например `steamdb.info` (`cloudflare-ech.com` не подписана), HTTPS-ответ без ключа больше не совпадает со своей подписью. Резолвер, проверяющий DNSSEC, отвергает такой ответ как недостоверный (bogus) и спрашивает другие серверы зоны. BIND отвечает на HTTPS-запрос SERVFAIL, когда ни один из них не дал ответа, прошедшего проверку. Устройство тогда остаётся без ключа, а ответы A и AAAA это не затрагивает. + +Если резолвер добирается до одного из серверов зоны путём, который b4 не обрабатывает, он получает там исходный ответ и проверяет его. Затем он раздаёт ключ каждому устройству, которое его спросит, пока не истечёт срок записи. Такие пути - DNS поверх TLS или HTTPS к вышестоящему резолверу, интерфейс, не вошедший в **Интерфейсы захвата**, и TCP, пока **Перехватывать DNS поверх TCP** выключено. Резолвер на самом хосте b4 тоже отправляет ответы устройствам через b4, и b4 убирает ключ из этих ответов уже после того, как резолвер их проверил. ## Запросы, которые b4 делает сам {#lookups-b4-makes-itself} diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/settings/core.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/settings/core.md index 0307e407..0984b109 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/settings/core.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/settings/core.md @@ -55,7 +55,7 @@ VPN-клиент или туннель может увести трафик на | Поле | Что делает | По умолчанию | | --- | --- | --- | | **Поддержка IPv4** | b4 обрабатывает трафик IPv4. | Вкл | -| **Поддержка IPv6** | b4 обрабатывает пакеты IPv6, DNS-ответы и правила файрвола для IPv6. | Выкл | +| **Поддержка IPv6** | b4 обрабатывает пакеты IPv6, DNS-ответы и правила файрвола для IPv6. Пока она выключена, сеты, у которых включено [Убирать ключи ECH из ответов DNS](../dns.md#удаление-ключей-ech), в режиме NFQUEUE всё равно убирают ключи ECH из DNS-ответов по IPv6. | Выкл | | **Форсировать IPv4 для совпавших доменов** | Пока поддержка IPv6 выключена, b4 убирает IPv6-адреса из DNS-ответов для доменов, совпавших с сетом, и клиенты подключаются к ним по IPv4. | Вкл | :::warning Поддержка IPv6 выключена diff --git a/src/config/echonly_family_test.go b/src/config/echonly_family_test.go new file mode 100644 index 00000000..ea46c972 --- /dev/null +++ b/src/config/echonly_family_test.go @@ -0,0 +1,72 @@ +package config + +import "testing" + +func echStripFamilies(ipv4, ipv6, strip bool) *Config { + cfg := NewConfig() + cfg.Queue.IPv4Enabled = ipv4 + cfg.Queue.IPv6Enabled = ipv6 + set := NewSetConfig() + set.Id, set.Name, set.Enabled = "ech", "ech", true + set.DNS.StripECH = strip + cfg.Sets = []*SetConfig{&set} + return &cfg +} + +func TestECHOnlyFamilyIsTheFamilyB4OtherwiseLeavesAlone(t *testing.T) { + for _, tc := range []struct { + name string + cfg func() *Config + v4, v6 bool + }{ + {"IPv6 support off and a set removes ECH keys", func() *Config { return echStripFamilies(true, false, true) }, false, true}, + {"IPv6 support off and no set removes them", func() *Config { return echStripFamilies(true, false, false) }, false, false}, + {"both families on", func() *Config { return echStripFamilies(true, true, true) }, false, false}, + {"IPv4 support off", func() *Config { return echStripFamilies(false, true, true) }, true, false}, + {"both families off", func() *Config { return echStripFamilies(false, false, true) }, false, false}, + {"the set is disabled", func() *Config { + c := echStripFamilies(true, false, true) + c.Sets[0].Enabled = false + return c + }, false, false}, + {"the TUN engine", func() *Config { + c := echStripFamilies(true, false, true) + c.Queue.Mode = "tun" + return c + }, false, false}, + {"Discovery", func() *Config { + c := echStripFamilies(true, false, true) + c.Queue.IsDiscovery = true + return c + }, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.cfg() + if got := cfg.ECHOnlyFamily(false); got != tc.v4 { + t.Errorf("ECHOnlyFamily(IPv4) = %v, want %v", got, tc.v4) + } + if got := cfg.ECHOnlyFamily(true); got != tc.v6 { + t.Errorf("ECHOnlyFamily(IPv6) = %v, want %v", got, tc.v6) + } + for _, v6 := range []bool{false, true} { + if got, want := cfg.QueuesIPFamily(v6), cfg.IPFamilyEnabled(v6) || cfg.ECHOnlyFamily(v6); got != want { + t.Errorf("QueuesIPFamily(v6=%v) = %v, want %v", v6, got, want) + } + } + }) + } +} + +func TestSwitchingECHRemovalWithIPv6OffRebuildsTheFirewall(t *testing.T) { + off, on := echStripFamilies(true, false, false), echStripFamilies(true, false, true) + off.System.DNS.TCPDisabled, on.System.DNS.TCPDisabled = true, true + if !FirewallRefreshNeeded(off, on) || !FirewallRefreshNeeded(on, off) { + t.Fatal("switching ECH key removal on or off with IPv6 support off must rebuild the firewall: only the rules decide whether IPv6 DNS answers reach b4") + } + + off6, on6 := echStripFamilies(true, true, false), echStripFamilies(true, true, true) + off6.System.DNS.TCPDisabled, on6.System.DNS.TCPDisabled = true, true + if FirewallRefreshNeeded(off6, on6) { + t.Fatal("with IPv6 support on, b4 already queues IPv6 DNS answers, so switching ECH key removal needs no rebuild") + } +} diff --git a/src/config/methods.go b/src/config/methods.go index 883b8869..f5e83165 100644 --- a/src/config/methods.go +++ b/src/config/methods.go @@ -371,6 +371,21 @@ func (c *Config) HasECHStrip() bool { return false } +func (c *Config) IPFamilyEnabled(v6 bool) bool { + if v6 { + return c.Queue.IPv6Enabled + } + return c.Queue.IPv4Enabled +} + +func (c *Config) ECHOnlyFamily(v6 bool) bool { + return !c.IPFamilyEnabled(v6) && c.IPFamilyEnabled(!v6) && c.Queue.Mode != "tun" && !c.Queue.IsDiscovery && c.HasECHStrip() +} + +func (c *Config) QueuesIPFamily(v6 bool) bool { + return c.IPFamilyEnabled(v6) || c.ECHOnlyFamily(v6) +} + func (c *Config) HasDNSRedirect() bool { for _, set := range c.Sets { if set == nil || !set.Enabled || !set.DNS.Enabled { @@ -1382,6 +1397,9 @@ func FirewallRefreshNeeded(oldCfg, newCfg *Config) bool { if oldCfg.DNSTCPInterceptEnabled() != newCfg.DNSTCPInterceptEnabled() { return true } + if oldCfg.ECHOnlyFamily(false) != newCfg.ECHOnlyFamily(false) || oldCfg.ECHOnlyFamily(true) != newCfg.ECHOnlyFamily(true) { + return true + } if oldCfg.Queue.Mark != newCfg.Queue.Mark || oldCfg.Queue.IPv4Enabled != newCfg.Queue.IPv4Enabled || oldCfg.Queue.IPv6Enabled != newCfg.Queue.IPv6Enabled { return true } diff --git a/src/dhcp/manager.go b/src/dhcp/manager.go index dab4b0e3..310428f3 100644 --- a/src/dhcp/manager.go +++ b/src/dhcp/manager.go @@ -2,6 +2,7 @@ package dhcp import ( "context" + "net" "os" "strings" "sync" @@ -27,7 +28,7 @@ type Manager struct { type DetectionResult struct { Available bool Source string - Path string + Path string } func Detect() DetectionResult { @@ -36,7 +37,7 @@ func Detect() DetectionResult { return DetectionResult{ Available: true, Source: "arp", - Path: arpPath, + Path: arpPath, } } } @@ -144,18 +145,7 @@ func (m *Manager) refresh() { } } - for _, d := range m.manualDevices { - if d.IP == "" || d.MAC == "" { - continue - } - mac := normalizeMAC(d.MAC) - m.ipToMAC[d.IP] = mac - m.macToIP[mac] = d.IP - if d.Name != "" { - m.hostnames[mac] = d.Name - } - log.Tracef("DHCP: manual device %s -> %s", d.IP, mac) - } + m.addManualDevicesLocked() count := len(m.ipToMAC) m.mu.Unlock() @@ -164,6 +154,31 @@ func (m *Manager) refresh() { m.notifyCallbacks() } +func (m *Manager) addManualDevicesLocked() { + for _, d := range m.manualDevices { + ip := canonicalIP(d.IP) + if ip == "" || d.MAC == "" { + continue + } + mac := normalizeMAC(d.MAC) + m.ipToMAC[ip] = mac + m.macToIP[mac] = ip + if d.Name != "" { + m.hostnames[mac] = d.Name + } + log.Tracef("DHCP: manual device %s -> %s", ip, mac) + } +} + +func canonicalIP(raw string) string { + addr, _, _ := strings.Cut(strings.TrimSpace(raw), "%") + ip := net.ParseIP(addr) + if ip == nil { + return "" + } + return ip.String() +} + func (m *Manager) TriggerRefresh() { select { case m.refreshCh <- struct{}{}: diff --git a/src/dhcp/manager_test.go b/src/dhcp/manager_test.go index a29ee8e1..c11390f1 100644 --- a/src/dhcp/manager_test.go +++ b/src/dhcp/manager_test.go @@ -160,14 +160,7 @@ func TestManualDevicesMerge(t *testing.T) { m.ipToMAC = map[string]string{"192.168.1.1": "AA:BB:CC:DD:EE:FF"} m.macToIP = map[string]string{"AA:BB:CC:DD:EE:FF": "192.168.1.1"} m.hostnames = make(map[string]string) - for _, d := range m.manualDevices { - mac := normalizeMAC(d.MAC) - m.ipToMAC[d.IP] = mac - m.macToIP[mac] = d.IP - if d.Name != "" { - m.hostnames[mac] = d.Name - } - } + m.addManualDevicesLocked() m.mu.Unlock() if mac := m.GetMACForIP("192.168.10.1"); mac != "02:B4:C0:A8:0A:01" { @@ -181,6 +174,31 @@ func TestManualDevicesMerge(t *testing.T) { } } +func TestAManualDeviceIsFoundByTheAddressFormItsPacketsCarry(t *testing.T) { + m := newTestManager() + m.manualDevices = []config.Device{ + {MAC: "02:B4:00:00:00:02", IP: "2001:DB8:0:0:0:0:0:0002", IsManual: true}, + {MAC: "02:B4:C0:A8:0A:05", IP: " ::ffff:192.168.10.5 ", IsManual: true}, + {MAC: "02:B4:00:00:00:01", IP: "fe80::1%br0", IsManual: true}, + {MAC: "02:B4:00:00:00:09", IP: "not an address", IsManual: true}, + } + m.mu.Lock() + m.addManualDevicesLocked() + m.mu.Unlock() + + for ip, want := range map[string]string{"2001:db8::2": "02:B4:00:00:00:02", "192.168.10.5": "02:B4:C0:A8:0A:05", "fe80::1": "02:B4:00:00:00:01"} { + if got := m.GetMACForIP(ip); got != want { + t.Errorf("GetMACForIP(%q) = %q, want %q: b4 looks a client up by the form net.IP prints, not by the form typed into the settings", ip, got, want) + } + } + if got := m.GetIPForMAC("02:B4:00:00:00:02"); got != "2001:db8::2" { + t.Errorf("GetIPForMAC = %q, want the address in the form b4 matches it by", got) + } + if got := len(m.GetAllMappings()); got != 3 { + t.Errorf("%d mappings, want 3: an entry that is not an address matches no packet", got) + } +} + func TestIsAvailableWithManualDevices(t *testing.T) { m := newTestManager() if m.IsAvailable() { diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index b22359b3..52213c82 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -191,7 +191,7 @@ "protoFeatures": "IPv4 / IPv6", "enableIPv4": "Enable IPv4 Support", "enableIPv6": "Enable IPv6 Support", - "enableIPv6Desc": "Lets b4 process IPv6 packets, DNS answers and firewall rules. With this off b4 ignores IPv6 entirely: it does not disable IPv6 on the router, and sites a set targets stay reachable over IPv6 with none of the set's strategies applied.", + "enableIPv6Desc": "Lets b4 process IPv6 packets, DNS answers and firewall rules. With this off b4 leaves IPv6 alone, except that it removes ECH keys from IPv6 DNS answers for the sets that ask for it. It does not disable IPv6 on the router, and sites a set targets stay reachable over IPv6 with none of the set's strategies applied.", "ipv6BypassWarning": "This host has a working global IPv6 address while b4 is processing IPv4 only. Anything that reaches a target over IPv6 goes out with no set applied. Turn IPv6 support on, or keep Force IPv4 for matched domains on.", "firewallFeatures": "Firewall Rules", "skipIptables": "Skip IPTables/NFTables Setup", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index cffdc217..0d34a4ea 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -194,7 +194,7 @@ "protoFeatures": "IPv4 / IPv6", "enableIPv4": "Поддержка IPv4", "enableIPv6": "Поддержка IPv6", - "enableIPv6Desc": "Позволяет b4 обрабатывать IPv6: пакеты, DNS-ответы и правила файрвола. Если выключено, b4 полностью игнорирует IPv6. Это не отключает IPv6 на роутере, и сайты, на которые нацелен сет, остаются доступны по IPv6 без единой стратегии сета.", + "enableIPv6Desc": "Позволяет b4 обрабатывать IPv6: пакеты, DNS-ответы и правила файрвола. Если выключено, b4 не трогает IPv6, кроме удаления ключей ECH из DNS-ответов по IPv6 для сетов, у которых это включено. Это не отключает IPv6 на роутере, и сайты, на которые нацелен сет, остаются доступны по IPv6 без единой стратегии сета.", "ipv6BypassWarning": "У этого хоста есть рабочий глобальный IPv6-адрес, а b4 обрабатывает только IPv4. Всё, что дойдёт до цели по IPv6, уйдёт мимо всех сетов. Включите поддержку IPv6 или оставьте включённым «Форсировать IPv4 для совпавших доменов».", "firewallFeatures": "Правила файрвола", "skipIptables": "Пропустить настройку IPTables/NFTables", diff --git a/src/nfq/dns.go b/src/nfq/dns.go index 3a9fb676..56e97dbd 100644 --- a/src/nfq/dns.go +++ b/src/nfq/dns.go @@ -493,13 +493,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp if sport == 53 { if txid, ok := dns.ParseTransactionID(payload); ok { - domain, _ := dns.ParseQueryDomain(payload) - if domain == "" { - if d, ok := parseDNSName(payload, 12); ok { - domain = d - } - } - domain = strings.ToLower(domain) + domain := replyDomain(payload) clientIP := pkt.dst dnsServerIP := pkt.src if w.dnsCopyGate(vc, pkt, dport, txid, domain, payload) { diff --git a/src/nfq/dnstcp.go b/src/nfq/dnstcp.go index 42ac4b28..c2657ea2 100644 --- a/src/nfq/dnstcp.go +++ b/src/nfq/dnstcp.go @@ -31,6 +31,8 @@ type dnsTCPServer struct { cancel context.CancelFunc lnV4 net.Listener lnV6 net.Listener + wantV4 bool + wantV6 bool wgAccept sync.WaitGroup wg sync.WaitGroup } @@ -52,8 +54,9 @@ func (s *dnsTCPServer) Start() error { lc := net.ListenConfig{} var err4, err6 error + s.wantV4, s.wantV6 = cfg.QueuesIPFamily(false), cfg.QueuesIPFamily(true) - if cfg.Queue.IPv4Enabled { + if s.wantV4 { var lnV4 net.Listener if lnV4, err4 = lc.Listen(s.ctx, "tcp4", net.JoinHostPort("0.0.0.0", fmt.Sprintf("%d", s.port))); err4 == nil { s.lnV4 = lnV4 @@ -65,7 +68,7 @@ func (s *dnsTCPServer) Start() error { } } - if cfg.Queue.IPv6Enabled { + if s.wantV6 { var lnV6 net.Listener if lnV6, err6 = lc.Listen(s.ctx, "tcp6", net.JoinHostPort("::", fmt.Sprintf("%d", s.port))); err6 == nil { s.lnV6 = lnV6 @@ -218,6 +221,7 @@ func (s *dnsTCPServer) handle(client net.Conn) { if clientIP != nil { srcMac = s.worker.getMacByIp(clientIP.String()) } + v6 := clientIP != nil && clientIP.To4() == nil upstream := &dnsTCPUpstream{server: s, ip: origIP, port: origPort, err: origErr} defer upstream.close() @@ -232,7 +236,7 @@ func (s *dnsTCPServer) handle(client net.Conn) { if resp == nil { return false } - if echless, actor := s.worker.stripECHKeys(cfg, set, domain, srcMac, resp, true); echless != nil { + if echless, actor := s.worker.stripECHKeys(cfg, set, domain, srcMac, resp, true, srcMac == "" && !cfg.IPFamilyEnabled(v6)); echless != nil { s.logEvent(actor, domain, clientIP, origIP, clientPort, srcMac, dnsActionECHStripped) resp = echless } @@ -261,6 +265,12 @@ func (s *dnsTCPServer) handle(client net.Conn) { } domain = strings.ToLower(domain) + if !cfg.IPFamilyEnabled(v6) { + if relay(cfg, s.worker.echMatchSet(domain, srcMac, srcMac == ""), domain, query) { + continue + } + return + } matched, set := s.worker.getMatcher().MatchSNIWithSource(domain, srcMac) if !matched { log.Tracef("DNS TCP: %s matched no set (src %s), forwarding unchanged", dns.SafeName(domain), srcMac) diff --git a/src/nfq/echonly_test.go b/src/nfq/echonly_test.go new file mode 100644 index 00000000..3eb7768e --- /dev/null +++ b/src/nfq/echonly_test.go @@ -0,0 +1,298 @@ +package nfq + +import ( + "bytes" + "encoding/binary" + "net" + "testing" + "time" + + "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/dns" + "github.com/florianl/go-nfqueue" +) + +func ipv6OffWorker(t *testing.T, sets ...*config.SetConfig) *Worker { + t.Helper() + cfg := config.NewConfig() + cfg.Queue.IPv4Enabled = true + cfg.Queue.IPv6Enabled = false + cfg.Sets = sets + return passiveDNSWorker(t, &cfg) +} + +func v6Answer(payload []byte) []byte { + return makeV6UDPPacketWithExt(payload, 53, 40000, 0, nil) +} + +func TestAnIPv6AnswerLosesItsECHKeyWhileIPv6SupportIsOff(t *testing.T) { + w := ipv6OffWorker(t, echTestSet("steamdb", true, "steamdb.info")) + sent := captureSends(t) + q := newFakeModQueue() + raw := v6Answer(httpsAnswerWithECH("steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com")) + + w.dispatch(&verdictCtx{id: 1, q: q}, raw) + + v := q.now(t) + if v.verdict != nfqueue.NfAccept || v.packet == nil { + t.Fatalf("verdict %d carrying %d bytes, want the answer accepted with its key removed in place", v.verdict, len(v.packet)) + } + if dns.ParseResponseECH(v.packet[48:]) != nil { + t.Fatal("the answer that came over IPv6 kept its ECH key: a validating resolver that reaches the zone's servers over IPv6 caches it and hands it to every device") + } + if binary.BigEndian.Uint16(v.packet[48+6:48+8]) != 1 { + t.Fatal("the HTTPS record itself must stay, only its key goes") + } + if !bytes.Equal(v.packet[8:40], raw[8:40]) { + t.Fatal("the rewritten answer changed its addresses") + } + sent.none(t, 30*time.Millisecond, "b4 sent a packet of its own although it rewrote the answer in place") +} + +func TestAnIPv6AnswerLosesTheKeyNamingACoverSet(t *testing.T) { + w := ipv6OffWorker(t, echTestSet("cloudflare-ech", true, "cloudflare-ech.com")) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, v6Answer(httpsAnswerWithECH("steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com"))) + + v := q.now(t) + if v.packet == nil || dns.ParseResponseECH(v.packet[48:]) != nil { + t.Fatal("a key whose public name a set strips must go from an IPv6 answer too") + } +} + +func TestAnIPv6AnswerIsLeftAloneWithoutASetThatRemovesKeys(t *testing.T) { + w := ipv6OffWorker(t, echTestSet("steamdb", false, "steamdb.info")) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, v6Answer(httpsAnswerWithECH("steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com"))) + + if v := q.now(t); v.verdict != nfqueue.NfAccept || v.packet != nil { + t.Fatalf("verdict %d carrying %d bytes, want the answer passed unchanged", v.verdict, len(v.packet)) + } +} + +func TestAnIPv6AnswerOnlyLosesItsKeyWhileIPv6SupportIsOff(t *testing.T) { + set := echTestSet("example", true, "example.com") + set.Routing.Enabled = true + w := ipv6OffWorker(t, set) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, v6Answer(buildTestMixedResponse("www.example.com", dnsTypeA, "203.0.113.11", "2001:db8::11"))) + + if v := q.now(t); v.verdict != nfqueue.NfAccept || v.packet != nil { + t.Fatalf("verdict %d carrying %d bytes: an answer that comes over IPv6 while IPv6 support is off is queued only for its ECH key, so its IPv6 addresses stay", v.verdict, len(v.packet)) + } + if _, host, ok := w.hostHints.Lookup(v6TestDst, "203.0.113.11"); ok || host != "" { + t.Fatalf("b4 learned %q from an answer it queued only for its ECH key", host) + } +} + +func TestAnIPv6QueryIsNotRedirectedWhileIPv6SupportIsOff(t *testing.T) { + set := echTestSet("steamdb", true, "steamdb.info") + set.DNS.Enabled = true + set.DNS.TargetDNS = "203.0.113.53" + w := ipv6OffWorker(t, set) + sent := captureSends(t) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, makeV6UDPPacketWithExt(dns.BuildQuery("steamdb.info", 7, dnsTypeA), 40000, 53, 0, nil)) + + if v := q.now(t); v.verdict != nfqueue.NfAccept || v.packet != nil { + t.Fatalf("verdict %d carrying %d bytes, want the query passed unchanged", v.verdict, len(v.packet)) + } + sent.none(t, 30*time.Millisecond, "b4 answered an IPv6 query although IPv6 support is off") +} + +func tcpQueriesFrom(t *testing.T, w *Worker, client net.IP, queries ...[]byte) [][]byte { + t.Helper() + s := newDNSTCPServer(w, 0) + t.Cleanup(s.cancel) + clientEnd, serverEnd := net.Pipe() + handled := make(chan struct{}) + go func() { + defer close(handled) + s.handle(remoteAddrConn{Conn: serverEnd, remote: &net.TCPAddr{IP: client, Port: 40000}}) + }() + + _ = clientEnd.SetDeadline(time.Now().Add(5 * time.Second)) + var answers [][]byte + for i, query := range queries { + if err := writeDNSTCPMessage(clientEnd, query, time.Second); err != nil { + t.Fatal(err) + } + resp, err := readDNSTCPMessage(clientEnd) + if err != nil { + t.Fatalf("query %d: no answer: %v", i, err) + } + answers = append(answers, resp) + } + clientEnd.Close() + select { + case <-handled: + case <-time.After(5 * time.Second): + t.Fatal("the connection handler never returned") + } + return answers +} + +func TestDNSOverTCPFromIPv6OnlyRemovesKeysWhileIPv6SupportIsOff(t *testing.T) { + set := echTestSet("steamdb", true, "steamdb.info") + set.DNS.Pins = map[string][]string{"steamdb.info": {"198.51.100.7"}} + cfg := config.NewConfig() + cfg.Queue.IPv6Enabled = false + cfg.Sets = []*config.SetConfig{set} + w := newTestWorker(t, &cfg) + tcpECHUpstream(t) + + answers := tcpQueriesFrom(t, w, net.ParseIP("2001:db8:15::116"), + dns.BuildQuery("steamdb.info", 1, dnsTypeA), + dns.BuildQuery("steamdb.info", 2, dnsTypeHTTPS)) + + if ips := dns.ParseResponseIPs(answers[0]); len(ips) != 1 || !ips[0].Equal(net.ParseIP("203.0.113.80")) { + t.Fatalf("A answer %v, want the upstream's: a connection over IPv6 while IPv6 support is off is relayed only to remove ECH keys, so the set's pin does not answer it", ips) + } + if names := dns.ResponseECHPublicNames(answers[1]); len(names) != 0 { + t.Fatalf("the HTTPS answer kept its key %v", names) + } +} + +func TestDNSOverTCPFromIPv4StillUsesThePin(t *testing.T) { + set := echTestSet("steamdb", true, "steamdb.info") + set.DNS.Pins = map[string][]string{"steamdb.info": {"198.51.100.7"}} + cfg := config.NewConfig() + cfg.Queue.IPv6Enabled = false + cfg.Sets = []*config.SetConfig{set} + w := newTestWorker(t, &cfg) + tcpECHUpstream(t) + + answers := tcpQueriesFrom(t, w, net.ParseIP("192.0.2.10"), dns.BuildQuery("steamdb.info", 1, dnsTypeA)) + + if ips := dns.ParseResponseIPs(answers[0]); len(ips) != 1 || !ips[0].Equal(net.ParseIP("198.51.100.7")) { + t.Fatalf("A answer %v, want the pin for a connection of a family b4 processes", ips) + } +} + +func deviceListSet(id string, exclude bool, domains ...string) *config.SetConfig { + set := echTestSet(id, true, domains...) + set.Targets.SourceDevices = []string{"AA:BB:CC:DD:EE:FF"} + set.Targets.SourceDevicesExclude = exclude + return set +} + +func TestAnIPv6AnswerToAnUnknownDeviceIsLeftAloneByASetWithADeviceList(t *testing.T) { + for _, exclude := range []bool{true, false} { + w := ipv6OffWorker(t, deviceListSet("steamdb", exclude, "steamdb.info")) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, v6Answer(httpsAnswerWithECH("steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com"))) + + if v := q.now(t); v.verdict != nfqueue.NfAccept || v.packet != nil { + t.Errorf("exclude=%v: verdict %d carrying %d bytes: b4 cannot tell which device an IPv6 answer goes to, so a set limited by a device list must leave it alone", exclude, v.verdict, len(v.packet)) + } + } +} + +func TestTheIPv4PathStillRemovesKeysForAnExcludeListWithAnUnknownDevice(t *testing.T) { + set := deviceListSet("steamdb", true, "steamdb.info") + w, cfg := echTestWorker(t, set) + + out, action, _ := w.filterDNSAnswer(cfg, set, "steamdb.info", "", httpsAnswerWithECH("steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com"), false) + if out == nil || action != dnsActionECHStripped { + t.Fatalf("action %q: the router's own lookups carry no MAC, and an exclude-list set has always removed keys from them", action) + } +} + +func TestDNSOverTCPFromIPv6LeavesKeysToASetWithADeviceList(t *testing.T) { + cfg := config.NewConfig() + cfg.Queue.IPv6Enabled = false + cfg.Sets = []*config.SetConfig{deviceListSet("steamdb", true, "steamdb.info")} + w := newTestWorker(t, &cfg) + tcpECHUpstream(t) + + answers := tcpQueriesFrom(t, w, net.ParseIP("2001:db8:15::116"), dns.BuildQuery("steamdb.info", 2, dnsTypeHTTPS)) + if names := dns.ResponseECHPublicNames(answers[0]); len(names) != 1 { + t.Fatalf("keys %v: a set limited by a device list must not act on a connection from a device b4 cannot identify", names) + } +} + +func TestAnIPv6AnswerToAnUnknownDeviceFollowsTheSetWithoutADeviceList(t *testing.T) { + for _, listed := range []string{"steamdb.info", "www.steamdb.info"} { + for _, exclude := range []bool{true, false} { + for _, generic := range []bool{true, false} { + devices := deviceListSet("steamdb-tv", exclude, listed) + devices.DNS.StripECH = !generic + w := ipv6OffWorker(t, devices, echTestSet("steamdb", generic, "steamdb.info")) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, v6Answer(httpsAnswerWithECH("www.steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com"))) + + v := q.now(t) + if stripped := v.packet != nil && dns.ParseResponseECH(v.packet[48:]) == nil; stripped != generic { + t.Errorf("device list set on %s, exclude=%v, the set without a device list removes keys=%v: key removed=%v, want that set to decide for a device b4 cannot identify", listed, exclude, generic, stripped) + } + } + } + } +} + +func TestAnIPv6AnswerToAnUnknownDeviceLosesTheKeyACoverSetWithoutADeviceListRemoves(t *testing.T) { + w := ipv6OffWorker(t, deviceListSet("cloudflare-ech-tv", true, "cloudflare-ech.com"), echTestSet("cloudflare-ech", true, "cloudflare-ech.com")) + q := newFakeModQueue() + + w.dispatch(&verdictCtx{id: 1, q: q}, v6Answer(httpsAnswerWithECH("steamdb.info", dnsTypeHTTPS, "cloudflare-ech.com"))) + + v := q.now(t) + if v.packet == nil || dns.ParseResponseECH(v.packet[48:]) != nil { + t.Fatal("the key names a set without a device list, but a set with one listed first took the match and the IPv6 answer kept its key") + } +} + +func TestDNSOverTCPFromIPv6FollowsTheSetWithoutADeviceList(t *testing.T) { + for _, listed := range []string{"steamdb.info", "www.steamdb.info"} { + t.Run(listed, func(t *testing.T) { + devices := deviceListSet("steamdb-tv", true, listed) + devices.DNS.StripECH = false + cfg := config.NewConfig() + cfg.Queue.IPv6Enabled = false + cfg.Sets = []*config.SetConfig{devices, echTestSet("steamdb", true, "steamdb.info")} + w := newTestWorker(t, &cfg) + tcpECHUpstream(t) + + answers := tcpQueriesFrom(t, w, net.ParseIP("2001:db8:15::116"), dns.BuildQuery("www.steamdb.info", 2, dnsTypeHTTPS)) + if names := dns.ResponseECHPublicNames(answers[0]); len(names) != 0 { + t.Fatalf("keys %v: an exclude list on %s took the name from the set without a device list that removes keys", names, listed) + } + }) + } +} + +func TestADNSTCPListenerWithoutItsIPv6SocketIsNotRestartedOnEverySave(t *testing.T) { + recordDNSTCPReady(t) + held, err := net.Listen("tcp6", "[::]:0") + if err != nil { + t.Skipf("no IPv6 socket on this host: %v", err) + } + defer held.Close() + + cfg := config.NewConfig() + cfg.Queue.IPv6Enabled = false + cfg.System.DNS.TCPPort = held.Addr().(*net.TCPAddr).Port + cfg.Sets = []*config.SetConfig{echTestSet("steamdb", true, "steamdb.info")} + p := &Pool{Workers: []*Worker{NewWorkerWithQueue(&cfg, 0)}} + p.startDNSTCP() + t.Cleanup(func() { + if p.dnsTCP != nil { + p.dnsTCP.Stop() + } + }) + if p.dnsTCP == nil || !p.dnsTCP.ReadyV4() || p.dnsTCP.ReadyV6() { + t.Skip("could not set up a listener with only its IPv4 socket bound") + } + + first := p.dnsTCP + p.reconcileDNSTCP(&cfg) + if p.dnsTCP != first { + t.Fatal("an unchanged config restarted the DNS-over-TCP listener because its IPv6 socket could not bind, dropping every open TCP DNS connection on each save") + } +} diff --git a/src/nfq/echstrip.go b/src/nfq/echstrip.go index afd73811..4cbb7e95 100644 --- a/src/nfq/echstrip.go +++ b/src/nfq/echstrip.go @@ -1,6 +1,10 @@ package nfq import ( + "encoding/binary" + "net" + "strings" + "github.com/daniellavrushin/b4/config" "github.com/daniellavrushin/b4/dns" "github.com/daniellavrushin/b4/log" @@ -21,7 +25,21 @@ func echStripWanted(cfg *config.Config, set *config.SetConfig, qtype uint16) boo return stripsECH(set) || cfg.HasECHStrip() } -func (w *Worker) echStripSet(cfg *config.Config, set *config.SetConfig, clientMac string, resp []byte) (*config.SetConfig, string) { +func (w *Worker) echMatchSet(host, clientMac string, unknownDevice bool) *config.SetConfig { + var matched bool + var set *config.SetConfig + if unknownDevice { + matched, set = w.getMatcher().MatchSNIForUnknownDevice(host) + } else { + matched, set = w.getMatcher().MatchSNIWithSource(host, clientMac) + } + if !matched { + return nil + } + return set +} + +func (w *Worker) echStripSet(cfg *config.Config, set *config.SetConfig, clientMac string, resp []byte, unknownDevice bool) (*config.SetConfig, string) { if stripsECH(set) { return set, "" } @@ -29,19 +47,19 @@ func (w *Worker) echStripSet(cfg *config.Config, set *config.SetConfig, clientMa return nil, "" } for _, cover := range dns.ResponseECHPublicNames(resp) { - if matched, coverSet := w.getMatcher().MatchSNIWithSource(cover, clientMac); matched && stripsECH(coverSet) { + if coverSet := w.echMatchSet(cover, clientMac, unknownDevice); stripsECH(coverSet) { return coverSet, cover } } return nil, "" } -func (w *Worker) stripECHKeys(cfg *config.Config, set *config.SetConfig, domain, clientMac string, resp []byte, overTCP bool) ([]byte, *config.SetConfig) { +func (w *Worker) stripECHKeys(cfg *config.Config, set *config.SetConfig, domain, clientMac string, resp []byte, overTCP, unknownDevice bool) ([]byte, *config.SetConfig) { qtype, ok := dns.QuestionType(resp) if !ok || !echStripWanted(cfg, set, qtype) { return nil, nil } - actor, cover := w.echStripSet(cfg, set, clientMac, resp) + actor, cover := w.echStripSet(cfg, set, clientMac, resp, unknownDevice) if actor == nil { return nil, nil } @@ -64,3 +82,43 @@ func (w *Worker) stripECHKeys(cfg *config.Config, set *config.SetConfig, domain, } return nil, nil } + +func replyDomain(payload []byte) string { + domain, _ := dns.ParseQueryDomain(payload) + if domain == "" { + if d, ok := parseDNSName(payload, 12); ok { + domain = d + } + } + return strings.ToLower(domain) +} + +func (w *Worker) handleECHOnlyPacket(vc *verdictCtx, pkt *pktInfo, cfg *config.Config) int { + if vc.copy != nil || vc.ifaceFiltered || pkt.proto != 17 || len(pkt.raw) < pkt.ihl+UDPHeaderLen { + return vc.accept() + } + udp := pkt.raw[pkt.ihl:] + payload := udp[UDPHeaderLen:] + if binary.BigEndian.Uint16(udp[0:2]) != 53 || len(payload) < 12 || payload[2]&0x80 == 0 { + return vc.accept() + } + + domain := replyDomain(payload) + client := append(net.IP(nil), pkt.dst...) + server := append(net.IP(nil), pkt.src...) + clientMac := w.getMacByIp(client.String()) + unknownDevice := clientMac == "" + echless, actor := w.stripECHKeys(cfg, w.echMatchSet(domain, clientMac, unknownDevice), domain, clientMac, payload, false, unknownDevice) + if echless == nil { + return vc.accept() + } + + raw := inPlaceReply(vc, pkt, echless) + if raw == nil && !vc.drop() { + return 0 + } + dport := binary.BigEndian.Uint16(udp[2:4]) + logDNSEvent("UDP", actor, domain, client, server, dport, clientMac, dnsActionECHStripped) + w.releaseFilteredReply(vc, raw, pkt.ver, server, client, dport, echless) + return 0 +} diff --git a/src/nfq/handler.go b/src/nfq/handler.go index ebb82862..f4a8ca27 100644 --- a/src/nfq/handler.go +++ b/src/nfq/handler.go @@ -106,6 +106,9 @@ func (w *Worker) dispatch(vc *verdictCtx, raw []byte) int { if !ok { return vc.accept() } + if !cfg.Queue.IsDiscovery && cfg.Queue.Mode != "tun" && !cfg.IPFamilyEnabled(pkt.ver == IPv6) { + return w.handleECHOnlyPacket(vc, pkt, cfg) + } matched, st := matcher.MatchIPWithSource(pkt.dst, pkt.srcMac) var set *config.SetConfig diff --git a/src/nfq/ipblock.go b/src/nfq/ipblock.go index 9b605fc2..bf022eda 100644 --- a/src/nfq/ipblock.go +++ b/src/nfq/ipblock.go @@ -226,7 +226,7 @@ func (w *Worker) filterDNSAnswer(cfg *config.Config, set *config.SetConfig, doma if w == nil || len(resp) == 0 { return nil, "", nil } - if echless, actor := w.stripECHKeys(cfg, set, domain, clientMac, resp, overTCP); echless != nil { + if echless, actor := w.stripECHKeys(cfg, set, domain, clientMac, resp, overTCP, false); echless != nil { return echless, dnsActionECHStripped, actor } diff --git a/src/nfq/nfq.go b/src/nfq/nfq.go index 675a1a65..346412c2 100644 --- a/src/nfq/nfq.go +++ b/src/nfq/nfq.go @@ -98,15 +98,11 @@ func (w *Worker) Start() error { log.Tracef("nfqueue %d: could not raise the netlink read buffer to %d bytes: %v", w.qnum, nfqReadBufferBytes, err) } - if cfg.Queue.IPv4Enabled { - if err := pfBind(q.Con, syscall.AF_INET); err != nil { - log.Warnf("nfqueue PF_BIND AF_INET: %v", err) - } + if err := pfBind(q.Con, syscall.AF_INET); err != nil { + log.Warnf("nfqueue PF_BIND AF_INET: %v", err) } - if cfg.Queue.IPv6Enabled { - if err := pfBind(q.Con, syscall.AF_INET6); err != nil { - log.Warnf("nfqueue PF_BIND AF_INET6: %v", err) - } + if err := pfBind(q.Con, syscall.AF_INET6); err != nil { + log.Warnf("nfqueue PF_BIND AF_INET6: %v", err) } w.wg.Add(1) diff --git a/src/nfq/pool.go b/src/nfq/pool.go index 7041059f..bba0b107 100644 --- a/src/nfq/pool.go +++ b/src/nfq/pool.go @@ -378,8 +378,8 @@ func (p *Pool) reconcileDNSTCP(newCfg *config.Config) { if p.dnsTCP != nil { samePort := p.dnsTCP.port == newCfg.DNSTCPListenPort() - sameFamilies := p.dnsTCP.ReadyV4() == newCfg.Queue.IPv4Enabled && - p.dnsTCP.ReadyV6() == newCfg.Queue.IPv6Enabled + sameFamilies := p.dnsTCP.wantV4 == newCfg.QueuesIPFamily(false) && + p.dnsTCP.wantV6 == newCfg.QueuesIPFamily(true) if wanted && samePort && sameFamilies { return } diff --git a/src/sni/match.go b/src/sni/match.go index 56c1b0a2..8617ec0e 100644 --- a/src/sni/match.go +++ b/src/sni/match.go @@ -31,6 +31,7 @@ type SuffixSet struct { sets map[string]*config.SetConfig multiSets map[string][]*config.SetConfig regexes []*regexWithSet + regexPatterns int regexCache sync.Map ipRanger cidranger.Ranger udpPortRanges []portRange @@ -69,8 +70,33 @@ type learnedIPEntry struct { } type regexWithSet struct { - regex *regexp.Regexp - set *config.SetConfig + regex *regexp.Regexp + set *config.SetConfig + pattern int +} + +type setPattern struct { + set *config.SetConfig + pattern string +} + +const regexMemoSize = 512 + +func (s *SuffixSet) regexMemo(buf *[regexMemoSize]int8) []int8 { + if s.regexPatterns <= len(buf) { + return buf[:s.regexPatterns] + } + return make([]int8, s.regexPatterns) +} + +func (r *regexWithSet) matches(host string, memo []int8) bool { + if memo[r.pattern] == 0 { + memo[r.pattern] = -1 + if r.regex.MatchString(host) { + memo[r.pattern] = 1 + } + } + return memo[r.pattern] > 0 } func (e *ipRange) Network() net.IPNet { @@ -105,7 +131,9 @@ func NewSuffixSet(sets []*config.SetConfig) *SuffixSet { learnedIPTTL: 10 * time.Minute, } - seenRegexes := make(map[string]bool) + seenRegexes := make(map[setPattern]bool) + patternIndex := make(map[string]int) + var patterns []*regexp.Regexp ipEntries := make(map[string]*ipRange) for setOrder, set := range sets { @@ -119,12 +147,22 @@ func NewSuffixSet(sets []*config.SetConfig) *SuffixSet { } if isRegex { - if seenRegexes[d] { + key := setPattern{set: set, pattern: d} + if seenRegexes[key] { continue } - if re, err := regexp.Compile(d); err == nil { - s.regexes = append(s.regexes, ®exWithSet{regex: re, set: set}) - seenRegexes[d] = true + seenRegexes[key] = true + idx, known := patternIndex[d] + if !known { + idx = -1 + if re, err := regexp.Compile(d); err == nil { + idx = len(patterns) + patterns = append(patterns, re) + } + patternIndex[d] = idx + } + if idx >= 0 { + s.regexes = append(s.regexes, ®exWithSet{regex: patterns[idx], set: set, pattern: idx}) } continue } @@ -196,6 +234,7 @@ func NewSuffixSet(sets []*config.SetConfig) *SuffixSet { } } + s.regexPatterns = len(patterns) return s } @@ -425,8 +464,10 @@ func (s *SuffixSet) matchRegex(host string) (bool, *config.SetConfig) { var matched bool var matchedSet *config.SetConfig + var buf [regexMemoSize]int8 + memo := s.regexMemo(&buf) for _, rws := range s.regexes { - if rws.regex.MatchString(host) { + if rws.matches(host, memo) { matched = true matchedSet = rws.set break @@ -633,6 +674,66 @@ func (s *SuffixSet) MatchSNIWithSourceTLS(host string, srcMAC string, tlsVersion return false, nil } +func (s *SuffixSet) MatchSNIForUnknownDevice(host string) (bool, *config.SetConfig) { + if s == nil || (len(s.sets) == 0 && len(s.regexes) == 0) || host == "" { + return false, nil + } + + lower := strings.ToLower(host) + + for name := lower; ; { + if listed, set := s.unknownDeviceSetAt(name); listed { + if set != nil { + return true, set + } + break + } + idx := strings.IndexByte(name, '.') + if idx == -1 { + break + } + name = name[idx+1:] + } + + var buf [regexMemoSize]int8 + memo := s.regexMemo(&buf) + for _, rws := range s.regexes { + if len(rws.set.Targets.SourceDevices) == 0 && acceptsAnyVersion(rws.set) && rws.matches(lower, memo) { + return true, rws.set + } + } + return false, nil +} + +func (s *SuffixSet) unknownDeviceSetAt(name string) (bool, *config.SetConfig) { + var only [1]*config.SetConfig + sets, ok := s.multiSets[name] + if !ok { + set, listed := s.sets[name] + if !listed { + return false, nil + } + only[0] = set + sets = only[:] + } + + listed := false + for _, set := range sets { + if len(set.Targets.SourceDevices) > 0 { + continue + } + listed = true + if acceptsAnyVersion(set) { + return true, set + } + } + return listed, nil +} + +func acceptsAnyVersion(set *config.SetConfig) bool { + return set.MatchesTLSVersion(0) && set.MatchesIPVersion(0) +} + func (s *SuffixSet) matchDomainWithSourceTLS(host string, srcMAC string, tlsVersion uint16, ipVersion uint8) (bool, *config.SetConfig) { single, candidates := s.findDomainCandidates(host) @@ -706,8 +807,10 @@ func (s *SuffixSet) SetHasDomain(setID, host string) bool { func (s *SuffixSet) matchRegexWithSourceTLS(host string, srcMAC string, tlsVersion uint16, ipVersion uint8) (bool, *config.SetConfig) { var candidates []*config.SetConfig + var buf [regexMemoSize]int8 + memo := s.regexMemo(&buf) for _, rws := range s.regexes { - if rws.regex.MatchString(host) { + if rws.matches(host, memo) { candidates = append(candidates, rws.set) } } diff --git a/src/sni/match_test.go b/src/sni/match_test.go index c93804aa..29267ee8 100644 --- a/src/sni/match_test.go +++ b/src/sni/match_test.go @@ -1,6 +1,7 @@ package sni import ( + "fmt" "net" "testing" "time" @@ -560,6 +561,198 @@ func TestMatchSNIWithSource_FallsBackToGeneric(t *testing.T) { } } +func excludingSetWithDomains(name string, domains ...string) *config.SetConfig { + s := makeSetWithDomains(name, domains...) + s.Targets.SourceDevices = []string{"aa:bb:cc:dd:ee:ff"} + s.Targets.SourceDevicesExclude = true + return s +} + +func setName(s *config.SetConfig) string { + if s == nil { + return "no set" + } + return s.Name +} + +func TestMatchSNIForUnknownDevice_SkipsAnExcludeListAheadOfAGenericSet(t *testing.T) { + ss := NewSuffixSet([]*config.SetConfig{excludingSetWithDomains("excluding", "example.com"), makeSetWithDomains("generic", "example.com")}) + + if _, set := ss.MatchSNIWithSource("www.example.com", ""); setName(set) != "excluding" { + t.Fatalf("the source path gave %s to a connection without a MAC, this test needs it to pick the exclude list", setName(set)) + } + matched, set := ss.MatchSNIForUnknownDevice("www.example.com") + if !matched { + t.Fatal("expected the set without a device list to match") + } + if set.Name != "generic" { + t.Errorf("expected the set without a device list, got %s", set.Name) + } +} + +func TestMatchSNIForUnknownDevice_IgnoresSetsWithADeviceList(t *testing.T) { + including := makeSetWithDomains("including", "example.com", "example.org") + including.Targets.SourceDevices = []string{"aa:bb:cc:dd:ee:ff"} + ss := NewSuffixSet([]*config.SetConfig{including, excludingSetWithDomains("excluding", "example.com")}) + + for _, host := range []string{"example.com", "example.org"} { + if matched, set := ss.MatchSNIForUnknownDevice(host); matched { + t.Errorf("%s matched %s: a set with a device list must not match a device b4 cannot identify", host, setName(set)) + } + } +} + +func TestMatchSNIForUnknownDevice_FallsThroughToARegexSet(t *testing.T) { + ss := NewSuffixSet([]*config.SetConfig{ + excludingSetWithDomains("excluding", "example.com"), + excludingSetWithDomains("excluding-regex", "regexp:^www\\.example\\.com$"), + makeSetWithDomains("regex", "regexp:.*\\.example\\.com$"), + }) + + matched, set := ss.MatchSNIForUnknownDevice("www.example.com") + if !matched { + t.Fatal("expected the regex set without a device list to match") + } + if set.Name != "regex" { + t.Errorf("expected the regex set without a device list, got %s", set.Name) + } +} + +func TestMatchSNIForUnknownDevice_ReachesAParentDomainPastASetWithADeviceList(t *testing.T) { + for _, exclude := range []bool{true, false} { + for _, devicesFirst := range []bool{true, false} { + devices := excludingSetWithDomains("tv", "www.example.com") + devices.Targets.SourceDevicesExclude = exclude + generic := makeSetWithDomains("generic", "example.com") + sets := []*config.SetConfig{generic, devices} + if devicesFirst { + sets = []*config.SetConfig{devices, generic} + } + ss := NewSuffixSet(sets) + + for _, host := range []string{"www.example.com", "a.www.example.com"} { + if _, set := ss.MatchSNIForUnknownDevice(host); setName(set) != "generic" { + t.Errorf("exclude=%v devicesFirst=%v %s: got %s, want the parent domain's set without a device list", exclude, devicesFirst, host, setName(set)) + } + } + } + } +} + +func TestMatchSNIForUnknownDevice_MatchesLikeAMatcherWithoutDeviceListSets(t *testing.T) { + scoped := func(s *config.SetConfig, exclude bool) *config.SetConfig { + s.Targets.SourceDevices = []string{"aa:bb:cc:dd:ee:ff"} + s.Targets.SourceDevicesExclude = exclude + return s + } + invalidTLS := makeSetWithDomains("invalid-tls", "tls.example.com") + invalidTLS.Targets.TLSVersion = "bogus" + v6 := makeSetWithDomains("v6", "example.org") + v6.Targets.IPVersion = "6" + sets := []*config.SetConfig{ + scoped(makeSetWithDomains("tv-www", "www.example.com", "video.example.org"), true), + scoped(makeSetWithDomains("tv-only", "example.com", "deep.a.example.net"), false), + makeSetWithDomains("generic", "example.com", "regexp:.*\\.example\\.net$"), + invalidTLS, + v6, + scoped(makeSetWithDomains("tv-regex", "regexp:^x\\.example\\.org$", "regexp:^y\\.example\\.edu$"), true), + makeSetWithDomains("late", "www.example.com"), + makeSetWithDomains("regex-copy", "regexp:^y\\.example\\.edu$"), + } + var withoutDevices []*config.SetConfig + for _, s := range sets { + if len(s.Targets.SourceDevices) == 0 { + withoutDevices = append(withoutDevices, s) + } + } + ss, oracle := NewSuffixSet(sets), NewSuffixSet(withoutDevices) + + for _, host := range []string{ + "example.com", "www.example.com", "a.www.example.com", "EXAMPLE.COM", + "tls.example.com", "a.tls.example.com", + "example.org", "video.example.org", "x.example.org", + "a.example.net", "deep.a.example.net", + "y.example.edu", "example.edu", "", + } { + wantMatched, want := oracle.MatchSNIWithSource(host, "") + matched, set := ss.MatchSNIForUnknownDevice(host) + if matched != wantMatched || set != want { + t.Errorf("%q: got %v %s, a matcher without the sets that have a device list gives %v %s", host, matched, setName(set), wantMatched, setName(want)) + } + } +} + +func TestARegexInTwoSetsReachesTheSecondSetForADeviceTheFirstLeavesOut(t *testing.T) { + first := makeSetWithDomains("tv", "regexp:(^|\\.)example\\.com$") + first.Id = "tv" + first.Targets.SourceDevices = []string{"aa:bb:cc:dd:ee:ff"} + second := makeSetWithDomains("everyone", "regexp:(^|\\.)example\\.com$") + second.Id = "everyone" + ss := NewSuffixSet([]*config.SetConfig{first, second}) + + if _, set := ss.MatchSNIWithSource("www.example.com", "aa:bb:cc:dd:ee:ff"); setName(set) != "tv" { + t.Errorf("the listed device got %s, want the set limited to it", setName(set)) + } + if _, set := ss.MatchSNIWithSource("www.example.com", "11:22:33:44:55:66"); setName(set) != "everyone" { + t.Errorf("another device got %s, want the second set, which lists the same pattern", setName(set)) + } + if _, set := ss.MatchSNI("www.example.com"); setName(set) != "tv" { + t.Errorf("MatchSNI got %s, want the first set that lists the pattern", setName(set)) + } + if !ss.SetHasDomain(second.Id, "www.example.com") { + t.Error("the second set lists the pattern, so the name belongs to it too") + } +} + +func TestEachRegexPatternGetsItsOwnResultWithinALookup(t *testing.T) { + netSet := makeSetWithDomains("net", "regexp:\\.example\\.net$") + orgSet := makeSetWithDomains("org", "regexp:\\.example\\.org$") + tvSet := makeSetWithDomains("org-tv", "regexp:\\.example\\.org$") + tvSet.Targets.SourceDevices = []string{"aa:bb:cc:dd:ee:ff"} + ss := NewSuffixSet([]*config.SetConfig{netSet, orgSet, tvSet}) + + for name, got := range map[string]*config.SetConfig{ + "MatchSNI": matchedSet(ss.MatchSNI("www.example.org")), + "MatchSNIWithSource": matchedSet(ss.MatchSNIWithSource("www.example.org", "11:22:33:44:55:66")), + "MatchSNIWithSource for TV": matchedSet(ss.MatchSNIWithSource("www.example.org", "aa:bb:cc:dd:ee:ff")), + "MatchSNIForUnknownDevice": matchedSet(ss.MatchSNIForUnknownDevice("www.example.org")), + } { + want := "org" + if name == "MatchSNIWithSource for TV" { + want = "org-tv" + } + if setName(got) != want { + t.Errorf("%s got %s, want %s: the failed .net pattern decided the .org one", name, setName(got), want) + } + } +} + +func TestARegexPastTheMemoBufferStillMatches(t *testing.T) { + domains := make([]string, regexMemoSize+10) + for i := range domains { + domains[i] = fmt.Sprintf("regexp:^host%d\\.example\\.com$", i) + } + many := makeSetWithDomains("many", domains...) + tv := makeSetWithDomains("tv", domains[len(domains)-1]) + tv.Targets.SourceDevices = []string{"aa:bb:cc:dd:ee:ff"} + ss := NewSuffixSet([]*config.SetConfig{many, tv}) + host := fmt.Sprintf("host%d.example.com", len(domains)-1) + + if _, set := ss.MatchSNIWithSource(host, "aa:bb:cc:dd:ee:ff"); setName(set) != "tv" { + t.Errorf("the TV got %s, want the set limited to it", setName(set)) + } + if _, set := ss.MatchSNIWithSource(host, ""); setName(set) != "many" { + t.Errorf("a device without a MAC got %s, want the set without a device list", setName(set)) + } + if _, set := ss.MatchSNI(host); setName(set) != "many" { + t.Errorf("MatchSNI got %s, want the first set that lists the pattern", setName(set)) + } +} + +func matchedSet(_ bool, set *config.SetConfig) *config.SetConfig { + return set +} + // --- MatchSNIWithSourceTLS --- func TestMatchSNIWithSourceTLS_TLSFilter(t *testing.T) { diff --git a/src/tables/dnstcp.go b/src/tables/dnstcp.go index 047b9b8c..9f775954 100644 --- a/src/tables/dnstcp.go +++ b/src/tables/dnstcp.go @@ -17,11 +17,30 @@ const dnsTCPChainName = "B4_DNSTCP" const nftDstnatPriority = -100 +const nftDNSTCPFailStreak = 3 + var ( dnsTCPListenerReadyV4 atomic.Bool dnsTCPListenerReadyV6 atomic.Bool + nftDNSTCPFailsV4 atomic.Int32 + nftDNSTCPFailsV6 atomic.Int32 ) +func nftDNSTCPFails(v6 bool) *atomic.Int32 { + if v6 { + return &nftDNSTCPFailsV6 + } + return &nftDNSTCPFailsV4 +} + +func nftDNSTCPWanted(cfg *config.Config, v6 bool) bool { + return dnsTCPEnabledFamily(cfg, v6) && cfg.QueuesIPFamily(v6) +} + +func nftDNSTCPExpected(cfg *config.Config, v6 bool) bool { + return nftDNSTCPWanted(cfg, v6) && nftDNSTCPFails(v6).Load() == 0 +} + func SetDNSTCPListenerReady(v4, v6 bool) { dnsTCPListenerReadyV4.Store(v4) dnsTCPListenerReadyV6.Store(v6) @@ -91,6 +110,10 @@ func dnsTCPRulesPresent(ipt string, port int, markAccept string) bool { im.existsRule(ipt, "nat", dnsTCPChainName, dnsTCPRedirectSpec(port)) } +func dnsTCPLinkLocalReturnSpec() []string { + return []string{"-d", "fe80::/10", "-j", "RETURN"} +} + func (manager *IPTablesManager) buildDNSTCPManifest(ipt string) ([]Chain, []Rule) { chains := []Chain{{manager: manager, IPT: ipt, Table: "nat", Name: dnsTCPChainName}} rules := []Rule{ @@ -100,6 +123,9 @@ func (manager *IPTablesManager) buildDNSTCPManifest(ipt string) ([]Chain, []Rule {manager: manager, IPT: ipt, Table: "nat", Chain: "PREROUTING", Action: "I", Spec: dnsTCPJumpSpec()}, {manager: manager, IPT: ipt, Table: "nat", Chain: "OUTPUT", Action: "I", Spec: dnsTCPJumpSpec()}, } + if strings.HasPrefix(ipt, "ip6") { + rules = append(rules, Rule{manager: manager, IPT: ipt, Table: "nat", Chain: dnsTCPChainName, Action: "I", Spec: dnsTCPLinkLocalReturnSpec()}) + } return chains, rules } @@ -215,6 +241,11 @@ func (n *NFTablesManager) applyDNSTCPFamily(v6 bool) error { "meta", "mark", "&", mark, "==", mark, "return"); err != nil { return fmt.Errorf("failed to add dns tcp mark-bypass rule (%s): %w", family, err) } + if v6 { + if _, err := n.runNft("add", "rule", family, table, chain, "ip6", "daddr", "fe80::/10", "return"); err != nil { + return fmt.Errorf("failed to add dns tcp link-local bypass rule (%s): %w", family, err) + } + } if _, err := n.runNft(append([]string{"add", "rule", family, table, chain}, redirect...)...); err != nil { return fmt.Errorf("failed to add dns tcp redirect rule (%s): %w", family, err) } @@ -231,20 +262,16 @@ func (n *NFTablesManager) ApplyDNSTCP() error { applied := make([]string, 0, 2) for _, v6 := range []bool{false, true} { - if !dnsTCPEnabledFamily(n.cfg, v6) { + if !nftDNSTCPWanted(n.cfg, v6) { + nftDNSTCPFails(v6).Store(0) continue } - if v6 && !n.cfg.Queue.IPv6Enabled { - continue - } - if !v6 && !n.cfg.Queue.IPv4Enabled { - continue - } - if err := n.applyDNSTCPFamily(v6); err != nil { - n.clearDNSTCPFamily(v6) + if err := n.buildDNSTCPFamily(v6); err != nil { + nftDNSTCPFails(v6).Store(1) log.Warnf("NFTABLES: %v, DNS over TCP stays with the upstream resolver for this family", err) continue } + nftDNSTCPFails(v6).Store(0) family, _ := nftDNSTCPTable(v6) applied = append(applied, family) } @@ -255,6 +282,34 @@ func (n *NFTablesManager) ApplyDNSTCP() error { return nil } +func (n *NFTablesManager) buildDNSTCPFamily(v6 bool) error { + if err := n.applyDNSTCPFamily(v6); err != nil { + n.clearDNSTCPFamily(v6) + return err + } + return nil +} + +func (n *NFTablesManager) retryFailedDNSTCP() { + for _, v6 := range []bool{false, true} { + fails := nftDNSTCPFails(v6) + if failed := fails.Load(); failed == 0 || failed >= nftDNSTCPFailStreak || !nftDNSTCPWanted(n.cfg, v6) { + continue + } + if err := n.buildDNSTCPFamily(v6); err != nil { + if fails.Add(1) < nftDNSTCPFailStreak { + log.Tracef("NFTABLES: %v, the tables monitor tries again", err) + } else { + log.Warnf("NFTABLES: %v, DNS over TCP stays with the upstream resolver for this family until b4 rebuilds its rules", err) + } + continue + } + fails.Store(0) + family, _ := nftDNSTCPTable(v6) + log.Infof("NFTABLES: DNS over TCP redirected to b4 on port %d (%s)", n.cfg.DNSTCPListenPort(), family) + } +} + func (n *NFTablesManager) clearDNSTCPFamily(v6 bool) { if !n.dnsTCPFamilyExists(v6) { return diff --git a/src/tables/echonly.go b/src/tables/echonly.go new file mode 100644 index 00000000..4ed06cb3 --- /dev/null +++ b/src/tables/echonly.go @@ -0,0 +1,152 @@ +package tables + +import ( + "fmt" + "slices" + "strings" + "sync" + + "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/engine" + "github.com/daniellavrushin/b4/log" +) + +var echOnlyDNSUnusable sync.Map + +func echOnlyDNSUsable(ipt string) bool { + _, unusable := echOnlyDNSUnusable.Load(ipt) + return !unusable +} + +func familyLabel(v6 bool) string { + if v6 { + return "IPv6" + } + return "IPv4" +} + +func echOnlyNfproto(cfg *config.Config) (string, bool) { + for _, v6 := range []bool{true, false} { + if cfg.ECHOnlyFamily(v6) { + if v6 { + return "ipv6", true + } + return "ipv4", false + } + } + return "", false +} + +func nftECHOnlyAnswerRule(family string) []string { + return []string{"meta", "nfproto", family, "udp", "sport", "53", "counter"} +} + +func (n *NFTablesManager) addDNSAnswerRules(queriesFromRaw bool) error { + answers := []string{"udp", "sport", "53", "counter"} + if queriesFromRaw { + answers = []string{"udp", "sport", "53", "udp", "dport", "!=", "53", "counter"} + } + for _, chain := range []string{"prerouting", "output"} { + if err := n.addQueueRule(chain, answers...); err != nil { + return err + } + } + + family, v6 := echOnlyNfproto(n.cfg) + if family == "" { + return nil + } + rule := append(nftECHOnlyAnswerRule(family), strings.Fields(n.buildNFQueueAction())...) + for _, chain := range []string{"prerouting", "output"} { + if err := n.addRule(chain, rule...); err != nil { + return err + } + } + log.Infof("NFTABLES: %s support is off, so %s DNS answers are queued only to remove ECH keys for the sets that ask for it", familyLabel(v6), familyLabel(v6)) + return nil +} + +func fieldsFollow(fields []string, first, second string) bool { + for i := 0; i+1 < len(fields); i++ { + if fields[i] == first && fields[i+1] == second { + return true + } + } + return false +} + +func nftListingQueuesAnswers(listing, family string) bool { + for _, line := range strings.Split(listing, "\n") { + f := strings.Fields(line) + if fieldsFollow(f, "nfproto", family) && fieldsFollow(f, "sport", "53") && slices.Contains(f, "queue") { + return true + } + } + return false +} + +func (manager *IPTablesManager) echOnlyBinaries() []string { + cfg := manager.cfg + var ipts []string + for _, v6 := range []bool{false, true} { + if !cfg.ECHOnlyFamily(v6) { + continue + } + ipt := manager.iptablesBin() + if v6 { + ipt = manager.ip6tablesBin() + } + if !hasBinary(ipt) { + echOnlyDNSUnusable.Store(ipt, true) + log.Warnf("IPTABLES: %s is missing, so %s DNS answers keep their ECH keys while %s support is off", ipt, familyLabel(v6), familyLabel(v6)) + continue + } + if err := manager.checkNFQueueSupport(ipt); err != nil { + echOnlyDNSUnusable.Store(ipt, true) + log.Warnf("IPTABLES[%s]: %s DNS answers keep their ECH keys, %v", ipt, familyLabel(v6), err) + continue + } + echOnlyDNSUnusable.Delete(ipt) + ipts = append(ipts, ipt) + } + return ipts +} + +func (manager *IPTablesManager) buildECHOnlyDNSManifest(ipt, preChainName string) ([]Chain, []Rule) { + cfg := manager.cfg + markAccept := iptMarkAccept(cfg.Queue.Mark) + answers := append(dnsAnswerMatch(false), manager.buildNFQSpec(cfg.Queue.StartNum, cfg.Queue.Threads)...) + + chains := []Chain{{manager: manager, IPT: ipt, Table: "mangle", Name: preChainName}} + rules := []Rule{ + {manager: manager, IPT: ipt, Table: "mangle", Chain: preChainName, Action: "I", Spec: answers}, + {manager: manager, IPT: ipt, Table: "mangle", Chain: "PREROUTING", Action: "I", Spec: []string{"-j", preChainName}}, + {manager: manager, IPT: ipt, Table: "mangle", Chain: "OUTPUT", Action: "I", Spec: answers}, + {manager: manager, IPT: ipt, Table: "mangle", Chain: "OUTPUT", Action: "I", + Spec: []string{"-m", "mark", "--mark", markAccept, "-j", "ACCEPT"}}, + } + if cfg.Queue.Mark != 0 && manager.hasConnmarkSupport(ipt) { + markMaskHex := fmt.Sprintf("0x%x", cfg.Queue.Mark) + ownMark := fmt.Sprintf("0x%x/0x%x", cfg.Queue.Mark, cfg.Queue.Mark|engine.ClientMark) + rules = append(rules, + Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: "OUTPUT", Action: "I", + Spec: []string{"-m", "mark", "--mark", ownMark, "-j", "CONNMARK", + "--save-mark", "--nfmask", markMaskHex, "--ctmask", markMaskHex}}, + Rule{manager: manager, IPT: ipt, Table: "mangle", Chain: preChainName, Action: "I", + Spec: []string{"-m", "connmark", "--mark", markAccept, "-j", "RETURN"}}, + ) + } + return chains, rules +} + +func iptECHOnlyDNSPresent(ipt string) bool { + pre, err := run(ipt, "-w", "-t", "mangle", "-S", "B4_PREROUTING") + if err != nil || !strings.Contains(pre, dnsResponsePortMatch) || !strings.Contains(pre, "NFQUEUE") { + return false + } + if _, err := run(ipt, "-w", "-t", "mangle", "-C", "PREROUTING", "-j", "B4_PREROUTING"); err != nil { + return false + } + out, _ := run(ipt, "-w", "-t", "mangle", "-S", "OUTPUT") + return strings.Contains(out, dnsResponsePortMatch) && strings.Contains(out, "NFQUEUE") +} diff --git a/src/tables/echonly_test.go b/src/tables/echonly_test.go new file mode 100644 index 00000000..09d4bdbf --- /dev/null +++ b/src/tables/echonly_test.go @@ -0,0 +1,307 @@ +package tables + +import ( + "slices" + "strings" + "testing" + + "github.com/daniellavrushin/b4/config" +) + +func echOnlyRig(t *testing.T, engine string, strip bool) (*goldenHost, *config.Config) { + t.Helper() + h := newGoldenHost(t) + goldenIsolate(t, h) + noteDNSQueryPlacement(nil) + resetECHOnlyState(t) + prevApplied := rulesAppliedCfg + t.Cleanup(func() { + noteDNSQueryPlacement(nil) + rulesAppliedCfg = prevApplied + }) + + c := config.NewConfig() + cfg := &c + cfg.System.Tables.Engine = engine + cfg.Queue.IPv4Enabled = true + cfg.Queue.IPv6Enabled = false + if strip { + stripECHOnly(t, cfg) + } + if err := AddRules(cfg); err != nil { + t.Fatalf("AddRules: %v", err) + } + return h, cfg +} + +func goldenIptRules(h *goldenHost, bin, table, chain string) []string { + tb := h.ipt[bin][table] + if tb == nil { + return nil + } + c := tb.chain(chain) + if c == nil { + return nil + } + return c.rules +} + +func goldenB4Leftovers(h *goldenHost, bin string) []string { + var left []string + for name, tb := range h.ipt[bin] { + for _, c := range tb.chains { + if strings.HasPrefix(c.name, "B4") { + left = append(left, name+" chain "+c.name) + } + for _, r := range c.rules { + if strings.Contains(r, "NFQUEUE") || strings.Contains(r, "B4") { + left = append(left, name+" "+c.name+": "+r) + } + } + } + } + return left +} + +func indexOfRule(rules []string, parts ...string) int { + return slices.IndexFunc(rules, func(r string) bool { + for _, p := range parts { + if !strings.Contains(r, p) { + return false + } + } + return true + }) +} + +func TestIPTablesQueuesIPv6AnswersOnlyForECHKeysWhileIPv6IsOff(t *testing.T) { + h, cfg := echOnlyRig(t, backendIPTables, true) + v6 := backendIP6Tables + + pre := goldenIptRules(h, v6, "mangle", "B4_PREROUTING") + if indexOfRule(pre, "--sport 53", "NFQUEUE") < 0 { + t.Fatalf("ip6tables B4_PREROUTING = %q, want it to queue DNS answers: a validating resolver that reaches a zone's servers over IPv6 otherwise gets the ECH key b4 removes over IPv4", pre) + } + if indexOfRule(goldenIptRules(h, v6, "mangle", "PREROUTING"), "-j B4_PREROUTING") < 0 { + t.Fatal("ip6tables PREROUTING does not jump to B4_PREROUTING") + } + out := goldenIptRules(h, v6, "mangle", "OUTPUT") + accept, answers := indexOfRule(out, "--mark", "-j ACCEPT"), indexOfRule(out, "--sport 53", "NFQUEUE") + if answers < 0 || accept < 0 || accept > answers { + t.Fatalf("ip6tables OUTPUT = %q, want b4's own packets let through ahead of the DNS answer queue rule", out) + } + for _, chain := range []string{"B4_PREROUTING", "OUTPUT"} { + if i := indexOfRule(goldenIptRules(h, v6, "mangle", chain), "--dport 53"); i >= 0 { + t.Errorf("ip6tables %s queues DNS queries, but with IPv6 support off only answers are queued", chain) + } + } + if pre := goldenIptRules(h, v6, "mangle", "B4_PREROUTING"); indexOfRule(pre, "-p tcp") >= 0 { + t.Errorf("ip6tables B4_PREROUTING = %q, it must not queue TCP while IPv6 support is off", pre) + } + if tb := h.ipt[v6]["mangle"]; tb != nil && tb.chain(iptChainName) != nil { + t.Errorf("ip6tables has the %s capture chain although IPv6 support is off", iptChainName) + } + if tb := h.ipt[v6]["nat"]; tb == nil || tb.chain(dnsTCPChainName) == nil { + t.Error("DNS over TCP over IPv6 is not redirected to b4, so a resolver that asks the zone over TCP gets the key") + } else if indexOfRule(tb.chain(dnsTCPChainName).rules, "fe80::/10", "RETURN") < 0 { + t.Errorf("%s = %q: the relay cannot dial a link-local resolver without its zone, so TCP to fe80::/10 must stay out of the redirect", dnsTCPChainName, tb.chain(dnsTCPChainName).rules) + } + if tb := h.ipt[backendIPTables]["nat"]; tb != nil && tb.chain(dnsTCPChainName) != nil && indexOfRule(tb.chain(dnsTCPChainName).rules, "fe80") >= 0 { + t.Error("the IPv4 DNS-over-TCP chain got an IPv6 link-local rule") + } + + m := &Monitor{} + if !m.checkRules(cfg) { + t.Fatal("the monitor finds rules missing right after they were installed") + } + tb := h.ipt[v6]["mangle"].chain("OUTPUT") + tb.rules = slices.Delete(tb.rules, answers, answers+1) + if m.checkRules(cfg) { + t.Error("the monitor did not notice that the IPv6 DNS answer rule left OUTPUT") + } + + if err := ClearRules(cfg); err != nil { + t.Fatalf("ClearRules: %v", err) + } + if left := goldenB4Leftovers(h, v6); len(left) > 0 { + t.Errorf("ip6tables keeps b4 rules after the teardown: %q", left) + } +} + +func TestIPTablesDropsTheIPv6AnswerRulesWhenNoSetRemovesKeysAnyMore(t *testing.T) { + h, _ := echOnlyRig(t, backendIPTables, true) + if len(goldenB4Leftovers(h, backendIP6Tables)) == 0 { + t.Fatal("the rig installed no IPv6 rules to begin with") + } + + c := config.NewConfig() + next := &c + next.System.Tables.Engine = backendIPTables + next.Queue.IPv6Enabled = false + if err := RefreshRules(next); err != nil { + t.Fatalf("RefreshRules: %v", err) + } + if left := goldenB4Leftovers(h, backendIP6Tables); len(left) > 0 { + t.Errorf("ip6tables keeps b4 rules after the last set stopped removing ECH keys: %q", left) + } + if !(&Monitor{}).checkRules(next) { + t.Error("the monitor still expects IPv6 answer rules after the last set stopped removing ECH keys") + } +} + +func resetECHOnlyState(t *testing.T) { + t.Helper() + reset := func() { + echOnlyDNSUnusable.Clear() + nftDNSTCPFailsV4.Store(0) + nftDNSTCPFailsV6.Store(0) + } + reset() + t.Cleanup(reset) +} + +func TestIPTablesMonitorNoticesIPv6AnswerRulesAMissedRefreshSkipped(t *testing.T) { + _, applied := echOnlyRig(t, backendIPTables, false) + + c := *applied + wanted := &c + stripECHOnly(t, wanted) + if !wanted.ECHOnlyFamily(true) { + t.Fatal("the wanted config must ask for ECH-only IPv6 answers") + } + if (&Monitor{}).checkRules(wanted) { + t.Error("a set that started removing ECH keys while the refresh was missed left ip6tables without its answer rules, and the monitor did not notice") + } +} + +func TestIPTablesMonitorLeavesAnIP6TablesThatCannotQueueAlone(t *testing.T) { + h, cfg := echOnlyRig(t, backendIPTables, true) + for _, chain := range []string{"B4_PREROUTING", "OUTPUT"} { + tb := h.ipt[backendIP6Tables]["mangle"].chain(chain) + tb.rules = slices.DeleteFunc(tb.rules, func(r string) bool { return strings.Contains(r, "--sport 53") }) + } + echOnlyDNSUnusable.Store(backendIP6Tables, true) + if !(&Monitor{}).checkRules(cfg) { + t.Error("ip6tables cannot queue, so the monitor must not ask for its answer rules and rebuild the firewall on every tick") + } +} + +func ech6Config(t *testing.T) *config.Config { + t.Helper() + c := config.NewConfig() + cfg := &c + cfg.Queue.IPv4Enabled = true + cfg.Queue.IPv6Enabled = false + cfg.Queue.StartNum = 537 + cfg.Queue.Threads = 1 + stripECHOnly(t, cfg) + return cfg +} + +func TestNftKeepsLinkLocalDNSOverTCPOutOfTheRelay(t *testing.T) { + argvLog := nftFailingListStub(t) + n := NewNFTablesManager(ech6Config(t)) + for _, v6 := range []bool{false, true} { + if err := n.applyDNSTCPFamily(v6); err != nil { + t.Fatalf("applyDNSTCPFamily(%v): %v", v6, err) + } + } + lines := strings.Split(readLog(t, argvLog), "\n") + for _, chain := range []string{nftDNSTCPPreChain, nftDNSTCPOutChain} { + prefix := "add rule ip6 " + nftDNSTCPTableName6 + " " + chain + " " + ret := slices.Index(lines, prefix+"ip6 daddr fe80::/10 return") + redirect := slices.IndexFunc(lines, func(l string) bool { return strings.HasPrefix(l, prefix+"tcp dport 53 redirect") }) + if ret < 0 || redirect < 0 || ret > redirect { + t.Errorf("%s: link-local destinations must return ahead of the redirect:\n%s", chain, strings.Join(lines, "\n")) + } + } + for _, l := range lines { + if strings.HasPrefix(l, "add rule ip "+nftDNSTCPTableName+" ") && strings.Contains(l, "fe80") { + t.Errorf("the IPv4 DNS-over-TCP table got an IPv6 rule: %s", l) + } + } +} + +func TestIPTablesLeavesIPv6AloneWithoutASetThatRemovesKeys(t *testing.T) { + h, _ := echOnlyRig(t, backendIPTables, false) + if left := goldenB4Leftovers(h, backendIP6Tables); len(left) > 0 { + t.Fatalf("ip6tables got b4 rules although IPv6 support is off and no set removes ECH keys: %q", left) + } +} + +type goldenNftCommands struct { + rules map[string][]string + tables []string +} + +func goldenNftEmitted(h *goldenHost) goldenNftCommands { + got := goldenNftCommands{rules: map[string][]string{}} + for _, line := range h.take() { + f := strings.Fields(line) + if len(f) < 6 || f[0] != "exec" || f[1] != "nft" || f[2] != "add" { + continue + } + switch { + case f[3] == "table": + got.tables = append(got.tables, f[4]+" "+f[5]) + case f[3] == "rule" && len(f) > 7 && f[4] == "inet" && f[5] == nftTableName: + got.rules[f[6]] = append(got.rules[f[6]], strings.Join(f[7:], " ")) + } + } + return got +} + +func TestNftQueuesIPv6AnswersOnlyForECHKeysWhileIPv6IsOff(t *testing.T) { + h, _ := echOnlyRig(t, backendNFTables, true) + got := goldenNftEmitted(h) + + for _, chain := range []string{"prerouting", "output"} { + rules := got.rules[chain] + if indexOfRule(rules, "meta nfproto ipv6 udp sport 53 counter", "queue") < 0 { + t.Errorf("%s = %q, want IPv6 DNS answers queued", chain, rules) + } + if indexOfRule(rules, "meta nfproto ipv4 udp sport 53", "queue") < 0 { + t.Errorf("%s = %q, the IPv4 answer rule must stay", chain, rules) + } + if i := indexOfRule(rules, "meta nfproto ipv6", "dport 53"); i >= 0 { + t.Errorf("%s queues IPv6 DNS queries: %q", chain, rules[i]) + } + } + for _, chain := range []string{nftRawPreChain, nftRawOutChain, nftChainName} { + if i := indexOfRule(got.rules[chain], "nfproto ipv6"); i >= 0 { + t.Errorf("%s handles IPv6 although only DNS answers are queued for ECH keys", chain) + } + } + if !slices.Contains(got.tables, "ip6 "+nftDNSTCPTableName6) { + t.Errorf("tables %q: DNS over TCP over IPv6 is not redirected to b4", got.tables) + } +} + +func TestNftLeavesIPv6AloneWithoutASetThatRemovesKeys(t *testing.T) { + h, _ := echOnlyRig(t, backendNFTables, false) + got := goldenNftEmitted(h) + if len(got.rules["prerouting"]) == 0 { + t.Fatal("the rig emitted no prerouting rules at all") + } + for chain, rules := range got.rules { + if i := indexOfRule(rules, "nfproto ipv6"); i >= 0 { + t.Errorf("%s queues IPv6 although IPv6 support is off and no set removes ECH keys: %q", chain, rules[i]) + } + } + if slices.Contains(got.tables, "ip6 "+nftDNSTCPTableName6) { + t.Error("DNS over TCP over IPv6 is redirected although no set needs it") + } +} + +func TestNftListingOfIPv6AnswerRules(t *testing.T) { + listing := "table inet b4_mangle {\n\tchain output {\n\t\tmeta nfproto ipv4 udp sport 53 udp dport != 53 counter packets 0 bytes 0 queue flags bypass to 537-540\n\t\tmeta nfproto ipv6 udp sport 53 counter packets 4 bytes 1188 queue flags bypass to 537-540\n\t}\n}\n" + if !nftListingQueuesAnswers(listing, "ipv6") { + t.Error("the IPv6 answer rule in a live listing was not found") + } + if nftListingQueuesAnswers(strings.Replace(listing, "meta nfproto ipv6 udp sport 53", "meta nfproto ipv6 udp sport 5353", 1), "ipv6") { + t.Error("a rule for port 5353 was taken for the DNS answer rule") + } + if nftListingQueuesAnswers(strings.Replace(listing, "nfproto ipv6", "nfproto ipv4", 1), "ipv6") { + t.Error("an IPv4 rule was taken for the IPv6 one") + } +} diff --git a/src/tables/iptables.go b/src/tables/iptables.go index c8fd94e0..f5baa6c4 100644 --- a/src/tables/iptables.go +++ b/src/tables/iptables.go @@ -401,6 +401,7 @@ type Manifest struct { Rules []Rule Sysctls []SysctlSetting DNSQueries map[string]dnsQueryPlacement + ECHOnlyDNS []string } func (m Manifest) Apply() error { @@ -828,6 +829,16 @@ func (manager *IPTablesManager) buildManifestFor(ipts []string, teardown bool) M } } + var echOnly []string + if !teardown { + echOnly = manager.echOnlyBinaries() + for _, ipt := range echOnly { + ec, er := manager.buildECHOnlyDNSManifest(ipt, preChainName) + chains = append(chains, ec...) + rules = append(rules, er...) + } + } + if cfg.System.Tables.Masquerade.Enabled { for _, ipt := range ipts { mc, mr := manager.buildMasqueradeManifest(ipt) @@ -837,7 +848,7 @@ func (manager *IPTablesManager) buildManifestFor(ipts []string, teardown bool) M } if dnsTCPWanted(cfg) { - for _, ipt := range ipts { + for _, ipt := range append(append([]string(nil), ipts...), echOnly...) { isV6 := ipt == manager.ip6tablesBin() if !dnsTCPEnabledFamily(cfg, isV6) || !hasNATRedirectSupport(ipt, cfg.DNSTCPListenPort()) { continue @@ -852,7 +863,7 @@ func (manager *IPTablesManager) buildManifestFor(ipts []string, teardown bool) M ipsets = append(ipsets, mssIPSets...) rules = append(rules, mssRules...) - return Manifest{IPSets: ipsets, Chains: chains, Rules: rules, Sysctls: b4SysctlSettings(), DNSQueries: dnsQueries} + return Manifest{IPSets: ipsets, Chains: chains, Rules: rules, Sysctls: b4SysctlSettings(), DNSQueries: dnsQueries, ECHOnlyDNS: echOnly} } func (manager *IPTablesManager) mssClampBinaries() []string { @@ -1057,6 +1068,10 @@ func (ipt *IPTablesManager) Apply() error { if result == nil { ipt.dropUnusedDNSQueryPlacement(m.DNSQueries) warnPortChangingDNSNat(m.DNSQueries) + for _, bin := range m.ECHOnlyDNS { + label := iptFamilyLabel(bin) + log.Infof("IPTABLES[%s]: %s support is off, so %s DNS answers are queued only to remove ECH keys for the sets that ask for it", bin, label, label) + } } noteDNSQueryPlacement(m.DNSQueries) diff --git a/src/tables/monitor.go b/src/tables/monitor.go index defb282c..7e9a9994 100644 --- a/src/tables/monitor.go +++ b/src/tables/monitor.go @@ -369,6 +369,24 @@ func (m *Monitor) checkIPTablesRules(cfg *config.Config) bool { } } + for _, v6 := range []bool{false, true} { + ipt := ipt4 + if v6 { + ipt = ipt6 + } + if !cfg.ECHOnlyFamily(v6) || !hasBinary(ipt) || !echOnlyDNSUsable(ipt) { + continue + } + if !iptECHOnlyDNSPresent(ipt) { + log.Tracef("Monitor[%s]: the rules that queue %s DNS answers to remove ECH keys are missing", ipt, familyLabel(v6)) + return false + } + if dnsTCPEnabledFamily(cfg, v6) && hasNATRedirectSupport(ipt, cfg.DNSTCPListenPort()) && !dnsTCPRulesPresent(ipt, cfg.DNSTCPListenPort(), dnsTCPMarkAccept(cfg)) { + log.Tracef("Monitor[%s]: DNS-over-TCP redirect rules missing (PREROUTING/OUTPUT jump, %s chain or its return for b4's own connections)", ipt, dnsTCPChainName) + return false + } + } + return true } @@ -440,6 +458,16 @@ func (m *Monitor) checkNFTablesRules(cfg *config.Config) bool { return false } + if family, _ := echOnlyNfproto(cfg); family != "" { + pre, _ := nft.runNft("list", "chain", "inet", nftTableName, "prerouting") + for _, queued := range []string{"ipv4", "ipv6"} { + if !nftListingQueuesAnswers(pre, queued) || !nftListingQueuesAnswers(out, queued) { + log.Tracef("Monitor: the rules that queue %s DNS answers are missing", familyLabel(queued == "ipv6")) + return false + } + } + } + if cfg.System.Tables.Masquerade.Enabled { if !nft.natTableExists() { log.Tracef("Monitor: nftables nat table missing") @@ -453,10 +481,7 @@ func (m *Monitor) checkNFTablesRules(cfg *config.Config) bool { } for _, v6 := range []bool{false, true} { - if !dnsTCPEnabledFamily(cfg, v6) { - continue - } - if (v6 && !cfg.Queue.IPv6Enabled) || (!v6 && !cfg.Queue.IPv4Enabled) { + if !nftDNSTCPExpected(cfg, v6) { continue } if !nft.dnsTCPFamilyIntact(v6, cfg.MainInjectedMark(), cfg.DNSTCPListenPort()) { @@ -493,6 +518,9 @@ func (m *Monitor) ensureRulesLocked(requested bool) (*config.Config, bool) { cfg := m.cfgPtr.Load() if m.checkRules(cfg) { m.pendingApply = nil + if !m.tun && m.firewallBackend() == backendNFTables { + NewNFTablesManager(cfg).retryFailedDNSTCP() + } return cfg, false } if applied := rulesAppliedCfg; applied != nil && applied != cfg && (m.pendingApply != cfg || activeSteering != nil) && config.FirewallRefreshNeeded(applied, cfg) && m.checkRules(applied) { diff --git a/src/tables/nft_dnstcp_test.go b/src/tables/nft_dnstcp_test.go index ec0f2f1e..f5b015bc 100644 --- a/src/tables/nft_dnstcp_test.go +++ b/src/tables/nft_dnstcp_test.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" + "sync/atomic" "testing" "github.com/daniellavrushin/b4/config" @@ -114,3 +116,222 @@ func TestTheIPv6DNSTCPTableIsNotTakenForTheIPv4One(t *testing.T) { } } } + +const statefulDNSTCPNftScript = `#!/bin/sh +d="$(dirname "$0")" +printf '%s\n' "$*" >> "$d/calls" +case "$*" in +"list tables") + echo "table inet @MANGLE@" + if [ -f "$d/ip" ]; then echo "table ip @NAT4@"; fi + if [ -f "$d/ip6" ]; then echo "table ip6 @NAT6@"; fi + ;; +"list chain inet @MANGLE@ "*) cat "$d/mangle" ;; +"list chain ip @NAT4@ "*|"list chain ip6 @NAT6@ "*) + if [ ! -f "$d/$3" ]; then echo "Error: No such file or directory" >&2; exit 1; fi + cat "$d/nat" + ;; +"add table ip @NAT4@"|"add table ip6 @NAT6@") touch "$d/$3" ;; +"delete table ip @NAT4@"|"delete table ip6 @NAT6@") rm -f "$d/$3" ;; +"add chain ip @NAT4@ "*|"add chain ip6 @NAT6@ "*) + left=0 + if [ -f "$d/refuse.$3" ]; then left=$(cat "$d/refuse.$3"); fi + if [ "$left" -gt 0 ]; then + echo $((left - 1)) > "$d/refuse.$3" + echo "Error: Could not process rule: No such file or directory" >&2 + exit 1 + fi + ;; +esac +exit 0 +` + +type dnsTCPMonitorRig struct { + dir string + cfg *config.Config + m *Monitor + builds int +} + +func newDNSTCPMonitorRig(t *testing.T) *dnsTCPMonitorRig { + t.Helper() + resetECHOnlyState(t) + cfg := ech6Config(t) + r := &dnsTCPMonitorRig{dir: t.TempDir(), cfg: cfg} + + markRule := fmt.Sprintf("meta mark & 0x%08x == 0x%08x", cfg.MainInjectedMark(), cfg.MainInjectedMark()) + answers := fmt.Sprintf("udp sport 53 counter queue flags bypass to %d", cfg.Queue.StartNum) + r.write(t, "nft", strings.NewReplacer("@MANGLE@", nftTableName, "@NAT4@", nftDNSTCPTableName, "@NAT6@", nftDNSTCPTableName6).Replace(statefulDNSTCPNftScript), 0o755) + r.write(t, "mangle", fmt.Sprintf("table inet %s {\n\tchain any {\n\t\tjump %s\n\t\tmeta nfproto ipv4 %s\n\t\tmeta nfproto ipv6 %s\n\t\tudp dport 53 counter queue flags bypass to %d\n\t\ttcp sport 443 counter queue flags bypass to %d\n\t\t%s accept\n\t}\n}\n", + nftTableName, nftChainName, answers, answers, cfg.Queue.StartNum, cfg.Queue.StartNum, markRule), 0o644) + r.write(t, "nat", fmt.Sprintf("table any any {\n\tchain any {\n\t\t%s return\n\t\t%s\n\t}\n}\n", + markRule, strings.Join(nftDNSTCPRedirectRule(cfg.DNSTCPListenPort()), " ")), 0o644) + t.Setenv("PATH", r.dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + sys := t.TempDir() + for _, module := range append(append([]string{}, kernelModuleList...), "xt_hashlimit") { + if err := os.MkdirAll(filepath.Join(sys, kmodCanonical(module)), 0o755); err != nil { + t.Fatal(err) + } + } + origRun, origAdd, origClear, origSys := run, addRulesFn, clearRulesFn, kmodSysRoot + run = func(...string) (string, error) { return "", nil } + kmodSysRoot = sys + addRulesFn = func(c *config.Config) error { + r.builds++ + return NewNFTablesManager(c).ApplyDNSTCP() + } + clearRulesFn = func(*config.Config) error { return nil } + rulesMu.Lock() + origApplied, origBackend := rulesAppliedCfg, rulesAppliedBackend + rulesAppliedCfg, rulesAppliedBackend = cfg, backendNFTables + rulesMu.Unlock() + t.Cleanup(func() { + run, addRulesFn, clearRulesFn, kmodSysRoot = origRun, origAdd, origClear, origSys + rulesMu.Lock() + rulesAppliedCfg, rulesAppliedBackend = origApplied, origBackend + rulesMu.Unlock() + }) + + var current atomic.Pointer[config.Config] + current.Store(cfg) + r.m = &Monitor{cfgPtr: ¤t, backend: backendNFTables} + return r +} + +func (r *dnsTCPMonitorRig) write(t *testing.T, name, body string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(filepath.Join(r.dir, name), []byte(body), mode); err != nil { + t.Fatal(err) + } +} + +func (r *dnsTCPMonitorRig) refuse(t *testing.T, family string, times int) { + t.Helper() + r.write(t, "refuse."+family, strconv.Itoa(times), 0o644) +} + +func (r *dnsTCPMonitorRig) apply() { + rulesMu.Lock() + defer rulesMu.Unlock() + _ = addRulesFn(r.cfg) +} + +func (r *dnsTCPMonitorRig) tick() bool { + rulesMu.Lock() + defer rulesMu.Unlock() + _, restored := r.m.ensureRulesLocked(false) + return restored +} + +func (r *dnsTCPMonitorRig) has(family string) bool { + _, err := os.Stat(filepath.Join(r.dir, family)) + return err == nil +} + +func (r *dnsTCPMonitorRig) tries(t *testing.T, v6 bool) int { + t.Helper() + family, table := nftDNSTCPTable(v6) + return strings.Count(readLog(t, filepath.Join(r.dir, "calls")), "add chain "+family+" "+table+" "+nftDNSTCPPreChain+" ") +} + +func TestTheNftMonitorRetriesOnlyTheDNSOverTCPFamilyTheKernelRefuses(t *testing.T) { + r := newDNSTCPMonitorRig(t) + r.refuse(t, "ip6", 1000) + r.apply() + + for i := 1; i <= 2*nftDNSTCPFailStreak; i++ { + if r.tick() { + t.Fatalf("tick %d rebuilt the whole firewall for the IPv6 DNS-over-TCP table nft refuses", i) + } + } + if got := r.tries(t, true); got != nftDNSTCPFailStreak { + t.Errorf("the IPv6 DNS-over-TCP table was tried %d times, want %d: once by the apply, then by the monitor until %d failures in a row", got, nftDNSTCPFailStreak, nftDNSTCPFailStreak) + } + if r.builds != 1 || r.tries(t, false) != 1 || !r.has("ip") { + t.Errorf("%d full builds, %d IPv4 builds, IPv4 table present %v: the IPv4 DNS-over-TCP table was built once and must be left alone", r.builds, r.tries(t, false), r.has("ip")) + } +} + +func TestTheNftMonitorRebuildsADNSOverTCPFamilyAfterAPassingFailure(t *testing.T) { + r := newDNSTCPMonitorRig(t) + r.refuse(t, "ip", 1) + r.apply() + if r.has("ip") { + t.Fatal("the stub was meant to refuse the first IPv4 build") + } + + if r.tick() { + t.Fatal("one failed DNS-over-TCP build made the monitor rebuild the whole firewall") + } + if !r.has("ip") { + t.Fatal("the monitor left the IPv4 DNS-over-TCP table out after nft worked again, so one passing error keeps DNS over TCP with the upstream resolver until b4 restarts") + } + if r.tick() { + t.Error("the rebuilt IPv4 DNS-over-TCP table did not satisfy the next check") + } +} + +func TestAnApplyGivesTheNftMonitorItsDNSOverTCPRetriesBack(t *testing.T) { + r := newDNSTCPMonitorRig(t) + r.refuse(t, "ip", nftDNSTCPFailStreak) + r.apply() + for i := 0; i < nftDNSTCPFailStreak; i++ { + r.tick() + } + if r.has("ip") { + t.Fatal("the stub was meant to refuse every IPv4 build until the monitor gave up") + } + + r.refuse(t, "ip", 1) + r.apply() + r.tick() + if !r.has("ip") { + t.Error("an apply after the monitor gave up must give it its retries back, or one later passing error lasts until b4 restarts") + } +} + +func TestADNSOverTCPFamilyAnApplyLeftOutIsBuiltOnceItsListenerIsUp(t *testing.T) { + r := newDNSTCPMonitorRig(t) + r.refuse(t, "ip", 1000) + r.apply() + for i := 0; i < nftDNSTCPFailStreak; i++ { + r.tick() + } + + SetDNSTCPListenerReady(false, false) + r.apply() + r.refuse(t, "ip", 0) + SetDNSTCPListenerReady(true, true) + r.tick() + if !r.has("ip") { + t.Error("the listener came back without an apply, and the failures from before the apply that left the family out kept its table missing") + } +} + +func TestTheNftMonitorWantsTheDNSAnswerRuleOfEachFamilyWhileOneOnlyLosesECHKeys(t *testing.T) { + for _, ipv6Support := range []bool{false, true} { + for _, gone := range []string{"ipv4", "ipv6"} { + t.Run(fmt.Sprintf("ipv6 support %v, %s answer rule gone", ipv6Support, gone), func(t *testing.T) { + r := newDNSTCPMonitorRig(t) + r.cfg.Queue.IPv4Enabled = !ipv6Support + r.cfg.Queue.IPv6Enabled = ipv6Support + r.apply() + if r.tick() { + t.Fatal("the full listing must satisfy the monitor") + } + + var kept []string + for _, line := range strings.Split(readLog(t, filepath.Join(r.dir, "mangle")), "\n") { + if !strings.Contains(line, "nfproto "+gone+" udp sport 53") { + kept = append(kept, line) + } + } + r.write(t, "mangle", strings.Join(kept, "\n"), 0o644) + if !r.tick() { + t.Errorf("the %s DNS answer rule was gone while the other family's stayed, and the monitor did not restore the rules", gone) + } + }) + } + } +} diff --git a/src/tables/nftables.go b/src/tables/nftables.go index ba3898ee..4c135af6 100644 --- a/src/tables/nftables.go +++ b/src/tables/nftables.go @@ -378,14 +378,7 @@ func (n *NFTablesManager) apply() error { } noteDNSQueryPlacement(map[string]dnsQueryPlacement{backendNFTables: placement}) - answers := []string{"udp", "sport", "53", "counter"} - if queriesFromRaw { - answers = []string{"udp", "sport", "53", "udp", "dport", "!=", "53", "counter"} - } - if err := n.addQueueRule("prerouting", answers...); err != nil { - return err - } - if err := n.addQueueRule("output", answers...); err != nil { + if err := n.addDNSAnswerRules(queriesFromRaw); err != nil { return err }