feat: add bridge netfilter support and diagnostics (#384)

This commit is contained in:
Daniel Lavrushin 2026-09-30 17:29:02 +02:00 • committed by GitHub
parent 9dd3827fb2
commit 2cd36386e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 872 additions and 25 deletions

View file

@ -1,5 +1,9 @@
# B4 - Bye Bye Big Bro
## [1.83.2] - 2026-09-30
- ADDED: **System Info, the log and the Telegram over WebSocket card name the network bridges that keep the devices behind them out of proxy sets and Telegram over WebSocket** - with `net.bridge.bridge-nf-call-iptables` at 1, which the dockerd package sets on OpenWrt, the kernel loses the handover to b4's listener for connections that enter through a bridge such as `br-lan`, so they hung without a single log line while the router's own connections worked. The Bridge netfilter row under Firewall in System Info names the setting to turn off.
## [1.83.1] - 2026-09-27
- ADDED: **When the packet engine fails to start, b4 keeps running without it, so the web interface stays reachable** - the dashboard shows the reason, such as a kernel without NFQUEUE support, with a button that switches to the other engine and restarts b4, and b4 retries on its own three times within about two minutes.

View file

@ -1,5 +1,9 @@
# B4 - Bye Bye Big Bro
## [1.83.2] - 2026-09-30
- ДОБАВЛЕНО: **Информация о системе, лог и карточка Telegram через WebSocket называют сетевые мосты, из-за которых устройства за ними не попадают в proxy-сеты и в Telegram через WebSocket** - при `net.bridge.bridge-nf-call-iptables`, равном 1, который на OpenWrt выставляет пакет dockerd, ядро теряет привязку к слушателю b4 у соединений, входящих через мост вроде `br-lan`, поэтому они висели без единой строки в логе, а соединения самого роутера работали. Строка Bridge netfilter в разделе Firewall окна Информация о системе называет настройку, которую нужно выключить.
## [1.83.1] - 2026-09-27
- ДОБАВЛЕНО: **Если движок пакетов не запускается, b4 продолжает работать без него, и веб-интерфейс остаётся доступен** - дашборд показывает причину, например ядро без поддержки NFQUEUE, с кнопкой, которая переключает на другой движок и перезапускает b4, а b4 сам повторяет запуск три раза примерно за две минуты.

View file

@ -378,6 +378,29 @@ The rule that keeps the router's own addresses out of the diversion uses the add
The **System Info** button on Settings -> Core reports, under **Kernel Capabilities**, whether TPROXY is usable, and names the packages that provide what is missing.
:::
### Bridge netfilter
With `net.bridge.bridge-nf-call-iptables` set to `1`, the kernel runs the IPv4 PREROUTING rules for traffic that enters through a network bridge, such as `br-lan` or Docker's `docker0`, inside the bridge code, and does not run them again when the packet reaches the IP layer. The TPROXY rule assigns the connection to b4's listener in that pass, and the IP layer drops the assignment when the bridge passes the packet up to it. The routing mark survives, so the packet is still delivered locally, but no socket takes it, and the kernel cannot answer from the original destination address either. Connections from the devices behind the bridge hang until they time out, and b4 logs no connection line for them. Connections the router opens itself do not pass through a bridge and are diverted as usual. `net.bridge.bridge-nf-call-ip6tables` does the same for IPv6. Each bridge also has its own `nf_call_iptables` and `nf_call_ip6tables` attributes under `/sys/class/net/<bridge>/bridge/`. The kernel runs the pass when either the global setting or the bridge's attribute is `1`, so a `0` written to an attribute turns the pass off for that bridge only while the global setting is `0` too.
On OpenWrt the `dockerd` package turns both settings on at boot through `/etc/sysctl.d/12-br-netfilter-ip.conf`. They are turned off at runtime with:
```sh
sysctl -w net.bridge.bridge-nf-call-iptables=0
sysctl -w net.bridge.bridge-nf-call-ip6tables=0
```
The same lines in `/etc/sysctl.conf`, which is applied after the files in `/etc/sysctl.d`, keep them off after a reboot, except in the two Docker configurations described below:
```sh
printf 'net.bridge.bridge-nf-call-iptables=0\nnet.bridge.bridge-nf-call-ip6tables=0\n' >> /etc/sysctl.conf
```
With bridge netfilter off, containers on a Docker network created with `icc=false` are no longer isolated from each other, and with Docker's userland proxy disabled a container cannot reach ports that other containers on the same network publish. OpenWrt's default Docker configuration uses neither. In either configuration dockerd sets `net.bridge.bridge-nf-call-iptables` back to `1` each time it sets up an affected network, which includes every dockerd start and on OpenWrt comes after the boot-time sysctl pass, and does the same with `net.bridge.bridge-nf-call-ip6tables` for a network with IPv6, so the settings above do not stay off there.
:::info
While bridge netfilter is on for a bridge with ports and a set in this mode or in the Telegram over WebSocket mode, or the Telegram over WebSocket switch, is active, the service logs a warning that names the bridge and the setting to change. **System Info** on Settings -> Core shows the same in the **Bridge netfilter** row under **Firewall**, and the Telegram over WebSocket card shows a warning while the switch is on.
:::
### Settings
| Setting | Description |

View file

@ -95,6 +95,10 @@ When only the IPv6 socket could not be opened, the card instead shows a note tha
The status reads **Not working**, and resting the pointer on it names a rule such as `DIVERT`. Another program has put a rule in mangle PREROUTING, above the bridge's, that accepts every packet addressed to a local transparent socket; XrayUI adds one each time xray starts. It takes the packets of connections the bridge diverted, so they never complete their handshake. At the next firewall check b4 moves its rule back above it and logs a warning naming the rule, and **Check again** does the same at once. While the firewall monitor is off, with the monitor interval at `0` or **Skip IPTables/NFTables setup** on in Settings, Core, no check runs, and only **Check again** or saving the settings restores the order. The mechanism is described under [b4 with Xray or XrayUI](../guides/xray.md#xrayuis-tproxy-rule-and-connections-b4-diverts).
### Bridge netfilter is on for a network bridge
The warning starts with "Bridge netfilter is on for" and names the network bridges. With `net.bridge.bridge-nf-call-iptables` at `1`, which the `dockerd` package sets on OpenWrt, connections from the devices behind those bridges never reach the listener and hang, while the router's own connections are relayed. The status still reads **Working**, because the rule and the listener are in place. The cause, the fix and its effect on Docker are described under [Bridge netfilter](../sets/routing.md#bridge-netfilter), and the **Bridge netfilter** row under **Firewall** in the **System Info** dialog on Settings, Core names the setting to change.
### The address list could not be downloaded
The warning carries the download error and names the source of the list still in use. Neither `core.telegram.org` nor either b4 mirror answered. The list already in use stays, whichever source it came from, and the built-in list is always part of it. The next attempt follows after 30 seconds, and the wait doubles after each further failure up to an hour; after a success the list is refreshed once a day. **Refresh addresses** tries again at once.
@ -134,6 +138,10 @@ The set mode has the same TPROXY requirement as the switch, see [TPROXY support
Matching also has to happen: the set needs the `telegram` GeoIP category and the GeoIP database configured, and a set whose category is named while the database path is empty is rejected at save time rather than running without it. A set scoped to source interfaces or devices leaves the router's own connections out, Telegram Desktop on the router included.
## Devices behind a network bridge hang while the router's own Telegram works
With bridge netfilter on, connections that enter through a network bridge such as `br-lan` never reach b4's listener. Telegram on the devices behind it stays at "Connecting" and the log shows no `[tg-bridge]` line for them, while connections the router opens itself, including those b4's SOCKS5 server opens for its clients, are relayed as usual. Sets in the Upstream SOCKS5 proxy mode are affected the same way. The cause and the settings that remove it are described under [Bridge netfilter](../sets/routing.md#bridge-netfilter).
## The WEB proxy hostname shows a placeholder page
That page is the answer to everything the relay does not recognise, and none of those cases is logged: a hostname that does not match, a missing or wrong token, an expired ticket, an ordinary `GET` where a WebSocket upgrade was expected, or the carrier limit being reached.

View file

@ -46,6 +46,8 @@ The bridge rides the TPROXY path, which needs the `tproxy` and `socket` kernel m
The diversion is a firewall rule, so b4 has to be managing the firewall. With **Skip IPTables/NFTables setup** on in [Settings, Core](../settings/core#firewall) (`system.tables.skip_setup`, the `--skip-tables` flag) no rule is installed at start-up. Saving the settings still installs the routing rules, and they stay until the next restart.
With bridge netfilter on (`net.bridge.bridge-nf-call-iptables=1`, which the `dockerd` package sets on OpenWrt), connections from devices behind a network bridge such as `br-lan` never reach the listener, while the router's own connections still do, and the card shows a warning. The cause and the fix are described under [Bridge netfilter](../sets/routing.md#bridge-netfilter).
The bridge has not been verified with the TUN engine, and the card says so while b4 runs in that mode.
IPv6 ranges are diverted only while **IPv6 support** is on in [Settings, Core](../settings/core#protocols). With it off, Telegram over IPv6 takes the normal path.

View file

@ -383,6 +383,29 @@ apk add kmod-nft-tproxy kmod-nft-socket
Кнопка **Информация о системе** в разделе Настройки -> Основные показывает в разделе **Возможности ядра**, доступен ли TPROXY, и называет пакеты с недостающими модулями.
:::
### Bridge netfilter
Когда `net.bridge.bridge-nf-call-iptables` равен `1`, ядро прогоняет правила IPv4 из PREROUTING для трафика, который входит через сетевой мост (bridge), например `br-lan` или `docker0` от Docker, прямо в коде моста и не прогоняет их повторно, когда пакет доходит до уровня IP. Правило TPROXY привязывает соединение к слушателю b4 именно в этом проходе, а уровень IP сбрасывает привязку, когда мост передаёт ему пакет. Метка маршрутизации при этом сохраняется, поэтому пакет всё равно доставляется локально, но его не принимает ни один сокет, а ответить от исходного адреса назначения ядро тоже не может. Соединения устройств за мостом висят до таймаута, и строк о соединениях для них в логе b4 нет. Соединения, которые открывает сам роутер, через мост не проходят и перехватываются как обычно. `net.bridge.bridge-nf-call-ip6tables` делает то же самое для IPv6. Кроме того, у каждого моста есть собственные атрибуты `nf_call_iptables` и `nf_call_ip6tables` в `/sys/class/net/<bridge>/bridge/`. Ядро выполняет этот проход, если `1` стоит либо в глобальной настройке, либо в атрибуте моста, поэтому `0`, записанный в атрибут, выключает проход для этого моста только при глобальной настройке, тоже равной `0`.
На OpenWrt обе настройки включает при загрузке пакет `dockerd` через файл `/etc/sysctl.d/12-br-netfilter-ip.conf`. Выключаются они без перезагрузки командами:
```sh
sysctl -w net.bridge.bridge-nf-call-iptables=0
sysctl -w net.bridge.bridge-nf-call-ip6tables=0
```
Чтобы значение пережило перезагрузку (кроме двух конфигураций Docker, описанных ниже), те же строки добавляются в `/etc/sysctl.conf`, который применяется после файлов из `/etc/sysctl.d`:
```sh
printf 'net.bridge.bridge-nf-call-iptables=0\nnet.bridge.bridge-nf-call-ip6tables=0\n' >> /etc/sysctl.conf
```
С выключенным bridge netfilter контейнеры в сети Docker, созданной с `icc=false`, перестают быть изолированы друг от друга, а при отключённом userland proxy Docker контейнер не достаёт до портов, которые публикуют другие контейнеры той же сети. Конфигурация Docker в OpenWrt по умолчанию не использует ни то, ни другое. В обеих конфигурациях dockerd сам снова выставляет `net.bridge.bridge-nf-call-iptables` в `1` всякий раз, когда настраивает затронутую сеть, в том числе при каждом своём запуске, который на OpenWrt происходит уже после применения sysctl при загрузке, а для сети с IPv6 делает то же с `net.bridge.bridge-nf-call-ip6tables`, поэтому выключенными эти настройки там не остаются.
:::info
Пока bridge netfilter включён для моста с портами и активен сет в этом режиме или в режиме «Telegram через WebSocket» либо переключатель «Telegram через WebSocket», сервис пишет в лог предупреждение с именем моста и настройкой, которую нужно изменить. Окно **Информация о системе** в разделе Настройки -> Основные показывает то же в строке **Bridge netfilter** раздела **Firewall**, а карточка «Telegram через WebSocket» при включённом переключателе показывает предупреждение.
:::
### Настройки
| Настройка | Описание |

View file

@ -95,6 +95,10 @@ b4 гасит этот код, понижает маршрут в приорит
Статус показывает **Не работает**, а подсказка называет правило, например `DIVERT`. Другая программа поставила в mangle PREROUTING выше правила моста правило, которое принимает любой пакет к локальному прозрачному сокету; XrayUI добавляет такое при каждом запуске xray. Оно забирает пакеты соединений, которые перенаправил мост, поэтому их рукопожатие не завершается. При следующей проверке файрвола b4 возвращает своё правило выше и пишет в лог предупреждение с именем правила, а **Проверить снова** делает то же сразу. Пока мониторинг файрвола выключен, то есть интервал мониторинга равен `0` или в разделе Настройки, Основные включено **Пропустить настройку IPTables/NFTables**, проверок нет, и порядок восстанавливают только **Проверить снова** или сохранение настроек. Механизм описан в разделе [b4 вместе с Xray или XrayUI](../guides/xray.md#правило-tproxy-от-xrayui-и-соединения-которые-перенаправляет-b4).
### Bridge netfilter включён для сетевого моста
Предупреждение начинается со слов «Bridge netfilter включён для» и называет сетевые мосты. Когда `net.bridge.bridge-nf-call-iptables` равен `1` (на OpenWrt его выставляет пакет `dockerd`), соединения устройств за этими мостами не доходят до слушателя и зависают, а соединения самого роутера ретранслируются. Статус при этом остаётся **Работает**, потому что правило и слушатель на месте. Причина, способ выключения и его влияние на Docker описаны в разделе [Bridge netfilter](../sets/routing.md#bridge-netfilter), а настройку, которую нужно изменить, называет строка **Bridge netfilter** раздела **Firewall** в окне **Информация о системе** (Настройки, Основные).
### Не удалось скачать список адресов
Предупреждение приводит ошибку скачивания и называет источник списка, который используется сейчас. Не ответили ни `core.telegram.org`, ни одно из зеркал b4. Используемый список остаётся, из какого бы источника он ни был взят, а встроенный список входит в него всегда. Следующая попытка идёт через 30 секунд, а после каждой новой неудачи пауза удваивается, но не больше часа; после успеха список обновляется раз в сутки. **Обновить адреса** повторяет попытку сразу.
@ -134,6 +138,10 @@ b4 гасит этот код, понижает маршрут в приорит
Сопоставление тоже должно происходить: сету нужна GeoIP-категория `telegram` и настроенная база GeoIP, а сет, в котором категория указана при пустом пути к базе, отклоняется при сохранении, а не работает вхолостую. Сет, ограниченный исходными интерфейсами или устройствами, не захватывает соединения самого роутера, в том числе Telegram Desktop на роутере.
## Устройства за сетевым мостом висят, а Telegram на самом роутере работает
При включённом bridge netfilter соединения, которые входят через сетевой мост вроде `br-lan`, не доходят до слушателя b4. Telegram на устройствах за ним висит на «Подключение», и строк `[tg-bridge]` для них в логе нет, тогда как соединения самого роутера, в том числе те, что SOCKS5-сервер b4 открывает для своих клиентов, ретранслируются как обычно. Сеты в режиме «Вышестоящий прокси SOCKS5» затронуты точно так же. Причина и настройки, которые её убирают, описаны в разделе [Bridge netfilter](../sets/routing.md#bridge-netfilter).
## На хосте WEB-прокси открывается страница-заглушка
Этой страницей релей отвечает на всё, что не распознал, и ни один из таких случаев не логируется: несовпавший хост, отсутствующий или неверный токен, истёкший тикет, обычный `GET` вместо WebSocket-апгрейда или достигнутый предел каналов.

View file

@ -46,6 +46,8 @@ flowchart TB
Перенаправление - это правило файрвола, поэтому файрволом должен управлять b4. Если в разделе [Настройки, Основные](../settings/core#фаервол) включено **Пропустить настройку IPTables/NFTables** (`system.tables.skip_setup`, флаг `--skip-tables`), при старте правила не устанавливаются. Сохранение настроек всё же устанавливает правила маршрутизации, и они остаются до следующего перезапуска.
При включённом bridge netfilter (`net.bridge.bridge-nf-call-iptables=1`, на OpenWrt его выставляет пакет `dockerd`) соединения устройств за сетевым мостом вроде `br-lan` не доходят до слушателя, а соединения самого роутера доходят, и карточка показывает предупреждение. Причина и способ выключения описаны в разделе [Bridge netfilter](../sets/routing.md#bridge-netfilter).
С движком TUN мост не проверялся, и карточка сообщает об этом, пока b4 работает в этом режиме.
Диапазоны IPv6 перенаправляются, только пока в разделе [Настройки, Основные](../settings/core#протоколы) включена **Поддержка IPv6**. Без неё Telegram по IPv6 идёт обычным путём.

View file

@ -0,0 +1,53 @@
package handler
import (
"slices"
"testing"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/tables"
)
func TestBridgeNetfilterDiag(t *testing.T) {
prev := routingTProxyFamilies
t.Cleanup(func() { routingTProxyFamilies = prev })
installed := func(ipv4, ipv6 bool) {
routingTProxyFamilies = func() (bool, bool) { return ipv4, ipv6 }
}
newCfg := func(ipv6 bool) *config.Config {
cfg := config.DefaultConfig
cfg.Queue.IPv4Enabled = true
cfg.Queue.IPv6Enabled = ipv6
return &cfg
}
on := tables.BridgeNetfilter{GlobalV4: true, BridgesV4: []string{"br-lan", "docker0"}}
both := tables.BridgeNetfilter{GlobalV4: true, GlobalV6: true, BridgesV4: []string{"br-lan"}, BridgesV6: []string{"br-lan", "br-v6"}}
cases := []struct {
name string
bn tables.BridgeNetfilter
cfg *config.Config
v4, v6 bool
bridges []string
safe bool
sysctls, att []string
}{
{name: "off", bn: tables.BridgeNetfilter{}, cfg: newCfg(false), v4: true, safe: true},
{name: "on without an installed transparent-proxy rule", bn: on, cfg: newCfg(false), bridges: []string{"br-lan", "docker0"}, safe: true},
{name: "on with an installed IPv4 rule", bn: on, cfg: newCfg(false), v4: true, bridges: []string{"br-lan", "docker0"}, sysctls: []string{"net.bridge.bridge-nf-call-iptables"}},
{name: "IPv6 bridges with IPv6 off", bn: tables.BridgeNetfilter{GlobalV6: true, BridgesV6: []string{"br-lan"}}, cfg: newCfg(false), v4: true, safe: true},
{name: "only the installed families conflict", bn: both, cfg: newCfg(true), v4: true, bridges: []string{"br-lan"}, sysctls: []string{"net.bridge.bridge-nf-call-iptables"}},
{name: "installed IPv6 rule", bn: both, cfg: newCfg(true), v4: true, v6: true, bridges: []string{"br-lan", "br-v6"}, sysctls: []string{"net.bridge.bridge-nf-call-iptables", "net.bridge.bridge-nf-call-ip6tables"}},
{name: "per-bridge flag", bn: tables.BridgeNetfilter{BridgesV4: []string{"br-lan"}}, cfg: newCfg(false), v4: true, bridges: []string{"br-lan"}, att: []string{"nf_call_iptables"}},
{name: "no config", bn: on, cfg: nil, bridges: []string{"br-lan", "docker0"}, safe: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
installed(tc.v4, tc.v6)
bridges, safe, sysctls, attrs := bridgeNetfilterDiag(tc.bn, tc.cfg)
if !slices.Equal(bridges, tc.bridges) || safe != tc.safe || !slices.Equal(sysctls, tc.sysctls) || !slices.Equal(attrs, tc.att) {
t.Fatalf("got bridges %v safe=%v sysctls %v attrs %v, want %v %v %v %v", bridges, safe, sysctls, attrs, tc.bridges, tc.safe, tc.sysctls, tc.att)
}
})
}
}

View file

@ -303,6 +303,7 @@ func collectFirewallInfo(cfg *config.Config) DiagFirewall {
info.NFQueueWorks = testNFQueue(info.Backend)
info.FlowOffload, info.FlowOffloadGuard = detectFlowOffload()
info.FlowOffloadSafe = flowOffloadSafe(info.FlowOffload, info.FlowOffloadGuard, cfg)
info.BridgeNetfilter, info.BridgeNetfilterSafe, info.BridgeNetfilterSysctls, info.BridgeNetfilterAttrs = bridgeNetfilterDiag(readBridgeNetfilter(), cfg)
if n, last := tables.RulesRestores(); n > 0 {
info.RulesRestores = n
@ -313,11 +314,35 @@ func collectFirewallInfo(cfg *config.Config) DiagFirewall {
}
func collectPolicyRuleGroup() []DiagRuleGroup {
rules := tables.RoutingPolicyRuleLines()
if len(rules) == 0 {
return nil
var groups []DiagRuleGroup
if rules := tables.RoutingPolicyRuleLines(); len(rules) > 0 {
groups = append(groups, DiagRuleGroup{Title: "ip rule (policy routing)", Rules: rules})
}
return []DiagRuleGroup{{Title: "ip rule (policy routing)", Rules: rules}}
if table, routes := tables.RoutingProxyTableLines(); len(routes) > 0 {
groups = append(groups, DiagRuleGroup{Title: fmt.Sprintf("ip route (proxy table %d)", table), Rules: routes})
}
return groups
}
var (
readBridgeNetfilter = tables.ReadBridgeNetfilter
routingTProxyFamilies = tables.RoutingTProxyFamilies
)
func diagFamilies(cfg *config.Config) (ipv4, ipv6 bool) {
if cfg == nil {
return true, false
}
return cfg.Queue.IPv4Enabled, cfg.Queue.IPv6Enabled
}
func bridgeNetfilterDiag(bn tables.BridgeNetfilter, cfg *config.Config) (bridges []string, safe bool, sysctls, attrs []string) {
ipv4, ipv6 := routingTProxyFamilies()
if conflicting := bn.Bridges(ipv4, ipv6); len(conflicting) > 0 {
sysctls, attrs = bn.Switches(ipv4, ipv6)
return conflicting, false, sysctls, attrs
}
return bn.Bridges(diagFamilies(cfg)), true, nil, nil
}
func detectFirewallBackend() string {
@ -851,7 +876,9 @@ func collectKernelModules(cfg *config.Config) DiagKernel {
Packages: tproxyPkgs,
Reasons: tables.KernelModuleReasons(tproxyMissing),
}
if tproxyOK {
if bridges, safe, _, _ := bridgeNetfilterDiag(readBridgeNetfilter(), cfg); tproxyOK && !safe {
tproxyCap.Detail = fmt.Sprintf("TPROXY + socket match available, but bridge netfilter is on for %s, so proxy and mtproto-ws routing modes cannot capture the devices behind these bridges", strings.Join(bridges, ", "))
} else if tproxyOK {
tproxyCap.Detail = "TPROXY + socket match available (proxy and mtproto-ws routing modes work)"
} else {
tproxyCap.Detail = "TPROXY/socket match unavailable - transparent redirect (proxy and mtproto-ws routing, e.g. Telegram bridge) will not work"

View file

@ -35,22 +35,25 @@ type BridgeLegacySet struct {
}
type TelegramBridgeStatus struct {
Success bool `json:"success"`
Enabled bool `json:"enabled"`
Addresses mtproto.TelegramCIDRStatus `json:"addresses"`
Listener BridgeListenerInfo `json:"listener"`
RuleInstalled bool `json:"rule_installed"`
RuleShadowed string `json:"rule_shadowed_by,omitempty"`
TProxy BridgeTProxyInfo `json:"tproxy"`
SkipSetup bool `json:"skip_setup"`
QueueMode string `json:"queue_mode"`
IPv6Enabled bool `json:"ipv6_enabled"`
LegacySets []BridgeLegacySet `json:"legacy_sets"`
Stats mtproto.BridgeStats `json:"stats"`
Success bool `json:"success"`
Enabled bool `json:"enabled"`
Addresses mtproto.TelegramCIDRStatus `json:"addresses"`
Listener BridgeListenerInfo `json:"listener"`
RuleInstalled bool `json:"rule_installed"`
RuleShadowed string `json:"rule_shadowed_by,omitempty"`
TProxy BridgeTProxyInfo `json:"tproxy"`
SkipSetup bool `json:"skip_setup"`
QueueMode string `json:"queue_mode"`
IPv6Enabled bool `json:"ipv6_enabled"`
BridgeNetfilter []string `json:"bridge_netfilter,omitempty"`
LegacySets []BridgeLegacySet `json:"legacy_sets"`
Stats mtproto.BridgeStats `json:"stats"`
}
var bridgeListenerFunc func() BridgeListenerInfo
var routingSetFamilies = tables.RoutingSetFamilies
var bridgeTProxyProbe = func(cfg *config.Config, recheck bool) BridgeTProxyInfo {
ok, checked, missing, packages := tables.TProxyCapability(cfg, recheck)
return bridgeTProxyResult(ok, checked, missing, packages)
@ -115,6 +118,11 @@ func buildTelegramBridgeStatus(cfg *config.Config, recheck, probe bool) Telegram
if st.RuleInstalled {
st.RuleShadowed = tables.RoutingPreJumpShadowedBy(config.TelegramBridgeSetID)
}
if enabled {
if ipv4, ipv6, installed := routingSetFamilies(config.TelegramBridgeSetID); installed {
st.BridgeNetfilter = readBridgeNetfilter().Bridges(ipv4, ipv6)
}
}
if b, ok := globalMTProtoBridge.(interface{ Stats() mtproto.BridgeStats }); ok {
st.Stats = b.Stats()
}

View file

@ -5,10 +5,12 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/tables"
)
func bridgeTestAPI(t *testing.T, cfg *config.Config) (*API, *http.ServeMux) {
@ -19,10 +21,12 @@ func bridgeTestAPI(t *testing.T, cfg *config.Config) (*API, *http.ServeMux) {
api.mux = mux
api.RegisterMTProtoApi()
prevProbe, prevCached, prevListener := bridgeTProxyProbe, bridgeTProxyCached, bridgeListenerFunc
prevProbe, prevCached, prevListener, prevNetfilter, prevFamilies := bridgeTProxyProbe, bridgeTProxyCached, bridgeListenerFunc, readBridgeNetfilter, routingSetFamilies
t.Cleanup(func() {
bridgeTProxyProbe, bridgeTProxyCached, bridgeListenerFunc = prevProbe, prevCached, prevListener
bridgeTProxyProbe, bridgeTProxyCached, bridgeListenerFunc, readBridgeNetfilter, routingSetFamilies = prevProbe, prevCached, prevListener, prevNetfilter, prevFamilies
})
readBridgeNetfilter = func() tables.BridgeNetfilter { return tables.BridgeNetfilter{} }
routingSetFamilies = func(string) (bool, bool, bool) { return false, false, false }
bridgeTProxyCached = func(*config.Config) BridgeTProxyInfo {
return BridgeTProxyInfo{Missing: []string{}, Packages: []string{}}
}
@ -115,6 +119,45 @@ func TestTelegramBridgeStatusDoesNotProbeWhileOff(t *testing.T) {
}
}
func TestTelegramBridgeStatusNamesBridgeNetfilter(t *testing.T) {
cfg := config.NewConfig()
_, mux := bridgeTestAPI(t, &cfg)
bridgeTProxyProbe = func(*config.Config, bool) BridgeTProxyInfo {
return BridgeTProxyInfo{Missing: []string{}, Packages: []string{}}
}
readBridgeNetfilter = func() tables.BridgeNetfilter {
return tables.BridgeNetfilter{GlobalV4: true, GlobalV6: true, BridgesV4: []string{"br-lan"}, BridgesV6: []string{"br-lan", "br-v6"}}
}
var ipv4, ipv6, installed bool
routingSetFamilies = func(id string) (bool, bool, bool) {
if id != config.TelegramBridgeSetID {
t.Errorf("asked for the families of set %q", id)
}
return ipv4, ipv6, installed
}
ipv4, ipv6, installed = true, true, true
if st := getBridgeStatus(t, mux, ""); len(st.BridgeNetfilter) != 0 {
t.Errorf("with the switch off the card has nothing to warn about, got %v", st.BridgeNetfilter)
}
cfg.System.MTProto.Bridge.Enabled = true
ipv4, ipv6, installed = false, false, false
if st := getBridgeStatus(t, mux, ""); len(st.BridgeNetfilter) != 0 {
t.Errorf("without an installed bridge rule there is nothing to warn about, got %v", st.BridgeNetfilter)
}
ipv4, ipv6, installed = true, false, true
if st := getBridgeStatus(t, mux, ""); !slices.Equal(st.BridgeNetfilter, []string{"br-lan"}) {
t.Errorf("with the IPv6 listener down only the IPv4 bridges matter, got %v", st.BridgeNetfilter)
}
ipv4, ipv6, installed = true, true, true
if st := getBridgeStatus(t, mux, ""); !slices.Equal(st.BridgeNetfilter, []string{"br-lan", "br-v6"}) {
t.Errorf("with both families installed every affected bridge matters, got %v", st.BridgeNetfilter)
}
}
func TestMTProtoConfigPostKeepsTheBridgeSwitch(t *testing.T) {
cfg := config.NewConfig()
cfg.System.MTProto.Bridge.Enabled = true

View file

@ -143,12 +143,16 @@ type DiagTUN struct {
}
type DiagFirewall struct {
Backend string `json:"backend"`
NFQueueWorks bool `json:"nfqueue_works"`
FlowOffload string `json:"flow_offload"`
FlowOffloadGuard int `json:"flow_offload_guard,omitempty"`
FlowOffloadSafe bool `json:"flow_offload_safe"`
RuleGroups []DiagRuleGroup `json:"rule_groups,omitempty"`
Backend string `json:"backend"`
NFQueueWorks bool `json:"nfqueue_works"`
FlowOffload string `json:"flow_offload"`
FlowOffloadGuard int `json:"flow_offload_guard,omitempty"`
FlowOffloadSafe bool `json:"flow_offload_safe"`
BridgeNetfilter []string `json:"bridge_netfilter,omitempty"`
BridgeNetfilterSafe bool `json:"bridge_netfilter_safe"`
BridgeNetfilterSysctls []string `json:"bridge_netfilter_sysctls,omitempty"`
BridgeNetfilterAttrs []string `json:"bridge_netfilter_attrs,omitempty"`
RuleGroups []DiagRuleGroup `json:"rule_groups,omitempty"`
RulesRestores int64 `json:"rules_restores,omitempty"`
LastRulesRestore string `json:"last_rules_restore,omitempty"`

View file

@ -184,6 +184,17 @@ const BridgeWarnings = ({ data }: { data: TelegramBridgeStatus }) => {
{data.queue_mode === "tun" && (
<B4Alert severity="info">{t(K("tunUnverified"))}</B4Alert>
)}
{!!data.bridge_netfilter?.length && (
<B4Alert severity="warning">
<Trans
i18nKey={K("bridgeNetfilter")}
values={{ bridges: data.bridge_netfilter.join(", ") }}
components={{
a: <Link component={RouterLink} to="/settings/general" />,
}}
/>
</B4Alert>
)}
{data.legacy_sets.length > 0 && (
<B4Alert severity="info">
{t(K("legacySets"))}{" "}

View file

@ -80,6 +80,30 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
});
};
const bridgeNetfilterText = (fw: Diagnostics["firewall"]) => {
const bridges = fw.bridge_netfilter ?? [];
if (bridges.length === 0)
return t("settings.SystemInfo.bridgeNetfilterOff");
const key = fw.bridge_netfilter_safe
? "bridgeNetfilterOn"
: "bridgeNetfilterUnsafe";
return t(`settings.SystemInfo.${key}`, { bridges: bridges.join(", ") });
};
const bridgeNetfilterHint = (fw: Diagnostics["firewall"]) => {
const sysctls = fw.bridge_netfilter_sysctls ?? [];
const attrs = fw.bridge_netfilter_attrs ?? [];
return [
t("settings.SystemInfo.bridgeNetfilterHint"),
...sysctls.map((key) =>
t("settings.SystemInfo.bridgeNetfilterFixSysctl", { key }),
),
...attrs.map((attr) =>
t("settings.SystemInfo.bridgeNetfilterFixAttr", { attr }),
),
].join(" ");
};
const formatRestoreTime = (iso?: string) =>
iso ? new Date(iso).toLocaleString(i18n.language) : "";
@ -483,6 +507,27 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => {
flowOffloadText(data.firewall),
),
)}
{row(
t("settings.SystemInfo.bridgeNetfilter"),
boolChip(
data.firewall.bridge_netfilter_safe,
bridgeNetfilterText(data.firewall),
bridgeNetfilterText(data.firewall),
),
)}
{!data.firewall.bridge_netfilter_safe && (
<Typography
variant="caption"
sx={{
display: "block",
px: 1,
color: colors.text.secondary,
fontSize: "0.7rem",
}}
>
{bridgeNetfilterHint(data.firewall)}
</Typography>
)}
{!!data.firewall.rules_restores &&
row(
t("settings.SystemInfo.fwRulesRestores"),

View file

@ -467,6 +467,7 @@
"listenerV6Error": "IPv4 goes through the bridge. The IPv6 listener could not start ({{error}}), so Telegram over IPv6 takes the normal path.",
"downloadFailed": "Downloading the Telegram address list failed: {{error}}. The list in use comes from: {{source}}.",
"tunUnverified": "The bridge has not been verified with the TUN capture engine.",
"bridgeNetfilter": "Bridge netfilter is on for {{bridges}}, so connections from the devices behind these network bridges hang instead of reaching the bridge, and only the router's own connections are relayed. System Info in <a>Settings -> Core</a> shows how to turn it off.",
"legacySets": "These sets also route Telegram through the bridge with the 'Telegram over WebSocket' routing mode. With the switch on they are redundant:",
"legacySetOff": "off",
"udpNote": "Voice calls (UDP) and QUIC are not bridged."
@ -805,6 +806,13 @@
"flowOffloadHwGuarded": "hardware, after {{packets}} packets",
"flowOffloadSwGuardedUnsafe": "software, after {{packets}} packets (bypasses b4)",
"flowOffloadHwGuardedUnsafe": "hardware, after {{packets}} packets (bypasses b4)",
"bridgeNetfilter": "Bridge netfilter",
"bridgeNetfilterOff": "off",
"bridgeNetfilterOn": "on: {{bridges}}",
"bridgeNetfilterUnsafe": "on: {{bridges}} (breaks proxy routing)",
"bridgeNetfilterHint": "Sets in the Upstream SOCKS5 proxy or Telegram over WebSocket mode, and the Telegram over WebSocket switch, cannot take over connections from the devices behind these bridges: the connections hang, and only the router's own connections get through.",
"bridgeNetfilterFixSysctl": "Turn it off with sysctl -w {{key}}=0, and add the line {{key}}=0 to /etc/sysctl.conf to keep it off after a reboot.",
"bridgeNetfilterFixAttr": "On the bridges that have it on by themselves, write 0 to /sys/class/net/<bridge>/bridge/{{attr}}.",
"network": "Network",
"engine": "Engine",
"engineMode": "Mode",

View file

@ -470,6 +470,7 @@
"listenerV6Error": "IPv4 идёт через мост. Слушатель IPv6 не запустился ({{error}}), поэтому Telegram по IPv6 идёт обычным путём.",
"downloadFailed": "Не удалось скачать список адресов Telegram: {{error}}. Сейчас используется список из источника: {{source}}.",
"tunUnverified": "Работа моста с движком захвата TUN не проверялась.",
"bridgeNetfilter": "Bridge netfilter включён для {{bridges}}, поэтому соединения устройств за этими bridge-интерфейсами зависают и не доходят до моста, ретранслируются только соединения самого роутера. Как его выключить, показывает окно «Информация о системе» в разделе <a>Настройки -> Основные</a>.",
"legacySets": "Эти сеты тоже направляют Telegram через мост режимом маршрутизации «Telegram через WebSocket». При включённом переключателе они избыточны:",
"legacySetOff": "выключен",
"udpNote": "Голосовые звонки (UDP) и QUIC через мост не идут."
@ -808,6 +809,13 @@
"flowOffloadHwGuarded": "аппаратный, после {{packets}} пакетов",
"flowOffloadSwGuardedUnsafe": "программный, после {{packets}} пакетов (в обход b4)",
"flowOffloadHwGuardedUnsafe": "аппаратный, после {{packets}} пакетов (в обход b4)",
"bridgeNetfilter": "Bridge netfilter",
"bridgeNetfilterOff": "выкл",
"bridgeNetfilterOn": "вкл: {{bridges}}",
"bridgeNetfilterUnsafe": "вкл: {{bridges}} (ломает маршрутизацию через proxy)",
"bridgeNetfilterHint": "Сеты в режимах «Upstream SOCKS5 proxy» и «Telegram через WebSocket», а также переключатель «Telegram через WebSocket» не могут перехватить соединения устройств за этими bridge-интерфейсами: соединения зависают, проходят только соединения самого роутера.",
"bridgeNetfilterFixSysctl": "Выключается командой sysctl -w {{key}}=0; чтобы значение сохранилось после перезагрузки, добавьте строку {{key}}=0 в /etc/sysctl.conf.",
"bridgeNetfilterFixAttr": "На bridge-интерфейсах, где он включён отдельно, запишите 0 в /sys/class/net/<bridge>/bridge/{{attr}}.",
"network": "Сеть",
"engine": "Движок",
"engineMode": "Режим",

View file

@ -57,6 +57,7 @@ export interface TelegramBridgeStatus {
skip_setup: boolean;
queue_mode: string;
ipv6_enabled: boolean;
bridge_netfilter?: string[];
legacy_sets: TelegramBridgeLegacySet[];
stats: TelegramBridgeStats;
}

View file

@ -67,6 +67,10 @@ interface DiagFirewall {
flow_offload: string;
flow_offload_guard?: number;
flow_offload_safe: boolean;
bridge_netfilter?: string[];
bridge_netfilter_safe: boolean;
bridge_netfilter_sysctls?: string[];
bridge_netfilter_attrs?: string[];
rule_groups?: DiagRuleGroup[];
rules_restores?: number;
last_rules_restore?: string;

View file

@ -0,0 +1,161 @@
package tables
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/log"
)
var (
bridgeNetfilterProcDir = "/proc/sys/net/bridge"
bridgeNetfilterNetDir = "/sys/class/net"
bridgeNetfilterDockerdConf = "/etc/sysctl.d/12-br-netfilter-ip.conf"
)
type BridgeNetfilter struct {
GlobalV4 bool
GlobalV6 bool
BridgesV4 []string
BridgesV6 []string
}
func ReadBridgeNetfilter() BridgeNetfilter {
var bn BridgeNetfilter
if _, err := os.Stat(bridgeNetfilterProcDir); err != nil {
return bn
}
bn.GlobalV4 = bridgeNetfilterFlag(filepath.Join(bridgeNetfilterProcDir, "bridge-nf-call-iptables"))
bn.GlobalV6 = bridgeNetfilterFlag(filepath.Join(bridgeNetfilterProcDir, "bridge-nf-call-ip6tables"))
entries, err := os.ReadDir(bridgeNetfilterNetDir)
if err != nil {
return bn
}
for _, e := range entries {
dir := filepath.Join(bridgeNetfilterNetDir, e.Name(), "bridge")
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
continue
}
if ports, err := os.ReadDir(filepath.Join(bridgeNetfilterNetDir, e.Name(), "brif")); err == nil && len(ports) == 0 {
continue
}
if bn.GlobalV4 || bridgeNetfilterFlag(filepath.Join(dir, "nf_call_iptables")) {
bn.BridgesV4 = append(bn.BridgesV4, e.Name())
}
if bn.GlobalV6 || bridgeNetfilterFlag(filepath.Join(dir, "nf_call_ip6tables")) {
bn.BridgesV6 = append(bn.BridgesV6, e.Name())
}
}
return bn
}
func bridgeNetfilterFlag(path string) bool {
b, err := os.ReadFile(path)
return err == nil && strings.TrimSpace(string(b)) == "1"
}
func (bn BridgeNetfilter) Bridges(ipv4, ipv6 bool) []string {
var out []string
if ipv4 {
out = append(out, bn.BridgesV4...)
}
if ipv6 {
out = append(out, bn.BridgesV6...)
}
slices.Sort(out)
return slices.Compact(out)
}
func (bn BridgeNetfilter) Switches(ipv4, ipv6 bool) (sysctls, attrs []string) {
if ipv4 && len(bn.BridgesV4) > 0 {
if bn.GlobalV4 {
sysctls = append(sysctls, "net.bridge.bridge-nf-call-iptables")
} else {
attrs = append(attrs, "nf_call_iptables")
}
}
if ipv6 && len(bn.BridgesV6) > 0 {
if bn.GlobalV6 {
sysctls = append(sysctls, "net.bridge.bridge-nf-call-ip6tables")
} else {
attrs = append(attrs, "nf_call_ip6tables")
}
}
return sysctls, attrs
}
func bridgeNetfilterWarning(bn BridgeNetfilter, ipv4, ipv6 bool) string {
bridges := bn.Bridges(ipv4, ipv6)
if len(bridges) == 0 {
return ""
}
sysctls, attrs := bn.Switches(ipv4, ipv6)
var b strings.Builder
fmt.Fprintf(&b, "Routing: bridge netfilter is on for %s, so proxy and mtproto-ws sets and Telegram over WebSocket cannot take over connections from the devices behind these bridges: those connections hang until they time out, and only the router's own connections get through.", strings.Join(bridges, ", "))
if len(sysctls) > 0 {
cmds := make([]string, 0, len(sysctls))
lines := make([]string, 0, len(sysctls))
for _, name := range sysctls {
cmds = append(cmds, "sysctl -w "+name+"=0")
lines = append(lines, name+"=0")
}
fmt.Fprintf(&b, " Turn it off with '%s' and add '%s' to /etc/sysctl.conf to keep it off after a reboot.", strings.Join(cmds, "; "), strings.Join(lines, "' and '"))
if _, err := os.Stat(bridgeNetfilterDockerdConf); err == nil {
fmt.Fprintf(&b, " Here %s, which the dockerd package installs, turns it on at boot.", bridgeNetfilterDockerdConf)
}
}
if len(attrs) > 0 {
fmt.Fprintf(&b, " Write 0 to %s under /sys/class/net/<bridge>/bridge/ for the bridges that have it on.", strings.Join(attrs, " and "))
}
return b.String()
}
var (
bridgeNetfilterMu sync.Mutex
bridgeNetfilterLogged string
)
func RoutingCheckBridgeNetfilter() {
routeNoteBridgeNetfilter(RoutingTProxyFamilies())
}
func RoutingTProxyFamilies() (ipv4, ipv6 bool) {
routeMu.Lock()
defer routeMu.Unlock()
return routeTProxyFamiliesLocked()
}
func routeTProxyFamiliesLocked() (ipv4, ipv6 bool) {
for _, st := range routeRuleCache {
if !config.RoutingUsesTProxy(st.mode) {
continue
}
ipv4 = ipv4 || st.ipv4
ipv6 = ipv6 || st.ipv6
}
return ipv4, ipv6
}
func routeNoteBridgeNetfilter(ipv4, ipv6 bool) {
msg := bridgeNetfilterWarning(ReadBridgeNetfilter(), ipv4, ipv6)
bridgeNetfilterMu.Lock()
defer bridgeNetfilterMu.Unlock()
if msg == bridgeNetfilterLogged {
return
}
cleared := bridgeNetfilterLogged != "" && msg == "" && (ipv4 || ipv6)
bridgeNetfilterLogged = msg
if msg != "" {
log.Warnf("%s", msg)
return
}
if cleared {
log.Infof("Routing: bridge netfilter no longer runs on a bridge with ports, so proxy and mtproto-ws sets and Telegram over WebSocket reach the devices behind the bridges again")
}
}

View file

@ -0,0 +1,359 @@
package tables
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/daniellavrushin/b4/config"
)
type bridgeNetfilterFixture struct {
root string
proc string
net string
}
func newBridgeNetfilterFixture(t *testing.T, loaded bool) bridgeNetfilterFixture {
t.Helper()
root := t.TempDir()
f := bridgeNetfilterFixture{root: root, proc: filepath.Join(root, "proc"), net: filepath.Join(root, "net")}
bnfMkdir(t, f.net)
if loaded {
bnfMkdir(t, f.proc)
}
oldProc, oldNet, oldConf := bridgeNetfilterProcDir, bridgeNetfilterNetDir, bridgeNetfilterDockerdConf
bridgeNetfilterProcDir = f.proc
bridgeNetfilterNetDir = f.net
bridgeNetfilterDockerdConf = filepath.Join(root, "12-br-netfilter-ip.conf")
t.Cleanup(func() {
bridgeNetfilterProcDir, bridgeNetfilterNetDir, bridgeNetfilterDockerdConf = oldProc, oldNet, oldConf
})
return f
}
func (f bridgeNetfilterFixture) global(t *testing.T, v4, v6 string) {
t.Helper()
bnfWrite(t, filepath.Join(f.proc, "bridge-nf-call-iptables"), v4)
bnfWrite(t, filepath.Join(f.proc, "bridge-nf-call-ip6tables"), v6)
}
func (f bridgeNetfilterFixture) bridge(t *testing.T, name, v4, v6 string) {
t.Helper()
dir := filepath.Join(f.net, name, "bridge")
bnfMkdir(t, dir)
bnfWrite(t, filepath.Join(dir, "nf_call_iptables"), v4)
bnfWrite(t, filepath.Join(dir, "nf_call_ip6tables"), v6)
}
func (f bridgeNetfilterFixture) ports(t *testing.T, bridge string, ports ...string) {
t.Helper()
dir := filepath.Join(f.net, bridge, "brif")
bnfMkdir(t, dir)
for _, p := range ports {
bnfMkdir(t, filepath.Join(dir, p))
}
}
func (f bridgeNetfilterFixture) iface(t *testing.T, name string) {
t.Helper()
bnfMkdir(t, filepath.Join(f.net, name))
}
func bnfMkdir(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
func bnfWrite(t *testing.T, path, value string) {
t.Helper()
if err := os.WriteFile(path, []byte(value+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadBridgeNetfilterModuleNotLoaded(t *testing.T) {
f := newBridgeNetfilterFixture(t, false)
f.bridge(t, "br-lan", "1", "1")
bn := ReadBridgeNetfilter()
if bn.GlobalV4 || bn.GlobalV6 || len(bn.BridgesV4) > 0 || len(bn.BridgesV6) > 0 {
t.Fatalf("without br_netfilter no bridge runs the IP hooks, got %+v", bn)
}
}
func TestReadBridgeNetfilterGlobalCoversEveryBridge(t *testing.T) {
f := newBridgeNetfilterFixture(t, true)
f.global(t, "1", "0")
f.bridge(t, "docker0", "0", "0")
f.bridge(t, "br-lan", "0", "0")
f.iface(t, "eth0")
bn := ReadBridgeNetfilter()
if !bn.GlobalV4 || bn.GlobalV6 {
t.Fatalf("global flags = v4 %v v6 %v, want true false", bn.GlobalV4, bn.GlobalV6)
}
if want := []string{"br-lan", "docker0"}; !slices.Equal(bn.BridgesV4, want) {
t.Fatalf("BridgesV4 = %v, want %v", bn.BridgesV4, want)
}
if len(bn.BridgesV6) != 0 {
t.Fatalf("BridgesV6 = %v, want none", bn.BridgesV6)
}
}
func TestReadBridgeNetfilterPerBridgeFlag(t *testing.T) {
f := newBridgeNetfilterFixture(t, true)
f.global(t, "0", "0")
f.bridge(t, "br-lan", "1", "0")
f.bridge(t, "docker0", "0", "1")
bn := ReadBridgeNetfilter()
if want := []string{"br-lan"}; !slices.Equal(bn.BridgesV4, want) {
t.Fatalf("BridgesV4 = %v, want %v", bn.BridgesV4, want)
}
if want := []string{"docker0"}; !slices.Equal(bn.BridgesV6, want) {
t.Fatalf("BridgesV6 = %v, want %v", bn.BridgesV6, want)
}
}
func TestReadBridgeNetfilterSkipsBridgesWithoutPorts(t *testing.T) {
f := newBridgeNetfilterFixture(t, true)
f.global(t, "1", "1")
f.bridge(t, "br-lan", "0", "0")
f.ports(t, "br-lan", "eth0", "eth2")
f.bridge(t, "docker0", "0", "0")
f.ports(t, "docker0")
f.bridge(t, "br-guest", "0", "0")
bn := ReadBridgeNetfilter()
want := []string{"br-guest", "br-lan"}
if !slices.Equal(bn.BridgesV4, want) || !slices.Equal(bn.BridgesV6, want) {
t.Fatalf("got v4 %v v6 %v, want %v for both: a bridge with an empty brif carries no traffic, an unreadable brif is kept", bn.BridgesV4, bn.BridgesV6, want)
}
}
func TestBridgeNetfilterSwitches(t *testing.T) {
cases := []struct {
name string
bn BridgeNetfilter
ipv4, ipv6 bool
sysctls, attr []string
}{
{
name: "global IPv4",
bn: BridgeNetfilter{GlobalV4: true, GlobalV6: true, BridgesV4: []string{"br-lan"}, BridgesV6: []string{"br-lan"}},
ipv4: true,
sysctls: []string{"net.bridge.bridge-nf-call-iptables"},
},
{
name: "global both families",
bn: BridgeNetfilter{GlobalV4: true, GlobalV6: true, BridgesV4: []string{"br-lan"}, BridgesV6: []string{"br-lan"}},
ipv4: true,
ipv6: true,
sysctls: []string{"net.bridge.bridge-nf-call-iptables", "net.bridge.bridge-nf-call-ip6tables"},
},
{
name: "per-bridge IPv4 only",
bn: BridgeNetfilter{BridgesV4: []string{"br-lan"}},
ipv4: true,
ipv6: true,
attr: []string{"nf_call_iptables"},
},
{
name: "global IPv4 and per-bridge IPv6",
bn: BridgeNetfilter{GlobalV4: true, BridgesV4: []string{"br-lan"}, BridgesV6: []string{"docker0"}},
ipv4: true,
ipv6: true,
sysctls: []string{"net.bridge.bridge-nf-call-iptables"},
attr: []string{"nf_call_ip6tables"},
},
{
name: "global flag without a bridge that has ports",
bn: BridgeNetfilter{GlobalV4: true},
ipv4: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sysctls, attrs := tc.bn.Switches(tc.ipv4, tc.ipv6)
if !slices.Equal(sysctls, tc.sysctls) || !slices.Equal(attrs, tc.attr) {
t.Fatalf("got sysctls %v attrs %v, want %v and %v", sysctls, attrs, tc.sysctls, tc.attr)
}
})
}
}
func TestBridgeNetfilterBridgesByFamily(t *testing.T) {
bn := BridgeNetfilter{BridgesV4: []string{"br-lan", "docker0"}, BridgesV6: []string{"br-guest", "br-lan"}}
if got, want := bn.Bridges(true, true), []string{"br-guest", "br-lan", "docker0"}; !slices.Equal(got, want) {
t.Fatalf("Bridges(v4, v6) = %v, want %v", got, want)
}
if got, want := bn.Bridges(false, true), []string{"br-guest", "br-lan"}; !slices.Equal(got, want) {
t.Fatalf("Bridges(v6) = %v, want %v", got, want)
}
if got := bn.Bridges(false, false); len(got) != 0 {
t.Fatalf("Bridges() = %v, want none", got)
}
if want := []string{"br-lan", "docker0"}; !slices.Equal(bn.BridgesV4, want) {
t.Fatalf("Bridges changed BridgesV4 to %v", bn.BridgesV4)
}
}
func TestBridgeNetfilterWarning(t *testing.T) {
newBridgeNetfilterFixture(t, true)
t.Run("no bridge runs the hooks", func(t *testing.T) {
if msg := bridgeNetfilterWarning(BridgeNetfilter{}, true, true); msg != "" {
t.Fatalf("got %q, want no warning", msg)
}
})
t.Run("only the family that is not in use", func(t *testing.T) {
bn := BridgeNetfilter{GlobalV6: true, BridgesV6: []string{"br-lan"}}
if msg := bridgeNetfilterWarning(bn, true, false); msg != "" {
t.Fatalf("got %q, want no warning for IPv6 bridges with IPv6 off", msg)
}
})
t.Run("global IPv4", func(t *testing.T) {
bn := BridgeNetfilter{GlobalV4: true, BridgesV4: []string{"br-lan", "docker0"}}
msg := bridgeNetfilterWarning(bn, true, false)
for _, want := range []string{"br-lan, docker0", "sysctl -w net.bridge.bridge-nf-call-iptables=0", "/etc/sysctl.conf"} {
if !strings.Contains(msg, want) {
t.Errorf("warning %q does not contain %q", msg, want)
}
}
for _, unwanted := range []string{"ip6tables", "12-br-netfilter-ip.conf", "/sys/class/net"} {
if strings.Contains(msg, unwanted) {
t.Errorf("warning %q should not contain %q", msg, unwanted)
}
}
})
t.Run("global both families", func(t *testing.T) {
bn := BridgeNetfilter{GlobalV4: true, GlobalV6: true, BridgesV4: []string{"br-lan"}, BridgesV6: []string{"br-lan"}}
msg := bridgeNetfilterWarning(bn, true, true)
for _, want := range []string{"sysctl -w net.bridge.bridge-nf-call-iptables=0", "sysctl -w net.bridge.bridge-nf-call-ip6tables=0"} {
if !strings.Contains(msg, want) {
t.Errorf("warning %q does not contain %q", msg, want)
}
}
})
t.Run("per-bridge flag only", func(t *testing.T) {
bn := BridgeNetfilter{BridgesV4: []string{"br-lan"}}
msg := bridgeNetfilterWarning(bn, true, false)
if !strings.Contains(msg, "nf_call_iptables under /sys/class/net/<bridge>/bridge/") {
t.Errorf("warning %q does not point at the per-bridge flag", msg)
}
if strings.Contains(msg, "sysctl -w") {
t.Errorf("warning %q suggests the global sysctl, which is already off", msg)
}
})
t.Run("dockerd sysctl file named when present", func(t *testing.T) {
bnfWrite(t, bridgeNetfilterDockerdConf, "net.bridge.bridge-nf-call-iptables=1")
bn := BridgeNetfilter{GlobalV4: true, BridgesV4: []string{"br-lan"}}
if msg := bridgeNetfilterWarning(bn, true, false); !strings.Contains(msg, bridgeNetfilterDockerdConf) {
t.Errorf("warning %q does not name %s", msg, bridgeNetfilterDockerdConf)
}
})
}
func TestRouteNoteBridgeNetfilterWarnsOnChange(t *testing.T) {
f := newBridgeNetfilterFixture(t, true)
f.bridge(t, "br-lan", "0", "0")
bridgeNetfilterMu.Lock()
saved := bridgeNetfilterLogged
bridgeNetfilterLogged = ""
bridgeNetfilterMu.Unlock()
t.Cleanup(func() {
bridgeNetfilterMu.Lock()
bridgeNetfilterLogged = saved
bridgeNetfilterMu.Unlock()
})
logged := func() string {
bridgeNetfilterMu.Lock()
defer bridgeNetfilterMu.Unlock()
return bridgeNetfilterLogged
}
f.global(t, "1", "0")
routeNoteBridgeNetfilter(true, false)
first := logged()
if !strings.Contains(first, "br-lan") {
t.Fatalf("expected a warning naming br-lan, got %q", first)
}
routeNoteBridgeNetfilter(true, false)
if logged() != first {
t.Fatalf("an unchanged state must not produce a new warning")
}
f.global(t, "0", "0")
routeNoteBridgeNetfilter(true, false)
if got := logged(); got != "" {
t.Fatalf("with bridge netfilter off the warning must clear, got %q", got)
}
f.global(t, "1", "0")
routeNoteBridgeNetfilter(false, false)
if got := logged(); got != "" {
t.Fatalf("without transparent-proxy sets there is nothing to warn about, got %q", got)
}
}
func TestRoutingInstalledFamilies(t *testing.T) {
routeMu.Lock()
saved := routeRuleCache
routeRuleCache = map[string]routeState{
config.TelegramBridgeSetID: {mode: config.RoutingModeMTProtoWS, ipv4: true},
"iface": {mode: config.RoutingModeInterface, ipv4: true, ipv6: true},
}
routeMu.Unlock()
t.Cleanup(func() {
routeMu.Lock()
routeRuleCache = saved
routeMu.Unlock()
})
if ipv4, ipv6, ok := RoutingSetFamilies(config.TelegramBridgeSetID); !ok || !ipv4 || ipv6 {
t.Errorf("bridge set: got v4 %v v6 %v installed %v, want true false true", ipv4, ipv6, ok)
}
if _, _, ok := RoutingSetFamilies("missing"); ok {
t.Error("a set that is not installed reported as installed")
}
if ipv4, ipv6 := RoutingTProxyFamilies(); !ipv4 || ipv6 {
t.Errorf("only the IPv4 mtproto-ws rule uses TPROXY: got v4 %v v6 %v", ipv4, ipv6)
}
}
func TestRouteTProxyFamiliesLocked(t *testing.T) {
routeMu.Lock()
saved := routeRuleCache
routeRuleCache = map[string]routeState{
"iface": {mode: config.RoutingModeInterface, ipv4: true, ipv6: true},
"proxy": {mode: config.RoutingModeProxy, ipv4: true},
}
v4, v6 := routeTProxyFamiliesLocked()
routeRuleCache["tg"] = routeState{mode: config.RoutingModeMTProtoWS, ipv6: true}
bothV4, bothV6 := routeTProxyFamiliesLocked()
routeRuleCache = map[string]routeState{"iface": {mode: config.RoutingModeInterface, ipv4: true, ipv6: true}}
noneV4, noneV6 := routeTProxyFamiliesLocked()
routeRuleCache = saved
routeMu.Unlock()
if !v4 || v6 {
t.Errorf("proxy set on IPv4 only: got v4 %v v6 %v", v4, v6)
}
if !bothV4 || !bothV6 {
t.Errorf("proxy on IPv4 and mtproto-ws on IPv6: got v4 %v v6 %v", bothV4, bothV6)
}
if noneV4 || noneV6 {
t.Errorf("interface sets do not use TPROXY: got v4 %v v6 %v", noneV4, noneV6)
}
}

View file

@ -222,6 +222,7 @@ func (m *Monitor) reconcileRouting(restored bool) bool {
RoutingEnsureJumpPrecedence(cfg)
routePhaseMu.Unlock()
RoutingCheckBridgeNetfilter()
RoutingPeriodicReResolve(cfg)
return restored
}

View file

@ -1261,6 +1261,7 @@ func routingSyncConfig(cfg *config.Config) {
routeReconcilePolicyRules(cfg.Queue.IPv4Enabled, cfg.Queue.IPv6Enabled)
routeReestablishJumpOrder(be, cfg, len(newRoutingSets) > 0)
routeEnsurePreJumpPrecedence(be, cfg)
routeNoteBridgeNetfilter(routeTProxyFamiliesLocked())
routeSyncedCfg = cfg
if failed {
@ -2253,6 +2254,29 @@ func RoutingPolicyRuleLines() []string {
return out
}
func RoutingProxyTableLines() (int, []string) {
table := proxyTableIfChosen()
if table <= 0 || !hasBinary("ip") {
return 0, nil
}
var out []string
for _, fam := range routeFamilyArgs(true, true) {
args := append([]string{"ip"}, fam.flag...)
args = append(args, "route", "show", "table", strconv.Itoa(table))
res, err := run(args...)
if err != nil {
continue
}
for _, line := range strings.Split(res, "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, fam.name+" "+line)
}
}
}
return table, out
}
func RoutingReinstallForInterface(cfg *config.Config, iface string) {
if cfg == nil || iface == "" || !hasBinary("ip") {
return

View file

@ -277,6 +277,15 @@ func proxyTable() int {
return proxyTableChosen
}
func proxyTableIfChosen() int {
proxyTableMu.Lock()
defer proxyTableMu.Unlock()
if !proxyTableResolved {
return 0
}
return proxyTableChosen
}
func proxyTableForget() {
proxyTableMu.Lock()
proxyTableChosen = 0

View file

@ -146,6 +146,13 @@ func RoutingSetInstalled(setID string) bool {
return ok
}
func RoutingSetFamilies(setID string) (ipv4, ipv6, installed bool) {
routeMu.Lock()
defer routeMu.Unlock()
st, ok := routeRuleCache[setID]
return st.ipv4, st.ipv6, ok
}
func telegramBridgeMarkMatch() string {
return fmt.Sprintf("0x%x/0x%x", config.TelegramBridgeMark, config.PerSetRouteMarkBits)
}