fix(dpi-bypass): TCP keepalive + conntrack tuning + nfqueue watchdog

- sysctl: tcp_keepalive=60/10/3, conntrack_established=600s (was 5 days)
- nfqws/nfqws2: --ctrack-timeouts=30,30,600,30,30,30,30,30 in default strategy
- watchdog: probe_nfqueue checks nfqws/nfqws2 process health every 300s
- watchdog: heal_nfqueue_stopped restarts tachyon if DPI bypass processes die

This eliminates the need for manual DNS flush + device restart when DPI
starts blocking connections. Dead connections are now detected in ~90s
instead of hours, and stale conntrack entries expire in 10 min instead
of 5 days.
This commit is contained in:
Dushnilin
2026-08-28 20:05:03 +03:00
parent 16ffc9f91f
commit 404db8ae05
4 changed files with 107 additions and 2 deletions
+2 -2
View File
@@ -181,7 +181,7 @@ function constants_map() {
c.ZAPRET_DESYNC_MARK = env("ZAPRET_DESYNC_MARK", "0x40000000");
c.ZAPRET_DESYNC_MARK_POSTNAT = env("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000");
c.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = env("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT", "--filter-tcp=80 <HOSTLIST> --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 <HOSTLIST_NOAUTO> --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 <HOSTLIST> --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1");
c.ZAPRET_DEFAULT_NFQWS_OPT = env("ZAPRET_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin");
c.ZAPRET_DEFAULT_NFQWS_OPT = env("ZAPRET_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --ctrack-timeouts=30,30,600,30,30,30,30,30");
c.ZAPRET2_PROVIDER_BASE_DIR = env("ZAPRET2_PROVIDER_BASE_DIR", "/opt/zapret2");
c.ZAPRET2_PROVIDER_NFQWS2_BIN = env("ZAPRET2_PROVIDER_NFQWS2_BIN", c.ZAPRET2_PROVIDER_BASE_DIR + "/nfq2/nfqws2");
@@ -199,7 +199,7 @@ function constants_map() {
c.ZAPRET2_NFQWS2_RESPAWN_DELAY = env("ZAPRET2_NFQWS2_RESPAWN_DELAY", "5");
c.ZAPRET2_DESYNC_MARK = env("ZAPRET2_DESYNC_MARK", "0x40000000");
c.ZAPRET2_DESYNC_MARK_POSTNAT = env("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000");
c.ZAPRET2_DEFAULT_NFQWS2_OPT = env("ZAPRET2_DEFAULT_NFQWS2_OPT", "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6");
c.ZAPRET2_DEFAULT_NFQWS2_OPT = env("ZAPRET2_DEFAULT_NFQWS2_OPT", "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6 --ctrack-timeouts=30,30,600,30,30,30,30,30");
c.BYEDPI_BIN = env("BYEDPI_BIN", "/usr/bin/ciadpi");
c.BYEDPI_SERVICE_INIT = env("BYEDPI_SERVICE_INIT", "/etc/init.d/byedpi");
+23
View File
@@ -1829,6 +1829,28 @@ function ensure_bridge_netfilter_disabled() {
run_args([ "sysctl", "-w", "net.bridge.bridge-nf-call-ip6tables=0" ]);
}
// TCP keepalive: detect dead connections in ~90s instead of kernel default (hours).
// Conntrack: expire stale TCP entries in 10 min instead of 5 days.
function apply_connection_tuning() {
let sysctls = [
[ "net.ipv4.tcp_keepalive_time", "60" ],
[ "net.ipv4.tcp_keepalive_intvl", "10" ],
[ "net.ipv4.tcp_keepalive_probes", "3" ],
[ "net.netfilter.nf_conntrack_tcp_timeout_established", "600" ],
[ "net.netfilter.nf_conntrack_tcp_timeout_time_wait", "30" ]
];
let ok = true;
for (let pair in sysctls) {
let current = trim(command_output_from_args([ "sysctl", "-n", pair[0] ]) || "");
if (current == pair[1]) continue;
if (!run_args_quiet([ "sysctl", "-w", pair[0] + "=" + pair[1] ]))
ok = false;
}
if (ok)
log_debug("Connection tuning applied: tcp_keepalive=60/10/3, conntrack_established=600");
return ok;
}
function community_service_has_subnet_list(value) {
return rule_config.community_service_has_subnet_list(value);
}
@@ -1960,6 +1982,7 @@ function nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_s
log_debug("Building nftables runtime model");
return ensure_bridge_netfilter_disabled() &&
apply_connection_tuning() &&
ensure_tproxy_route_rule(rt_table, fakeip_mark) &&
nft_create_runtime_base_from_uci(table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) &&
nft_add_section_priority_rules_from_sections(uci_sections("section"), table, interface_set, localv4_set, localv6_set, fakeip_mark) &&
@@ -70,6 +70,8 @@ const EV = {
SECTIONS_EMPTY: "sections.empty",
ANOMALY_RECONNECTS: "anomaly.reconnects",
PAUSE_EXPIRED: "pause.expired",
NFQUEUE_DOWN: "nfqueue.down",
NFQUEUE_UP: "nfqueue.up",
TICK: "tick"
};
@@ -957,6 +959,46 @@ function controller(bus, opts) {
bus.emit(EV.OOM_RECOVERABLE, { last_oom: state.last_oom_time, scale: current_scale });
}
// ── Probe: nfqueue/zapret runtime health ──────────────────────────────────
// Checks if nfqws/nfqws2 supervisor processes are alive by counting PID
// files. A mismatch means the DPI bypass engine crashed or was killed.
function probe_nfqueue() {
if (!bus.has(EV.NFQUEUE_DOWN) && !bus.has(EV.NFQUEUE_UP)) return;
if (is_reload_in_progress()) return;
let pid_dirs = [
"/var/run/tachyon/zapret/child-pid",
"/var/run/tachyon/zapret2/child-pid"
];
for (let i = 0; i < length(pid_dirs); i++) {
let child_dir = pid_dirs[i];
let child_entries = fs.readdir(child_dir);
if (child_entries == null) continue;
let expected = length(child_entries);
if (expected == 0) continue;
let running = 0;
for (let entry in child_entries) {
let pid_data = fs.readfile(child_dir + "/" + entry);
if (pid_data == null) continue;
let pid = trim(as_string(pid_data));
if (pid == "") continue;
try { system("kill -0 " + pid + " 2>/dev/null"); running++; } catch (e) {}
}
if (running == expected) {
bus.emit(EV.NFQUEUE_UP, { running: running, expected: expected });
} else {
bus.emit(EV.NFQUEUE_DOWN, {
running: running,
expected: expected,
dir: child_dir
});
}
}
}
// ── Tier drivers ──────────────────────────────────────────────────────────
// Each probe is wrapped so one failing observation cannot stop the tier;
// this mirrors the safe_call() isolation the old per-check loop had.
@@ -1004,6 +1046,7 @@ function controller(bus, opts) {
run_probe(probe_empty_sections, "empty_sections");
run_probe(probe_anomalies, "anomalies");
run_probe(probe_oom_recovery, "oom_recovery");
run_probe(probe_nfqueue, "nfqueue");
};
// Health is graded by how long we go without publishing a fault: after a
+39
View File
@@ -649,6 +649,10 @@ function note_dns_recovered(ev) {
settle_recovery("dns", "fixed");
}
function note_nfqueue_recovered(ev) {
settle_recovery("nfqueue", "fixed");
}
// Guard: skip if a tachyon reload is already in progress (prevents concurrent reload_firewall races)
function is_reload_in_progress() {
return fs.stat("/var/run/tachyon.reload.lock") != null
@@ -1022,6 +1026,35 @@ function heal_dns_continuous(ev) {
);
}
// ─── AI nfqueue/zapret Runtime Recovery ──────────────────────────────────────
// When nfqws/nfqws2 processes die (crash, OOM, kill), DPI bypass stops working.
// The simplest fix is a full tachyon restart which respawns everything.
function heal_nfqueue_stopped(ev) {
if (settings().ai_nfqueue_health_enabled == "0") return;
if (suppressed_by_root_cause("heal_nfqueue_stopped")) return;
let running = int(ev.payload.running) || 0;
let expected = int(ev.payload.expected) || 0;
// Only act when some processes are running but not all — a full crash is
// already caught by the sing-box stopped or proxy down healers.
if (running > 0 && running >= expected) return;
let incident = {
type: "nfqueue_health",
description: sprintf("nfqueue/zapret runtime: %d/%d processes alive (%s)",
running, expected, as_string(ev.payload.dir || "")),
resolution: "Restarting tachyon to respawn nfqws/nfqws2"
};
if (!safe_proxy_restart("nfqueue_health")) {
ai_heal_report(incident.type, incident.description,
"Restart skipped due to rate limit", "skipped");
return;
}
watch_recovery("nfqueue", incident, "nfqueue_health");
}
// ─── AI Empty Sections Recovery ─────────────────────────────────────────────
function heal_empty_sections(ev) {
let recovered = common.array_or_empty(ev.payload.sections);
@@ -1619,6 +1652,10 @@ function register_subscribers() {
subscribe(EV.DNS_DOWN, heal_dns_continuous,
{ name: "heal_dns_continuous", priority: PRIORITY_DNS + 1 });
// nfqueue/zapret runtime: respawn crashed nfqws/nfqws2 processes.
subscribe(EV.NFQUEUE_DOWN, heal_nfqueue_stopped,
{ name: "heal_nfqueue_stopped", priority: 45, cooldown: 300 });
// Recovery facts. These were published from the start but had no subscriber,
// which is why a repair could only ever report its own intent. No cooldown:
// settle_recovery() is a no-op unless a watch is actually open, and skipping
@@ -1632,6 +1669,8 @@ function register_subscribers() {
// suppression deadline.
subscribe(EV.WAN_UP, note_wan_recovered,
{ name: "note_wan_recovered", priority: 5 });
subscribe(EV.NFQUEUE_UP, note_nfqueue_recovered,
{ name: "note_nfqueue_recovered", priority: 5 });
subscribe(EV.SECTIONS_EMPTY, heal_empty_sections,
{ name: "heal_empty_sections", priority: 60, cooldown: 120 });