feat: add IP Health settings to manage unreachable address retest intervals

This commit is contained in:
Daniel Lavrushin 2026-08-08 17:50:58 +02:00 committed by Daniel Lavrushin
parent 47bd723a0b
commit 7be08fbe76
13 changed files with 180 additions and 59 deletions

View file

@ -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

View file

@ -22,6 +22,7 @@
- ИЗМЕНЕНО: **Обнаружение блокировки по IP охватывает и адрес, который не отвечает на SYN** - функция описана для адресов, отбрасываемых файрволом на уровне IP, но считала повторы TLS ClientHello, а ClientHello появляется только после установленного рукопожатия. Блокировка по адресу убивает само рукопожатие, поэтому единственный случай, названный в описании, оказался тем самым, который проверка увидеть не могла, а ловила она блокировку с отслеживанием состояния уже после рукопожатия.
- ДОБАВЛЕНО: **Роутер сам проверяет адрес, прежде чем считать его заблокированным** - отсутствие ответов у одного устройства одинаково хорошо объясняется медленным сервером, кратким сбоем маршрутизации или обрывом аплинка, и решение по одному этому признаку сбрасывало соединения к адресу, с которым всё было в порядке. Десяток адресов, отвалившихся за минуту, читается как обрыв аплинка и приостанавливает обнаружение, вместо того чтобы записать их все в заблокированные.
- ИСПРАВЛЕНО: **Адрес, помеченный как заблокированный, оставался таким, пока к нему хоть что-то обращалось** - каждая проверка кэша обновляла отметку времени, поэтому пятиминутный срок не наступал, пока устройство продолжало попытки, и ничто не перепроверяло адрес. Ложное срабатывание или уже снятая блокировка держались столько же, сколько шёл трафик.
- ДОБАВЛЕНО: **Раздел «Доступность IP» в настройках** - у срока, в течение которого адрес числится недоступным, не было собственной настройки, и место ей именно там, а не среди параметров сета: отвечает адрес или нет, зависит от самого адреса и пути к нему, а не от сета, который его поймал.
- ДОБАВЛЕНО: **Переключатель, вырезающий недоступные адреса из DNS-ответов** - CDN отдаёт несколько адресов, из которых файрвол отбрасывает лишь часть, поэтому одно устройство зависает на заблокированном, пока другое в той же сети открывает сайт. Рабочий адрес приходилось прописывать вручную в hosts или через alias в `dnsmasq` - для каждого адреса, для каждого домена и заново при каждой смене адресов у CDN.
## [1.74.2] - 2026-08-02

View file

@ -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: "",
},

View file

@ -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)
}
}

View file

@ -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"`

View file

@ -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
}

View file

@ -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) => {
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<B4Slider
label={t("sets.tcp.general.ibdBlockedTtl")}
value={ibd.blocked_ttl_sec}
onChange={(value: number) =>
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"
/>
</Grid>
{ibd.heal_dns && (
<Grid size={{ xs: 12, md: 6 }}>
<B4Slider

View file

@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { IpIcon } from "@b4.icons";
import { B4FormGroup, B4Hint, B4NumberField, B4Section } from "@b4.elements";
import { B4Config } from "@models/config";
interface IPHealthSettingsProps {
config: B4Config;
onChange: (
field: string,
value: number | boolean | string | string[],
) => void;
}
const DEFAULTS = {
retest_interval_sec: 300,
};
export const IPHealthSettings = ({
config,
onChange,
}: IPHealthSettingsProps) => {
const { t } = useTranslation();
const ipHealth = { ...DEFAULTS, ...(config.system.ip_health ?? {}) };
return (
<B4Section
title={t("settings.IPHealth.title")}
description={t("settings.IPHealth.description")}
icon={<IpIcon />}
>
<B4Hint>{t("settings.IPHealth.info")}</B4Hint>
<B4FormGroup label={t("settings.IPHealth.group")} columns={2}>
<B4NumberField
label={t("settings.IPHealth.retestInterval")}
value={ipHealth.retest_interval_sec}
min={30}
max={3600}
onChange={(value) =>
onChange("system.ip_health.retest_interval_sec", value)
}
helperText={t("settings.IPHealth.retestIntervalHelper")}
aiTopic="system.ip_health.retest_interval_sec"
/>
</B4FormGroup>
</B4Section>
);
};
export default IPHealthSettings;

View file

@ -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() {
<DnsSettings config={config} onChange={handleChange} />
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex" }}>
<Box sx={{ width: "100%" }}>
<IPHealthSettings config={config} onChange={handleChange} />
</Box>
</Grid>
</Grid>
</TabPanel>

View file

@ -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",

View file

@ -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",

View file

@ -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;
}

View file

@ -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 {