From 7be08fbe761ee4cea06183aa16e90fddf083f2e4 Mon Sep 17 00:00:00 2001 From: Daniel Lavrushin Date: Sat, 8 Aug 2026 17:50:58 +0200 Subject: [PATCH] feat: add IP Health settings to manage unreachable address retest intervals --- changelog.md | 1 + changelog_ru.md | 1 + src/config/config.go | 5 +- src/config/ipblock_test.go | 78 +++++++++++++++++-- src/config/types.go | 26 ++++--- src/config/validation.go | 7 +- .../ui/src/components/sets/tcp/TcpGeneral.tsx | 17 ---- .../ui/src/components/settings/IPHealth.tsx | 50 ++++++++++++ src/http/ui/src/components/settings/Page.tsx | 7 ++ src/http/ui/src/i18n/en.json | 10 ++- src/http/ui/src/i18n/ru.json | 10 ++- src/http/ui/src/models/config.ts | 6 +- src/nfq/pool.go | 21 ++--- 13 files changed, 180 insertions(+), 59 deletions(-) create mode 100644 src/http/ui/src/components/settings/IPHealth.tsx diff --git a/changelog.md b/changelog.md index cff94837..d6495a28 100644 --- a/changelog.md +++ b/changelog.md @@ -22,6 +22,7 @@ - CHANGED: **IP block detection also covers a destination that never answers its SYN** - the feature is described for addresses the firewall drops at the IP level, but it counted TLS ClientHello retransmissions, and a ClientHello exists only once a handshake has completed. An address-level block kills the handshake itself, so the single case the description named was the one case the check could not see, and what it did catch was a stateful block after the handshake. - ADDED: **The router tests a destination itself before treating it as blocked** - packets going unanswered for one device is equally consistent with a slow server, a brief routing glitch or an uplink outage, and acting on that alone resets connections to a destination nothing was ever wrong with. A dozen destinations failing within a minute reads as an uplink outage and suspends detection rather than condemning all of them. - FIXED: **A destination marked blocked stayed blocked for as long as anything kept trying it** - each lookup refreshed the entry's timestamp, so the five-minute expiry was never reached while a device kept retrying, and nothing re-tested the address in the meantime. A false positive, or a block that had since been lifted, held for the lifetime of the traffic. +- ADDED: **An IP Health section in Settings** - how long a destination stays marked unreachable had no control of its own, and it belongs there rather than among a set's options, since whether an address answers depends on the address and the path to it rather than on the set that happened to match it. - ADDED: **A switch that strips unreachable addresses out of DNS answers** - a CDN answers with several addresses of which the firewall drops only some, so one device hangs on a blocked one while another on the same network loads the site. Pinning a working address by hand in the hosts file or as a `dnsmasq` alias had to be done per address, per domain, and again whenever the CDN changed its addresses. ## [1.74.2] - 2026-08-02 diff --git a/changelog_ru.md b/changelog_ru.md index f943da35..53575703 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -22,6 +22,7 @@ - ИЗМЕНЕНО: **Обнаружение блокировки по IP охватывает и адрес, который не отвечает на SYN** - функция описана для адресов, отбрасываемых файрволом на уровне IP, но считала повторы TLS ClientHello, а ClientHello появляется только после установленного рукопожатия. Блокировка по адресу убивает само рукопожатие, поэтому единственный случай, названный в описании, оказался тем самым, который проверка увидеть не могла, а ловила она блокировку с отслеживанием состояния уже после рукопожатия. - ДОБАВЛЕНО: **Роутер сам проверяет адрес, прежде чем считать его заблокированным** - отсутствие ответов у одного устройства одинаково хорошо объясняется медленным сервером, кратким сбоем маршрутизации или обрывом аплинка, и решение по одному этому признаку сбрасывало соединения к адресу, с которым всё было в порядке. Десяток адресов, отвалившихся за минуту, читается как обрыв аплинка и приостанавливает обнаружение, вместо того чтобы записать их все в заблокированные. - ИСПРАВЛЕНО: **Адрес, помеченный как заблокированный, оставался таким, пока к нему хоть что-то обращалось** - каждая проверка кэша обновляла отметку времени, поэтому пятиминутный срок не наступал, пока устройство продолжало попытки, и ничто не перепроверяло адрес. Ложное срабатывание или уже снятая блокировка держались столько же, сколько шёл трафик. +- ДОБАВЛЕНО: **Раздел «Доступность IP» в настройках** - у срока, в течение которого адрес числится недоступным, не было собственной настройки, и место ей именно там, а не среди параметров сета: отвечает адрес или нет, зависит от самого адреса и пути к нему, а не от сета, который его поймал. - ДОБАВЛЕНО: **Переключатель, вырезающий недоступные адреса из DNS-ответов** - CDN отдаёт несколько адресов, из которых файрвол отбрасывает лишь часть, поэтому одно устройство зависает на заблокированном, пока другое в той же сети открывает сайт. Рабочий адрес приходилось прописывать вручную в hosts или через alias в `dnsmasq` - для каждого адреса, для каждого домена и заново при каждой смене адресов у CDN. ## [1.74.2] - 2026-08-02 diff --git a/src/config/config.go b/src/config/config.go index 8fc8ff60..76512090 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -77,7 +77,6 @@ var DefaultSetConfig = SetConfig{ SynDetect: true, SynThreshold: DefaultIPBlockSynThreshold, HealDNS: false, - BlockedTTLSec: DefaultIPBlockBlockedTTLSec, HealTTLSec: DefaultIPBlockHealTTLSec, }, @@ -302,6 +301,10 @@ var DefaultConfig = Config{ TCPDialSec: DefaultDNSTCPDialSec, }, + IPHealth: IPHealthConfig{ + RetestIntervalSec: DefaultIPHealthRetestSec, + }, + Timezone: "", MemoryLimit: "", }, diff --git a/src/config/ipblock_test.go b/src/config/ipblock_test.go index 3e90f934..1a774976 100644 --- a/src/config/ipblock_test.go +++ b/src/config/ipblock_test.go @@ -15,9 +15,6 @@ func TestIPBlockDetectResolvedDefaults(t *testing.T) { if got := zero.ResolvedSynThreshold(); got != DefaultIPBlockSynThreshold { t.Errorf("ResolvedSynThreshold = %d, want %d", got, DefaultIPBlockSynThreshold) } - if got := zero.ResolvedBlockedTTL(); got != DefaultIPBlockBlockedTTLSec*time.Second { - t.Errorf("ResolvedBlockedTTL = %s, want %ds", got, DefaultIPBlockBlockedTTLSec) - } if got := zero.ResolvedHealTTL(); got != DefaultIPBlockHealTTLSec { t.Errorf("ResolvedHealTTL = %d, want %d", got, DefaultIPBlockHealTTLSec) } @@ -55,7 +52,6 @@ func TestValidateIPBlockDetectFillsBlanks(t *testing.T) { set.Id = "s1" set.TCP.IPBlockDetect.Enabled = true set.TCP.IPBlockDetect.SynThreshold = 0 - set.TCP.IPBlockDetect.BlockedTTLSec = 0 set.TCP.IPBlockDetect.HealTTLSec = 0 cfg.Sets = []*SetConfig{&set} @@ -67,10 +63,78 @@ func TestValidateIPBlockDetectFillsBlanks(t *testing.T) { if ibd.SynThreshold != DefaultIPBlockSynThreshold { t.Errorf("syn_threshold = %d, want %d", ibd.SynThreshold, DefaultIPBlockSynThreshold) } - if ibd.BlockedTTLSec != DefaultIPBlockBlockedTTLSec { - t.Errorf("blocked_ttl_sec = %d, want %d", ibd.BlockedTTLSec, DefaultIPBlockBlockedTTLSec) - } if ibd.HealTTLSec != DefaultIPBlockHealTTLSec { t.Errorf("heal_ttl_sec = %d, want %d", ibd.HealTTLSec, DefaultIPBlockHealTTLSec) } } + +func TestIPHealthRetestIntervalIsGlobal(t *testing.T) { + var zero IPHealthConfig + if got := zero.RetestInterval(); got != DefaultIPHealthRetestSec*time.Second { + t.Errorf("RetestInterval = %s, want %ds", got, DefaultIPHealthRetestSec) + } + + var nilCfg *IPHealthConfig + if got := nilCfg.RetestInterval(); got != DefaultIPHealthRetestSec*time.Second { + t.Errorf("nil RetestInterval = %s, want %ds", got, DefaultIPHealthRetestSec) + } + + cfg := NewConfig() + cfg.System.IPHealth.RetestIntervalSec = 0 + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if cfg.System.IPHealth.RetestIntervalSec != DefaultIPHealthRetestSec { + t.Errorf("retest_interval_sec = %d, want validation to fill the default", cfg.System.IPHealth.RetestIntervalSec) + } + + cfg.System.IPHealth.RetestIntervalSec = 90 + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if got := cfg.System.IPHealth.RetestInterval(); got != 90*time.Second { + t.Errorf("RetestInterval = %s, want an explicit 90s kept", got) + } +} + +func TestIPHealthSparseRoundtrip(t *testing.T) { + cfg := NewConfig() + set := NewSetConfig() + set.Id = "s1" + cfg.Sets = []*SetConfig{&set} + + data, err := MarshalSparse(&cfg) + if err != nil { + t.Fatalf("MarshalSparse: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if system, ok := raw["system"].(map[string]any); ok { + if _, present := system["ip_health"]; present { + t.Errorf("ip_health was written while it holds the default; the config file omits defaults") + } + } + + cfg.System.IPHealth.RetestIntervalSec = 900 + data, err = MarshalSparse(&cfg) + if err != nil { + t.Fatalf("MarshalSparse: %v", err) + } + raw = map[string]any{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + system, ok := raw["system"].(map[string]any) + if !ok { + t.Fatal("system section missing") + } + ipHealth, ok := system["ip_health"].(map[string]any) + if !ok { + t.Fatal("ip_health missing after being set away from the default") + } + if got := ipHealth["retest_interval_sec"]; got != float64(900) { + t.Errorf("retest_interval_sec = %v, want 900", got) + } +} diff --git a/src/config/types.go b/src/config/types.go index 4b698f6f..ce466609 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -40,9 +40,9 @@ const ( ) const ( - DefaultIPBlockSynThreshold = 3 - DefaultIPBlockBlockedTTLSec = 300 - DefaultIPBlockHealTTLSec = 60 + DefaultIPBlockSynThreshold = 3 + DefaultIPBlockHealTTLSec = 60 + DefaultIPHealthRetestSec = 300 ) const ( @@ -157,10 +157,20 @@ type IPBlockDetectConfig struct { SynDetect bool `json:"syn_detect"` SynThreshold int `json:"syn_threshold"` HealDNS bool `json:"heal_dns"` - BlockedTTLSec int `json:"blocked_ttl_sec"` HealTTLSec int `json:"heal_ttl_sec"` } +type IPHealthConfig struct { + RetestIntervalSec int `json:"retest_interval_sec"` +} + +func (c *IPHealthConfig) RetestInterval() time.Duration { + if c == nil || c.RetestIntervalSec <= 0 { + return DefaultIPHealthRetestSec * time.Second + } + return time.Duration(c.RetestIntervalSec) * time.Second +} + func (c *IPBlockDetectConfig) ResolvedSynThreshold() int { if c == nil || c.SynThreshold <= 0 { return DefaultIPBlockSynThreshold @@ -168,13 +178,6 @@ func (c *IPBlockDetectConfig) ResolvedSynThreshold() int { return c.SynThreshold } -func (c *IPBlockDetectConfig) ResolvedBlockedTTL() time.Duration { - if c == nil || c.BlockedTTLSec <= 0 { - return DefaultIPBlockBlockedTTLSec * time.Second - } - return time.Duration(c.BlockedTTLSec) * time.Second -} - func (c *IPBlockDetectConfig) ResolvedHealTTL() uint32 { if c == nil || c.HealTTLSec <= 0 { return DefaultIPBlockHealTTLSec @@ -302,6 +305,7 @@ type SystemConfig struct { API ApiConfig `json:"api"` AI AIConfig `json:"ai"` DNS DNSSystemConfig `json:"dns"` + IPHealth IPHealthConfig `json:"ip_health"` Timezone string `json:"timezone"` MemoryLimit string `json:"memory_limit,omitempty"` Pprof bool `json:"pprof,omitempty"` diff --git a/src/config/validation.go b/src/config/validation.go index 10fe6089..772c0dc1 100644 --- a/src/config/validation.go +++ b/src/config/validation.go @@ -119,14 +119,15 @@ func (c *Config) Validate() error { c.System.Logging.Directory = filepath.Clean(c.System.Logging.Directory) } + if c.System.IPHealth.RetestIntervalSec <= 0 { + c.System.IPHealth.RetestIntervalSec = DefaultIPHealthRetestSec + } + for setIdx, set := range c.Sets { ibd := &set.TCP.IPBlockDetect if ibd.SynThreshold <= 0 { ibd.SynThreshold = DefaultIPBlockSynThreshold } - if ibd.BlockedTTLSec <= 0 { - ibd.BlockedTTLSec = DefaultIPBlockBlockedTTLSec - } if ibd.HealTTLSec <= 0 { ibd.HealTTLSec = DefaultIPBlockHealTTLSec } diff --git a/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx b/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx index 696adb6f..9f85caa2 100644 --- a/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx +++ b/src/http/ui/src/components/sets/tcp/TcpGeneral.tsx @@ -31,7 +31,6 @@ export const TcpGeneral = ({ config, queue, onChange }: TcpGeneralProps) => { syn_detect: true, syn_threshold: 3, heal_dns: false, - blocked_ttl_sec: 300, heal_ttl_sec: 60, ...config.tcp.ip_block_detect, }; @@ -242,22 +241,6 @@ export const TcpGeneral = ({ config, queue, onChange }: TcpGeneralProps) => { /> - - - onChange("tcp.ip_block_detect.blocked_ttl_sec", value) - } - min={60} - max={3600} - step={60} - valueSuffix=" s" - helperText={t("sets.tcp.general.ibdBlockedTtlHelper")} - aiTopic="tcp.ip_block_detect.blocked_ttl_sec" - /> - - {ibd.heal_dns && ( void; +} + +const DEFAULTS = { + retest_interval_sec: 300, +}; + +export const IPHealthSettings = ({ + config, + onChange, +}: IPHealthSettingsProps) => { + const { t } = useTranslation(); + const ipHealth = { ...DEFAULTS, ...(config.system.ip_health ?? {}) }; + + return ( + } + > + {t("settings.IPHealth.info")} + + + + onChange("system.ip_health.retest_interval_sec", value) + } + helperText={t("settings.IPHealth.retestIntervalHelper")} + aiTopic="system.ip_health.retest_interval_sec" + /> + + + ); +}; + +export default IPHealthSettings; diff --git a/src/http/ui/src/components/settings/Page.tsx b/src/http/ui/src/components/settings/Page.tsx index 2315d158..7645d026 100644 --- a/src/http/ui/src/components/settings/Page.tsx +++ b/src/http/ui/src/components/settings/Page.tsx @@ -34,6 +34,7 @@ import { useSnackbar } from "@context/SnackbarProvider"; import { useAiStatus } from "@context/AiStatusProvider"; import { ApiSettings } from "./Api"; import { DnsSettings } from "./Dns"; +import { IPHealthSettings } from "./IPHealth"; import { CaptureSettings } from "./Capture"; import { DevicesSettings } from "./Devices"; import { CheckerSettings } from "./Discovery"; @@ -502,6 +503,12 @@ export function SettingsPage() { + + + + + + diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index 488c0ff2..34f18b1c 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -368,6 +368,14 @@ "mssSizeHelp": "Lower values = more fragmentation. 88 is commonly used for YouTube bypass.", "info": "Reduces the TCP Maximum Segment Size on SYN/SYN-ACK packets for all TCP port 443 traffic, forcing data fragmentation. Most DPI systems cannot reassemble fragmented ClientHello. For per-device MSS clamping, use the Device Filtering settings." }, + "IPHealth": { + "title": "IP Health", + "description": "How long a destination stays marked unreachable", + "info": "IP block detection is configured per set, but whether an address answers is a property of the address and the path to it, not of the set that matched it. The verdict is therefore shared across sets, and so is how long it lasts.", + "group": "Retesting", + "retestInterval": "Retest interval (seconds)", + "retestIntervalHelper": "How long a destination stays marked unreachable before the router tests it again. Blocks get lifted and addresses get reassigned, so a verdict that never expires would strand devices on a stale one. This is also how long recovery takes, since a condemned address is reset before it can complete a handshake." + }, "Dns": { "title": "DNS", "description": "How b4 resolves DNS for matched sets, and how it handles DNS over TCP", @@ -974,8 +982,6 @@ "ibdThresholdWarn": "A low threshold may cause false positives - a single dropped packet or slow server could be mistaken for an IP block.", "ibdTimeout": "Detection Timeout", "ibdTimeoutHelper": "Alternative trigger: time since first attempt with no response", - "ibdBlockedTtl": "Retest Interval", - "ibdBlockedTtlHelper": "How long a destination stays marked blocked before it is tested again. Blocks get lifted and addresses get reassigned, so a verdict that never expires would strand devices on a stale one.", "ibdHealTtl": "Healed Answer TTL", "ibdHealTtlHelper": "TTL written into curated DNS answers. Keep it short so a device picks up a recovered address quickly instead of caching the curated answer for hours.", "ibdCache": "Cache Blocked IPs", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index 3799b4e2..c781efce 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -364,6 +364,14 @@ "mssSizeHelp": "Меньше значение = больше фрагментация. 88 часто используется для обхода YouTube.", "info": "Уменьшает TCP Maximum Segment Size на SYN/SYN-ACK пакетах для всего TCP трафика на порт 443, вызывая фрагментацию данных. Большинство DPI систем не могут пересобрать фрагментированный ClientHello. Для MSS clamping на отдельных устройствах используйте настройки фильтрации устройств." }, + "IPHealth": { + "title": "Доступность IP", + "description": "Сколько адрес числится недоступным", + "info": "Обнаружение блокировки по IP настраивается для каждого сета, но отвечает адрес или нет - это свойство самого адреса и пути к нему, а не сета, который его поймал. Поэтому вердикт общий для всех сетов, и срок его жизни тоже.", + "group": "Перепроверка", + "retestInterval": "Интервал перепроверки (секунды)", + "retestIntervalHelper": "Сколько адрес числится недоступным до следующей проверки роутером. Блокировки снимают, а адреса переиспользуют, поэтому бессрочный вердикт оставил бы устройства на устаревшем адресе. Столько же занимает и восстановление: соединение к отбракованному адресу сбрасывается раньше, чем успевает пройти рукопожатие." + }, "Dns": { "title": "DNS", "description": "Как b4 разрешает DNS для совпавших сетов и как обрабатывает DNS поверх TCP", @@ -971,8 +979,6 @@ "ibdThresholdWarn": "Низкий порог может вызвать ложные срабатывания - один потерянный пакет или медленный сервер могут быть приняты за блокировку IP.", "ibdTimeout": "Таймаут обнаружения", "ibdTimeoutHelper": "Альтернативный триггер: время с первой попытки без ответа", - "ibdBlockedTtl": "Интервал перепроверки", - "ibdBlockedTtlHelper": "Сколько адрес числится заблокированным до следующей проверки. Блокировки снимают, а адреса переиспользуют, поэтому бессрочный вердикт оставил бы устройства на устаревшем адресе.", "ibdHealTtl": "TTL исправленного ответа", "ibdHealTtlHelper": "TTL, который записывается в исправленные DNS-ответы. Держите его небольшим, чтобы устройство быстро подхватило восстановившийся адрес, а не кэшировало исправленный ответ часами.", "ibdCache": "Кэшировать заблокированные IP", diff --git a/src/http/ui/src/models/config.ts b/src/http/ui/src/models/config.ts index 88f6cd41..4a31b21e 100644 --- a/src/http/ui/src/models/config.ts +++ b/src/http/ui/src/models/config.ts @@ -352,6 +352,7 @@ export interface SystemConfig { api: ApiConfig; ai: AIConfig; dns: DnsSystemConfig; + ip_health?: IPHealthConfig; timezone: string; memory_limit?: string; } @@ -458,6 +459,10 @@ export interface DuplicateConfig { count: number; } +export interface IPHealthConfig { + retest_interval_sec: number; +} + export interface IPBlockDetectConfig { enabled: boolean; retransmit_threshold: number; @@ -466,7 +471,6 @@ export interface IPBlockDetectConfig { syn_detect: boolean; syn_threshold: number; heal_dns: boolean; - blocked_ttl_sec: number; heal_ttl_sec: number; } diff --git a/src/nfq/pool.go b/src/nfq/pool.go index 6bb55d13..f0f6c2f4 100644 --- a/src/nfq/pool.go +++ b/src/nfq/pool.go @@ -109,12 +109,12 @@ func NewPool(cfg *config.Config) *Pool { for { select { case <-cleanupTicker.C: - blockedTTL := pool.blockedCacheTTL() + retest := pool.retestInterval() pool.state.connState.Cleanup() pool.state.tlsCache.Cleanup() - pool.state.destState.Cleanup(blockedTTL) + pool.state.destState.Cleanup(retest) pool.state.hostHints.Cleanup() - pool.state.ipHealth.Cleanup(blockedTTL) + pool.state.ipHealth.Cleanup(retest) pool.state.goodIPs.Cleanup() case <-escalationTicker.C: pool.state.pendingHello.Cleanup() @@ -335,21 +335,12 @@ func (p *Pool) GetIPBlockCache() IPBlockCache { return p.state.destState } -func (p *Pool) blockedCacheTTL() time.Duration { - ttl := time.Duration(config.DefaultIPBlockBlockedTTLSec) * time.Second +func (p *Pool) retestInterval() time.Duration { cfg := p.GetFirstWorkerConfig() if cfg == nil { - return ttl + return config.DefaultIPHealthRetestSec * time.Second } - for _, set := range cfg.Sets { - if set == nil || !set.TCP.IPBlockDetect.Enabled { - continue - } - if d := set.TCP.IPBlockDetect.ResolvedBlockedTTL(); d > ttl { - ttl = d - } - } - return ttl + return cfg.System.IPHealth.RetestInterval() } func (p *Pool) GetEscalations() []metrics.EscalationEntry {