mirror of
https://github.com/necronicle/z2k.git
synced 2026-04-28 03:20:25 +00:00
Critical: CRLF→LF in hostlists (broke nfqws2 matching), die in pipe subshell (errors silently ignored), update_z2k /bin/sh overwrite guard, missing quic_rutracker blob registration, telemetry race condition (multi-process merge). Security: sed injection via user input → grep -vxF, grep -qx → -qxF for domain matching, chmod 777/666 → 755/644 + chown nobody, predictable /tmp/z2k cleanup before mkdir. High: PID path mismatch /opt/var/run → /var/run, zapret2_nat chain in cleanup, orphaned tcpdump on monitor stop, cross-filesystem mv → cp+rm, base.sh re-source clobber fix. Medium: version mismatch, root check for default install path, early exit for help/version, backup/restore dir fix, awk special chars, HTTP RKN failure_detector, youtube_tcp_tcp typo, hostlist warning, kernel module regex anchoring, state eviction + locking in Lua. Dead code removed (~200 lines): check_installation_status, menu_view_strategy, apply_category_strategies, update_init_section, get_init_tcp/udp_params, TEST_DOMAINS, HTTP_STRATEGIES_CONF, check_keenetic_specifics, discord_tcp variable. Added .gitattributes to enforce LF line endings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1075 lines
35 KiB
Lua
1075 lines
35 KiB
Lua
-- z2k-autocircular.lua
|
|
-- Persist zapret-auto.lua "circular" per-host strategy across nfqws2 restarts.
|
|
--
|
|
-- Design:
|
|
-- - zapret-auto.lua stores nstrategy in global table `autostate[askey][hostkey]`.
|
|
-- - This file wraps `circular()` to:
|
|
-- 1) seed autostate from a TSV file on disk (best effort),
|
|
-- 2) save nstrategy back to disk when it changes (rate-limited).
|
|
--
|
|
-- Notes:
|
|
-- - We do NOT change rotation logic; only persist/restore.
|
|
-- - Storage key uses `desync.arg.key` when provided; otherwise falls back to `desync.func_instance`.
|
|
|
|
local STATE_DIR_PRIMARY = "/opt/zapret2/extra_strats/cache/autocircular"
|
|
local STATE_FILE_PRIMARY = STATE_DIR_PRIMARY .. "/state.tsv"
|
|
local STATE_FILE_FALLBACK = "/tmp/z2k-autocircular-state.tsv"
|
|
local TELEMETRY_FILE_PRIMARY = STATE_DIR_PRIMARY .. "/telemetry.tsv"
|
|
local TELEMETRY_FILE_FALLBACK = "/tmp/z2k-autocircular-telemetry.tsv"
|
|
local DEBUG_FLAG_PRIMARY = STATE_DIR_PRIMARY .. "/debug.flag"
|
|
local DEBUG_FLAG_FALLBACK = "/tmp/z2k-autocircular-debug.flag"
|
|
local DEBUG_LOG_PRIMARY = STATE_DIR_PRIMARY .. "/debug.log"
|
|
local DEBUG_LOG_FALLBACK = "/tmp/z2k-autocircular-debug.log"
|
|
|
|
local loaded = false
|
|
local state = {} -- state[askey][host_norm] = { strategy = N, ts = unix_time }
|
|
local telemetry_loaded = false
|
|
local telemetry = {} -- telemetry[askey][hostn][strategy] = { ok, fail, lat, ts, cooldown_until }
|
|
|
|
local last_write = 0
|
|
local write_interval = 2 -- seconds
|
|
local pending_write = false
|
|
local last_telemetry_write = 0
|
|
local telemetry_write_interval = 5 -- seconds
|
|
|
|
local debug_enabled = false
|
|
local debug_checked_at = 0
|
|
local debug_refresh_interval = 5 -- seconds
|
|
|
|
math.randomseed(os.time() or 0)
|
|
|
|
local policy_enabled = true
|
|
local policy_epsilon = 0.15
|
|
local policy_cooldown_sec = 120
|
|
local policy_ucb_c = 0.65
|
|
local policy_lat_penalty = 0.10
|
|
|
|
local function now_f()
|
|
if type(clock_getfloattime) == "function" then
|
|
local ok, v = pcall(clock_getfloattime)
|
|
if ok and tonumber(v) then
|
|
return tonumber(v)
|
|
end
|
|
end
|
|
return tonumber(os.time() or 0) or 0
|
|
end
|
|
|
|
local function is_blank(s)
|
|
return (s == nil) or (tostring(s) == "")
|
|
end
|
|
|
|
local function normalize_hostkey_for_state(hostkey)
|
|
if hostkey == nil then return nil end
|
|
local s = tostring(hostkey)
|
|
if s == "" then return nil end
|
|
s = s:gsub("%.$", "") -- trailing dot
|
|
return string.lower(s)
|
|
end
|
|
|
|
local function choose_state_file_for_read()
|
|
local f = io.open(STATE_FILE_PRIMARY, "r")
|
|
if f then f:close(); return STATE_FILE_PRIMARY end
|
|
f = io.open(STATE_FILE_FALLBACK, "r")
|
|
if f then f:close(); return STATE_FILE_FALLBACK end
|
|
return nil
|
|
end
|
|
|
|
local function choose_debug_log_file_for_write()
|
|
local f = io.open(DEBUG_LOG_PRIMARY, "a")
|
|
if f then f:close(); return DEBUG_LOG_PRIMARY end
|
|
f = io.open(DEBUG_LOG_FALLBACK, "a")
|
|
if f then f:close(); return DEBUG_LOG_FALLBACK end
|
|
return nil
|
|
end
|
|
|
|
local function refresh_debug_enabled()
|
|
local now = os.time() or 0
|
|
if now ~= 0 and (now - debug_checked_at) < debug_refresh_interval then
|
|
return debug_enabled
|
|
end
|
|
debug_checked_at = now
|
|
|
|
local f = io.open(DEBUG_FLAG_PRIMARY, "r")
|
|
if f then
|
|
f:close()
|
|
debug_enabled = true
|
|
return true
|
|
end
|
|
f = io.open(DEBUG_FLAG_FALLBACK, "r")
|
|
if f then
|
|
f:close()
|
|
debug_enabled = true
|
|
return true
|
|
end
|
|
|
|
debug_enabled = false
|
|
return false
|
|
end
|
|
|
|
local function debug_log(msg)
|
|
if not refresh_debug_enabled() then return end
|
|
local path = choose_debug_log_file_for_write()
|
|
if not path then return end
|
|
local f = io.open(path, "a")
|
|
if not f then return end
|
|
f:write(tostring(os.time() or 0), "\t", tostring(msg), "\n")
|
|
f:close()
|
|
end
|
|
|
|
local function create_empty_state_file(path)
|
|
local f = io.open(path, "w")
|
|
if not f then return false end
|
|
f:write("# z2k autocircular state (persisted circular nstrategy)\n")
|
|
f:write("# key\thost\tstrategy\tts\n")
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local function choose_state_file_for_write()
|
|
local f = io.open(STATE_FILE_PRIMARY, "a")
|
|
if f then f:close(); return STATE_FILE_PRIMARY end
|
|
f = io.open(STATE_FILE_FALLBACK, "a")
|
|
if f then f:close(); return STATE_FILE_FALLBACK end
|
|
return nil
|
|
end
|
|
|
|
local function ensure_state_file_exists()
|
|
local existing = choose_state_file_for_read()
|
|
if existing then return existing end
|
|
|
|
local writable = choose_state_file_for_write()
|
|
if not writable then return nil end
|
|
|
|
if create_empty_state_file(writable) then
|
|
return writable
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function create_empty_telemetry_file(path)
|
|
local f = io.open(path, "w")
|
|
if not f then return false end
|
|
f:write("# z2k autocircular telemetry\n")
|
|
f:write("# key\thost\tstrategy\tok\tfail\tlat\tts\tcooldown_until\n")
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local function choose_telemetry_file_for_read()
|
|
local f = io.open(TELEMETRY_FILE_PRIMARY, "r")
|
|
if f then f:close(); return TELEMETRY_FILE_PRIMARY end
|
|
f = io.open(TELEMETRY_FILE_FALLBACK, "r")
|
|
if f then f:close(); return TELEMETRY_FILE_FALLBACK end
|
|
return nil
|
|
end
|
|
|
|
local function choose_telemetry_file_for_write()
|
|
local f = io.open(TELEMETRY_FILE_PRIMARY, "a")
|
|
if f then f:close(); return TELEMETRY_FILE_PRIMARY end
|
|
f = io.open(TELEMETRY_FILE_FALLBACK, "a")
|
|
if f then f:close(); return TELEMETRY_FILE_FALLBACK end
|
|
return nil
|
|
end
|
|
|
|
local function ensure_telemetry_file_exists()
|
|
local existing = choose_telemetry_file_for_read()
|
|
if existing then return existing end
|
|
local writable = choose_telemetry_file_for_write()
|
|
if not writable then return nil end
|
|
if create_empty_telemetry_file(writable) then
|
|
return writable
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function load_state()
|
|
if loaded then return end
|
|
loaded = true
|
|
state = {}
|
|
|
|
local path = ensure_state_file_exists()
|
|
if not path then return end
|
|
|
|
local f = io.open(path, "r")
|
|
if not f then return end
|
|
|
|
for line in f:lines() do
|
|
if line ~= "" and not line:match("^%s*#") then
|
|
local askey, host, strat, ts = line:match("^([^\t]+)\t([^\t]+)\t([0-9]+)\t?([0-9]*)")
|
|
if askey and host and strat then
|
|
local n = tonumber(strat)
|
|
if n and n >= 1 then
|
|
local hn = normalize_hostkey_for_state(host)
|
|
if hn then
|
|
if not state[askey] then state[askey] = {} end
|
|
state[askey][hn] = { strategy = n, ts = tonumber(ts) or 0 }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
f:close()
|
|
end
|
|
|
|
local MAX_ENTRIES_PER_KEY = 500
|
|
|
|
local function evict_state_entries(merged)
|
|
for askey, hosts in pairs(merged) do
|
|
local count = 0
|
|
for _ in pairs(hosts) do count = count + 1 end
|
|
if count > MAX_ENTRIES_PER_KEY then
|
|
-- Collect entries with timestamps, sort by ts ascending, remove oldest
|
|
local entries = {}
|
|
for hostn, rec in pairs(hosts) do
|
|
table.insert(entries, { hostn = hostn, ts = (rec and rec.ts) or 0 })
|
|
end
|
|
table.sort(entries, function(a, b) return a.ts < b.ts end)
|
|
local to_remove = count - MAX_ENTRIES_PER_KEY
|
|
for i = 1, to_remove do
|
|
hosts[entries[i].hostn] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function evict_telemetry_entries(merged)
|
|
for askey, hosts in pairs(merged) do
|
|
local count = 0
|
|
for _ in pairs(hosts) do count = count + 1 end
|
|
if count > MAX_ENTRIES_PER_KEY then
|
|
-- Collect entries with total attempts, sort by att ascending, remove lowest
|
|
local entries = {}
|
|
for hostn, strats in pairs(hosts) do
|
|
local att = 0
|
|
if type(strats) == "table" then
|
|
for _, rec in pairs(strats) do
|
|
if rec then
|
|
att = att + (tonumber(rec.ok) or 0) + (tonumber(rec.fail) or 0)
|
|
end
|
|
end
|
|
end
|
|
table.insert(entries, { hostn = hostn, att = att })
|
|
end
|
|
table.sort(entries, function(a, b) return a.att < b.att end)
|
|
local to_remove = count - MAX_ENTRIES_PER_KEY
|
|
for i = 1, to_remove do
|
|
hosts[entries[i].hostn] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function acquire_lock(path)
|
|
local lockfile = path .. ".lock"
|
|
-- Check for stale lock (older than 10 seconds)
|
|
local lf_ts = io.open(lockfile, "r")
|
|
if lf_ts then
|
|
local content = lf_ts:read("*a")
|
|
lf_ts:close()
|
|
local lock_time = tonumber(content)
|
|
if lock_time and ((os.time() or 0) - lock_time) > 10 then
|
|
os.remove(lockfile)
|
|
else
|
|
return nil, lockfile -- lock is fresh, another process holds it
|
|
end
|
|
end
|
|
-- Try exclusive create: "wx" works in Lua 5.3+/glibc; fallback to "w" with
|
|
-- prior existence check (not perfectly atomic but good enough for our use case).
|
|
local lf = io.open(lockfile, "wx")
|
|
if not lf then
|
|
-- "wx" not supported or file appeared between check and open
|
|
local recheck = io.open(lockfile, "r")
|
|
if recheck then
|
|
recheck:close()
|
|
return nil, lockfile -- another process created it
|
|
end
|
|
lf = io.open(lockfile, "w")
|
|
end
|
|
if not lf then return nil, lockfile end
|
|
lf:write(tostring(os.time() or 0))
|
|
lf:close()
|
|
return true, lockfile
|
|
end
|
|
|
|
local function release_lock(lockfile)
|
|
if lockfile then os.remove(lockfile) end
|
|
end
|
|
|
|
local function write_state()
|
|
local now = os.time() or 0
|
|
if now ~= 0 and (now - last_write) < write_interval then
|
|
pending_write = true
|
|
return
|
|
end
|
|
last_write = now
|
|
pending_write = false
|
|
|
|
local path = choose_state_file_for_write()
|
|
if not path then return end
|
|
|
|
-- Acquire lock to prevent concurrent writes
|
|
local locked, lockfile = acquire_lock(path)
|
|
if not locked then return end -- another process is writing, skip this cycle
|
|
|
|
local tmp = path .. ".tmp"
|
|
|
|
-- Read existing file to merge state (prevents split-brain across processes)
|
|
local merged_state = {}
|
|
local f_in = io.open(path, "r")
|
|
if f_in then
|
|
for line in f_in:lines() do
|
|
if line ~= "" and not line:match("^%s*#") then
|
|
local askey, host, strat, ts = line:match("^([^\t]+)\t([^\t]+)\t([0-9]+)\t?([0-9]*)")
|
|
if askey and host and strat then
|
|
if not merged_state[askey] then merged_state[askey] = {} end
|
|
merged_state[askey][host] = { strategy = tonumber(strat), ts = tonumber(ts) or 0 }
|
|
end
|
|
end
|
|
end
|
|
f_in:close()
|
|
end
|
|
|
|
-- Apply our in-memory state over the merged state
|
|
for askey, hosts in pairs(state) do
|
|
if not merged_state[askey] then merged_state[askey] = {} end
|
|
for hostn, rec in pairs(hosts) do
|
|
if rec.deleted then
|
|
merged_state[askey][hostn] = nil
|
|
else
|
|
merged_state[askey][hostn] = rec
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Evict oldest entries if any key exceeds MAX_ENTRIES_PER_KEY
|
|
evict_state_entries(merged_state)
|
|
|
|
local f = io.open(tmp, "w")
|
|
if not f then
|
|
release_lock(lockfile)
|
|
return
|
|
end
|
|
|
|
f:write("# z2k autocircular state (persisted circular nstrategy)\n")
|
|
f:write("# key\thost\tstrategy\tts\n")
|
|
|
|
for askey, hosts in pairs(merged_state) do
|
|
for hostn, rec in pairs(hosts) do
|
|
if rec and rec.strategy then
|
|
f:write(tostring(askey), "\t", tostring(hostn), "\t", tostring(rec.strategy), "\t", tostring(rec.ts or 0), "\n")
|
|
end
|
|
end
|
|
end
|
|
|
|
f:close()
|
|
local ok, err = os.rename(tmp, path)
|
|
if not ok then
|
|
DLOG("ERROR: rename %s -> %s failed: %s\n", tmp, path, tostring(err))
|
|
os.remove(tmp)
|
|
end
|
|
release_lock(lockfile)
|
|
end
|
|
|
|
local function telemetry_host(askey, hostn, create)
|
|
if not askey or not hostn then return nil end
|
|
local a = telemetry[askey]
|
|
if not a then
|
|
if not create then return nil end
|
|
a = {}
|
|
telemetry[askey] = a
|
|
end
|
|
local h = a[hostn]
|
|
if not h then
|
|
if not create then return nil end
|
|
h = {}
|
|
a[hostn] = h
|
|
end
|
|
return h
|
|
end
|
|
|
|
local function telemetry_rec(askey, hostn, strategy, create)
|
|
local s = tonumber(strategy)
|
|
if not s or s < 1 then return nil end
|
|
local h = telemetry_host(askey, hostn, create)
|
|
if not h then return nil end
|
|
local r = h[s]
|
|
if not r and create then
|
|
r = { ok = 0, fail = 0, lat = 0, ts = 0, cooldown_until = 0 }
|
|
h[s] = r
|
|
end
|
|
return r
|
|
end
|
|
|
|
local function load_telemetry()
|
|
if telemetry_loaded then return end
|
|
telemetry_loaded = true
|
|
telemetry = {}
|
|
|
|
local path = ensure_telemetry_file_exists()
|
|
if not path then return end
|
|
local f = io.open(path, "r")
|
|
if not f then return end
|
|
|
|
for line in f:lines() do
|
|
if line ~= "" and not line:match("^%s*#") then
|
|
local askey, hostn, strat, okv, failv, latv, tsv, cdv =
|
|
line:match("^([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]*)\t?([^\t]*)")
|
|
local s = tonumber(strat)
|
|
if askey and hostn and s and s >= 1 then
|
|
local r = telemetry_rec(askey, hostn, s, true)
|
|
if r then
|
|
r.ok = tonumber(okv) or 0
|
|
r.fail = tonumber(failv) or 0
|
|
r.lat = tonumber(latv) or 0
|
|
r.ts = tonumber(tsv) or 0
|
|
r.cooldown_until = tonumber(cdv) or 0
|
|
end
|
|
end
|
|
end
|
|
end
|
|
f:close()
|
|
end
|
|
|
|
local function write_telemetry()
|
|
local now = now_f()
|
|
if now ~= 0 and (now - last_telemetry_write) < telemetry_write_interval then
|
|
return
|
|
end
|
|
last_telemetry_write = now
|
|
|
|
local path = choose_telemetry_file_for_write()
|
|
if not path then return end
|
|
|
|
-- Acquire lock to prevent concurrent writes
|
|
local locked, lockfile = acquire_lock(path)
|
|
if not locked then return end
|
|
|
|
local tmp = path .. ".tmp"
|
|
|
|
-- Read existing file to merge telemetry (prevents split-brain across processes)
|
|
local merged = {}
|
|
local f_in = io.open(path, "r")
|
|
if f_in then
|
|
for line in f_in:lines() do
|
|
if line ~= "" and not line:match("^%s*#") then
|
|
local askey, hostn, strat, okv, failv, latv, tsv, cdv =
|
|
line:match("^([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]*)\t?([^\t]*)")
|
|
local s = tonumber(strat)
|
|
if askey and hostn and s and s >= 1 then
|
|
if not merged[askey] then merged[askey] = {} end
|
|
if not merged[askey][hostn] then merged[askey][hostn] = {} end
|
|
merged[askey][hostn][s] = {
|
|
ok = tonumber(okv) or 0,
|
|
fail = tonumber(failv) or 0,
|
|
lat = tonumber(latv) or 0,
|
|
ts = tonumber(tsv) or 0,
|
|
cooldown_until = tonumber(cdv) or 0,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
f_in:close()
|
|
end
|
|
|
|
-- Apply our in-memory telemetry over the merged data
|
|
for askey, hosts in pairs(telemetry) do
|
|
if not merged[askey] then merged[askey] = {} end
|
|
for hostn, strats in pairs(hosts) do
|
|
if not merged[askey][hostn] then merged[askey][hostn] = {} end
|
|
for s, rec in pairs(strats) do
|
|
if rec then
|
|
merged[askey][hostn][s] = rec
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Evict entries with lowest total attempts if any key exceeds MAX_ENTRIES_PER_KEY
|
|
evict_telemetry_entries(merged)
|
|
|
|
local f = io.open(tmp, "w")
|
|
if not f then
|
|
release_lock(lockfile)
|
|
return
|
|
end
|
|
|
|
f:write("# z2k autocircular telemetry\n")
|
|
f:write("# key\thost\tstrategy\tok\tfail\tlat\tts\tcooldown_until\n")
|
|
for askey, hosts in pairs(merged) do
|
|
for hostn, strats in pairs(hosts) do
|
|
for s, rec in pairs(strats) do
|
|
if rec then
|
|
f:write(
|
|
tostring(askey), "\t",
|
|
tostring(hostn), "\t",
|
|
tostring(s), "\t",
|
|
tostring(rec.ok or 0), "\t",
|
|
tostring(rec.fail or 0), "\t",
|
|
tostring(rec.lat or 0), "\t",
|
|
tostring(rec.ts or 0), "\t",
|
|
tostring(rec.cooldown_until or 0), "\n"
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
f:close()
|
|
local ok, err = os.rename(tmp, path)
|
|
if not ok then
|
|
DLOG("ERROR: rename %s -> %s failed: %s\n", tmp, path, tostring(err))
|
|
os.remove(tmp)
|
|
end
|
|
release_lock(lockfile)
|
|
end
|
|
|
|
local function telemetry_total_attempts(h)
|
|
if type(h) ~= "table" then return 0 end
|
|
local n = 0
|
|
for _, rec in pairs(h) do
|
|
if rec then
|
|
n = n + (tonumber(rec.ok) or 0) + (tonumber(rec.fail) or 0)
|
|
end
|
|
end
|
|
return n
|
|
end
|
|
|
|
local function telemetry_is_cooldown(rec, now)
|
|
if not rec then return false end
|
|
local cd = tonumber(rec.cooldown_until) or 0
|
|
return cd > now
|
|
end
|
|
|
|
local function telemetry_record_event(askey, hostn, strategy, success, latency_s, now)
|
|
local r = telemetry_rec(askey, hostn, strategy, true)
|
|
if not r then return end
|
|
if success then
|
|
r.ok = (tonumber(r.ok) or 0) + 1
|
|
r.cooldown_until = 0
|
|
else
|
|
r.fail = (tonumber(r.fail) or 0) + 1
|
|
r.cooldown_until = now + policy_cooldown_sec
|
|
end
|
|
local lat = tonumber(latency_s)
|
|
if lat and lat > 0 then
|
|
local prev = tonumber(r.lat) or 0
|
|
if prev <= 0 then
|
|
r.lat = lat
|
|
else
|
|
r.lat = prev * 0.8 + lat * 0.2
|
|
end
|
|
end
|
|
r.ts = now
|
|
write_telemetry()
|
|
end
|
|
|
|
local function policy_pick_strategy(askey, hostn, ct, now)
|
|
if not policy_enabled then return nil, nil end
|
|
local ctn = tonumber(ct)
|
|
if not ctn or ctn < 2 then return nil, nil end
|
|
|
|
local h = telemetry_host(askey, hostn, false)
|
|
local total = telemetry_total_attempts(h)
|
|
local candidates = {}
|
|
local fallback = {}
|
|
|
|
for s = 1, ctn do
|
|
local rec = h and h[s] or nil
|
|
if not telemetry_is_cooldown(rec, now) then
|
|
table.insert(candidates, s)
|
|
end
|
|
table.insert(fallback, s)
|
|
end
|
|
if #candidates == 0 then
|
|
candidates = fallback
|
|
end
|
|
if #candidates == 0 then
|
|
return 1, 0
|
|
end
|
|
|
|
if math.random() < policy_epsilon then
|
|
local s = candidates[math.random(1, #candidates)]
|
|
return s, 0
|
|
end
|
|
|
|
local best_s = candidates[1]
|
|
local best_score = -1e9
|
|
for i = 1, #candidates do
|
|
local s = candidates[i]
|
|
local rec = h and h[s] or nil
|
|
local okn = rec and (tonumber(rec.ok) or 0) or 0
|
|
local fn = rec and (tonumber(rec.fail) or 0) or 0
|
|
local att = okn + fn
|
|
local mean = (okn + 1) / (att + 2)
|
|
local explore = policy_ucb_c * math.sqrt(math.log(total + 2) / (att + 1))
|
|
local lat = rec and (tonumber(rec.lat) or 0) or 0
|
|
local penalty = 0
|
|
if lat > 0 then
|
|
penalty = policy_lat_penalty * math.min(lat, 5.0)
|
|
end
|
|
local score = mean + explore - penalty
|
|
if score > best_score then
|
|
best_score = score
|
|
best_s = s
|
|
end
|
|
end
|
|
return best_s, best_score
|
|
end
|
|
|
|
local function flow_state(desync)
|
|
local ls = desync and desync.track and desync.track.lua_state
|
|
if type(ls) ~= "table" then return nil end
|
|
local key = "__z2k_flow_" .. tostring(desync.func_instance or "circular")
|
|
local st = ls[key]
|
|
if type(st) ~= "table" then
|
|
st = {}
|
|
ls[key] = st
|
|
end
|
|
return st
|
|
end
|
|
|
|
local function flow_start_if_needed(desync, strategy)
|
|
local st = flow_state(desync)
|
|
if not st then return end
|
|
if st.t0 and st.t0 > 0 then return end
|
|
local p = desync and desync.l7payload
|
|
if desync and desync.outgoing and (p == "tls_client_hello" or p == "quic_initial" or p == "http_req") then
|
|
st.t0 = now_f()
|
|
st.strategy = tonumber(strategy) or 1
|
|
end
|
|
end
|
|
|
|
local function flow_finish(desync)
|
|
local st = flow_state(desync)
|
|
if not st or not st.t0 then return nil, nil end
|
|
local dt = now_f() - (tonumber(st.t0) or 0)
|
|
local s = tonumber(st.strategy) or nil
|
|
st.t0 = nil
|
|
st.strategy = nil
|
|
if dt and dt > 0 then
|
|
return dt, s
|
|
end
|
|
return nil, s
|
|
end
|
|
|
|
local function get_hostkey_func(desync)
|
|
if desync and desync.arg and desync.arg.hostkey then
|
|
local fname = tostring(desync.arg.hostkey)
|
|
local f = _G[fname]
|
|
if type(f) == "function" then
|
|
return f
|
|
end
|
|
-- Keep it non-fatal: original automate_host_record would throw, but we just skip persistence.
|
|
return nil
|
|
end
|
|
if type(standard_hostkey) == "function" then
|
|
return standard_hostkey
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function get_askey(desync)
|
|
if desync and desync.arg and not is_blank(desync.arg.key) then
|
|
return tostring(desync.arg.key)
|
|
end
|
|
if desync and desync.func_instance then
|
|
return tostring(desync.func_instance)
|
|
end
|
|
return "default"
|
|
end
|
|
|
|
local function ensure_autostate_record(askey, hostkey)
|
|
if not autostate then autostate = {} end
|
|
if not autostate[askey] then autostate[askey] = {} end
|
|
if not autostate[askey][hostkey] then autostate[askey][hostkey] = {} end
|
|
return autostate[askey][hostkey]
|
|
end
|
|
|
|
local function get_record_for_desync(desync, do_seed)
|
|
if do_seed then
|
|
load_state()
|
|
load_telemetry()
|
|
end
|
|
local hkf = get_hostkey_func(desync)
|
|
if not hkf then return nil, nil, nil end
|
|
|
|
local hostkey = hkf(desync)
|
|
if not hostkey then return nil, nil, nil end
|
|
|
|
local askey = get_askey(desync)
|
|
local hostn = normalize_hostkey_for_state(hostkey)
|
|
if not hostn then return nil, nil, nil end
|
|
|
|
local hrec = ensure_autostate_record(askey, hostkey)
|
|
if do_seed and not hrec.nstrategy then
|
|
local rec = state[askey] and state[askey][hostn]
|
|
if rec and rec.strategy then
|
|
hrec.nstrategy = rec.strategy
|
|
end
|
|
end
|
|
|
|
return askey, hostn, hrec
|
|
end
|
|
|
|
local function clear_persisted(askey, hostn)
|
|
if not askey or not hostn then return end
|
|
if state[askey] and state[askey][hostn] then
|
|
-- Mark as deleted instead of nil to propagate deletion during merge
|
|
state[askey][hostn] = { deleted = true, ts = os.time() or 0 }
|
|
write_state()
|
|
end
|
|
end
|
|
|
|
local function persist_if_changed(askey, hostn, hrec)
|
|
if not askey or not hostn or not hrec or not hrec.nstrategy then return false end
|
|
local n = tonumber(hrec.nstrategy)
|
|
if not n or n < 1 then return false end
|
|
|
|
local prev = state[askey] and state[askey][hostn] and state[askey][hostn].strategy or nil
|
|
if prev == n then return false end
|
|
|
|
if not state[askey] then state[askey] = {} end
|
|
state[askey][hostn] = { strategy = n, ts = os.time() or 0 }
|
|
write_state()
|
|
return true
|
|
end
|
|
|
|
local policy_explore_good = 0.03 -- exploration chance when current strategy is working (3%)
|
|
|
|
local function policy_seed_strategy(desync, askey, hostn, hrec)
|
|
if not policy_enabled then return nil, nil end
|
|
if not askey or not hostn or not hrec then return nil, nil end
|
|
if not desync or not desync.outgoing then return nil, nil end
|
|
|
|
local st = flow_state(desync)
|
|
if st and st.policy_seeded then
|
|
return nil, nil
|
|
end
|
|
local p = desync and desync.l7payload
|
|
if p ~= "tls_client_hello" and p ~= "quic_initial" and p ~= "http_req" then
|
|
return nil, nil
|
|
end
|
|
|
|
local ct = tonumber(hrec.ctstrategy)
|
|
if not ct or ct < 2 then
|
|
if st then st.policy_seeded = true end
|
|
return nil, nil
|
|
end
|
|
|
|
local now = now_f()
|
|
|
|
-- Respect current working strategy: if it has good telemetry and is not in
|
|
-- cooldown, keep it. Only override with very low probability (3%) to allow
|
|
-- occasional exploration without destroying a known-good choice.
|
|
local current = tonumber(hrec.nstrategy)
|
|
if current and current >= 1 and current <= ct then
|
|
local cur_rec = telemetry_rec(askey, hostn, current, false)
|
|
if cur_rec then
|
|
local cur_ok = tonumber(cur_rec.ok) or 0
|
|
local cur_fail = tonumber(cur_rec.fail) or 0
|
|
local cur_total = cur_ok + cur_fail
|
|
if not telemetry_is_cooldown(cur_rec, now) and cur_total >= 2 then
|
|
local cur_rate = cur_ok / cur_total
|
|
if cur_rate > 0.5 then
|
|
if math.random() >= policy_explore_good then
|
|
if st then st.policy_seeded = true end
|
|
return nil, nil
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- No telemetry for current strategy yet: let it accumulate data.
|
|
-- This is critical for TCP profiles where success telemetry is never
|
|
-- recorded (incoming packets don't reach circular). Without this,
|
|
-- UCB would override persisted working strategies on every connection.
|
|
if st then st.policy_seeded = true end
|
|
return nil, nil
|
|
end
|
|
end
|
|
|
|
local pick, score = policy_pick_strategy(askey, hostn, ct, now)
|
|
if pick and pick >= 1 and pick <= ct then
|
|
hrec.nstrategy = pick
|
|
end
|
|
if st then
|
|
st.policy_seeded = true
|
|
st.strategy = tonumber(hrec.nstrategy) or pick or 1
|
|
st.policy_score = score
|
|
end
|
|
return pick, score
|
|
end
|
|
|
|
local function conn_record_flags(desync)
|
|
local tr = desync and desync.track
|
|
local ls = tr and tr.lua_state
|
|
local crec = ls and ls.automate
|
|
if not crec then return false, false end
|
|
return (crec.nocheck and true or false), (crec.failure and true or false)
|
|
end
|
|
|
|
local function has_positive_incoming_response(desync)
|
|
if not desync or desync.outgoing then return false end
|
|
local p = desync.l7payload
|
|
return p == "tls_server_hello" or p == "http_reply"
|
|
end
|
|
|
|
local function should_debug_key(askey)
|
|
-- User requested monitoring for all keys (not only selected ones).
|
|
return not is_blank(askey)
|
|
end
|
|
|
|
local function is_quic_key(askey)
|
|
if not askey then return false end
|
|
local s = tostring(askey)
|
|
return s == "yt_quic" or s == "rkn_quic" or s == "custom_quic" or s == "cf_quic"
|
|
end
|
|
|
|
-- Some strategy packs gate tcp_ts-based variants using `stopif:iff=cond_tcp_has_ts`.
|
|
-- Provide this iff helper even if upstream zapret-auto.lua version is older.
|
|
if type(cond_tcp_has_ts) ~= "function" then
|
|
function cond_tcp_has_ts(desync)
|
|
local dis = desync and desync.dis
|
|
local tcp = dis and dis.tcp
|
|
local opts = tcp and tcp.options
|
|
if not tcp or not opts then return false end
|
|
|
|
-- Prefer upstream helper if available.
|
|
if type(find_tcp_option) == "function" then
|
|
local ts_kind = TCP_KIND_TS or 8
|
|
local ok = find_tcp_option(opts, ts_kind)
|
|
return ok and true or false
|
|
end
|
|
|
|
if type(opts) ~= "table" then
|
|
return false
|
|
end
|
|
|
|
-- Fallback: try to detect TCP TS option kind=8 in dissected options.
|
|
for _, opt in pairs(opts) do
|
|
if type(opt) == "table" then
|
|
local kind = tonumber(opt.kind or opt.type or opt[1])
|
|
if kind == 8 then return true end
|
|
else
|
|
if tonumber(opt) == 8 then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
end
|
|
|
|
-- Extended failure detector beyond standard_failure_detector:
|
|
-- 1. HTTP DPI redirect to block page (SLD mismatch for 301/303/308, not just 302/307)
|
|
-- 2. HTTP block page keywords (lawfilter, rkn, etc.) as fallback
|
|
-- 3. TLS fatal alert (Cloudflare ECH handshake_failure, etc.)
|
|
|
|
-- Keyword-based block page detection (fallback when SLD check is unavailable).
|
|
-- Catches DPI block pages by ISP-specific patterns in Location header or body.
|
|
local function z2k_http_block_reply(payload)
|
|
if type(payload) ~= "string" then return false end
|
|
local code_s = payload:match("^HTTP/%d%.%d%s+([0-9][0-9][0-9])")
|
|
local code = tonumber(code_s)
|
|
if not code then return false end
|
|
|
|
-- Unambiguous block status codes
|
|
if code == 403 or code == 451 then
|
|
return true
|
|
end
|
|
|
|
-- Any redirect with block-indicating keywords in Location
|
|
if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then
|
|
local low = string.lower(payload)
|
|
if low:find("\r\nlocation:", 1, true) then
|
|
if low:find("block", 1, true) or
|
|
low:find("forbidden", 1, true) or
|
|
low:find("zapret", 1, true) or
|
|
low:find("rkn", 1, true) or
|
|
low:find("lawfilter", 1, true) or
|
|
low:find("restrict", 1, true) or
|
|
low:find("vigruzki", 1, true) or
|
|
low:find("eais", 1, true) or
|
|
low:find("warning", 1, true) or
|
|
low:find("blackhole", 1, true) then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- SLD-based redirect detection: any redirect (301/302/303/307/308) to a
|
|
-- different second-level domain is a DPI redirect. This is the most universal
|
|
-- check — works for any ISP regardless of their block page URL patterns.
|
|
-- standard_failure_detector only checks 302/307; we extend to all codes.
|
|
local function z2k_http_dpi_redirect(desync)
|
|
if not desync or desync.outgoing then return false end
|
|
if desync.l7payload ~= "http_reply" then return false end
|
|
if not desync.track or not desync.track.hostname then return false end
|
|
if type(http_dissect_reply) ~= "function" then return false end
|
|
if type(array_field_search) ~= "function" then return false end
|
|
if type(is_dpi_redirect) ~= "function" then return false end
|
|
|
|
local hdis = http_dissect_reply(desync.dis.payload)
|
|
if not hdis then return false end
|
|
local c = hdis.code
|
|
-- 302/307 are already caught by standard_failure_detector, but re-checking
|
|
-- them here is harmless (crec.nocheck prevents double-counting) and makes
|
|
-- this function self-contained.
|
|
if c ~= 301 and c ~= 302 and c ~= 303 and c ~= 307 and c ~= 308 then
|
|
return false
|
|
end
|
|
local idx = array_field_search(hdis.headers, "header_low", "location")
|
|
if not idx then return false end
|
|
return is_dpi_redirect(desync.track.hostname, hdis.headers[idx].value)
|
|
end
|
|
|
|
function z2k_tls_alert_fatal(desync, crec)
|
|
if type(standard_failure_detector) == "function" then
|
|
local ok, res = pcall(standard_failure_detector, desync, crec)
|
|
if ok and res then return true end
|
|
end
|
|
|
|
if not desync or desync.outgoing then return false end
|
|
local dis = desync.dis
|
|
|
|
-- RST and FIN are handled by standard_failure_detector (RST within inseq=4K,
|
|
-- retransmissions within maxseq=32K). We do NOT extend these checks because:
|
|
-- - DPI sends RST early (within first few hundred bytes), already covered
|
|
-- - FIN is normal TCP close, NOT a DPI signal. Short connections (TLS 1.3
|
|
-- session resumption + small API response < 4K) would cause false positives:
|
|
-- success_detector (inseq=4K) hasn't fired yet when FIN arrives, so the
|
|
-- failure detector runs and counts normal connection close as failure.
|
|
-- With fails=2, two short API calls within 60s = false rotation.
|
|
|
|
-- HTTP DPI redirect: ISP redirects to block page (e.g. lawfilter.ertelecom.ru).
|
|
-- SLD-based check is universal for any ISP; keyword-based is a fallback.
|
|
if z2k_http_dpi_redirect(desync) then
|
|
return true
|
|
end
|
|
local payload = dis and dis.payload
|
|
if z2k_http_block_reply(payload) then
|
|
return true
|
|
end
|
|
|
|
-- TLS fatal alert (e.g. Cloudflare ECH handshake_failure)
|
|
if type(payload) ~= "string" then return false end
|
|
if #payload < 7 then return false end
|
|
if payload:byte(1) ~= 0x15 then return false end -- TLS record: alert (21)
|
|
if payload:byte(6) ~= 0x02 then return false end -- alert level: fatal (2)
|
|
return true
|
|
end
|
|
|
|
-- Wrap circular() from zapret-auto.lua.
|
|
if type(circular) == "function" then
|
|
local orig_circular = circular
|
|
circular = function(ctx, desync)
|
|
local askey_before, hostn_before, hrec_before
|
|
local policy_pick_before, policy_score_before
|
|
pcall(function()
|
|
askey_before, hostn_before, hrec_before = get_record_for_desync(desync, true)
|
|
if hrec_before then
|
|
policy_pick_before, policy_score_before = policy_seed_strategy(desync, askey_before, hostn_before, hrec_before)
|
|
flow_start_if_needed(desync, hrec_before.nstrategy)
|
|
end
|
|
end)
|
|
local verdict = orig_circular(ctx, desync)
|
|
pcall(function()
|
|
local askey_after, hostn_after, hrec_after
|
|
pcall(function()
|
|
askey_after, hostn_after, hrec_after = get_record_for_desync(desync, false)
|
|
end)
|
|
|
|
-- For persistence we must stay bound to circular() host record key (askey_before).
|
|
-- askey_after can point to an executed instance (e.g. fake_1_2), not to circular state.
|
|
local askey = askey_before or askey_after
|
|
local hostn = hostn_before or hostn_after
|
|
local hrec = hrec_before
|
|
if (not hrec or not hrec.nstrategy) and hrec_after and hrec_after.nstrategy then
|
|
hrec = hrec_after
|
|
elseif not hrec then
|
|
hrec = hrec_after
|
|
end
|
|
if not hrec then return end
|
|
|
|
local nocheck_after, failure_after = conn_record_flags(desync)
|
|
local n_after = hrec and tonumber(hrec.nstrategy) or nil
|
|
flow_start_if_needed(desync, n_after)
|
|
|
|
-- If persisted state became incompatible with current strategy count (config changed),
|
|
-- normalize it to strategy 1 for the next connection and drop the persisted entry.
|
|
local ct = hrec and tonumber(hrec.ctstrategy) or nil
|
|
if ct and ct > 0 and n_after and (n_after < 1 or n_after > ct) then
|
|
hrec.nstrategy = 1
|
|
clear_persisted(askey, hostn)
|
|
return
|
|
end
|
|
|
|
-- Persist whenever circular is in a known-good state.
|
|
-- This is intentionally broader than only first success transition to avoid missing saves.
|
|
local successful_state = nocheck_after and (not failure_after)
|
|
local response_state = has_positive_incoming_response(desync) and (not failure_after)
|
|
-- QUIC flows may not reliably trigger success detector, but nstrategy>1 already indicates
|
|
-- that circular has rotated this host. Persist that candidate for QUIC keys.
|
|
local quic_candidate_state =
|
|
is_quic_key(askey) and
|
|
(desync and desync.l7payload == "quic_initial") and
|
|
(not failure_after) and
|
|
n_after and n_after > 1
|
|
-- TCP strategies use --payload=tls_client_hello, so circular() is only called for outgoing
|
|
-- ClientHello packets. success_detector (inseq/maxseq) never fires because incoming data
|
|
-- packets never reach circular(). Persist on every outgoing initial packet instead:
|
|
-- write_state() is rate-limited (2s) and persist_if_changed() skips redundant writes.
|
|
local outgoing_initial = desync and desync.outgoing and n_after and
|
|
(desync.l7payload == "tls_client_hello" or
|
|
desync.l7payload == "quic_initial" or
|
|
desync.l7payload == "http_req")
|
|
local success_event = successful_state or response_state or quic_candidate_state
|
|
local failure_event = failure_after and (not success_event)
|
|
local persisted = false
|
|
if success_event or outgoing_initial then
|
|
persisted = persist_if_changed(askey, hostn, hrec)
|
|
end
|
|
|
|
local latency_s, flow_strategy = nil, nil
|
|
if success_event or failure_event then
|
|
latency_s, flow_strategy = flow_finish(desync)
|
|
local strat_for_stat = n_after or flow_strategy
|
|
if strat_for_stat then
|
|
telemetry_record_event(askey, hostn, strat_for_stat, success_event and (not failure_event), latency_s, now_f())
|
|
end
|
|
end
|
|
|
|
if pending_write then
|
|
write_state()
|
|
end
|
|
|
|
local debug_event = persisted or failure_after or success_event or failure_event or outgoing_initial or (policy_pick_before ~= nil)
|
|
if debug_event and (should_debug_key(askey_before) or should_debug_key(askey_after)) then
|
|
local track = desync and desync.track
|
|
local hn = track and track.hostname or ""
|
|
debug_log(
|
|
"key_before=" .. tostring(askey_before) ..
|
|
" key_after=" .. tostring(askey_after) ..
|
|
" host_before=" .. tostring(hostn_before) ..
|
|
" host_after=" .. tostring(hostn_after) ..
|
|
" track_host=" .. tostring(hn) ..
|
|
" l7=" .. tostring(desync and desync.l7payload) ..
|
|
" out=" .. tostring(desync and desync.outgoing and 1 or 0) ..
|
|
" policy_pick=" .. tostring(policy_pick_before or "") ..
|
|
" policy_score=" .. tostring(policy_score_before or "") ..
|
|
" nstrategy=" .. tostring(n_after) ..
|
|
" failure=" .. tostring(failure_after and 1 or 0) ..
|
|
" nocheck=" .. tostring(nocheck_after and 1 or 0) ..
|
|
" success_state=" .. tostring(successful_state and 1 or 0) ..
|
|
" response_state=" .. tostring(response_state and 1 or 0) ..
|
|
" quic_candidate_state=" .. tostring(quic_candidate_state and 1 or 0) ..
|
|
" outgoing_initial=" .. tostring(outgoing_initial and 1 or 0) ..
|
|
" success_event=" .. tostring(success_event and 1 or 0) ..
|
|
" failure_event=" .. tostring(failure_event and 1 or 0) ..
|
|
" latency_s=" .. tostring(latency_s or "") ..
|
|
" persisted=" .. tostring(persisted and 1 or 0)
|
|
)
|
|
end
|
|
end)
|
|
return verdict
|
|
end
|
|
end
|