mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
feat(block): implement block routing mode with configurable actions (#238)
This commit is contained in:
parent
6be763ce53
commit
4b9fae47d3
26 changed files with 1321 additions and 174 deletions
|
|
@ -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.
|
||||
|
|
|
|||
92
docs/docs/sets/blocking.md
Normal file
92
docs/docs/sets/blocking.md
Normal 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.
|
||||
:::
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
:::
|
||||
|
|
@ -70,6 +70,10 @@ flowchart LR
|
|||
|
||||
Направляет трафик, совпавший с целями сета, через указанный сетевой интерфейс - например, VPN, WireGuard или другой туннель.
|
||||
|
||||
:::tip
|
||||
Чтобы **заблокировать** совпавший трафик вместо того, чтобы куда-либо его направлять, установите режим Блокировка. См. [Блокировка](./blocking.md).
|
||||
:::
|
||||
|
||||
### Общая схема
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ var DefaultSetConfig = SetConfig{
|
|||
Table: 0,
|
||||
SourceInterfaces: []string{},
|
||||
IPTTLSeconds: 3600,
|
||||
BlockAction: BlockActionReject,
|
||||
},
|
||||
|
||||
Fragmentation: FragmentationConfig{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ const SortableCardWrapper = ({
|
|||
<Box
|
||||
ref={combinedRef}
|
||||
style={{
|
||||
height: "100%",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Выбранный интерфейс сейчас недоступен",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
251
src/tables/routing_block.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue