feat(block): implement block routing mode with configurable actions (#238)

This commit is contained in:
Daniel Lavrushin 2026-06-06 00:02:29 +02:00 committed by GitHub
parent 6be763ce53
commit 4b9fae47d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1321 additions and 174 deletions

View file

@ -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.

View file

@ -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<br/>an ad or tracker"]
DEV --> Q{"How is it<br/>connecting?"}
Q -->|"Looking up the address<br/>(the usual way)"| A["b4 replies that the<br/>site does not exist"]
Q -->|"Already has the address<br/>(hidden or cached)"| C["b4 recognizes the site<br/>and cuts the connection"]
Q -->|"Going to a blocked IP"| D["b4 blocks the<br/>address directly"]
A --> R["🚫 The ad does not load<br/>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.
:::

View file

@ -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

View file

@ -0,0 +1,94 @@
---
sidebar_position: 4.5
title: Блокировка
---
# Блокировка
Режим блокировки останавливает трафик, совпавший с целями сета, вместо того чтобы куда-либо его направлять. Укажите в целях сета домены рекламы и трекеров, нежелательные IP или категорию GeoSite - и b4 заблокирует их во всей сети: на каждом устройстве LAN и на самом роутере, без выходного интерфейса и upstream-прокси.
Выбирается на вкладке [Маршрутизация](./routing.md) как режим **Блокировка**.
## Как это работает
Каким бы способом устройство ни пыталось добраться до заблокированного сайта, b4 его останавливает - и только этот сайт, поэтому остальная часть страницы продолжает работать.
```mermaid
flowchart TB
DEV["📱 Устройство пытается открыть<br/>рекламу или трекер"]
DEV --> Q{"Как оно<br/>подключается?"}
Q -->|"Узнаёт адрес<br/>(обычный случай)"| A["b4 отвечает, что<br/>сайт не существует"]
Q -->|"Уже знает адрес<br/>(скрытый или кешированный)"| C["b4 узнаёт сайт<br/>и обрывает соединение"]
Q -->|"Идёт на заблокированный IP"| D["b4 блокирует<br/>адрес напрямую"]
A --> R["🚫 Реклама не загружается<br/>Остальная часть страницы работает"]
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.
:::

View file

@ -70,6 +70,10 @@ flowchart LR
Направляет трафик, совпавший с целями сета, через указанный сетевой интерфейс - например, VPN, WireGuard или другой туннель.
:::tip
Чтобы **заблокировать** совпавший трафик вместо того, чтобы куда-либо его направлять, установите режим Блокировка. См. [Блокировка](./blocking.md).
:::
### Общая схема
```mermaid

View file

@ -101,6 +101,7 @@ var DefaultSetConfig = SetConfig{
Table: 0,
SourceInterfaces: []string{},
IPTTLSeconds: 3600,
BlockAction: BlockActionReject,
},
Fragmentation: FragmentationConfig{

View file

@ -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 {

View file

@ -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 {

View file

@ -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()

View file

@ -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

View file

@ -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})

View file

@ -108,6 +108,7 @@ const SortableCardWrapper = ({
<Box
ref={combinedRef}
style={{
height: "100%",
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,

View file

@ -27,15 +27,20 @@ import {
DnsIcon,
FakingIcon,
TcpIcon,
CheckIcon,
CloseIcon,
BlockIcon,
ProxyIcon,
NetworkIcon,
SwapIcon,
DeviceIcon,
FilterIcon,
SecurityIcon,
EscalateOutIcon,
EscalateInIcon,
} from "@b4.icons";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { B4Badge } from "@b4.elements";
import { colors, radius } from "@design";
import { B4SetConfig } from "@models/config";
import { B4SetConfig, RoutingMode } from "@models/config";
import { useTranslation } from "react-i18next";
import { SetStats } from "./Manager";
@ -116,6 +121,127 @@ const STRATEGY_LABELS: Record<string, string> = {
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: <SecurityIcon sx={{ fontSize: 12 }} />,
tooltip: t("sets.card.tlsFilter"),
});
}
if (set.tcp?.dport_filter) {
chips.push({
key: "tcp-ports",
label: `TCP ${set.tcp.dport_filter}`,
icon: <FilterIcon sx={{ fontSize: 12 }} />,
tooltip: t("sets.card.portFilter"),
});
}
if (set.udp?.dport_filter) {
chips.push({
key: "udp-ports",
label: `UDP ${set.udp.dport_filter}`,
icon: <FilterIcon sx={{ fontSize: 12 }} />,
tooltip: t("sets.card.portFilter"),
});
}
const deviceCount = set.targets.source_devices?.length ?? 0;
if (deviceCount > 0) {
chips.push({
key: "devices",
label: String(deviceCount),
icon: <DeviceIcon sx={{ fontSize: 12 }} />,
tooltip: t("sets.card.sourceDevices"),
});
}
if (set.mss_clamp?.enabled) {
chips.push({
key: "mss",
label: `MSS ${set.mss_clamp.size}`,
icon: <TcpIcon sx={{ fontSize: 12 }} />,
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: <DnsIcon sx={{ fontSize: 12 }} />,
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: <BlockIcon sx={{ fontSize: 12 }} />,
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: <ProxyIcon sx={{ fontSize: 12 }} />,
tooltip: t("sets.card.routeProxy"),
variant: "outlined",
color: "info",
});
} else if (mode === "mtproto-ws") {
chips.push({
key: "route",
label: "MTPROTO-WS",
icon: <SwapIcon sx={{ fontSize: 12 }} />,
tooltip: t("sets.card.routeMtproto"),
variant: "outlined",
color: "info",
});
} else {
chips.push({
key: "route",
label: `${routing.egress_interface || "?"}`,
icon: <NetworkIcon sx={{ fontSize: 12 }} />,
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<HTMLElement>) => {
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 = ({
}}
/>
<Box
sx={{
position: "absolute",
top: -9,
left: -9,
zIndex: 2,
minWidth: 24,
height: 24,
px: 0.5,
borderRadius: "12px",
bgcolor: colors.secondary,
color: colors.background.dark,
border: `2px solid ${colors.background.default}`,
boxShadow: `0 2px 6px rgba(0,0,0,0.45)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
fontWeight={800}
sx={{ fontSize: "0.7rem" }}
>
{index + 1}
</Typography>
</Box>
{/* Header row */}
<Box
sx={{
@ -238,7 +399,6 @@ export const SetCard = ({
onClick={(e) => e.stopPropagation()}
/>
</Tooltip>
</Stack>
{!selectionMode && (
@ -286,33 +446,79 @@ export const SetCard = ({
</Box>
{/* Clickable content area */}
<CardActionArea onClick={selectionMode ? onSelect : onEdit} sx={{ borderRadius: 0 }}>
<CardContent sx={{ pt: 0, pb: 2 }}>
<CardActionArea
onClick={selectionMode ? onSelect : onEdit}
sx={{
borderRadius: 0,
flexGrow: 1,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
"& .MuiCardActionArea-focusHighlight": { display: "none" },
}}
>
<CardContent sx={{ pt: 0, pb: 1.5 }}>
{/* Name */}
<Typography
variant="h6"
sx={{
fontWeight: 600,
my: 1,
textTransform: "uppercase",
color: set.enabled ? colors.text.primary : colors.text.secondary,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ my: 1, minHeight: 30 }}
>
{set.name}
</Typography>
<Typography
variant="h6"
sx={{
flex: 1,
minWidth: 0,
fontWeight: 600,
textTransform: "uppercase",
color: set.enabled
? colors.text.primary
: colors.text.secondary,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{set.name}
</Typography>
{routeChips.length > 0 && (
<Stack
direction="row"
spacing={0.5}
sx={{ flexShrink: 0, maxWidth: "55%" }}
>
{routeChips.map((chip) => (
<Tooltip key={chip.key} title={chip.tooltip}>
<B4Badge
icon={chip.icon}
label={chip.label}
size="small"
variant={chip.variant}
color={chip.color}
sx={{
maxWidth: 160,
"& .MuiChip-label": {
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
/>
</Tooltip>
))}
</Stack>
)}
</Stack>
{/* Target preview */}
<Box
sx={{
p: 2,
mb: 2,
p: 1.5,
mb: 1.5,
borderRadius: radius.sm,
bgcolor: colors.background.dark,
border: `1px solid ${colors.border.light}`,
minHeight: 48,
minHeight: 44,
}}
>
{set.targets.geosite_categories.length > 0 ||
@ -393,31 +599,8 @@ export const SetCard = ({
)}
</Box>
{/* Active techniques */}
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mb: 2 }}>
<B4Badge
label={STRATEGY_LABELS[strategy]}
size="small"
sx={{ bgcolor: colors.primary, color: colors.text.primary }}
/>
{set.faking.sni && (
<B4Badge label="FAKE" size="small" color="primary" />
)}
{set.dns?.enabled && (
<B4Badge
label="DNS"
size="small"
variant="outlined"
color="secondary"
/>
)}
{set.fragmentation.reverse_order && (
<B4Badge label="REV" size="small" variant="outlined" />
)}
</Stack>
{/* Domain/IP counts */}
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ mb: 1.5 }}>
<Tooltip
title={`${stats?.manual_domains || 0} ${t("sets.card.manual")}, ${
stats?.geosite_domains || 0
@ -470,55 +653,61 @@ export const SetCard = ({
</Tooltip>
</Stack>
{/* Quick flags */}
<Box
sx={{
display: "flex",
gap: 1,
p: 1,
borderRadius: radius.sm,
bgcolor: colors.background.dark,
border: `1px solid ${colors.border.light}`,
}}
<Stack
divider={
<Box sx={{ height: "1px", bgcolor: colors.border.light }} />
}
sx={{ borderTop: `1px solid ${colors.border.light}`, mt: 0.5 }}
>
<QuickFlag
icon={<TcpIcon />}
label={`${set.tcp.conn_bytes_limit}B`}
tooltip={t("sets.card.tcpBytesLimit")}
/>
<QuickFlag
icon={<FakingIcon />}
enabled={set.faking.sni}
tooltip={set.faking.sni ? t("sets.card.sniFakingOn") : t("sets.card.sniFakingOff")}
/>
<QuickFlag
icon={<DnsIcon />}
enabled={set.dns?.enabled}
tooltip={
set.dns?.enabled ? `DNS → ${set.dns.target_dns}` : t("sets.card.dnsOff")
}
/>
</Box>
<FacetRow label={t("sets.card.facetMethod")}>
<B4Badge
label={STRATEGY_LABELS[strategy]}
size="small"
sx={{ bgcolor: colors.primary, color: colors.text.primary }}
/>
{set.faking.sni && (
<Tooltip title={t("sets.card.sniFakingOn")}>
<B4Badge
icon={<FakingIcon sx={{ fontSize: 12 }} />}
label="FAKE"
size="small"
color="primary"
/>
</Tooltip>
)}
{set.fragmentation.reverse_order && (
<B4Badge label="REV" size="small" variant="outlined" />
)}
</FacetRow>
{scopeChips.length > 0 && (
<FacetRow label={t("sets.card.facetScope")}>
{scopeChips.map((chip) => (
<Tooltip key={chip.key} title={chip.tooltip}>
<B4Badge
icon={chip.icon}
label={chip.label}
size="small"
variant="outlined"
/>
</Tooltip>
))}
</FacetRow>
)}
</Stack>
</CardContent>
</CardActionArea>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 2,
pb: 1.5,
minHeight: 30,
}}
>
{(escalatesTo || (escalatedFrom && escalatedFrom.length > 0)) && (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 0.5,
flex: 1,
minWidth: 0,
px: 2,
pt: 1,
pb: 1.25,
}}
>
{escalatesTo && (
@ -542,81 +731,44 @@ export const SetCard = ({
/>
))}
</Box>
<Box
sx={{
flexShrink: 0,
width: 22,
height: 22,
borderRadius: "50%",
bgcolor: colors.accent.primary,
border: `1px solid ${colors.border.default}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
color="text.secondary"
fontWeight={600}
sx={{ fontSize: "0.65rem" }}
>
{index + 1}
</Typography>
</Box>
</Box>
)}
</Card>
);
};
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 (
<Tooltip title={tooltip}>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
sx={{
flex: 1,
justifyContent: "center",
py: 0.5,
px: 0.5,
borderRadius: radius.sm,
bgcolor: isActive ? colors.accent.secondary : "transparent",
}}
>
<Box sx={{ color, display: "flex", "& svg": { fontSize: 14 } }}>
{icon}
</Box>
{label ? (
<Typography
variant="caption"
fontWeight={600}
sx={{ color, fontSize: "0.7rem" }}
>
{label}
</Typography>
) : (
<Box sx={{ color, display: "flex", "& svg": { fontSize: 12 } }}>
{enabled ? <CheckIcon /> : <CloseIcon />}
</Box>
)}
</Stack>
</Tooltip>
);
};
const FacetRow = ({ label, children }: FacetRowProps) => (
<Stack direction="row" alignItems="center" sx={{ py: 0.875, minHeight: 34 }}>
<Typography
variant="caption"
sx={{
color: colors.text.disabled,
fontWeight: 700,
fontSize: "0.6rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
width: 56,
flexShrink: 0,
borderRight: `1px solid ${colors.border.light}`,
mr: 1.5,
}}
>
{label}
</Typography>
<Stack
direction="row"
flexWrap="wrap"
gap={0.5}
sx={{ flex: 1, minWidth: 0 }}
>
{children}
</Stack>
</Stack>
);
interface EscalationChipProps {
icon: React.ReactNode;

View file

@ -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 = ({
<MenuItem value="mtproto-ws">
{t("sets.routing.modeMTProtoWS")}
</MenuItem>
<MenuItem value="block">{t("sets.routing.modeBlock")}</MenuItem>
</B4TextField>
{isMTProtoWS && (
<B4Alert severity="info" sx={{ mt: 2 }}>
{t("sets.routing.mtprotoWsNote")}
</B4Alert>
)}
{isBlock && (
<B4TextField
label={t("sets.routing.blockActionLabel")}
select
value={blockAction}
onChange={(e) =>
onChange("routing.block_action", e.target.value)
}
helperText={t("sets.routing.blockActionHelper")}
sx={{ mt: 2 }}
>
<MenuItem value="reject">
{t("sets.routing.blockActionReject")}
</MenuItem>
<MenuItem value="drop">
{t("sets.routing.blockActionDrop")}
</MenuItem>
</B4TextField>
)}
</Grid>
<Grid size={{ xs: 12 }}>
@ -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")}
</Typography>
</Box>
</Grid>
@ -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")}
</B4Hint>
<Grid size={{ xs: 12 }}>
@ -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")}
</B4Hint>
</Grid>

View file

@ -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",

View file

@ -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": "Выбранный интерфейс сейчас недоступен",

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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)
}

View file

@ -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
}

251
src/tables/routing_block.go Normal file
View file

@ -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
}
}
}
}

View file

@ -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

View file

@ -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