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 {