From 3ea48d91f220eaa4591998d1d00a89059cf6384a Mon Sep 17 00:00:00 2001 From: Daniel Lavrushin Date: Sat, 8 Aug 2026 15:21:53 +0200 Subject: [PATCH] fix: enhance flow offloading detection and handling for improved performance and diagnostics --- changelog.md | 1 + changelog_ru.md | 1 + docs/docs/install/openwrt.md | 31 ++++ .../current/install/openwrt.md | 31 ++++ install.sh | 80 +++++++++- installer/actions/sysinfo.sh | 23 ++- installer/lib/utils.sh | 64 ++++++++ src/http/handler/diagnostics.go | 151 +++++++++++++++--- src/http/handler/flowoffload_test.go | 112 +++++++++++++ src/http/handler/system_types.go | 10 +- .../components/settings/SystemInfoDialog.tsx | 25 ++- src/http/ui/src/i18n/en.json | 2 + src/http/ui/src/i18n/ru.json | 2 + src/http/ui/src/models/sysinfo.ts | 2 + 14 files changed, 498 insertions(+), 37 deletions(-) create mode 100644 src/http/handler/flowoffload_test.go diff --git a/changelog.md b/changelog.md index b045d42c..25add676 100644 --- a/changelog.md +++ b/changelog.md @@ -17,6 +17,7 @@ - ADDED: **A warm spare connection to your Cloudflare Worker** - one ready connection per Worker and data center, in place of the 65-90 ms handshake every new Telegram connection paid for. - FIXED: **Traffic to a Cloudflare Worker was cut into one small write per Telegram message** - that framing was only ever required by Telegram's own WebSocket edge. - CHANGED: **Several Worker addresses are tried in a random order** - they were tried strictly as written, so the first absorbed all the traffic and all the rate limiting. +- FIXED: **Flow offloading was reported as a problem even when it had been set up to leave b4 alone** - the check could not tell a fast path that skips b4 entirely from one held back until b4 has seen the start of a connection. ## [1.74.2] - 2026-08-02 diff --git a/changelog_ru.md b/changelog_ru.md index 12ccc245..a6bf4607 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -17,6 +17,7 @@ - ДОБАВЛЕНО: **Прогретое запасное соединение к вашему Cloudflare Worker** - одно готовое соединение на каждый Worker и дата-центр вместо рукопожатия в 65-90 мс, которое оплачивало каждое новое соединение Telegram. - ИСПРАВЛЕНО: **Трафик к Cloudflare Worker резался на мелкие записи, по одной на сообщение Telegram** - такая нарезка требовалась только собственному WebSocket-узлу Telegram. - ИЗМЕНЕНО: **Несколько адресов Worker перебираются в случайном порядке** - они перебирались строго как записаны, поэтому первый принимал весь трафик и все ограничения по частоте запросов. +- ИСПРАВЛЕНО: **Flow offloading отмечался как проблема, даже когда был настроен так, чтобы не мешать b4** - проверка не отличала быстрый путь, полностью обходящий b4, от того, который включается только после начала соединения. ## [1.74.2] - 2026-08-02 diff --git a/docs/docs/install/openwrt.md b/docs/docs/install/openwrt.md index 6e7e17d4..9b3f6a57 100644 --- a/docs/docs/install/openwrt.md +++ b/docs/docs/install/openwrt.md @@ -143,3 +143,34 @@ Then start b4 again: ### Slow speed / video stuttering Check the **Software flow offloading** setting under Network -> Firewall. Try turning it on or off - on some devices this affects b4 performance. + +### Keeping flow offloading and b4 together + +Flow offloading moves an established connection to a fast path that skips the netfilter hooks b4 works from, so with it enabled b4 runs but never sees the traffic. On weaker hardware turning it off costs a lot of throughput. + +b4 only inspects the first packets of a connection (19 for TCP, 8 for UDP by default, see `queue.tcp_conn_bytes_limit` and `queue.udp_conn_bytes_limit`), so offloading can be delayed until b4 is done. Edit `/usr/share/firewall4/templates/ruleset.uc` and find: + +```text +meta l4proto { tcp, udp } flow offload @ft; +``` + +Replace it with: + +```text +meta l4proto { tcp, udp } ct original packets ge 40 flow offload @ft; +``` + +Then reload the firewall: + +```bash +fw4 restart +``` + +Points to check: + +- The threshold has to stay above the packet limits configured in b4. Raising `tcp_conn_bytes_limit` above the threshold puts the connection on the fast path before b4 is finished with it. +- `ct original packets` reads conntrack accounting. If `sysctl net.netfilter.nf_conntrack_acct` returns `0`, the counter stays at zero, the rule never matches and nothing is offloaded at all. +- Sets with **Duplicate** enabled for TCP are inspected for the whole life of the connection, so no threshold is high enough for them. +- The file belongs to the `firewall4` package and is overwritten by package upgrades and by sysupgrade. + +The system diagnostics (Settings -> System info, and the installer's diagnostics screen) report the threshold they find and compare it against b4's own limits. diff --git a/docs/i18n/ru/docusaurus-plugin-content-docs/current/install/openwrt.md b/docs/i18n/ru/docusaurus-plugin-content-docs/current/install/openwrt.md index ece43b94..a632d56a 100644 --- a/docs/i18n/ru/docusaurus-plugin-content-docs/current/install/openwrt.md +++ b/docs/i18n/ru/docusaurus-plugin-content-docs/current/install/openwrt.md @@ -143,3 +143,34 @@ nft delete table inet b4_mangle 2>/dev/null ### Низкая скорость / тормозит видео Проверьте настройку **Software flow offloading** в разделе Network → Firewall. Попробуйте включить или выключить её - на некоторых устройствах это влияет на производительность b4. + +### Как совместить flow offloading и b4 + +Flow offloading переводит установленное соединение на быстрый путь, минуя хуки netfilter, с которыми работает b4: при включённом offloading b4 запущен, но трафика не видит. На слабом железе выключение offloading заметно снижает пропускную способность. + +b4 разбирает только первые пакеты соединения (по умолчанию 19 для TCP и 8 для UDP, параметры `queue.tcp_conn_bytes_limit` и `queue.udp_conn_bytes_limit`), поэтому offloading можно отложить до момента, когда b4 уже отработал. Откройте `/usr/share/firewall4/templates/ruleset.uc` и найдите строку: + +```text +meta l4proto { tcp, udp } flow offload @ft; +``` + +Замените её на: + +```text +meta l4proto { tcp, udp } ct original packets ge 40 flow offload @ft; +``` + +Перезапустите файрвол: + +```bash +fw4 restart +``` + +На что обратить внимание: + +- Порог должен оставаться выше лимитов пакетов, заданных в b4. Если поднять `tcp_conn_bytes_limit` выше порога, соединение уйдёт на быстрый путь раньше, чем b4 с ним закончит. +- `ct original packets` читает счётчики conntrack. Если `sysctl net.netfilter.nf_conntrack_acct` возвращает `0`, счётчик остаётся нулевым, правило не срабатывает и offloading не включается вообще. +- Сеты с включённым **Duplicate** для TCP разбираются на протяжении всего соединения, для них не подойдёт никакой порог. +- Файл принадлежит пакету `firewall4` и перезаписывается при обновлении пакета и при sysupgrade. + +Системная диагностика (Настройки -> Информация о системе и экран диагностики установщика) показывает найденный порог и сравнивает его с лимитами b4. diff --git a/install.sh b/install.sh index 34f07f0c..107c3cc1 100755 --- a/install.sh +++ b/install.sh @@ -639,6 +639,70 @@ _ipt_connbytes_works() { --connbytes-mode packets --connbytes 0:10 -j ACCEPT } +_nft_flow_guard() { + awk ' + /flow add @|flow offload @/ { + g = 0 + for (i = 1; i <= NF; i++) { + if ($i == "ct" && $(i + 1) == "original" && $(i + 2) == "packets") { + op = $(i + 3) + val = $(i + 4) + 0 + if (op == ">=" || op == "ge") g = val + else if (op == ">" || op == "gt") g = val + 1 + } + } + if (!seen || g < min) min = g + seen = 1 + } + END { print (seen ? min : 0) } + ' +} + +_ipt_flow_guard() { + awk ' + /FLOWOFFLOAD/ { + g = 0 + if (index($0, "--connbytes-dir original") && index($0, "--connbytes-mode packets") && !index($0, "! --connbytes ")) { + for (i = 1; i <= NF; i++) { + if ($i == "--connbytes") { + split($(i + 1), b, ":") + g = b[1] + 0 + } + } + } + if (!seen || g < min) min = g + seen = 1 + } + END { print (seen ? min : 0) } + ' +} + +_b4_queue_window() { + _qw_tcp=19 + _qw_udp=8 + if [ -n "$B4_CONFIG_FILE" ] && [ -f "$B4_CONFIG_FILE" ] && command_exists jq; then + _qw_tcp=$(jq -r '.queue.tcp_conn_bytes_limit // 19' "$B4_CONFIG_FILE" 2>/dev/null || echo 19) + _qw_udp=$(jq -r '.queue.udp_conn_bytes_limit // 8' "$B4_CONFIG_FILE" 2>/dev/null || echo 8) + fi + case "$_qw_tcp" in '' | *[!0-9]*) _qw_tcp=19 ;; esac + case "$_qw_udp" in '' | *[!0-9]*) _qw_udp=8 ;; esac + if [ "$_qw_udp" -gt "$_qw_tcp" ]; then + echo "$_qw_udp" + else + echo "$_qw_tcp" + fi +} + +_b4_duplicate_sets() { + if [ -z "$B4_CONFIG_FILE" ] || [ ! -f "$B4_CONFIG_FILE" ] || ! command_exists jq; then + echo 0 + return 0 + fi + _ds=$(jq -r '[.sets[]? | select(.enabled == true) | select(.tcp.duplicate.enabled == true)] | length' "$B4_CONFIG_FILE" 2>/dev/null || echo 0) + case "$_ds" in '' | *[!0-9]*) _ds=0 ;; esac + echo "$_ds" +} + _queue_functional() { case "$1" in nftables) _nft_queue_works ;; @@ -3615,6 +3679,7 @@ action_sysinfo() { fi _flow_offload="" + _flow_guard=0 if command_exists nft; then _nft_ruleset=$(nft list ruleset 2>/dev/null) if echo "$_nft_ruleset" | grep -q "flow add @\|flow offload @"; then @@ -3623,6 +3688,7 @@ action_sysinfo() { else _flow_offload="software" fi + _flow_guard=$(echo "$_nft_ruleset" | _nft_flow_guard) fi fi if [ -z "$_flow_offload" ]; then @@ -3635,14 +3701,22 @@ action_sysinfo() { else _flow_offload="software" fi + _flow_guard=$(echo "$_ipt_filter" | _ipt_flow_guard) break fi done fi - if [ -n "$_flow_offload" ]; then - printf " ${RED} WARN${NC} %s\n" "Flow offloading active (${_flow_offload}) — bypasses b4; disable it for b4 to work" >&2 - else + if [ -z "$_flow_offload" ]; then printf " ${GREEN} OK${NC} %s\n" "Flow offloading off (b4 can intercept traffic)" >&2 + elif [ "$_flow_guard" -gt "$(_b4_queue_window)" ]; then + printf " ${GREEN} OK${NC} %s\n" "Flow offloading active (${_flow_offload}), held off until packet ${_flow_guard} of a connection - b4 still sees the start of every connection" >&2 + if [ "$(_b4_duplicate_sets)" != "0" ]; then + printf " ${YELLOW} WARN${NC} %s\n" "A routing set has TCP duplication enabled, which needs every packet of the connection - turn flow offloading off or disable duplication" >&2 + fi + else + printf " ${RED} WARN${NC} %s\n" "Flow offloading active (${_flow_offload}) - bypasses b4; disable it for b4 to work" >&2 + printf " ${DIM} Or hold it off until b4 has seen the start of a connection: in /usr/share/firewall4/templates/ruleset.uc${NC}\n" >&2 + printf " ${DIM} replace 'flow offload @ft' with 'ct original packets ge 40 flow offload @ft', then: fw4 restart${NC}\n" >&2 fi echo "" diff --git a/installer/actions/sysinfo.sh b/installer/actions/sysinfo.sh index afe6fd38..9c0bb178 100644 --- a/installer/actions/sysinfo.sh +++ b/installer/actions/sysinfo.sh @@ -295,11 +295,13 @@ action_sysinfo() { fi fi - # Flow offloading — offloaded flows skip the netfilter hooks where b4's + # Flow offloading - offloaded flows skip the netfilter hooks where b4's # NFQUEUE rules live, so b4 never sees the traffic (common cause of "b4 - # installed but nothing is bypassed" on OpenWrt). Detect the active runtime - # state, not just the UCI config. + # installed but nothing is bypassed" on OpenWrt). A flowtable guarded by a + # packet counter is fine as long as it lets more packets through the slow + # path than b4 queues. Detect the active runtime state, not the UCI config. _flow_offload="" + _flow_guard=0 if command_exists nft; then _nft_ruleset=$(nft list ruleset 2>/dev/null) if echo "$_nft_ruleset" | grep -q "flow add @\|flow offload @"; then @@ -308,6 +310,7 @@ action_sysinfo() { else _flow_offload="software" fi + _flow_guard=$(echo "$_nft_ruleset" | _nft_flow_guard) fi fi if [ -z "$_flow_offload" ]; then @@ -320,14 +323,22 @@ action_sysinfo() { else _flow_offload="software" fi + _flow_guard=$(echo "$_ipt_filter" | _ipt_flow_guard) break fi done fi - if [ -n "$_flow_offload" ]; then - printf " ${RED} WARN${NC} %s\n" "Flow offloading active (${_flow_offload}) — bypasses b4; disable it for b4 to work" >&2 - else + if [ -z "$_flow_offload" ]; then printf " ${GREEN} OK${NC} %s\n" "Flow offloading off (b4 can intercept traffic)" >&2 + elif [ "$_flow_guard" -gt "$(_b4_queue_window)" ]; then + printf " ${GREEN} OK${NC} %s\n" "Flow offloading active (${_flow_offload}), held off until packet ${_flow_guard} of a connection - b4 still sees the start of every connection" >&2 + if [ "$(_b4_duplicate_sets)" != "0" ]; then + printf " ${YELLOW} WARN${NC} %s\n" "A routing set has TCP duplication enabled, which needs every packet of the connection - turn flow offloading off or disable duplication" >&2 + fi + else + printf " ${RED} WARN${NC} %s\n" "Flow offloading active (${_flow_offload}) - bypasses b4; disable it for b4 to work" >&2 + printf " ${DIM} Or hold it off until b4 has seen the start of a connection: in /usr/share/firewall4/templates/ruleset.uc${NC}\n" >&2 + printf " ${DIM} replace 'flow offload @ft' with 'ct original packets ge 40 flow offload @ft', then: fw4 restart${NC}\n" >&2 fi # --- Tools & dependencies --- diff --git a/installer/lib/utils.sh b/installer/lib/utils.sh index 803e8800..fcbc8836 100644 --- a/installer/lib/utils.sh +++ b/installer/lib/utils.sh @@ -609,6 +609,70 @@ _ipt_connbytes_works() { --connbytes-mode packets --connbytes 0:10 -j ACCEPT } +_nft_flow_guard() { + awk ' + /flow add @|flow offload @/ { + g = 0 + for (i = 1; i <= NF; i++) { + if ($i == "ct" && $(i + 1) == "original" && $(i + 2) == "packets") { + op = $(i + 3) + val = $(i + 4) + 0 + if (op == ">=" || op == "ge") g = val + else if (op == ">" || op == "gt") g = val + 1 + } + } + if (!seen || g < min) min = g + seen = 1 + } + END { print (seen ? min : 0) } + ' +} + +_ipt_flow_guard() { + awk ' + /FLOWOFFLOAD/ { + g = 0 + if (index($0, "--connbytes-dir original") && index($0, "--connbytes-mode packets") && !index($0, "! --connbytes ")) { + for (i = 1; i <= NF; i++) { + if ($i == "--connbytes") { + split($(i + 1), b, ":") + g = b[1] + 0 + } + } + } + if (!seen || g < min) min = g + seen = 1 + } + END { print (seen ? min : 0) } + ' +} + +_b4_queue_window() { + _qw_tcp=19 + _qw_udp=8 + if [ -n "$B4_CONFIG_FILE" ] && [ -f "$B4_CONFIG_FILE" ] && command_exists jq; then + _qw_tcp=$(jq -r '.queue.tcp_conn_bytes_limit // 19' "$B4_CONFIG_FILE" 2>/dev/null || echo 19) + _qw_udp=$(jq -r '.queue.udp_conn_bytes_limit // 8' "$B4_CONFIG_FILE" 2>/dev/null || echo 8) + fi + case "$_qw_tcp" in '' | *[!0-9]*) _qw_tcp=19 ;; esac + case "$_qw_udp" in '' | *[!0-9]*) _qw_udp=8 ;; esac + if [ "$_qw_udp" -gt "$_qw_tcp" ]; then + echo "$_qw_udp" + else + echo "$_qw_tcp" + fi +} + +_b4_duplicate_sets() { + if [ -z "$B4_CONFIG_FILE" ] || [ ! -f "$B4_CONFIG_FILE" ] || ! command_exists jq; then + echo 0 + return 0 + fi + _ds=$(jq -r '[.sets[]? | select(.enabled == true) | select(.tcp.duplicate.enabled == true)] | length' "$B4_CONFIG_FILE" 2>/dev/null || echo 0) + case "$_ds" in '' | *[!0-9]*) _ds=0 ;; esac + echo "$_ds" +} + _queue_functional() { case "$1" in nftables) _nft_queue_works ;; diff --git a/src/http/handler/diagnostics.go b/src/http/handler/diagnostics.go index f2f77cfc..59dedbd0 100644 --- a/src/http/handler/diagnostics.go +++ b/src/http/handler/diagnostics.go @@ -45,7 +45,7 @@ func (api *API) buildDiagnostics() Diagnostics { Tools: collectTools(), Network: collectNetworkInterfaces(), Engine: collectEngineInfo(cfg), - Firewall: collectFirewallInfo(), + Firewall: collectFirewallInfo(cfg), Geodata: api.collectGeodataInfo(), Storage: collectStorage(), Paths: collectPaths(cfg.ConfigPath, cfg.System.Logging.ErrorFilePath(), cfg.System.Geo.GeoSitePath, cfg.System.Geo.GeoIpPath), @@ -268,14 +268,15 @@ func collectNetworkInterfaces() DiagNetwork { return DiagNetwork{Interfaces: result} } -func collectFirewallInfo() DiagFirewall { +func collectFirewallInfo(cfg *config.Config) DiagFirewall { info := DiagFirewall{Backend: detectFirewallBackend()} info.RuleGroups = append(info.RuleGroups, collectNftRuleGroups()...) info.RuleGroups = append(info.RuleGroups, collectIptablesRuleGroups(info.Backend)...) info.NFQueueWorks = testNFQueue(info.Backend) - info.FlowOffload = detectFlowOffload() + info.FlowOffload, info.FlowOffloadGuard = detectFlowOffload() + info.FlowOffloadSafe = flowOffloadSafe(info.FlowOffload, info.FlowOffloadGuard, cfg) return info } @@ -478,21 +479,38 @@ func collectTUNInfo(cfg *config.Config) *DiagTUN { // detectFlowOffload reports whether netfilter flow offloading is active on the // system. Offloaded flows take a fast path that skips the forward/postrouting -// hooks where b4's NFQUEUE rules live, so an active flowtable means b4 never -// sees the traffic. Returns "hardware", "software" or "off". -func detectFlowOffload() string { +// hooks where b4's NFQUEUE rules live, so an unguarded flowtable means b4 never +// sees the traffic. Returns "hardware", "software" or "off", plus the smallest +// original-direction packet count that has to pass before a flow is offloaded +// (0 when offloading starts immediately). +func detectFlowOffload() (string, int) { if _, err := exec.LookPath("nft"); err == nil { out, err := exec.Command("nft", "list", "ruleset").CombinedOutput() if err == nil { s := string(out) - if strings.Contains(s, "flow add @") || strings.Contains(s, "flow offload @") { - for _, line := range strings.Split(s, "\n") { - l := strings.TrimSpace(line) - if strings.HasPrefix(l, "flags") && strings.Contains(l, "offload") { - return "hardware" - } + hardware := false + mode := "" + guard := 0 + for _, line := range strings.Split(s, "\n") { + l := strings.TrimSpace(line) + if strings.HasPrefix(l, "flags") && strings.Contains(l, "offload") { + hardware = true + continue } - return "software" + if !strings.Contains(l, "flow add @") && !strings.Contains(l, "flow offload @") { + continue + } + g := nftFlowOffloadGuard(l) + if mode == "" || g < guard { + guard = g + } + mode = "software" + } + if mode != "" { + if hardware { + mode = "hardware" + } + return mode, guard } } } @@ -502,15 +520,110 @@ func detectFlowOffload() string { continue } out, err := exec.Command(bin, append(tables.WaitArgs(bin), "-t", "filter", "-S")...).CombinedOutput() - if err == nil && strings.Contains(string(out), "FLOWOFFLOAD") { - if strings.Contains(string(out), "--hw") { - return "hardware" - } - return "software" + if err != nil || !strings.Contains(string(out), "FLOWOFFLOAD") { + continue } + mode := "software" + guard := 0 + first := true + for _, line := range strings.Split(string(out), "\n") { + if !strings.Contains(line, "FLOWOFFLOAD") { + continue + } + if strings.Contains(line, "--hw") { + mode = "hardware" + } + g := iptablesFlowOffloadGuard(line) + if first || g < guard { + guard = g + first = false + } + } + return mode, guard } - return "off" + return "off", 0 +} + +// nftFlowOffloadGuard returns the minimum number of original-direction packets a +// connection must carry before the given nftables flow-offload rule applies. +// Only "ct original packets" guards count: a guard on any other counter cannot +// be compared against b4's original-direction connbytes window. +func nftFlowOffloadGuard(rule string) int { + idx := strings.Index(rule, "ct original packets") + if idx < 0 { + return 0 + } + rest := strings.TrimSpace(rule[idx+len("ct original packets"):]) + + for _, op := range []string{">=", "ge", ">", "gt"} { + if !strings.HasPrefix(rest, op) { + continue + } + fields := strings.Fields(rest[len(op):]) + if len(fields) == 0 { + return 0 + } + n, err := strconv.Atoi(fields[0]) + if err != nil || n <= 0 { + return 0 + } + if op == ">" || op == "gt" { + return n + 1 + } + return n + } + + return 0 +} + +// iptablesFlowOffloadGuard is the xt_FLOWOFFLOAD equivalent of +// nftFlowOffloadGuard, reading the lower bound of an original-direction +// packet-mode connbytes match on the same rule. +func iptablesFlowOffloadGuard(rule string) int { + if !strings.Contains(rule, "--connbytes-dir original") || !strings.Contains(rule, "--connbytes-mode packets") { + return 0 + } + if strings.Contains(rule, "! --connbytes ") { + return 0 + } + idx := strings.Index(rule, "--connbytes ") + if idx < 0 { + return 0 + } + fields := strings.Fields(rule[idx+len("--connbytes "):]) + if len(fields) == 0 { + return 0 + } + lower, _, _ := strings.Cut(fields[0], ":") + n, err := strconv.Atoi(lower) + if err != nil || n <= 0 { + return 0 + } + return n +} + +// flowOffloadSafe reports whether an active flowtable still lets b4 do its job: +// every offloaded flow has to stay on the slow path for longer than the widest +// connbytes window b4 queues on. Sets with TCP duplication are queued for the +// whole life of the connection, so no guard is wide enough for them. +func flowOffloadSafe(mode string, guard int, cfg *config.Config) bool { + if mode == "off" { + return true + } + if guard <= 0 || cfg == nil { + return false + } + if dup4, dup6 := cfg.CollectDuplicateIPs(); len(dup4) > 0 || len(dup6) > 0 { + return false + } + + window := cfg.Queue.TCPConnBytesLimit + if cfg.Queue.UDPConnBytesLimit > window { + window = cfg.Queue.UDPConnBytesLimit + } + + return guard > window } func testNFQueue(backend string) bool { diff --git a/src/http/handler/flowoffload_test.go b/src/http/handler/flowoffload_test.go new file mode 100644 index 00000000..5efb720b --- /dev/null +++ b/src/http/handler/flowoffload_test.go @@ -0,0 +1,112 @@ +package handler + +import ( + "testing" + + "github.com/daniellavrushin/b4/config" +) + +func TestNftFlowOffloadGuard(t *testing.T) { + cases := []struct { + name string + rule string + want int + }{ + {"unguarded", "meta l4proto { tcp, udp } flow add @ft", 0}, + {"ge", "meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft", 30}, + {"symbolic ge", "meta l4proto { tcp, udp } ct original packets >= 40 flow add @ft", 40}, + {"gt", "meta l4proto { tcp, udp } ct original packets gt 29 flow add @ft", 30}, + {"symbolic gt", "meta l4proto { tcp, udp } ct original packets > 29 flow add @ft", 30}, + {"reply counter is not a guard", "ct reply packets ge 30 flow add @ft", 0}, + {"total counter is not a guard", "ct packets ge 30 flow add @ft", 0}, + {"zero threshold", "ct original packets ge 0 flow add @ft", 0}, + {"garbage threshold", "ct original packets ge abc flow add @ft", 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nftFlowOffloadGuard(tc.rule); got != tc.want { + t.Errorf("nftFlowOffloadGuard(%q) = %d, want %d", tc.rule, got, tc.want) + } + }) + } +} + +func TestIptablesFlowOffloadGuard(t *testing.T) { + cases := []struct { + name string + rule string + want int + }{ + {"unguarded", "-A FORWARD -j FLOWOFFLOAD", 0}, + {"guarded", "-A FORWARD -m connbytes --connbytes 30:0 --connbytes-mode packets --connbytes-dir original -j FLOWOFFLOAD", 30}, + {"open upper bound", "-A FORWARD -m connbytes --connbytes 40: --connbytes-mode packets --connbytes-dir original -j FLOWOFFLOAD", 40}, + {"bytes mode is not a guard", "-A FORWARD -m connbytes --connbytes 30:0 --connbytes-mode bytes --connbytes-dir original -j FLOWOFFLOAD", 0}, + {"reply direction is not a guard", "-A FORWARD -m connbytes --connbytes 30:0 --connbytes-mode packets --connbytes-dir reply -j FLOWOFFLOAD", 0}, + {"inverted match is not a guard", "-A FORWARD -m connbytes ! --connbytes 0:29 --connbytes-mode packets --connbytes-dir original -j FLOWOFFLOAD", 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := iptablesFlowOffloadGuard(tc.rule); got != tc.want { + t.Errorf("iptablesFlowOffloadGuard(%q) = %d, want %d", tc.rule, got, tc.want) + } + }) + } +} + +func TestFlowOffloadSafe(t *testing.T) { + newCfg := func() *config.Config { + cfg := config.DefaultConfig + return &cfg + } + + t.Run("off is always safe", func(t *testing.T) { + if !flowOffloadSafe("off", 0, newCfg()) { + t.Error("expected off to be safe") + } + }) + + t.Run("unguarded is unsafe", func(t *testing.T) { + if flowOffloadSafe("software", 0, newCfg()) { + t.Error("expected unguarded offload to be unsafe") + } + }) + + t.Run("guard above the queue window is safe", func(t *testing.T) { + cfg := newCfg() + if !flowOffloadSafe("hardware", cfg.Queue.TCPConnBytesLimit+1, cfg) { + t.Error("expected guard above the window to be safe") + } + }) + + t.Run("guard equal to the queue window is unsafe", func(t *testing.T) { + cfg := newCfg() + if flowOffloadSafe("software", cfg.Queue.TCPConnBytesLimit, cfg) { + t.Error("expected guard equal to the window to be unsafe") + } + }) + + t.Run("udp window is taken into account", func(t *testing.T) { + cfg := newCfg() + cfg.Queue.TCPConnBytesLimit = 10 + cfg.Queue.UDPConnBytesLimit = 25 + if flowOffloadSafe("software", 20, cfg) { + t.Error("expected guard below the udp window to be unsafe") + } + if !flowOffloadSafe("software", 26, cfg) { + t.Error("expected guard above both windows to be safe") + } + }) + + t.Run("duplication is never safe", func(t *testing.T) { + cfg := newCfg() + set := &config.SetConfig{Enabled: true} + set.TCP.Duplicate.Enabled = true + set.Targets.IpsToMatch = []string{"1.2.3.4"} + cfg.Sets = []*config.SetConfig{set} + if flowOffloadSafe("software", 1000, cfg) { + t.Error("expected duplication sets to make any guard unsafe") + } + }) +} diff --git a/src/http/handler/system_types.go b/src/http/handler/system_types.go index e2baa3ac..b6e51e6d 100644 --- a/src/http/handler/system_types.go +++ b/src/http/handler/system_types.go @@ -119,10 +119,12 @@ type DiagTUN struct { } type DiagFirewall struct { - Backend string `json:"backend"` - NFQueueWorks bool `json:"nfqueue_works"` - FlowOffload string `json:"flow_offload"` - 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"` + RuleGroups []DiagRuleGroup `json:"rule_groups,omitempty"` } type DiagRuleGroup struct { diff --git a/src/http/ui/src/components/settings/SystemInfoDialog.tsx b/src/http/ui/src/components/settings/SystemInfoDialog.tsx index fe237dee..397bb0b0 100644 --- a/src/http/ui/src/components/settings/SystemInfoDialog.tsx +++ b/src/http/ui/src/components/settings/SystemInfoDialog.tsx @@ -68,6 +68,23 @@ export const SystemInfoDialog = ({ open, onClose }: SystemInfoDialogProps) => { ); }; + const flowOffloadText = (fw: Diagnostics["firewall"]) => { + if (fw.flow_offload === "off") return t("settings.SystemInfo.flowOffloadOff"); + const hw = fw.flow_offload === "hardware"; + if (fw.flow_offload_safe) + return t( + hw + ? "settings.SystemInfo.flowOffloadHwGuarded" + : "settings.SystemInfo.flowOffloadSwGuarded", + { packets: fw.flow_offload_guard ?? 0 }, + ); + return t( + hw + ? "settings.SystemInfo.flowOffloadHw" + : "settings.SystemInfo.flowOffloadSw", + ); + }; + const boolChip = (ok: boolean, yesLabel: string, noLabel: string) => ( { {row( t("settings.SystemInfo.flowOffload"), boolChip( - data.firewall.flow_offload === "off", - t("settings.SystemInfo.flowOffloadOff"), - data.firewall.flow_offload === "hardware" - ? t("settings.SystemInfo.flowOffloadHw") - : t("settings.SystemInfo.flowOffloadSw"), + data.firewall.flow_offload_safe, + flowOffloadText(data.firewall), + flowOffloadText(data.firewall), ), )} {data.firewall.rule_groups && diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index f7bfbd3a..9599322b 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -656,6 +656,8 @@ "flowOffloadOff": "off", "flowOffloadSw": "software (bypasses b4)", "flowOffloadHw": "hardware (bypasses b4)", + "flowOffloadSwGuarded": "software, after {{packets}} packets", + "flowOffloadHwGuarded": "hardware, after {{packets}} packets", "network": "Network", "engine": "Engine", "engineMode": "Mode", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index bb2e7e76..b37fc9b1 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -652,6 +652,8 @@ "flowOffloadOff": "выкл", "flowOffloadSw": "программный (в обход b4)", "flowOffloadHw": "аппаратный (в обход b4)", + "flowOffloadSwGuarded": "программный, после {{packets}} пакетов", + "flowOffloadHwGuarded": "аппаратный, после {{packets}} пакетов", "network": "Сеть", "engine": "Движок", "engineMode": "Режим", diff --git a/src/http/ui/src/models/sysinfo.ts b/src/http/ui/src/models/sysinfo.ts index 11ecf458..00b3dbf5 100644 --- a/src/http/ui/src/models/sysinfo.ts +++ b/src/http/ui/src/models/sysinfo.ts @@ -64,6 +64,8 @@ interface DiagFirewall { backend: string; nfqueue_works: boolean; flow_offload: string; + flow_offload_guard?: number; + flow_offload_safe: boolean; rule_groups?: DiagRuleGroup[]; }