diff --git a/changelog.md b/changelog.md
index a5fcf439..c6fb30c6 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,9 @@
# B4 - Bye Bye Big Bro
+## [1.65.0] - 2026-06-06
+
+- ADDED: **Block (blackhole) routing mode** - a new "Block" option in a set's Routing tab that blocks all matched traffic (ad/tracker domains, IPs, or a GeoSite category like `category-ads-all`) across the whole network - every LAN device and the router itself - with no output interface needed. It blocks by name and not just by IP, so it keeps working even with encrypted DNS and won't break unrelated sites sharing the same servers. See [Blocking](https://daniellavrushin.github.io/b4/docs/sets/blocking) for setup and details.
+
## [1.64.0] - 2026-06-01
- FIXED: **DNS redirect didn't work** - when a set sent its DNS lookups to a chosen server (the set's DNS tab), names failed to resolve and the internet seemed to hang. Redirected DNS now works as expected.
diff --git a/docs/docs/sets/blocking.md b/docs/docs/sets/blocking.md
new file mode 100644
index 00000000..d55f7ba2
--- /dev/null
+++ b/docs/docs/sets/blocking.md
@@ -0,0 +1,92 @@
+---
+sidebar_position: 4.5
+title: Blocking
+---
+
+Block mode stops the traffic a set matches instead of routing it anywhere. Point a set at ad and tracker domains, unwanted IPs, or a GeoSite category, and b4 blocks them across the whole network - every LAN device and the router itself - with no output interface or upstream proxy.
+
+It is selected in the [Routing](./routing.md) tab as the **Block** mode.
+
+## How it works
+
+Whatever way a device tries to reach a blocked site, b4 stops it - and only that site, so the rest of the page keeps working.
+
+```mermaid
+flowchart TB
+ DEV["📱 A device tries to open
an ad or tracker"]
+ DEV --> Q{"How is it
connecting?"}
+
+ Q -->|"Looking up the address
(the usual way)"| A["b4 replies that the
site does not exist"]
+ Q -->|"Already has the address
(hidden or cached)"| C["b4 recognizes the site
and cuts the connection"]
+ Q -->|"Going to a blocked IP"| D["b4 blocks the
address directly"]
+
+ A --> R["🚫 The ad does not load
The rest of the page still works"]
+ C --> R
+ D --> R
+
+ style DEV fill:#4a9eff,color:#fff,stroke:none
+ style Q fill:#e91e63,color:#fff,stroke:none
+ style A fill:#ff9800,color:#fff,stroke:none
+ style C fill:#ff9800,color:#fff,stroke:none
+ style D fill:#ff9800,color:#fff,stroke:none
+ style R fill:#4caf50,color:#fff,stroke:none
+```
+
+### How it works (in detail)
+
+1. **DNS sinkhole (domains).** When a device looks up a domain in the set, b4 answers the query itself with "does not exist" (NXDOMAIN). The name never resolves to an address, so the device cannot connect to it at all. This is the primary layer, and it covers every device on the network whose DNS passes through the router.
+
+2. **Connection block (domains).** If a device already has an address - for example it uses encrypted DNS that b4 does not see, or a cached result - b4 still reads the destination name from the TLS or QUIC handshake (the SNI) and fails the connection when that name is in the set. This keeps blocking effective even when the DNS layer is bypassed.
+
+3. **Address block (IPs).** Targets given as plain IPs, CIDR ranges, or a GeoIP category are blocked in the firewall by destination address.
+
+Because the first two layers work by **name**, block mode does not break unrelated sites that happen to share the same servers as a blocked host - a common problem when blocking purely by IP address on shared CDNs.
+
+Changes apply live, without a restart.
+
+## Setup
+
+1. Open the set's [Routing](./routing.md) tab
+2. Set the **Mode** to **Block**
+3. Pick a **Block action** (see below)
+4. Add the destinations to block in the set's [Targets](./targets.md)
+
+No output interface or upstream proxy is required.
+
+## Block actions
+
+| Action | What the device sees |
+| --- | --- |
+| **Reject** (default) | The connection fails immediately - a TCP reset for TCP, or an ICMP unreachable for UDP and QUIC. Best for ads and trackers, because the page gives up on the slot right away. |
+| **Drop** | The connection is silently swallowed and simply times out. Use it when you want no response sent back at all. |
+
+## Targets
+
+Block mode reuses the same [targeting](./targets.md) as the other routing modes:
+
+- **Domains** - exact names or `regexp:` patterns
+- **IPs and CIDR ranges** - single addresses or subnets
+- **GeoSite categories** - for example `category-ads-all` for a ready-made ad and tracker list
+- **GeoIP categories** - block by country or ASN
+
+## Ready-made ad blocker
+
+To start blocking without adding domains by hand, import a ready-made set. On the [Sets](./index.md) page create a new set, open its **Import/Export** tab, and paste the configuration below. It targets a broad list of ad and tracker domains plus the `category-ads-all` GeoSite category, with the mode already set to Block.
+
+```json
+{"b4_version":"1.65.0","name":"adblock","fragmentation":{"strategy":"none"},"faking":{"sni":false},"targets":{"sni_domains":["ad.mail.ru","advertronic.io","vu.okcdn.ru","adkernel.com","adfox.ru","nr-data.net","rubiconproject.com","adition.com","mc.yandex.com","log.strm.yandex.ru","analytics.yahoo.com","criteo.com","taboola.com","ad4m.at","1rx.io","hbx.media.net","media.net","adform.net","analyticsengine.s3.amazonaws.com","analytics.s3.amazonaws.com","ad.doubleclick.net","analytics.google.com","api.bugsnag.com","app.bugsnag.com","browser.sentry-cdn.com","app.getsentry.com","ads-api.twitter.com","log.byteoversea.com","log.fc.yahoo.com","adtech.yahooinc.com","appmetrica.yandex.ru","metrika.yandex.ru","fingerprintjs.com","px.srvcs.tumblr.com","device-metrics-us.amazon.com","cdn.cookielaw.org","consent.cookiebot.com","sdk.privacy-center.org","cdn.privacy-mgmt.com","api.impact.com","cdn.dynamicyield.com","widget.intercom.io","bnc.lt","bingads.microsoft.com","ads.microsoft.com","snap.licdn.com","ct.pinterest.com","geolocation.onetrust.com","consent.trustarc.com","app.usercentrics.eu","advertising-api-eu.amazon.com","fls-na.amazon.com","advertising.yandex.ru","ironsource.mobi","is.com","pangleglobal.com","posthog.com","o0.ingest.sentry.io","lr-ingest.com","mineralt.io","pixel.quora.com","qevents.quora.com","ads.vk.com","advertising.apple.com","ads.huawei.com","ngfts.lge.com","xp.apple.com","cmp.osano.com","consentcdn.cookiebot.com","zenaps.com","clientstream.launchdarkly.com","match.adsrvr.org","smartyads.com","adcolony.com","mouseflow.com","edge.fullstory.com","stats.wp.com","adsrvr.org","c.bing.com","app-measurement.com","cdn.segment.com","quantcast.com","rudderstack.com","snowplowanalytics.com","adjust.com","singular.net","wzrkt.com","ads.pinterest.com","ads.x.com","ads-sg.tiktok.com","tracking.rus.miui.com","cookiebot.com"],"ip":["5.255.255.77/32"],"geosite_categories":["category-ads-all"]},"enabled":true,"routing":{"enabled":true,"mode":"block"}}
+```
+
+The `category-ads-all` part needs the GeoSite database loaded - see [Targets](./targets.md).
+
+## Scope
+
+Block mode applies network-wide: it stops matched destinations for every device on the network and for the router itself. It needs no output interface, routing table, or proxy, so it works on any setup that the other routing modes support.
+
+:::tip
+A quick network-wide ad blocker: create a set, add the `category-ads-all` GeoSite category to its [Targets](./targets.md), set the Routing mode to **Block**, and leave the action on **Reject**.
+:::
+
+:::info
+Blocking by name needs the device's DNS or its TLS/QUIC handshake to pass through the router. Traffic that leaves the network through a separate tunnel the router does not see (for example a VPN configured directly on a device) is outside b4's reach.
+:::
diff --git a/docs/docs/sets/routing.md b/docs/docs/sets/routing.md
index fe7040c3..4e40dd06 100644
--- a/docs/docs/sets/routing.md
+++ b/docs/docs/sets/routing.md
@@ -68,6 +68,10 @@ Fragmentation only affects DNS queries for domains in the current set. Other DNS
Routes traffic matched by the set through a specific network interface - for example, a VPN, WireGuard, or another tunnel.
+:::tip
+To **block** matched traffic instead of sending it anywhere, set the mode to Block. See [Blocking](./blocking.md).
+:::
+
### General diagram
```mermaid
diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/blocking.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/blocking.md
new file mode 100644
index 00000000..f911dc1b
--- /dev/null
+++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/blocking.md
@@ -0,0 +1,94 @@
+---
+sidebar_position: 4.5
+title: Блокировка
+---
+
+# Блокировка
+
+Режим блокировки останавливает трафик, совпавший с целями сета, вместо того чтобы куда-либо его направлять. Укажите в целях сета домены рекламы и трекеров, нежелательные IP или категорию GeoSite - и b4 заблокирует их во всей сети: на каждом устройстве LAN и на самом роутере, без выходного интерфейса и upstream-прокси.
+
+Выбирается на вкладке [Маршрутизация](./routing.md) как режим **Блокировка**.
+
+## Как это работает
+
+Каким бы способом устройство ни пыталось добраться до заблокированного сайта, b4 его останавливает - и только этот сайт, поэтому остальная часть страницы продолжает работать.
+
+```mermaid
+flowchart TB
+ DEV["📱 Устройство пытается открыть
рекламу или трекер"]
+ DEV --> Q{"Как оно
подключается?"}
+
+ Q -->|"Узнаёт адрес
(обычный случай)"| A["b4 отвечает, что
сайт не существует"]
+ Q -->|"Уже знает адрес
(скрытый или кешированный)"| C["b4 узнаёт сайт
и обрывает соединение"]
+ Q -->|"Идёт на заблокированный IP"| D["b4 блокирует
адрес напрямую"]
+
+ A --> R["🚫 Реклама не загружается
Остальная часть страницы работает"]
+ C --> R
+ D --> R
+
+ style DEV fill:#4a9eff,color:#fff,stroke:none
+ style Q fill:#e91e63,color:#fff,stroke:none
+ style A fill:#ff9800,color:#fff,stroke:none
+ style C fill:#ff9800,color:#fff,stroke:none
+ style D fill:#ff9800,color:#fff,stroke:none
+ style R fill:#4caf50,color:#fff,stroke:none
+```
+
+### Как это работает (подробно)
+
+1. **Блокировка на уровне DNS (домены).** Когда устройство запрашивает домен из сета, b4 сам отвечает на запрос «не существует» (NXDOMAIN). Имя не разрешается в адрес, поэтому устройство вообще не может к нему подключиться. Это основной уровень, и он охватывает каждое устройство в сети, чей DNS проходит через роутер.
+
+2. **Блокировка соединения (домены).** Если у устройства уже есть адрес - например, оно использует шифрованный DNS, который b4 не видит, или кешированный результат - b4 всё равно читает имя назначения из TLS- или QUIC-рукопожатия (SNI) и завершает соединение, когда это имя есть в сете. Это сохраняет блокировку рабочей, даже когда уровень DNS обойдён.
+
+3. **Блокировка по адресу (IP).** Цели, заданные как обычные IP, диапазоны CIDR или категория GeoIP, блокируются в файрволе по адресу назначения.
+
+Поскольку первые два уровня работают по **имени**, режим блокировки не ломает посторонние сайты, которые оказались на тех же серверах, что и заблокированный хост - частая проблема при блокировке только по IP-адресу на общих CDN.
+
+Изменения применяются на лету, без перезапуска.
+
+## Настройка
+
+1. Откройте вкладку [Маршрутизация](./routing.md) сета
+2. Установите **Режим** в **Блокировка**
+3. Выберите **Действие блокировки** (см. ниже)
+4. Добавьте назначения для блокировки в [Цели](./targets.md) сета
+
+Выходной интерфейс или upstream-прокси не нужны.
+
+## Действия блокировки
+
+| Действие | Что видит устройство |
+| --- | --- |
+| **Отклонить** (по умолчанию) | Соединение сразу завершается ошибкой - TCP reset для TCP или ICMP unreachable для UDP и QUIC. Лучший вариант для рекламы и трекеров: страница сразу отказывается от слота. |
+| **Отбросить** | Соединение молча поглощается и просто завершается по таймауту. Используйте, когда нужно вообще не отправлять ответ. |
+
+## Цели
+
+Режим блокировки использует те же [цели](./targets.md), что и остальные режимы маршрутизации:
+
+- **Домены** - точные имена или шаблоны `regexp:`
+- **IP и диапазоны CIDR** - отдельные адреса или подсети
+- **Категории GeoSite** - например, `category-ads-all` для готового списка рекламы и трекеров
+- **Категории GeoIP** - блокировка по стране или ASN
+
+## Готовый блокировщик рекламы
+
+Чтобы начать блокировку без ручного добавления доменов, импортируйте готовый сет. На странице [Сеты](./index.md) создайте новый сет, откройте его вкладку **Импорт/Экспорт** и вставьте конфигурацию ниже. Сет нацелен на широкий список доменов рекламы и трекеров плюс категорию GeoSite `category-ads-all`, режим уже установлен в Блокировка.
+
+```json
+{"b4_version":"1.65.0","name":"adblock","fragmentation":{"strategy":"none"},"faking":{"sni":false},"targets":{"sni_domains":["ad.mail.ru","advertronic.io","vu.okcdn.ru","adkernel.com","adfox.ru","nr-data.net","rubiconproject.com","adition.com","mc.yandex.com","log.strm.yandex.ru","analytics.yahoo.com","criteo.com","taboola.com","ad4m.at","1rx.io","hbx.media.net","media.net","adform.net","analyticsengine.s3.amazonaws.com","analytics.s3.amazonaws.com","ad.doubleclick.net","analytics.google.com","api.bugsnag.com","app.bugsnag.com","browser.sentry-cdn.com","app.getsentry.com","ads-api.twitter.com","log.byteoversea.com","log.fc.yahoo.com","adtech.yahooinc.com","appmetrica.yandex.ru","metrika.yandex.ru","fingerprintjs.com","px.srvcs.tumblr.com","device-metrics-us.amazon.com","cdn.cookielaw.org","consent.cookiebot.com","sdk.privacy-center.org","cdn.privacy-mgmt.com","api.impact.com","cdn.dynamicyield.com","widget.intercom.io","bnc.lt","bingads.microsoft.com","ads.microsoft.com","snap.licdn.com","ct.pinterest.com","geolocation.onetrust.com","consent.trustarc.com","app.usercentrics.eu","advertising-api-eu.amazon.com","fls-na.amazon.com","advertising.yandex.ru","ironsource.mobi","is.com","pangleglobal.com","posthog.com","o0.ingest.sentry.io","lr-ingest.com","mineralt.io","pixel.quora.com","qevents.quora.com","ads.vk.com","advertising.apple.com","ads.huawei.com","ngfts.lge.com","xp.apple.com","cmp.osano.com","consentcdn.cookiebot.com","zenaps.com","clientstream.launchdarkly.com","match.adsrvr.org","smartyads.com","adcolony.com","mouseflow.com","edge.fullstory.com","stats.wp.com","adsrvr.org","c.bing.com","app-measurement.com","cdn.segment.com","quantcast.com","rudderstack.com","snowplowanalytics.com","adjust.com","singular.net","wzrkt.com","ads.pinterest.com","ads.x.com","ads-sg.tiktok.com","tracking.rus.miui.com","cookiebot.com"],"ip":["5.255.255.77/32"],"geosite_categories":["category-ads-all"]},"enabled":true,"routing":{"enabled":true,"mode":"block"}}
+```
+
+Для работы части с `category-ads-all` нужна загруженная база GeoSite - см. [Цели](./targets.md).
+
+## Охват
+
+Режим блокировки действует на всю сеть: он останавливает совпавшие назначения для каждого устройства в сети и для самого роутера. Ему не нужны выходной интерфейс, таблица маршрутизации или прокси, поэтому он работает на любой конфигурации, которую поддерживают остальные режимы маршрутизации.
+
+:::tip
+Быстрый блокировщик рекламы для всей сети: создайте сет, добавьте категорию GeoSite `category-ads-all` в его [Цели](./targets.md), установите режим маршрутизации **Блокировка** и оставьте действие **Отклонить**.
+:::
+
+:::info
+Блокировка по имени требует, чтобы DNS устройства или его TLS/QUIC-рукопожатие проходили через роутер. Трафик, который уходит из сети через отдельный туннель, не видимый роутеру (например, VPN, настроенный прямо на устройстве), находится вне досягаемости b4.
+:::
diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/routing.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/routing.md
index 7851df7c..8a9f3ad5 100644
--- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/routing.md
+++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/sets/routing.md
@@ -70,6 +70,10 @@ flowchart LR
Направляет трафик, совпавший с целями сета, через указанный сетевой интерфейс - например, VPN, WireGuard или другой туннель.
+:::tip
+Чтобы **заблокировать** совпавший трафик вместо того, чтобы куда-либо его направлять, установите режим Блокировка. См. [Блокировка](./blocking.md).
+:::
+
### Общая схема
```mermaid
diff --git a/src/config/config.go b/src/config/config.go
index f6111fa8..1d5e2cc7 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -101,6 +101,7 @@ var DefaultSetConfig = SetConfig{
Table: 0,
SourceInterfaces: []string{},
IPTTLSeconds: 3600,
+ BlockAction: BlockActionReject,
},
Fragmentation: FragmentationConfig{
diff --git a/src/config/migration.go b/src/config/migration.go
index 345a33ec..8969fb38 100644
--- a/src/config/migration.go
+++ b/src/config/migration.go
@@ -63,6 +63,17 @@ var migrationRegistry = map[int]MigrationFunc{
39: migrateV39to40, // Add geo auto_update config
40: migrateV40to41, // Add MTProto CF-proxy fallback config
41: migrateV41to42, // Add MTProto CF Worker domain config
+ 42: migrateV42to43, // Add per-set routing block action
+}
+
+func migrateV42to43(c *Config, _ map[string]interface{}) error {
+ log.Tracef("Migration v42->v43: Adding per-set routing block action")
+ for _, set := range c.Sets {
+ if set.Routing.BlockAction == "" {
+ set.Routing.BlockAction = DefaultSetConfig.Routing.BlockAction
+ }
+ }
+ return nil
}
func migrateV41to42(c *Config, _ map[string]interface{}) error {
diff --git a/src/config/types.go b/src/config/types.go
index 65150237..b8ca8a01 100644
--- a/src/config/types.go
+++ b/src/config/types.go
@@ -18,12 +18,31 @@ const (
RoutingModeInterface = "interface"
RoutingModeProxy = "proxy"
RoutingModeMTProtoWS = "mtproto-ws"
+ RoutingModeBlock = "block"
+)
+
+const (
+ BlockActionDrop = "drop"
+ BlockActionReject = "reject"
)
func RoutingUsesTProxy(mode string) bool {
return mode == RoutingModeProxy || mode == RoutingModeMTProtoWS
}
+func RoutingIsBlock(mode string) bool {
+ return mode == RoutingModeBlock
+}
+
+func NormalizeBlockAction(action string) string {
+ if action == BlockActionDrop {
+ return BlockActionDrop
+ }
+ // Default (and any legacy value such as "reject-tcp-reset") -> fast reject:
+ // TCP RST, ICMP unreachable for UDP/QUIC.
+ return BlockActionReject
+}
+
const (
FakePayloadRandom = iota
FakePayloadCustom
@@ -388,6 +407,7 @@ type RoutingConfig struct {
Table int `json:"table"`
SourceInterfaces []string `json:"source_interfaces"`
IPTTLSeconds int `json:"ip_ttl_seconds"`
+ BlockAction string `json:"block_action"`
}
type UpstreamProxyConfig struct {
diff --git a/src/config/validation.go b/src/config/validation.go
index 69c7b68e..4cf54b8f 100644
--- a/src/config/validation.go
+++ b/src/config/validation.go
@@ -121,6 +121,8 @@ func (c *Config) Validate() error {
case "":
set.Routing.Mode = RoutingModeInterface
case RoutingModeProxy, RoutingModeInterface, RoutingModeMTProtoWS:
+ case RoutingModeBlock:
+ set.Routing.BlockAction = NormalizeBlockAction(set.Routing.BlockAction)
default:
v.addf(fmt.Sprintf("sets[%d].routing.mode", setIdx), "invalid_routing_mode", map[string]any{"set": set.Name, "mode": set.Routing.Mode}, "set %q: unknown routing mode %q", set.Name, set.Routing.Mode)
return v.result()
diff --git a/src/dns/dns.go b/src/dns/dns.go
index 2e09bf53..afcb52a5 100644
--- a/src/dns/dns.go
+++ b/src/dns/dns.go
@@ -42,6 +42,37 @@ func ParseTransactionID(payload []byte) (uint16, bool) {
return binary.BigEndian.Uint16(payload[:2]), true
}
+// BuildBlockResponse builds an NXDOMAIN DNS response for the given query,
+// echoing the original question. This sinkholes the domain: the client gets
+// "no such host" and never obtains an IP, which blocks the domain regardless
+// of TLS SNI (defeating HTTP/2 connection coalescing and Encrypted ClientHello).
+// Returns nil if the query is too short or malformed to parse.
+func BuildBlockResponse(query []byte) []byte {
+ if len(query) < 12 {
+ return nil
+ }
+ qend, ok := skipDNSName(query, 12)
+ if !ok || qend+4 > len(query) {
+ return nil
+ }
+ questionEnd := qend + 4 // QTYPE + QCLASS
+
+ resp := make([]byte, questionEnd)
+ copy(resp, query[:questionEnd])
+
+ // Flags: QR=1, keep Opcode+RD, clear AA/TC, RA=1, RCODE=3 (NXDOMAIN).
+ resp[2] = 0x80 | (query[2] & 0x79)
+ resp[3] = 0x83
+
+ // QDCOUNT=1, ANCOUNT=0, NSCOUNT=0, ARCOUNT=0.
+ binary.BigEndian.PutUint16(resp[4:6], 1)
+ binary.BigEndian.PutUint16(resp[6:8], 0)
+ binary.BigEndian.PutUint16(resp[8:10], 0)
+ binary.BigEndian.PutUint16(resp[10:12], 0)
+
+ return resp
+}
+
func ParseResponseIPs(payload []byte) []net.IP {
if len(payload) < 12 {
return nil
diff --git a/src/dns/dns_test.go b/src/dns/dns_test.go
index 83b0f812..af8bdd03 100644
--- a/src/dns/dns_test.go
+++ b/src/dns/dns_test.go
@@ -31,6 +31,56 @@ func buildDNSResponse(qdCount, anCount uint16, body []byte) []byte {
return msg
}
+func buildDNSQuery(txid uint16, name string, qtype uint16) []byte {
+ msg := make([]byte, 12)
+ binary.BigEndian.PutUint16(msg[0:2], txid)
+ binary.BigEndian.PutUint16(msg[2:4], 0x0100) // RD set, standard query
+ binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT
+ msg = append(msg, encodeDNSName(name)...)
+ q := make([]byte, 4)
+ binary.BigEndian.PutUint16(q[0:2], qtype)
+ binary.BigEndian.PutUint16(q[2:4], 1) // IN
+ return append(msg, q...)
+}
+
+func TestBuildBlockResponse(t *testing.T) {
+ t.Run("nxdomain echoes question and sets response flags", func(t *testing.T) {
+ query := buildDNSQuery(0xABCD, "ads.example.com", 1)
+ resp := BuildBlockResponse(query)
+ if resp == nil {
+ t.Fatal("expected non-nil response")
+ }
+ if binary.BigEndian.Uint16(resp[0:2]) != 0xABCD {
+ t.Errorf("txid not preserved: got 0x%x", binary.BigEndian.Uint16(resp[0:2]))
+ }
+ if resp[2]&0x80 == 0 {
+ t.Error("QR bit not set (should be a response)")
+ }
+ if resp[2]&0x01 == 0 {
+ t.Error("RD bit not preserved from query")
+ }
+ if resp[3]&0x0f != 3 {
+ t.Errorf("RCODE should be 3 (NXDOMAIN), got %d", resp[3]&0x0f)
+ }
+ if binary.BigEndian.Uint16(resp[4:6]) != 1 {
+ t.Error("QDCOUNT should be 1")
+ }
+ if binary.BigEndian.Uint16(resp[6:8]) != 0 {
+ t.Error("ANCOUNT should be 0")
+ }
+ domain, ok := ParseQueryDomain(resp)
+ if !ok || domain != "ads.example.com" {
+ t.Errorf("question not echoed: got %q ok=%v", domain, ok)
+ }
+ })
+
+ t.Run("short payload returns nil", func(t *testing.T) {
+ if BuildBlockResponse([]byte{0x00, 0x01}) != nil {
+ t.Error("expected nil for short payload")
+ }
+ })
+}
+
func TestParseTransactionID(t *testing.T) {
t.Run("ok", func(t *testing.T) {
got, ok := ParseTransactionID([]byte{0x12, 0x34, 0x00})
diff --git a/src/http/ui/src/components/sets/Manager.tsx b/src/http/ui/src/components/sets/Manager.tsx
index 7af8ef1a..a79aa727 100644
--- a/src/http/ui/src/components/sets/Manager.tsx
+++ b/src/http/ui/src/components/sets/Manager.tsx
@@ -108,6 +108,7 @@ const SortableCardWrapper = ({
= {
none: "NONE",
};
+type TFn = (key: string) => string;
+
+interface FacetChip {
+ key: string;
+ label: string;
+ icon: React.ReactElement;
+ tooltip: string;
+ variant?: "filled" | "outlined";
+ color?: "default" | "primary" | "secondary" | "info" | "error";
+}
+
+const buildScopeChips = (set: B4SetConfig, t: TFn): FacetChip[] => {
+ const chips: FacetChip[] = [];
+ if (set.targets.tls) {
+ chips.push({
+ key: "tls",
+ label: `TLS ${set.targets.tls}`,
+ icon: ,
+ tooltip: t("sets.card.tlsFilter"),
+ });
+ }
+ if (set.tcp?.dport_filter) {
+ chips.push({
+ key: "tcp-ports",
+ label: `TCP ${set.tcp.dport_filter}`,
+ icon: ,
+ tooltip: t("sets.card.portFilter"),
+ });
+ }
+ if (set.udp?.dport_filter) {
+ chips.push({
+ key: "udp-ports",
+ label: `UDP ${set.udp.dport_filter}`,
+ icon: ,
+ tooltip: t("sets.card.portFilter"),
+ });
+ }
+ const deviceCount = set.targets.source_devices?.length ?? 0;
+ if (deviceCount > 0) {
+ chips.push({
+ key: "devices",
+ label: String(deviceCount),
+ icon: ,
+ tooltip: t("sets.card.sourceDevices"),
+ });
+ }
+ if (set.mss_clamp?.enabled) {
+ chips.push({
+ key: "mss",
+ label: `MSS ${set.mss_clamp.size}`,
+ icon: ,
+ tooltip: t("sets.card.mssClamp"),
+ });
+ }
+ return chips;
+};
+
+const resolveRoutingMode = (mode: RoutingMode | undefined): RoutingMode => {
+ if (mode === "proxy") return "proxy";
+ if (mode === "mtproto-ws") return "mtproto-ws";
+ if (mode === "block") return "block";
+ return "interface";
+};
+
+const buildRouteChips = (set: B4SetConfig, t: TFn): FacetChip[] => {
+ const chips: FacetChip[] = [];
+ if (set.dns?.enabled) {
+ chips.push({
+ key: "dns",
+ label: set.dns.target_dns ? `DNS → ${set.dns.target_dns}` : "DNS",
+ icon: ,
+ tooltip: t("sets.card.dnsRedirect"),
+ variant: "outlined",
+ color: "secondary",
+ });
+ }
+ const routing = set.routing;
+ if (!routing?.enabled) return chips;
+
+ const mode = resolveRoutingMode(routing.mode);
+ if (mode === "block") {
+ chips.push({
+ key: "route",
+ label: "BLOCK",
+ icon: ,
+ tooltip: `${t("sets.card.routeBlock")}: ${routing.block_action || "reject"}`,
+ variant: "filled",
+ color: "error",
+ });
+ } else if (mode === "proxy") {
+ const up = routing.upstream;
+ chips.push({
+ key: "route",
+ label: up?.host && up?.port ? `→ ${up.host}:${up.port}` : "→ PROXY",
+ icon: ,
+ tooltip: t("sets.card.routeProxy"),
+ variant: "outlined",
+ color: "info",
+ });
+ } else if (mode === "mtproto-ws") {
+ chips.push({
+ key: "route",
+ label: "MTPROTO-WS",
+ icon: ,
+ tooltip: t("sets.card.routeMtproto"),
+ variant: "outlined",
+ color: "info",
+ });
+ } else {
+ chips.push({
+ key: "route",
+ label: `→ ${routing.egress_interface || "?"}`,
+ icon: ,
+ tooltip: t("sets.card.routeInterface"),
+ variant: "outlined",
+ color: "info",
+ });
+ }
+ return chips;
+};
+
export const SetCard = ({
set,
stats,
@@ -145,6 +271,9 @@ export const SetCard = ({
const domainCount = stats?.total_domains ?? set.targets.sni_domains.length;
const ipCount = stats?.total_ips ?? set.targets.ip.length;
+ const scopeChips = buildScopeChips(set, t);
+ const routeChips = buildRouteChips(set, t);
+
const handleMenuOpen = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget);
@@ -162,6 +291,10 @@ export const SetCard = ({
elevation={1}
sx={{
position: "relative",
+ overflow: "visible",
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
opacity: set.enabled ? 1 : 0.5,
transition: "all 0.2s ease",
border: `1px solid ${borderColor}`,
@@ -186,6 +319,34 @@ export const SetCard = ({
}}
/>
+
+
+ {index + 1}
+
+
+
{/* Header row */}
e.stopPropagation()}
/>
-
{!selectionMode && (
@@ -286,33 +446,79 @@ export const SetCard = ({
{/* Clickable content area */}
-
-
+
+
{/* Name */}
-
- {set.name}
-
+
+ {set.name}
+
+ {routeChips.length > 0 && (
+
+ {routeChips.map((chip) => (
+
+
+
+ ))}
+
+ )}
+
{/* Target preview */}
{set.targets.geosite_categories.length > 0 ||
@@ -393,31 +599,8 @@ export const SetCard = ({
)}
- {/* Active techniques */}
-
-
- {set.faking.sni && (
-
- )}
- {set.dns?.enabled && (
-
- )}
- {set.fragmentation.reverse_order && (
-
- )}
-
-
{/* Domain/IP counts */}
-
+
- {/* Quick flags */}
-
+ }
+ sx={{ borderTop: `1px solid ${colors.border.light}`, mt: 0.5 }}
>
- }
- label={`${set.tcp.conn_bytes_limit}B`}
- tooltip={t("sets.card.tcpBytesLimit")}
- />
- }
- enabled={set.faking.sni}
- tooltip={set.faking.sni ? t("sets.card.sniFakingOn") : t("sets.card.sniFakingOff")}
- />
- }
- enabled={set.dns?.enabled}
- tooltip={
- set.dns?.enabled ? `DNS → ${set.dns.target_dns}` : t("sets.card.dnsOff")
- }
- />
-
+
+
+ {set.faking.sni && (
+
+ }
+ label="FAKE"
+ size="small"
+ color="primary"
+ />
+
+ )}
+ {set.fragmentation.reverse_order && (
+
+ )}
+
+
+ {scopeChips.length > 0 && (
+
+ {scopeChips.map((chip) => (
+
+
+
+ ))}
+
+ )}
+
-
+ {(escalatesTo || (escalatedFrom && escalatedFrom.length > 0)) && (
{escalatesTo && (
@@ -542,81 +731,44 @@ export const SetCard = ({
/>
))}
-
-
-
- {index + 1}
-
-
-
+ )}
);
};
-interface QuickFlagProps {
- icon: React.ReactNode;
- label?: string;
- enabled?: boolean;
- tooltip: string;
+interface FacetRowProps {
+ label: string;
+ children: React.ReactNode;
}
-const QuickFlag = ({ icon, label, enabled, tooltip }: QuickFlagProps) => {
- const isBoolean = enabled !== undefined;
- const isActive = isBoolean ? enabled : true;
- const color = isActive ? colors.secondary : colors.text.disabled;
-
- return (
-
-
-
- {icon}
-
- {label ? (
-
- {label}
-
- ) : (
-
- {enabled ? : }
-
- )}
-
-
- );
-};
+const FacetRow = ({ label, children }: FacetRowProps) => (
+
+
+ {label}
+
+
+ {children}
+
+
+);
interface EscalationChipProps {
icon: React.ReactNode;
diff --git a/src/http/ui/src/components/sets/routing/TrafficRouting.tsx b/src/http/ui/src/components/sets/routing/TrafficRouting.tsx
index 33a3ead3..44ea6fa1 100644
--- a/src/http/ui/src/components/sets/routing/TrafficRouting.tsx
+++ b/src/http/ui/src/components/sets/routing/TrafficRouting.tsx
@@ -33,10 +33,14 @@ export const TrafficRouting = ({
? "proxy"
: routing.mode === "mtproto-ws"
? "mtproto-ws"
- : "interface";
+ : routing.mode === "block"
+ ? "block"
+ : "interface";
const isProxy = mode === "proxy";
const isMTProtoWS = mode === "mtproto-ws";
+ const isBlock = mode === "block";
const isInterface = mode === "interface";
+ const blockAction = routing.block_action || "reject";
const selectedIfaceAvailable = availableIfaces.includes(
routing.egress_interface,
@@ -70,6 +74,8 @@ export const TrafficRouting = ({
: t("sets.routing.flowNoUpstream");
} else if (isMTProtoWS) {
flowDestination = t("sets.routing.flowMTProtoWS");
+ } else if (isBlock) {
+ flowDestination = t("sets.routing.flowBlocked");
} else {
flowDestination =
routing.egress_interface || t("sets.routing.flowNoOutput");
@@ -106,12 +112,32 @@ export const TrafficRouting = ({
+
{isMTProtoWS && (
{t("sets.routing.mtprotoWsNote")}
)}
+ {isBlock && (
+
+ onChange("routing.block_action", e.target.value)
+ }
+ helperText={t("sets.routing.blockActionHelper")}
+ sx={{ mt: 2 }}
+ >
+
+
+
+ )}
@@ -212,7 +238,9 @@ export const TrafficRouting = ({
? t("sets.routing.flowProxyCaption")
: isMTProtoWS
? t("sets.routing.flowMTProtoWSCaption")
- : t("sets.routing.flowCaption")}
+ : isBlock
+ ? t("sets.routing.flowBlockCaption")
+ : t("sets.routing.flowCaption")}
@@ -222,7 +250,9 @@ export const TrafficRouting = ({
? t("sets.routing.howItWorksProxy")
: isMTProtoWS
? t("sets.routing.howItWorksMTProtoWS")
- : t("sets.routing.howItWorks")}
+ : isBlock
+ ? t("sets.routing.howItWorksBlock")
+ : t("sets.routing.howItWorks")}
@@ -280,7 +310,9 @@ export const TrafficRouting = ({
? t("sets.routing.infoProxy")
: isMTProtoWS
? t("sets.routing.infoMTProtoWS")
- : t("sets.routing.info")}
+ : isBlock
+ ? t("sets.routing.infoBlock")
+ : t("sets.routing.info")}
diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json
index 70b03f9e..0051e923 100644
--- a/src/http/ui/src/i18n/en.json
+++ b/src/http/ui/src/i18n/en.json
@@ -687,10 +687,18 @@
"manual": "manual",
"geosite": "geosite",
"geoip": "geoip",
- "tcpBytesLimit": "TCP bytes limit",
"sniFakingOn": "SNI Faking ON",
- "sniFakingOff": "SNI Faking OFF",
- "dnsOff": "DNS OFF",
+ "facetMethod": "Method",
+ "facetScope": "Scope",
+ "tlsFilter": "TLS version filter",
+ "portFilter": "Destination port filter",
+ "sourceDevices": "Source devices",
+ "mssClamp": "MSS clamp",
+ "dnsRedirect": "DNS redirect",
+ "routeBlock": "Blocks matching traffic",
+ "routeProxy": "Routes via upstream proxy",
+ "routeMtproto": "Routes via MTProto WebSocket bridge",
+ "routeInterface": "Routes via egress interface",
"escalatesTo": "escalates to",
"escalatedFrom": "fallback for"
},
@@ -1218,6 +1226,15 @@
"howItWorksMTProtoWS": "When a device connects to a Telegram datacenter, B4 transparently intercepts it and relays the session over Telegram's WebSocket edge (with Cloudflare fallback), so Telegram works without configuring a proxy inside the app. Pair this with the 'telegram' geoip target. Uses the shared 'Telegram upstream' settings (Settings -> MTProto), which apply even when the proxy server is off. Best-effort: only TCP MTProto is bridged (voice calls and unknown transports fall through directly).",
"infoMTProtoWS": "Routing intercepts matched Telegram traffic from the source interfaces above and bridges it over the WebSocket edge. Connections B4 cannot map to a datacenter fall open to a direct connection.",
"mtprotoWsNote": "This mode runs on its own - the MTProto proxy under Settings -> MTProto does not need to be enabled. Matched Telegram traffic is relayed over B4's built-in WebSocket endpoint, with a Cloudflare fallback.",
+ "modeBlock": "Block (blackhole)",
+ "blockActionLabel": "Block action",
+ "blockActionHelper": "How matched traffic is blocked. Reject fails the connection immediately (TCP reset, or ICMP unreachable for UDP/QUIC); Drop is silent.",
+ "blockActionDrop": "Drop (silent blackhole)",
+ "blockActionReject": "Reject (fail fast)",
+ "flowBlocked": "Blocked",
+ "flowBlockCaption": "Matched destinations are dropped or rejected by B4 - traffic never leaves the router.",
+ "howItWorksBlock": "When a device on your network connects to a matched domain or IP, B4 blocks it outright instead of routing it anywhere. Add ad/tracker domains, IPs, or geo categories to this set's targets. 'Reject' (the default) fails the connection instantly (TCP reset, or ICMP unreachable for UDP/QUIC); 'Drop' silently blackholes it so the connection just times out. Works for both LAN devices (forward) and the router itself (output).",
+ "infoBlock": "Routing blocks this set's matched domains and IPs. No output interface or upstream is needed.",
"outputInterface": "Output Interface",
"outputInterfaceHelper": "Select egress interface for this set",
"interfaceUnavailable": "Selected interface is currently unavailable",
diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json
index 13180ba1..b1f9e98f 100644
--- a/src/http/ui/src/i18n/ru.json
+++ b/src/http/ui/src/i18n/ru.json
@@ -684,10 +684,18 @@
"manual": "вручную",
"geosite": "geosite",
"geoip": "geoip",
- "tcpBytesLimit": "TCP лимит байтов",
"sniFakingOn": "SNI Faking ВКЛ",
- "sniFakingOff": "SNI Faking ВЫКЛ",
- "dnsOff": "DNS ВЫКЛ",
+ "facetMethod": "Метод",
+ "facetScope": "Область",
+ "tlsFilter": "Фильтр по версии TLS",
+ "portFilter": "Фильтр по порту назначения",
+ "sourceDevices": "Устройства-источники",
+ "mssClamp": "MSS clamp",
+ "dnsRedirect": "Перенаправление DNS",
+ "routeBlock": "Блокирует подходящий трафик",
+ "routeProxy": "Через вышестоящий прокси",
+ "routeMtproto": "Через мост MTProto WebSocket",
+ "routeInterface": "Через исходящий интерфейс",
"escalatesTo": "переключается на",
"escalatedFrom": "запасной для"
},
@@ -1215,6 +1223,15 @@
"howItWorksMTProtoWS": "Когда устройство подключается к дата-центру Telegram, B4 прозрачно перехватывает соединение и ретранслирует сессию через WebSocket-узел Telegram (с запасным вариантом через Cloudflare), поэтому Telegram работает без настройки прокси в приложении. Используйте вместе с geoip-целью «telegram». Используются общие настройки «Транспорт к Telegram» (Настройки -> MTProto), которые действуют даже когда прокси-сервер выключен. Best-effort: переправляется только TCP MTProto (голосовые звонки и неизвестные транспорты идут напрямую).",
"infoMTProtoWS": "Маршрутизация перехватывает совпавший трафик Telegram с указанных выше source-интерфейсов и переправляет его через WebSocket-узел. Соединения, для которых B4 не может определить дата-центр, идут напрямую (fail-open).",
"mtprotoWsNote": "Этот режим работает самостоятельно - MTProto-прокси в разделе Настройки -> MTProto включать не требуется. Совпавший трафик Telegram переправляется через встроенный WebSocket-узел B4, с запасным вариантом через Cloudflare.",
+ "modeBlock": "Блокировка (чёрная дыра)",
+ "blockActionLabel": "Действие блокировки",
+ "blockActionHelper": "Как блокируется совпавший трафик. Reject сразу завершает соединение ошибкой (TCP reset, или ICMP unreachable для UDP/QUIC); Drop - молча.",
+ "blockActionDrop": "Отбросить (молча)",
+ "blockActionReject": "Отклонить (быстрый отказ)",
+ "flowBlocked": "Заблокировано",
+ "flowBlockCaption": "Совпавшие назначения отбрасываются или отклоняются B4 - трафик не покидает роутер.",
+ "howItWorksBlock": "Когда устройство в вашей сети подключается к совпавшему домену или IP, B4 блокирует соединение, а не маршрутизирует его. Добавьте домены рекламы/трекеров, IP или гео-категории в цели этого набора. 'Отклонить' (по умолчанию) сразу завершает соединение ошибкой (TCP reset, или ICMP unreachable для UDP/QUIC); 'Отбросить' молча поглощает его, и соединение завершается по таймауту. Работает как для устройств LAN (forward), так и для самого роутера (output).",
+ "infoBlock": "Маршрутизация блокирует совпавшие домены и IP этого набора. Выходной интерфейс или upstream не требуются.",
"outputInterface": "Выходной интерфейс",
"outputInterfaceHelper": "Выберите выходной интерфейс для этого сета",
"interfaceUnavailable": "Выбранный интерфейс сейчас недоступен",
diff --git a/src/http/ui/src/models/config.ts b/src/http/ui/src/models/config.ts
index 14359eb4..335f006b 100644
--- a/src/http/ui/src/models/config.ts
+++ b/src/http/ui/src/models/config.ts
@@ -380,7 +380,9 @@ export interface DNSConfig {
fragment_query: boolean;
}
-export type RoutingMode = "interface" | "proxy" | "mtproto-ws";
+export type RoutingMode = "interface" | "proxy" | "mtproto-ws" | "block";
+
+export type BlockAction = "drop" | "reject";
export interface UpstreamProxyConfig {
host: string;
@@ -400,6 +402,7 @@ export interface RoutingConfig {
table: number;
source_interfaces: string[];
ip_ttl_seconds: number;
+ block_action: BlockAction;
}
export interface DuplicateConfig {
diff --git a/src/main.go b/src/main.go
index 768acc49..41b08fd4 100644
--- a/src/main.go
+++ b/src/main.go
@@ -175,6 +175,7 @@ func runB4(cmd *cobra.Command, args []string) error {
})
handler.SetDiscoveryRuntime(discoveryRT)
nfq.RoutingHandleDNSFunc = tables.RoutingHandleDNS
+ nfq.RoutingLearnIPFunc = tables.RoutingLearnIP
if err := initLogging(&cfg); err != nil {
return fmt.Errorf("logging initialization failed: %w", err)
diff --git a/src/nfq/dns.go b/src/nfq/dns.go
index e10c35ba..49c992b2 100644
--- a/src/nfq/dns.go
+++ b/src/nfq/dns.go
@@ -54,7 +54,7 @@ func parseDNSName(msg []byte, offset int) (string, bool) {
return strings.Join(labels, "."), true
}
-func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, payload []byte, raw []byte, ihl int, id uint32, srcMac string) int {
+func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, payload []byte, raw []byte, id uint32, srcMac string) int {
if dport == 53 {
domain, ok := dns.ParseQueryDomain(payload)
@@ -64,6 +64,42 @@ func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, pa
matcher := w.getMatcher()
if matchedSet, set := matcher.MatchSNIWithSource(domain, srcMac); matchedSet {
cfg := w.getConfig()
+
+ if set.Routing.Enabled && config.RoutingIsBlock(set.Routing.Mode) && !cfg.Queue.IsDiscovery {
+ if config.NormalizeBlockAction(set.Routing.BlockAction) == config.BlockActionDrop {
+ if err := w.q.SetVerdict(id, nfqueue.NfDrop); err != nil {
+ log.Tracef("failed to set drop verdict on packet %d: %v", id, err)
+ }
+ return 0
+ }
+ ipv6Disabled := ipVersion == IPv6 && !cfg.Queue.IPv6Enabled
+ if !ipv6Disabled {
+ if resp := dns.BuildBlockResponse(payload); resp != nil {
+ var clientIP, originalDst net.IP
+ switch ipVersion {
+ case IPv4:
+ clientIP = append(net.IP(nil), raw[12:16]...)
+ originalDst = append(net.IP(nil), raw[16:20]...)
+ default:
+ clientIP = append(net.IP(nil), raw[8:24]...)
+ originalDst = append(net.IP(nil), raw[24:40]...)
+ }
+ if ipVersion == IPv4 {
+ if pkt := sock.BuildUDPPacketV4(originalDst, clientIP, 53, sport, resp); pkt != nil {
+ _ = w.sock.SendIPv4(pkt, clientIP)
+ }
+ } else if pkt := sock.BuildUDPPacketV6(originalDst, clientIP, 53, sport, resp); pkt != nil {
+ _ = w.sock.SendIPv6(pkt, clientIP)
+ }
+ log.Tracef("DNS sinkhole: %s -> NXDOMAIN for %s (set: %s)", domain, clientIP, set.Name)
+ if err := w.q.SetVerdict(id, nfqueue.NfDrop); err != nil {
+ log.Tracef("failed to set drop verdict on packet %d: %v", id, err)
+ }
+ return 0
+ }
+ }
+ }
+
if txidOK && set.Routing.Enabled && !cfg.Queue.IsDiscovery {
var clientIP, dnsServerIP net.IP
switch ipVersion {
diff --git a/src/nfq/dns_routing.go b/src/nfq/dns_routing.go
index 84bd7b8c..02441f18 100644
--- a/src/nfq/dns_routing.go
+++ b/src/nfq/dns_routing.go
@@ -14,6 +14,10 @@ import (
// Set from main.go to break the tables ↔ nfq import cycle.
var RoutingHandleDNSFunc func(cfg *config.Config, set *config.SetConfig, ips []net.IP)
+// RoutingLearnIPFunc adds a destination IP observed at SNI-match time to an existing
+// routing set's ipset (cheap, hot-path safe). Set from main.go.
+var RoutingLearnIPFunc func(cfg *config.Config, set *config.SetConfig, ip net.IP)
+
func registerEscalatedRoute(cfg *config.Config, escSet *config.SetConfig, dst net.IP) {
if cfg == nil || escSet == nil || dst == nil || !escSet.Routing.Enabled || RoutingHandleDNSFunc == nil {
return
@@ -25,6 +29,19 @@ func registerEscalatedRoute(cfg *config.Config, escSet *config.SetConfig, dst ne
RoutingHandleDNSFunc(cfg, escSet, []net.IP{dst})
}
+// registerLearnedRoute steers future connections to dst by adding it to the matched
+// routing set's ipset, using the IP seen at the TLS/QUIC ClientHello. No-op for
+// non-routing and block-mode sets (filtered inside RoutingLearnIPFunc).
+func registerLearnedRoute(cfg *config.Config, set *config.SetConfig, dst net.IP) {
+ if cfg == nil || set == nil || dst == nil || !set.Routing.Enabled || RoutingLearnIPFunc == nil {
+ return
+ }
+ if cfg.Queue.IsDiscovery {
+ return
+ }
+ RoutingLearnIPFunc(cfg, set, dst)
+}
+
type pendingDNSRoute struct {
setID string
expires time.Time
diff --git a/src/nfq/handler.go b/src/nfq/handler.go
index e23bcdff..4cc856e7 100644
--- a/src/nfq/handler.go
+++ b/src/nfq/handler.go
@@ -342,6 +342,7 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
matched = true
set = stSNI
matcher.LearnIPToDomain(pkt.dst, host, stSNI)
+ registerLearnedRoute(cfg, stSNI, pkt.dst)
}
}
}
@@ -432,6 +433,26 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
m.RecordPacket(uint64(len(pkt.raw)))
}
+ if matched && set != nil && set.Routing.Enabled && config.RoutingIsBlock(set.Routing.Mode) {
+ if matchedSNI || (matchedIP && !matchedLearned) {
+ if config.NormalizeBlockAction(set.Routing.BlockAction) != config.BlockActionDrop {
+ if pkt.ver == IPv4 {
+ w.sendRSTToClientV4(pkt.raw, pkt.ihl, pkt.src, pkt.dst)
+ } else {
+ w.sendRSTToClientV6(pkt.raw, pkt.src, pkt.dst)
+ }
+ }
+ if !cfg.Queue.IsDiscovery {
+ log.LogConnection("TCP", sniTarget, host, pkt.srcStr, sport, ipTarget, pkt.dstStr, dport, pkt.srcMac, config.TLSVersionString(tlsVersion), "block")
+ }
+ if err := q.SetVerdict(id, nfqueue.NfDrop); err != nil {
+ log.Tracef("failed to set drop verdict on packet %d: %v", id, err)
+ }
+ return 0
+ }
+ return accept(q, id)
+ }
+
if matched && set != nil && set.Routing.Enabled && config.RoutingUsesTProxy(set.Routing.Mode) {
return accept(q, id)
}
@@ -555,7 +576,7 @@ func (w *Worker) handleUDPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
connKey := fmt.Sprintf(connKeyFormat, pkt.srcStr, sport, pkt.dstStr, dport)
if sport == 53 || dport == 53 {
- return w.processDnsPacket(pkt.ver, sport, dport, payload, pkt.raw, pkt.ihl, id, pkt.srcMac)
+ return w.processDnsPacket(pkt.ver, sport, dport, payload, pkt.raw, id, pkt.srcMac)
}
if utils.IsPrivateIP(pkt.dst) {
@@ -564,6 +585,7 @@ func (w *Worker) handleUDPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
matchedIP := st != nil
matchedQUIC := false
+ matchedLearned := false
isSTUN := false
host := ""
ipTarget := ""
@@ -584,6 +606,7 @@ func (w *Worker) handleUDPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
if mLearned, learnedSet, learnedDomain := matcher.MatchLearnedIPWithSource(pkt.dst, pkt.srcMac); mLearned {
if learnedSet.MatchesUDPDPort(dport) {
matchedIP = true
+ matchedLearned = true
matched = true
set = learnedSet
host = learnedDomain
@@ -622,6 +645,7 @@ func (w *Worker) handleUDPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
set = sniSet
sniTarget = sniSet.Name
matcher.LearnIPToDomain(pkt.dst, host, sniSet)
+ registerLearnedRoute(cfg, sniSet, pkt.dst)
}
}
}
@@ -685,6 +709,31 @@ func (w *Worker) handleUDPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
m.RecordConnection("UDP", host, pkt.srcStr, pkt.dstStr, matched, pkt.srcMac, setName, udpTLS)
m.RecordPacket(uint64(len(pkt.raw)))
+ if set.Routing.Enabled && config.RoutingIsBlock(set.Routing.Mode) {
+ if matchedQUIC || (matchedIP && !matchedLearned) {
+ // UDP has no RST; both reject actions map to an ICMP unreachable.
+ if config.NormalizeBlockAction(set.Routing.BlockAction) != config.BlockActionDrop {
+ if pkt.ver == IPv4 {
+ if icmp := sock.BuildICMPv4Reject(pkt.raw, pkt.src.To4(), pkt.dst.To4()); icmp != nil {
+ _ = w.sock.SendIPv4(icmp, pkt.src)
+ }
+ } else {
+ if icmp := sock.BuildICMPv6Reject(pkt.raw, pkt.src.To16(), pkt.dst.To16()); icmp != nil {
+ _ = w.sock.SendIPv6(icmp, pkt.src)
+ }
+ }
+ }
+ if !cfg.Queue.IsDiscovery {
+ log.LogConnection("UDP", sniTarget, host, pkt.srcStr, sport, ipTarget, pkt.dstStr, dport, pkt.srcMac, udpTLS, "block")
+ }
+ if err := q.SetVerdict(id, nfqueue.NfDrop); err != nil {
+ log.Tracef("failed to set drop verdict on packet %d: %v", id, err)
+ }
+ return 0
+ }
+ return accept(q, id)
+ }
+
switch set.UDP.Mode {
case "drop":
if err := q.SetVerdict(id, nfqueue.NfDrop); err != nil {
diff --git a/src/tables/monitor.go b/src/tables/monitor.go
index ee3002f9..268f5f38 100644
--- a/src/tables/monitor.go
+++ b/src/tables/monitor.go
@@ -119,11 +119,18 @@ func (m *Monitor) monitorLoop() {
log.Infof("Tables rules restored successfully")
}
m.snapshotRoutingIfaces(cfg)
- } else if m.routingIfacesChanged(cfg) {
+ }
+
+ if m.routingIfacesChanged(cfg) {
log.Warnf("Routing interface change detected, resyncing routing rules...")
RoutingSyncConfig(cfg)
m.snapshotRoutingIfaces(cfg)
log.Tracef("Routing rules resynced after interface change")
+ } else if !RoutingRulesPresent(cfg) {
+ log.Warnf("Routing rules missing, restoring...")
+ RoutingForceResync(cfg)
+ m.snapshotRoutingIfaces(cfg)
+ log.Infof("Routing rules restored successfully")
} else {
RoutingPeriodicReResolve(cfg)
}
diff --git a/src/tables/routing.go b/src/tables/routing.go
index 0f23101d..a392ec53 100644
--- a/src/tables/routing.go
+++ b/src/tables/routing.go
@@ -24,6 +24,7 @@ type routeState struct {
tproxyPort int
upstreamKey string
sourcesKey string
+ blockAction string
setV4 string
setV6 string
chainPre string
@@ -56,6 +57,7 @@ var (
routeIfaceAuto = make(map[string]routeState)
routeEngine routeBackend
routeLastReResolve = make(map[string]time.Time)
+ routeLearnLast = make(map[string]time.Time)
)
func getRouteBackend(cfg *config.Config) routeBackend {
@@ -125,7 +127,9 @@ func RoutingHandleDNS(cfg *config.Config, set *config.SetConfig, ips []net.IP) {
if _, ok := routeRuleCache[set.Id]; !ok {
var err error
- if config.RoutingUsesTProxy(cur.mode) {
+ if config.RoutingIsBlock(cur.mode) {
+ err = routeEnsureBlockRule(be, cfg, set, cur, sources)
+ } else if config.RoutingUsesTProxy(cur.mode) {
err = routeEnsureProxyRule(be, cfg, set, cur, sources)
} else {
err = routeEnsureRule(be, cfg, set, cur, sources)
@@ -140,6 +144,8 @@ func RoutingHandleDNS(cfg *config.Config, set *config.SetConfig, ips []net.IP) {
log.Infof("Routing [%s]: enabled MTProto-WS set '%s' mark=0x%x port=%d", be.name(), set.Name, cur.mark, cur.tproxyPort)
case config.RoutingModeProxy:
log.Infof("Routing [%s]: enabled proxy set '%s' -> %s:%d mark=0x%x port=%d", be.name(), set.Name, set.Routing.Upstream.Host, set.Routing.Upstream.Port, cur.mark, cur.tproxyPort)
+ case config.RoutingModeBlock:
+ log.Infof("Routing [%s]: enabled block set '%s' action=%s", be.name(), set.Name, cur.blockAction)
default:
log.Infof("Routing [%s]: enabled set '%s' -> iface=%s mark=0x%x table=%d", be.name(), set.Name, set.Routing.EgressInterface, cur.mark, cur.table)
}
@@ -153,6 +159,51 @@ func RoutingHandleDNS(cfg *config.Config, set *config.SetConfig, ips []net.IP) {
routeAddIPsToSets(be, cur, ttl, ips, cfg.Queue.IPv4Enabled, cfg.Queue.IPv6Enabled)
}
+func RoutingLearnIP(cfg *config.Config, set *config.SetConfig, ip net.IP) {
+ if cfg == nil || set == nil || ip == nil || !set.Routing.Enabled {
+ return
+ }
+ if config.RoutingIsBlock(set.Routing.Mode) {
+ return
+ }
+
+ routeMu.Lock()
+ defer routeMu.Unlock()
+
+ st, ok := routeRuleCache[set.Id]
+ if !ok {
+ return
+ }
+ be := routeEngine
+ if be == nil {
+ return
+ }
+
+ ttl := set.Routing.IPTTLSeconds
+ if ttl <= 0 {
+ ttl = 3600
+ }
+
+ now := time.Now()
+ refresh := time.Duration(ttl) * time.Second / 2
+ key := set.Id + "|" + ip.String()
+ if last, seen := routeLearnLast[key]; seen && now.Sub(last) < refresh {
+ return
+ }
+ routeLearnLast[key] = now
+
+ if len(routeLearnLast) > 4096 {
+ cutoff := time.Duration(ttl) * time.Second
+ for k, t := range routeLearnLast {
+ if now.Sub(t) > cutoff {
+ delete(routeLearnLast, k)
+ }
+ }
+ }
+
+ routeAddIPsToSets(be, st, ttl, []net.IP{ip}, cfg.Queue.IPv4Enabled, cfg.Queue.IPv6Enabled)
+}
+
func buildRouteState(cfg *config.Config, set *config.SetConfig) routeState {
mode := set.Routing.Mode
if mode == "" {
@@ -170,7 +221,9 @@ func buildRouteState(cfg *config.Config, set *config.SetConfig) routeState {
chainPre: chainPre, chainOut: chainOut, chainSNAT: chainSNAT,
}
- if config.RoutingUsesTProxy(mode) {
+ if config.RoutingIsBlock(mode) {
+ st.blockAction = config.NormalizeBlockAction(set.Routing.BlockAction)
+ } else if config.RoutingUsesTProxy(mode) {
mark, port := proxyMarkAndPort(set)
st.mark = mark
st.table = proxyTable()
@@ -192,10 +245,15 @@ func routeStateEqual(a, b routeState) bool {
a.iface == b.iface &&
a.tproxyPort == b.tproxyPort &&
a.upstreamKey == b.upstreamKey &&
+ a.blockAction == b.blockAction &&
a.sourcesKey == b.sourcesKey
}
func routeCleanupAny(be routeBackend, st routeState) {
+ if config.RoutingIsBlock(st.mode) {
+ routeCleanupBlockRule(be, st)
+ return
+ }
if config.RoutingUsesTProxy(st.mode) {
routeCleanupProxyRule(be, st)
return
@@ -316,6 +374,132 @@ func RoutingClearAll() {
routeIfaceAuto = make(map[string]routeState)
routeEngine = nil
routeLastReResolve = make(map[string]time.Time)
+ routeLearnLast = make(map[string]time.Time)
+}
+
+func RoutingRulesPresent(cfg *config.Config) bool {
+ if cfg == nil {
+ return true
+ }
+
+ routeMu.Lock()
+ defer routeMu.Unlock()
+
+ if len(routeRuleCache) == 0 {
+ return true
+ }
+
+ be := getRouteBackend(cfg)
+ if be == nil {
+ return true
+ }
+
+ switch eng := be.(type) {
+ case *routeNftBackend:
+ return routeNftRulesPresent()
+ case *routeIptBackend:
+ return routeIptRulesPresent(eng, cfg)
+ }
+ return true
+}
+
+func routeNftRulesPresent() bool {
+ out, err := run("nft", "list", "table", "inet", routeNftTable)
+ if err != nil || strings.TrimSpace(out) == "" {
+ return false
+ }
+
+ present := make(map[string]bool)
+ for _, line := range strings.Split(out, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "chain ") {
+ name := strings.TrimSpace(strings.TrimSuffix(line[len("chain "):], "{"))
+ present[name] = true
+ }
+ }
+
+ for _, st := range routeRuleCache {
+ for _, c := range routeStateChains(st) {
+ if !present[c.chain] {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+type routeChainRef struct{ chain, table string }
+
+func routeStateChains(st routeState) []routeChainRef {
+ switch {
+ case config.RoutingIsBlock(st.mode):
+ return []routeChainRef{{st.chainPre, "filter"}}
+ case config.RoutingUsesTProxy(st.mode):
+ return []routeChainRef{{st.chainPre, "mangle"}}
+ default:
+ return []routeChainRef{{st.chainPre, "mangle"}, {st.chainOut, "mangle"}, {st.chainSNAT, "nat"}}
+ }
+}
+
+func routeIptRulesPresent(be *routeIptBackend, cfg *config.Config) bool {
+ needed := make(map[string]map[string]bool)
+ for _, st := range routeRuleCache {
+ for _, c := range routeStateChains(st) {
+ if needed[c.table] == nil {
+ needed[c.table] = make(map[string]bool)
+ }
+ needed[c.table][c.chain] = true
+ }
+ }
+ if len(needed) == 0 {
+ return true
+ }
+
+ for _, v6 := range []bool{false, true} {
+ if v6 && !cfg.Queue.IPv6Enabled {
+ continue
+ }
+ if !v6 && !cfg.Queue.IPv4Enabled {
+ continue
+ }
+ cmd := be.iptFor(v6)
+ if !hasBinary(cmd) {
+ continue
+ }
+ for table, wantChains := range needed {
+ out, err := run(cmd, "-w", "-t", table, "-S")
+ if err != nil {
+ return false
+ }
+ present := make(map[string]bool)
+ for _, line := range strings.Split(out, "\n") {
+ if strings.HasPrefix(line, "-N ") {
+ present[strings.TrimSpace(line[len("-N "):])] = true
+ }
+ }
+ for chain := range wantChains {
+ if !present[chain] {
+ return false
+ }
+ }
+ }
+ }
+ return true
+}
+
+func RoutingForceResync(cfg *config.Config) {
+ if cfg == nil {
+ return
+ }
+
+ routeMu.Lock()
+ routeRuleCache = make(map[string]routeState)
+ routeIfaceAuto = make(map[string]routeState)
+ routeLastReResolve = make(map[string]time.Time)
+ routeLearnLast = make(map[string]time.Time)
+ routeMu.Unlock()
+
+ RoutingSyncConfig(cfg)
}
func RoutingSyncConfig(cfg *config.Config) {
@@ -361,6 +545,9 @@ func RoutingSyncConfig(cfg *config.Config) {
if mode == config.RoutingModeProxy && set.Routing.Upstream.Port < 1 {
continue
}
+ if config.RoutingIsBlock(mode) && len(set.Targets.IpsToMatch) == 0 && len(set.Targets.DomainsToMatch) == 0 {
+ continue
+ }
desired[set.Id] = set
}
@@ -392,7 +579,9 @@ func RoutingSyncConfig(cfg *config.Config) {
if _, ok := routeRuleCache[set.Id]; !ok {
var err error
- if config.RoutingUsesTProxy(cur.mode) {
+ if config.RoutingIsBlock(cur.mode) {
+ err = routeEnsureBlockRule(be, cfg, set, cur, sources)
+ } else if config.RoutingUsesTProxy(cur.mode) {
err = routeEnsureProxyRule(be, cfg, set, cur, sources)
} else {
err = routeEnsureRule(be, cfg, set, cur, sources)
@@ -489,6 +678,9 @@ func RoutingPeriodicReResolve(cfg *config.Config) {
func routePreResolveDomains(cfg *config.Config, sets []*config.SetConfig) {
for _, set := range sets {
+ if config.RoutingIsBlock(set.Routing.Mode) {
+ continue
+ }
for _, domain := range set.Targets.SNIDomains {
domain = strings.TrimSpace(domain)
if domain == "" {
@@ -740,7 +932,6 @@ func routeDefaultGatewayForIface(iface string, ipv6 bool) string {
}
}
- // fallback
if gw := routeMainDefaultGateway(ipv6); gw != "" && ifaceReachesIP(iface, gw) {
return gw
}
diff --git a/src/tables/routing_block.go b/src/tables/routing_block.go
new file mode 100644
index 00000000..4ae3d9f5
--- /dev/null
+++ b/src/tables/routing_block.go
@@ -0,0 +1,251 @@
+package tables
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/daniellavrushin/b4/config"
+ "github.com/daniellavrushin/b4/log"
+)
+
+const (
+ routeNftBlockFwd = "block_fwd"
+ routeNftBlockOut = "block_out"
+)
+
+func routeEnsureBlockRule(be routeBackend, cfg *config.Config, set *config.SetConfig, st routeState, sources []string) error {
+ if cfg.Queue.IPv4Enabled {
+ if err := be.ensureIPSet(st.setV4, false); err != nil {
+ return err
+ }
+ }
+ if cfg.Queue.IPv6Enabled {
+ if err := be.ensureIPSet(st.setV6, true); err != nil {
+ return err
+ }
+ }
+
+ switch be.name() {
+ case backendNFTables:
+ if err := ensureBlockBaseNft(); err != nil {
+ return err
+ }
+ if err := be.ensureChain(st.chainPre, true); err != nil {
+ return err
+ }
+ be.flushChain(st.chainPre, true)
+ if cfg.Queue.IPv4Enabled {
+ addBlockRuleNft(st.chainPre, false, st.setV4, st.blockAction, sources)
+ }
+ if cfg.Queue.IPv6Enabled {
+ addBlockRuleNft(st.chainPre, true, st.setV6, st.blockAction, sources)
+ }
+ ensureBlockJumpNft(routeNftBlockFwd, st.chainPre)
+ ensureBlockJumpNft(routeNftBlockOut, st.chainPre)
+ default:
+ legacy := isLegacyIptBackend(be)
+ if err := ensureBlockChainIpt(st.chainPre, legacy); err != nil {
+ return err
+ }
+ if cfg.Queue.IPv4Enabled {
+ addBlockRuleIpt(false, st.chainPre, st.setV4, st.blockAction, sources, legacy)
+ }
+ if cfg.Queue.IPv6Enabled {
+ addBlockRuleIpt(true, st.chainPre, st.setV6, st.blockAction, sources, legacy)
+ }
+ ensureBlockJumpIpt("FORWARD", st.chainPre, legacy)
+ ensureBlockJumpIpt("OUTPUT", st.chainPre, legacy)
+ }
+ return nil
+}
+
+func routeCleanupBlockRule(be routeBackend, st routeState) {
+ switch be.name() {
+ case backendNFTables:
+ deleteNftJumpRules(routeNftTable, routeNftBlockFwd, st.chainPre)
+ deleteNftJumpRules(routeNftTable, routeNftBlockOut, st.chainPre)
+ be.flushChain(st.chainPre, true)
+ be.deleteChain(st.chainPre, true)
+ default:
+ legacy := isLegacyIptBackend(be)
+ deleteBlockJumpIpt("FORWARD", st.chainPre, legacy)
+ deleteBlockJumpIpt("OUTPUT", st.chainPre, legacy)
+ flushDeleteBlockChainIpt(st.chainPre, legacy)
+ }
+
+ be.flushIPSet(st.setV4)
+ be.destroyIPSet(st.setV4)
+ be.flushIPSet(st.setV6)
+ be.destroyIPSet(st.setV6)
+}
+
+func ensureBlockBaseNft() error {
+ if err := runEnsure("nft", "add", "chain", "inet", routeNftTable, routeNftBlockFwd,
+ "{", "type", "filter", "hook", "forward", "priority", "-150", ";", "policy", "accept", ";", "}"); err != nil {
+ return fmt.Errorf("ensure block forward chain: %w", err)
+ }
+ if err := runEnsure("nft", "add", "chain", "inet", routeNftTable, routeNftBlockOut,
+ "{", "type", "filter", "hook", "output", "priority", "-150", ";", "policy", "accept", ";", "}"); err != nil {
+ return fmt.Errorf("ensure block output chain: %w", err)
+ }
+ return nil
+}
+
+func addBlockRuleNft(chain string, v6 bool, setName, action string, sources []string) {
+ daddr := []string{"ip", "daddr", "@" + setName}
+ if v6 {
+ daddr = []string{"ip6", "daddr", "@" + setName}
+ }
+
+ emit := func(src string) {
+ base := []string{"nft", "add", "rule", "inet", routeNftTable, chain}
+ if src != "" {
+ base = append(base, "iifname", fmt.Sprintf("%q", src))
+ }
+
+ switch action {
+ case config.BlockActionReject:
+ rst := append(append([]string{}, base...), "meta", "l4proto", "tcp")
+ rst = append(rst, daddr...)
+ rst = append(rst, "reject", "with", "tcp", "reset")
+ runLogged("routing: add block reset "+chain, rst...)
+ rej := append(append([]string{}, base...), daddr...)
+ rej = append(rej, "reject", "with", "icmpx", "type", "port-unreachable")
+ runLogged("routing: add block reject "+chain, rej...)
+ default:
+ args := append(append([]string{}, base...), daddr...)
+ args = append(args, "drop")
+ runLogged("routing: add block drop "+chain, args...)
+ }
+ }
+
+ if len(sources) == 0 {
+ emit("")
+ return
+ }
+ for _, src := range sources {
+ emit(src)
+ }
+}
+
+func ensureBlockJumpNft(base, target string) {
+ deleteNftJumpRules(routeNftTable, base, target)
+ runLogged("routing: add block jump "+base+"->"+target,
+ "nft", "add", "rule", "inet", routeNftTable, base, "jump", target)
+}
+
+func iptBlockCmd(v6, legacy bool) string {
+ if legacy {
+ if v6 {
+ return backendIP6TablesLegacy
+ }
+ return backendIPTablesLegacy
+ }
+ if v6 {
+ return backendIP6Tables
+ }
+ return backendIPTables
+}
+
+func ensureBlockChainIpt(chain string, legacy bool) error {
+ ipt4 := iptBlockCmd(false, legacy)
+ for _, v6 := range []bool{false, true} {
+ cmd := iptBlockCmd(v6, legacy)
+ if !hasBinary(cmd) {
+ continue
+ }
+ out, err := run(cmd, "-w", "-t", "filter", "-N", chain)
+ if err != nil && !strings.Contains(strings.TrimSpace(out), "already exists") {
+ if cmd == ipt4 {
+ return fmt.Errorf("%s -N %s in filter: %v: %s", cmd, chain, err, strings.TrimSpace(out))
+ }
+ log.Tracef("routing: %s -N %s in filter failed (non-fatal): %s", cmd, chain, strings.TrimSpace(out))
+ }
+ runLogged("routing: flush block chain "+chain, cmd, "-w", "-t", "filter", "-F", chain)
+ }
+ return nil
+}
+
+func flushDeleteBlockChainIpt(chain string, legacy bool) {
+ for _, v6 := range []bool{false, true} {
+ cmd := iptBlockCmd(v6, legacy)
+ if !hasBinary(cmd) {
+ continue
+ }
+ runLogged("routing: flush block chain "+chain, cmd, "-w", "-t", "filter", "-F", chain)
+ runLogged("routing: delete block chain "+chain, cmd, "-w", "-t", "filter", "-X", chain)
+ }
+}
+
+func addBlockRuleIpt(v6 bool, chain, setName, action string, sources []string, legacy bool) {
+ cmd := iptBlockCmd(v6, legacy)
+ if !hasBinary(cmd) {
+ return
+ }
+
+ emit := func(src string) {
+ match := []string{cmd, "-w", "-t", "filter", "-A", chain}
+ if src != "" {
+ match = append(match, "-i", src)
+ }
+ match = append(match, "-m", "set", "--match-set", setName, "dst")
+
+ switch action {
+ case config.BlockActionReject:
+ rst := []string{cmd, "-w", "-t", "filter", "-A", chain}
+ if src != "" {
+ rst = append(rst, "-i", src)
+ }
+ rst = append(rst, "-p", "tcp", "-m", "set", "--match-set", setName, "dst",
+ "-j", "REJECT", "--reject-with", "tcp-reset")
+ runLogged("routing: add block reset "+chain, rst...)
+ icmpReject := "icmp-port-unreachable"
+ if v6 {
+ icmpReject = "icmp6-port-unreachable"
+ }
+ rej := append(append([]string{}, match...), "-j", "REJECT", "--reject-with", icmpReject)
+ runLogged("routing: add block reject "+chain, rej...)
+ default:
+ args := append(append([]string{}, match...), "-j", "DROP")
+ runLogged("routing: add block drop "+chain, args...)
+ }
+ }
+
+ if len(sources) == 0 {
+ emit("")
+ return
+ }
+ for _, src := range sources {
+ emit(src)
+ }
+}
+
+func ensureBlockJumpIpt(parent, chain string, legacy bool) {
+ for _, v6 := range []bool{false, true} {
+ cmd := iptBlockCmd(v6, legacy)
+ if !hasBinary(cmd) {
+ continue
+ }
+ for i := 0; i < 100; i++ {
+ if _, err := run(cmd, "-w", "-t", "filter", "-D", parent, "-j", chain); err != nil {
+ break
+ }
+ }
+ runLogged("routing: add block jump "+parent+"->"+chain,
+ cmd, "-w", "-t", "filter", "-A", parent, "-j", chain)
+ }
+}
+
+func deleteBlockJumpIpt(parent, chain string, legacy bool) {
+ for _, v6 := range []bool{false, true} {
+ cmd := iptBlockCmd(v6, legacy)
+ if !hasBinary(cmd) {
+ continue
+ }
+ for i := 0; i < 100; i++ {
+ if _, err := run(cmd, "-w", "-t", "filter", "-D", parent, "-j", chain); err != nil {
+ break
+ }
+ }
+ }
+}
diff --git a/src/tables/routing_ipt.go b/src/tables/routing_ipt.go
index a8134ba9..3dafa0aa 100644
--- a/src/tables/routing_ipt.go
+++ b/src/tables/routing_ipt.go
@@ -212,7 +212,7 @@ func (b *routeIptBackend) destroyIPSet(name string) {
}
func (b *routeIptBackend) clearAll() {
- for _, table := range []string{"mangle", "nat"} {
+ for _, table := range []string{"mangle", "nat", "filter"} {
for _, cmd := range b.iptBoth() {
if !hasBinary(cmd) {
continue
diff --git a/src/tables/tables_test.go b/src/tables/tables_test.go
index 0ece5854..223e32c5 100644
--- a/src/tables/tables_test.go
+++ b/src/tables/tables_test.go
@@ -684,6 +684,66 @@ func TestRouteCollectEntries(t *testing.T) {
})
}
+func TestRoutingRulesPresent(t *testing.T) {
+ origCache := routeRuleCache
+ defer func() { routeRuleCache = origCache }()
+
+ t.Run("nil config", func(t *testing.T) {
+ if !RoutingRulesPresent(nil) {
+ t.Error("expected true for nil config")
+ }
+ })
+
+ t.Run("empty cache means nothing to verify", func(t *testing.T) {
+ routeRuleCache = make(map[string]routeState)
+ cfg := config.NewConfig()
+ if !RoutingRulesPresent(&cfg) {
+ t.Error("expected true when no routing rules are cached")
+ }
+ })
+}
+
+func TestRoutingLearnIP(t *testing.T) {
+ origCache := routeRuleCache
+ origLearn := routeLearnLast
+ defer func() {
+ routeRuleCache = origCache
+ routeLearnLast = origLearn
+ }()
+
+ newSet := func(mode string) *config.SetConfig {
+ s := &config.SetConfig{Id: "s1"}
+ s.Routing.Enabled = true
+ s.Routing.Mode = mode
+ s.Routing.EgressInterface = "wg0"
+ return s
+ }
+
+ t.Run("block mode is skipped", func(t *testing.T) {
+ routeRuleCache = make(map[string]routeState)
+ routeLearnLast = make(map[string]time.Time)
+ cfg := config.NewConfig()
+ RoutingLearnIP(&cfg, newSet(config.RoutingModeBlock), net.ParseIP("1.2.3.4"))
+ if len(routeLearnLast) != 0 {
+ t.Error("block-mode set must not be learned into the routing ipset")
+ }
+ })
+
+ t.Run("set with no installed rule is a no-op", func(t *testing.T) {
+ routeRuleCache = make(map[string]routeState)
+ routeLearnLast = make(map[string]time.Time)
+ cfg := config.NewConfig()
+ RoutingLearnIP(&cfg, newSet(config.RoutingModeInterface), net.ParseIP("1.2.3.4"))
+ if len(routeLearnLast) != 0 {
+ t.Error("set absent from routeRuleCache should be a no-op")
+ }
+ })
+
+ t.Run("nil args are safe", func(t *testing.T) {
+ RoutingLearnIP(nil, nil, nil)
+ })
+}
+
func TestRouteResolveIDs(t *testing.T) {
origCache := routeRuleCache
origAuto := routeIfaceAuto