913 lines
34 KiB
Lua
913 lines
34 KiB
Lua
-- Strategy Lock Manager - centralized locked/blocked strategy management
|
|
-- Single source of truth for hostname normalization
|
|
--
|
|
-- Usage: require("strategy-lock-manager") in mega_circular.lua
|
|
-- All other modules should use slm_* functions for hostname operations
|
|
|
|
-- ==================== GLOBAL TABLES ====================
|
|
-- These tables are the single source of truth for strategy state
|
|
|
|
-- Strategies that WORK for a hostname (LOCK)
|
|
-- Key: normalized hostname, Value: strategy number
|
|
SLM_LOCKED = SLM_LOCKED or {}
|
|
|
|
-- Strategies that DON'T WORK for a hostname (BLOCK)
|
|
-- Key: normalized hostname, Value: table of blocked strategy numbers
|
|
SLM_BLOCKED = SLM_BLOCKED or {}
|
|
|
|
-- Strategies currently being tested (in-flight)
|
|
-- Key: normalized hostname, Value: {strategy=N, start_time=T}
|
|
SLM_TESTING = SLM_TESTING or {}
|
|
|
|
-- ==================== SEPARATE SUBDOMAINS ====================
|
|
-- Domains that should NOT be grouped by NLD (need separate strategies)
|
|
-- These are subdomains that behave differently from their parent domain
|
|
|
|
local SEPARATE_SUBDOMAINS = {
|
|
["updates.discord.com"] = true, -- Discord/app updates
|
|
["clients.google.com"] = true, -- Google clients API
|
|
["update.googleapis.com"] = true, -- Google update API
|
|
["dl.google.com"] = true, -- Downloads
|
|
["redirector.googlevideo.com"] = true, -- Different from regular googlevideo.com
|
|
}
|
|
|
|
-- ==================== NORMALIZATION FUNCTIONS ====================
|
|
-- ЕДИНСТВЕННОЕ место для hostname:lower() - все остальные модули должны использовать это
|
|
|
|
--- Check if hostname should be kept as full subdomain (not NLD-cut)
|
|
--- @param hostname string The hostname to check
|
|
--- @return boolean True if hostname should NOT be cut to NLD
|
|
function slm_should_keep_full_hostname(hostname)
|
|
if not hostname then return false end
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if not key then return false end
|
|
return SEPARATE_SUBDOMAINS[key] == true
|
|
end
|
|
|
|
--- Normalize hostname to consistent key format
|
|
--- This is THE ONLY place where hostname:lower() should be called
|
|
--- @param hostname string The hostname to normalize
|
|
--- @return string|nil Normalized hostname or nil if invalid
|
|
function slm_normalize_hostkey(hostname)
|
|
if not hostname then return nil end
|
|
if type(hostname) ~= "string" then return nil end
|
|
if hostname == "" then return nil end
|
|
|
|
-- Single point of lowercase conversion
|
|
local normalized = hostname:lower()
|
|
|
|
-- Remove trailing dots (DNS root)
|
|
normalized = normalized:gsub("%.+$", "")
|
|
|
|
-- Remove leading dots (malformed)
|
|
normalized = normalized:gsub("^%.+", "")
|
|
|
|
-- Skip if empty after cleanup
|
|
if normalized == "" then return nil end
|
|
|
|
return normalized
|
|
end
|
|
|
|
--- Add a hostname to SEPARATE_SUBDOMAINS dynamically
|
|
--- @param hostname string The hostname to add
|
|
function slm_add_separate_subdomain(hostname)
|
|
if not hostname then return end
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if key then
|
|
SEPARATE_SUBDOMAINS[key] = true
|
|
end
|
|
end
|
|
|
|
-- ==================== TO BE CONTINUED ====================
|
|
-- Next parts will add:
|
|
-- - slm_lock_strategy(hostname, strategy)
|
|
-- - slm_block_strategy(hostname, strategy)
|
|
-- - slm_get_locked(hostname)
|
|
-- - slm_get_blocked(hostname)
|
|
-- - slm_clear_testing(hostname)
|
|
-- - Persistence functions for learned-strategies.lua integration
|
|
|
|
-- ==================== BLOCKED СТРАТЕГИИ ====================
|
|
-- BLOCKED_STRATEGIES заполняется из learned-strategies.lua (генерируется Python)
|
|
-- Two-level structure: BLOCKED_STRATEGIES[askey][hostname] = {strat1, strat2, ...}
|
|
-- For backward compatibility, also supports flat: BLOCKED_STRATEGIES[hostname] = {strat1, ...}
|
|
|
|
--- Check if strategy is blocked for hostname
|
|
--- ВАЖНО: нормализует hostname через slm_normalize_hostkey()
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostname string The hostname to check
|
|
--- @param strategy number The strategy number to check
|
|
--- @return boolean True if strategy is blocked for this hostname
|
|
function slm_is_blocked(askey, hostname, strategy)
|
|
if not BLOCKED_STRATEGIES then return false end
|
|
if not hostname or not strategy then return false end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if not key then return false end
|
|
|
|
-- Check two-level structure first: BLOCKED_STRATEGIES[askey][hostname]
|
|
local as_blocked = BLOCKED_STRATEGIES[askey]
|
|
if as_blocked and type(as_blocked) == "table" then
|
|
-- Check exact match
|
|
local blocked = as_blocked[key]
|
|
if blocked and type(blocked) == "table" then
|
|
for _, s in ipairs(blocked) do
|
|
if s == strategy then return true end
|
|
end
|
|
end
|
|
|
|
-- Check subdomain matching
|
|
for domain, strategies in pairs(as_blocked) do
|
|
if type(strategies) == "table" then
|
|
if key == domain or key:sub(-#domain - 1) == "." .. domain then
|
|
for _, s in ipairs(strategies) do
|
|
if s == strategy then return true end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Backward compatibility: check flat structure BLOCKED_STRATEGIES[hostname]
|
|
local blocked = BLOCKED_STRATEGIES[key]
|
|
if blocked and type(blocked) == "table" then
|
|
-- Check if it's an array of numbers (old format) vs nested table (new format)
|
|
if #blocked > 0 and type(blocked[1]) == "number" then
|
|
for _, s in ipairs(blocked) do
|
|
if s == strategy then return true end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Backward compatibility: subdomain matching for flat structure
|
|
for domain, strategies in pairs(BLOCKED_STRATEGIES) do
|
|
-- Skip askey tables (they are tables of tables)
|
|
if type(strategies) == "table" and #strategies > 0 and type(strategies[1]) == "number" then
|
|
if key == domain or key:sub(-#domain - 1) == "." .. domain then
|
|
for _, s in ipairs(strategies) do
|
|
if s == strategy then return true end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- ==================== SKIP PASS (стратегия 1 запрещена) ====================
|
|
-- Домены и IP где pass не работает (заблокированные сервисы)
|
|
-- Скопировано из strategy-stats.lua для централизации
|
|
|
|
-- Домены где стратегия pass (1) не работает
|
|
local SLM_SKIP_PASS_DOMAINS = {
|
|
-- Discord (TLS for discord.com, UDP for voice servers)
|
|
["discord.com"] = true,
|
|
["discordapp.com"] = true,
|
|
["discord.gg"] = true,
|
|
["discord.media"] = true,
|
|
["discordapp.net"] = true,
|
|
-- YouTube / Google Video
|
|
["youtube.com"] = true,
|
|
["googlevideo.com"] = true,
|
|
["ytimg.com"] = true,
|
|
["yt3.ggpht.com"] = true,
|
|
["youtu.be"] = true,
|
|
-- Google (часто блокируется)
|
|
["google.com"] = true,
|
|
["googleapis.com"] = true,
|
|
["gstatic.com"] = true,
|
|
-- Twitch
|
|
["twitch.tv"] = true,
|
|
["twitchcdn.net"] = true,
|
|
-- Twitter/X
|
|
["twitter.com"] = true,
|
|
["x.com"] = true,
|
|
["twimg.com"] = true,
|
|
-- Instagram
|
|
["instagram.com"] = true,
|
|
["cdninstagram.com"] = true,
|
|
["igcdn.com"] = true,
|
|
["ig.me"] = true,
|
|
-- Facebook / Meta
|
|
["facebook.com"] = true,
|
|
["fbcdn.net"] = true,
|
|
["fb.com"] = true,
|
|
["fb.me"] = true,
|
|
["messenger.com"] = true,
|
|
["meta.com"] = true,
|
|
-- Telegram
|
|
["telegram.org"] = true,
|
|
["t.me"] = true,
|
|
-- Spotify
|
|
["spotify.com"] = true,
|
|
["spotifycdn.com"] = true,
|
|
["scdn.co"] = true,
|
|
-- Roblox
|
|
["roblox.com"] = true,
|
|
["rbxcdn.com"] = true,
|
|
["robloxcdn.com"] = true,
|
|
["rbx.com"] = true,
|
|
-- Torrents / Rutracker
|
|
["rutracker.org"] = true,
|
|
["rutracker.net"] = true,
|
|
["rutracker.cc"] = true,
|
|
["rutor.info"] = true,
|
|
["rutor.is"] = true,
|
|
["nnmclub.to"] = true,
|
|
["nnm-club.me"] = true,
|
|
["kinozal.tv"] = true,
|
|
["kinozal.me"] = true,
|
|
["thepiratebay.org"] = true,
|
|
["1337x.to"] = true,
|
|
["rarbg.to"] = true,
|
|
-- Amazon
|
|
["amazon.com"] = true,
|
|
["amazon.co.uk"] = true,
|
|
["amazon.de"] = true,
|
|
["amazonaws.com"] = true,
|
|
["amazonvideo.com"] = true,
|
|
["primevideo.com"] = true,
|
|
["amazon-adsystem.com"] = true,
|
|
-- Cloudflare
|
|
["cloudflare.com"] = true,
|
|
["cloudflare-dns.com"] = true,
|
|
["cloudflareinsights.com"] = true,
|
|
["cloudflareclient.com"] = true,
|
|
["one.one"] = true,
|
|
["1.1.1.1"] = true,
|
|
}
|
|
|
|
-- IP ranges that should NEVER use strategy 1 (pass)
|
|
-- Loaded from ipset files in lists/ folder
|
|
-- Format: {start_ip_num, end_ip_num} for CIDR ranges
|
|
local SLM_SKIP_PASS_IP_RANGES = {}
|
|
local slm_skip_pass_ips_loaded = false
|
|
|
|
-- Convert IP string to number for range comparison
|
|
local function slm_ip_to_number(ip)
|
|
if not ip then return nil end
|
|
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
|
if not a then return nil end
|
|
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
|
if not a or not b or not c or not d then return nil end
|
|
if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
|
|
return a * 16777216 + b * 65536 + c * 256 + d
|
|
end
|
|
|
|
-- Parse CIDR notation (e.g., "34.0.48.0/24") into start and end IP numbers
|
|
local function slm_parse_cidr(cidr)
|
|
local ip, mask = cidr:match("^([%d%.]+)/(%d+)$")
|
|
if not ip or not mask then
|
|
-- Single IP without mask
|
|
local ip_num = slm_ip_to_number(cidr)
|
|
if ip_num then
|
|
return ip_num, ip_num
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
local ip_num = slm_ip_to_number(ip)
|
|
if not ip_num then return nil, nil end
|
|
|
|
mask = tonumber(mask)
|
|
if not mask or mask < 0 or mask > 32 then return nil, nil end
|
|
|
|
-- Calculate range
|
|
local host_bits = 32 - mask
|
|
local range_size = 2 ^ host_bits
|
|
local start_ip = ip_num
|
|
local end_ip = ip_num + range_size - 1
|
|
|
|
return start_ip, end_ip
|
|
end
|
|
|
|
-- Load IP ranges from ipset files
|
|
local function slm_load_skip_pass_ips()
|
|
if slm_skip_pass_ips_loaded then return end
|
|
slm_skip_pass_ips_loaded = true
|
|
|
|
-- List of ipset files to load (relative to zapret folder)
|
|
local ipset_files = {
|
|
"lists/ipset-discord.txt",
|
|
"lists/ipset-youtube.txt",
|
|
"lists/ipset-telegram.txt",
|
|
"lists/ipset-cloudflare.txt",
|
|
"lists/ipset-cloudflare1.txt",
|
|
"lists/ipset-roblox.txt",
|
|
"lists/ipset-rutracker.txt",
|
|
"lists/ipset-amazon.txt",
|
|
"lists/ipset-whatsapp.txt",
|
|
"lists/russia-discord-ipset.txt",
|
|
}
|
|
|
|
local total_ranges = 0
|
|
|
|
for _, filename in ipairs(ipset_files) do
|
|
local file = io.open(filename, "r")
|
|
if file then
|
|
for line in file:lines() do
|
|
-- Skip empty lines and comments
|
|
line = line:match("^%s*(.-)%s*$") -- trim
|
|
if line and #line > 0 and not line:match("^#") then
|
|
local start_ip, end_ip = slm_parse_cidr(line)
|
|
if start_ip and end_ip then
|
|
table.insert(SLM_SKIP_PASS_IP_RANGES, {start_ip, end_ip})
|
|
total_ranges = total_ranges + 1
|
|
end
|
|
end
|
|
end
|
|
file:close()
|
|
end
|
|
end
|
|
|
|
if total_ranges > 0 and DLOG then
|
|
DLOG("strategy-lock-manager: loaded " .. total_ranges .. " IP ranges for SKIP_PASS")
|
|
end
|
|
end
|
|
|
|
-- Check if IP is in skip_pass ranges (internal)
|
|
local function slm_ip_in_skip_pass_ranges(ip)
|
|
if not ip then return false end
|
|
|
|
-- Load ranges on first call
|
|
slm_load_skip_pass_ips()
|
|
|
|
local ip_num = slm_ip_to_number(ip)
|
|
if not ip_num then return false end
|
|
|
|
for _, range in ipairs(SLM_SKIP_PASS_IP_RANGES) do
|
|
if ip_num >= range[1] and ip_num <= range[2] then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Helper: check if string is an IP address (not a hostname)
|
|
local function slm_is_ip_address(str)
|
|
if not str then return false end
|
|
return str:match("^%d+%.%d+%.%d+%.%d+$") ~= nil
|
|
end
|
|
|
|
--- Check if hostname/IP should skip strategy 1 (pass)
|
|
--- Checks domains, subdomains, and IP ranges from ipset files
|
|
--- @param hostname string The hostname or IP to check
|
|
--- @return boolean True if strategy 1 (pass) should be skipped
|
|
function slm_should_skip_pass(hostname)
|
|
if not hostname then return false end
|
|
|
|
-- Normalize hostname
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if not key then return false end
|
|
|
|
-- For IP addresses: check against ipset ranges
|
|
if slm_is_ip_address(key) then
|
|
return slm_ip_in_skip_pass_ranges(key)
|
|
end
|
|
|
|
-- Check exact domain match
|
|
if SLM_SKIP_PASS_DOMAINS[key] then
|
|
return true
|
|
end
|
|
|
|
-- Check if it's a subdomain of a skip_pass domain
|
|
-- e.g., "cdn.discord.com" should match "discord.com"
|
|
for domain, _ in pairs(SLM_SKIP_PASS_DOMAINS) do
|
|
-- Check if hostname ends with .domain or equals domain
|
|
if key == domain or key:sub(-#domain - 1) == "." .. domain then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Public function to check if IP is in skip_pass ranges
|
|
--- @param ip string IP address to check
|
|
--- @return boolean True if IP should skip pass strategy
|
|
function slm_ip_in_skip_pass(ip)
|
|
return slm_ip_in_skip_pass_ranges(ip)
|
|
end
|
|
|
|
-- ==================== LOCKED СТРАТЕГИИ (Quality Tracker) ====================
|
|
-- Отслеживает успешность стратегий и лочит лучшую
|
|
-- Логика адаптирована из combined-detector.lua
|
|
|
|
-- Global table for strategy quality scores (per askey, per host key)
|
|
-- Two-level structure: SLM_QUALITY[askey][hostkey] = quality record
|
|
-- askey: "tls", "http", "quic", "discord", "wireguard", "mtproto", "dns", "stun", or "default"
|
|
SLM_QUALITY = SLM_QUALITY or {}
|
|
|
|
-- Приватная функция: получить/создать запись качества для хоста
|
|
-- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
-- @param hostkey string Нормализованный ключ хоста
|
|
-- @return table Quality record
|
|
local function slm_get_quality_record(askey, hostkey)
|
|
askey = askey or "default"
|
|
|
|
if not SLM_QUALITY[askey] then
|
|
SLM_QUALITY[askey] = {}
|
|
end
|
|
|
|
if not SLM_QUALITY[askey][hostkey] then
|
|
SLM_QUALITY[askey][hostkey] = {
|
|
strategy_successes = {}, -- strategy_id -> success count
|
|
strategy_tests = {}, -- strategy_id -> total test count
|
|
total_tests = 0,
|
|
locked_strategy = nil,
|
|
lock_reason = nil,
|
|
is_user_lock = false -- true if user manually locked this strategy
|
|
}
|
|
end
|
|
return SLM_QUALITY[askey][hostkey]
|
|
end
|
|
|
|
--- Записать результат теста стратегии
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @param strategy_id number ID стратегии
|
|
--- @param success boolean Успех или провал
|
|
function slm_record_result(askey, hostkey, strategy_id, success)
|
|
if not hostkey or not strategy_id then return end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return end
|
|
|
|
local qrec = slm_get_quality_record(askey, key)
|
|
|
|
-- Initialize counters for this strategy
|
|
if not qrec.strategy_successes[strategy_id] then
|
|
qrec.strategy_successes[strategy_id] = 0
|
|
qrec.strategy_tests[strategy_id] = 0
|
|
end
|
|
|
|
qrec.strategy_tests[strategy_id] = qrec.strategy_tests[strategy_id] + 1
|
|
qrec.total_tests = qrec.total_tests + 1
|
|
|
|
if success then
|
|
qrec.strategy_successes[strategy_id] = qrec.strategy_successes[strategy_id] + 1
|
|
if DLOG then
|
|
DLOG("slm_quality: [" .. askey .. "] " .. key .. " strat=" .. strategy_id ..
|
|
" SUCCESS " .. qrec.strategy_successes[strategy_id] .. "/" .. qrec.strategy_tests[strategy_id])
|
|
end
|
|
else
|
|
if DLOG then
|
|
DLOG("slm_quality: [" .. askey .. "] " .. key .. " strat=" .. strategy_id ..
|
|
" FAIL " .. qrec.strategy_successes[strategy_id] .. "/" .. qrec.strategy_tests[strategy_id])
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Найти лучшую стратегию по успешности
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @param skip_strategy number|nil ID стратегии для пропуска (например, 1 для pass)
|
|
--- @return number|nil best_id Лучшая стратегия или nil
|
|
--- @return number successes Количество успехов
|
|
--- @return number tests Количество тестов
|
|
function slm_get_best(askey, hostkey, skip_strategy)
|
|
if not hostkey then return nil, 0, 0 end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return nil, 0, 0 end
|
|
|
|
local as_table = SLM_QUALITY[askey]
|
|
if not as_table then return nil, 0, 0 end
|
|
|
|
local qrec = as_table[key]
|
|
if not qrec then return nil, 0, 0 end
|
|
|
|
local best_id = nil
|
|
local best_successes = 0
|
|
local best_tests = 0
|
|
|
|
for strat_id, successes in pairs(qrec.strategy_successes) do
|
|
-- Skip strategy (e.g., 1 = pass - it's not a real bypass strategy)
|
|
if skip_strategy and strat_id == skip_strategy then
|
|
if DLOG then
|
|
DLOG("slm_get_best: skipping strategy " .. strat_id .. " (pass)")
|
|
end
|
|
-- Skip blocked strategies using slm_is_blocked
|
|
elseif slm_is_blocked(askey, key, strat_id) then
|
|
if DLOG then
|
|
DLOG("slm_get_best: skipping strategy " .. strat_id .. " (blocked for [" .. askey .. "] " .. key .. ")")
|
|
end
|
|
else
|
|
local tests = qrec.strategy_tests[strat_id] or 0
|
|
-- Prefer strategy with more successes
|
|
-- If equal successes, prefer one with higher success RATE
|
|
if successes > best_successes or
|
|
(successes == best_successes and tests > 0 and best_tests > 0 and
|
|
successes/tests > best_successes/best_tests) then
|
|
best_id = strat_id
|
|
best_successes = successes
|
|
best_tests = tests
|
|
end
|
|
end
|
|
end
|
|
|
|
return best_id, best_successes, best_tests
|
|
end
|
|
|
|
--- Проверить нужно ли лочить стратегию
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @param desync_arg table|nil Параметры: lock_successes, lock_tests, lock_rate, skip_strategy
|
|
--- @return boolean should_lock Нужно ли лочить
|
|
--- @return number|nil strategy_id ID стратегии для лока
|
|
function slm_should_lock(askey, hostkey, desync_arg)
|
|
if not hostkey then return false, nil end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return false, nil end
|
|
|
|
local as_table = SLM_QUALITY[askey]
|
|
if not as_table then return false, nil end
|
|
|
|
local qrec = as_table[key]
|
|
if not qrec then return false, nil end
|
|
|
|
-- Already locked? Check if still valid (not blocked)
|
|
if qrec.locked_strategy then
|
|
-- If locked strategy is now blocked, unlock and find new one
|
|
if slm_is_blocked(askey, key, qrec.locked_strategy) then
|
|
if DLOG then
|
|
DLOG("slm_quality: UNLOCK [" .. askey .. "] " .. key .. " strat=" .. qrec.locked_strategy .. " (now blocked)")
|
|
end
|
|
qrec.locked_strategy = nil
|
|
qrec.lock_reason = nil
|
|
-- Continue to find new best strategy below
|
|
else
|
|
return true, qrec.locked_strategy
|
|
end
|
|
end
|
|
|
|
local min_successes = tonumber(desync_arg and desync_arg.lock_successes) or 3
|
|
local min_tests = tonumber(desync_arg and desync_arg.lock_tests) or 5
|
|
local min_rate = tonumber(desync_arg and desync_arg.lock_rate) or 0.6
|
|
local skip_strategy = tonumber(desync_arg and desync_arg.skip_strategy) or 1 -- skip strategy 1 (pass) by default
|
|
|
|
-- Not enough tests yet
|
|
if qrec.total_tests < min_tests then
|
|
return false, nil
|
|
end
|
|
|
|
-- Find best strategy (excluding skip_strategy and blocked strategies)
|
|
local best_id, best_successes, best_tests = slm_get_best(askey, hostkey, skip_strategy)
|
|
|
|
if not best_id then
|
|
return false, nil
|
|
end
|
|
|
|
-- Check if best strategy meets lock criteria
|
|
local success_rate = best_tests > 0 and (best_successes / best_tests) or 0
|
|
|
|
if best_successes >= min_successes and success_rate >= min_rate then
|
|
qrec.locked_strategy = best_id
|
|
qrec.lock_reason = string.format("successes=%d tests=%d rate=%.0f%%",
|
|
best_successes, best_tests, success_rate * 100)
|
|
if DLOG then
|
|
DLOG("slm_quality: LOCK [" .. askey .. "] " .. key .. " -> strat=" .. best_id ..
|
|
" (" .. qrec.lock_reason .. ")")
|
|
end
|
|
return true, best_id
|
|
end
|
|
|
|
return false, nil
|
|
end
|
|
|
|
--- Получить залоченную стратегию для хоста
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @return number|nil strategy_id Залоченная стратегия или nil
|
|
function slm_get_locked(askey, hostkey)
|
|
if not hostkey then return nil end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return nil end
|
|
|
|
local as_table = SLM_QUALITY[askey]
|
|
if not as_table then return nil end
|
|
|
|
local qrec = as_table[key]
|
|
if qrec and qrec.locked_strategy then
|
|
return qrec.locked_strategy
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Проверить, является ли лок пользовательским (защищён от auto-unlock)
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @return boolean True если это user lock
|
|
function slm_is_user_lock(askey, hostkey)
|
|
if not hostkey then return false end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return false end
|
|
|
|
local as_table = SLM_QUALITY[askey]
|
|
if not as_table then return false end
|
|
|
|
local qrec = as_table[key]
|
|
return qrec and qrec.is_user_lock == true
|
|
end
|
|
|
|
--- Установить лок на стратегию вручную
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @param strategy_id number ID стратегии
|
|
--- @param reason string|nil Причина лока
|
|
--- @return boolean Успех установки лока
|
|
function slm_set_locked(askey, hostkey, strategy_id, reason)
|
|
if not hostkey or not strategy_id then return false end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return false end
|
|
|
|
-- Проверка что стратегия не заблокирована
|
|
if slm_is_blocked(askey, key, strategy_id) then
|
|
if DLOG then
|
|
DLOG("slm_set_locked: REJECTED [" .. askey .. "] " .. key .. " strat=" .. strategy_id .. " (blocked)")
|
|
end
|
|
return false
|
|
end
|
|
|
|
local qrec = slm_get_quality_record(askey, key)
|
|
qrec.locked_strategy = strategy_id
|
|
qrec.lock_reason = reason or "manual"
|
|
|
|
if DLOG then
|
|
DLOG("slm_set_locked: [" .. askey .. "] " .. key .. " -> strat=" .. strategy_id .. " reason=" .. (reason or "manual"))
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Сбросить качество для хоста (для переобучения)
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
function slm_reset(askey, hostkey)
|
|
if not hostkey then return end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return end
|
|
|
|
local as_table = SLM_QUALITY[askey]
|
|
if not as_table then return end
|
|
|
|
as_table[key] = nil
|
|
if DLOG then
|
|
DLOG("slm_quality: RESET [" .. askey .. "] " .. key)
|
|
end
|
|
end
|
|
|
|
--- Получить статистику по стратегиям для логов
|
|
--- @param askey string Ключ autostate (tls, http, quic, etc.) - optional, defaults to "default"
|
|
--- @param hostkey string Имя хоста (будет нормализовано)
|
|
--- @return string Статистика в виде строки
|
|
function slm_get_stats(askey, hostkey)
|
|
if not hostkey then return "no host" end
|
|
|
|
-- Нормализуем askey
|
|
askey = askey or "default"
|
|
|
|
-- Нормализуем hostkey
|
|
local key = slm_normalize_hostkey(hostkey)
|
|
if not key then return "invalid host" end
|
|
|
|
local as_table = SLM_QUALITY[askey]
|
|
if not as_table then return "no data" end
|
|
|
|
local qrec = as_table[key]
|
|
if not qrec then return "no data" end
|
|
|
|
local parts = {}
|
|
for strat_id, successes in pairs(qrec.strategy_successes) do
|
|
local tests = qrec.strategy_tests[strat_id] or 0
|
|
local rate = tests > 0 and math.floor(successes / tests * 100) or 0
|
|
table.insert(parts, string.format("#%d:%d/%d(%d%%)", strat_id, successes, tests, rate))
|
|
end
|
|
|
|
if #parts == 0 then return "empty" end
|
|
|
|
table.sort(parts)
|
|
local result = table.concat(parts, " ")
|
|
|
|
if qrec.locked_strategy then
|
|
result = result .. " [LOCK=#" .. qrec.locked_strategy .. "]"
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
-- ==================== PRELOAD (вызывается из Python) ====================
|
|
-- Эти функции вызываются из learned-strategies.lua (генерируется Python)
|
|
|
|
--- Предзагрузка залоченной стратегии
|
|
--- @param askey string Ключ протокола (tls, http, quic, discord, wireguard, mtproto, dns, stun)
|
|
--- @param hostname string Имя хоста
|
|
--- @param strategy number Номер стратегии
|
|
--- @param is_user_lock boolean|nil True если это пользовательский лок (защищён от auto-unlock)
|
|
--- @return boolean Успех загрузки
|
|
function slm_preload_locked(askey, hostname, strategy, is_user_lock)
|
|
-- Default askey to "tls" for backward compatibility
|
|
if not askey or askey == "" then askey = "tls" end
|
|
if not hostname then return false end
|
|
if not strategy then return false end
|
|
|
|
-- Нормализуем hostname
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if not key then return false end
|
|
|
|
-- Проверяем что стратегия не заблокирована
|
|
if slm_is_blocked(askey, key, strategy) then
|
|
if DLOG then
|
|
DLOG("slm_preload_locked: SKIP [" .. askey .. "] " .. key .. " strat=" .. strategy .. " (blocked)")
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Создаём запись качества и устанавливаем лок
|
|
-- Передаём askey для правильного размещения в SLM_QUALITY[askey][hostkey]
|
|
local qrec = slm_get_quality_record(askey, key)
|
|
qrec.locked_strategy = strategy
|
|
qrec.is_user_lock = is_user_lock or false
|
|
|
|
-- Формируем lock_reason в зависимости от типа лока
|
|
if is_user_lock then
|
|
qrec.lock_reason = "user"
|
|
else
|
|
qrec.lock_reason = "preload"
|
|
end
|
|
|
|
if DLOG then
|
|
local lock_type = is_user_lock and "USER" or "preload"
|
|
DLOG("slm_preload_locked: [" .. askey .. "] " .. key .. " -> strat=" .. strategy .. " lock=" .. lock_type)
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--- Предзагрузка заблокированных стратегий для хоста
|
|
--- Создает two-level структуру: BLOCKED_STRATEGIES[askey][hostname] = {strat1, strat2, ...}
|
|
--- @param askey string Ключ протокола (tls, http, quic, discord, wireguard, mtproto, dns, stun)
|
|
--- @param hostname string Имя хоста
|
|
--- @param strategies table Массив номеров заблокированных стратегий {1, 2, 3, ...}
|
|
--- @return boolean Успех загрузки
|
|
function slm_preload_blocked(askey, hostname, strategies)
|
|
-- Default askey to "default" for backward compatibility
|
|
if not askey or askey == "" then askey = "default" end
|
|
if not hostname then return false end
|
|
if not strategies then return false end
|
|
if type(strategies) ~= "table" then return false end
|
|
|
|
-- Нормализуем hostname
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if not key then return false end
|
|
|
|
-- Инициализируем BLOCKED_STRATEGIES если не существует
|
|
if not BLOCKED_STRATEGIES then
|
|
BLOCKED_STRATEGIES = {}
|
|
end
|
|
|
|
-- Инициализируем askey уровень если не существует
|
|
if not BLOCKED_STRATEGIES[askey] then
|
|
BLOCKED_STRATEGIES[askey] = {}
|
|
end
|
|
|
|
-- Инициализируем массив для хоста или добавляем к существующему
|
|
if not BLOCKED_STRATEGIES[askey][key] then
|
|
BLOCKED_STRATEGIES[askey][key] = {}
|
|
end
|
|
|
|
-- Добавляем стратегии (избегая дубликатов)
|
|
local existing = {}
|
|
for _, s in ipairs(BLOCKED_STRATEGIES[askey][key]) do
|
|
existing[s] = true
|
|
end
|
|
|
|
local added = 0
|
|
for _, strat in ipairs(strategies) do
|
|
if type(strat) == "number" and not existing[strat] then
|
|
table.insert(BLOCKED_STRATEGIES[askey][key], strat)
|
|
existing[strat] = true
|
|
added = added + 1
|
|
end
|
|
end
|
|
|
|
if DLOG then
|
|
DLOG("slm_preload_blocked: [" .. askey .. "] " .. key .. " added " .. added .. " strategies")
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--- Загрузка таблицы BLOCKED_STRATEGIES (DEPRECATED - для обратной совместимости)
|
|
--- Использует flat структуру BLOCKED_STRATEGIES[hostname] = {strat1, strat2, ...}
|
|
--- ВНИМАНИЕ: Эта функция устарела, используйте slm_preload_blocked(askey, hostname, strategies)
|
|
--- @param blocked_table table Таблица { ["hostname"] = {strat1, strat2, ...}, ... }
|
|
--- @return number Количество загруженных записей
|
|
function slm_preload_blocked_flat(blocked_table)
|
|
if not blocked_table then return 0 end
|
|
if type(blocked_table) ~= "table" then return 0 end
|
|
|
|
local count = 0
|
|
|
|
for hostname, strategies in pairs(blocked_table) do
|
|
if type(strategies) == "table" then
|
|
-- Нормализуем hostname
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if key then
|
|
-- Инициализируем BLOCKED_STRATEGIES если не существует
|
|
if not BLOCKED_STRATEGIES then
|
|
BLOCKED_STRATEGIES = {}
|
|
end
|
|
|
|
-- Копируем массив стратегий
|
|
BLOCKED_STRATEGIES[key] = {}
|
|
for _, strat in ipairs(strategies) do
|
|
if type(strat) == "number" then
|
|
table.insert(BLOCKED_STRATEGIES[key], strat)
|
|
end
|
|
end
|
|
|
|
count = count + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
if DLOG then
|
|
DLOG("slm_preload_blocked_flat: loaded " .. count .. " hosts (DEPRECATED)")
|
|
end
|
|
|
|
return count
|
|
end
|
|
|
|
--- Предзагрузка истории успехов/неудач
|
|
--- @param askey string Ключ протокола (tls, http, quic, discord, wireguard, mtproto, dns, stun)
|
|
--- @param hostname string Имя хоста
|
|
--- @param strategy number Номер стратегии
|
|
--- @param successes number Количество успехов
|
|
--- @param failures number Количество неудач
|
|
--- @return boolean Успех загрузки
|
|
function slm_preload_history(askey, hostname, strategy, successes, failures)
|
|
-- Default askey to "tls" for backward compatibility
|
|
if not askey or askey == "" then askey = "tls" end
|
|
if not hostname then return false end
|
|
if not strategy then return false end
|
|
|
|
successes = tonumber(successes) or 0
|
|
failures = tonumber(failures) or 0
|
|
|
|
-- Нормализуем hostname
|
|
local key = slm_normalize_hostkey(hostname)
|
|
if not key then return false end
|
|
|
|
-- Создаём запись качества
|
|
-- Передаём askey для правильного размещения в SLM_QUALITY[askey][hostkey]
|
|
local qrec = slm_get_quality_record(askey, key)
|
|
|
|
-- Устанавливаем счётчики
|
|
qrec.strategy_successes[strategy] = successes
|
|
qrec.strategy_tests[strategy] = successes + failures
|
|
qrec.total_tests = (qrec.total_tests or 0) + successes + failures
|
|
|
|
if DLOG then
|
|
DLOG("slm_preload_history: [" .. askey .. "] " .. key .. " strat=" .. strategy ..
|
|
" successes=" .. successes .. " failures=" .. failures)
|
|
end
|
|
|
|
return true
|
|
end
|