baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
This commit is contained in:
645
selective-vpn-gui/agvpn-resolver.py
Executable file
645
selective-vpn-gui/agvpn-resolver.py
Executable file
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
# --- dnspython --------------------------------------------------------
|
||||
try:
|
||||
import dns.resolver
|
||||
import dns.reversename
|
||||
import dns.exception
|
||||
except ImportError as e:
|
||||
print(f"[resolver] dnspython is required: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Общий DNS-конфиг
|
||||
# --------------------------------------------------------------------
|
||||
DNS_CONFIG_PATH = "/etc/selective-vpn/dns-upstreams.conf"
|
||||
|
||||
DEFAULT_DNS_DEFAULT = ["94.140.14.14", "94.140.15.15"]
|
||||
DEFAULT_DNS_META = ["46.243.231.30", "46.243.231.41"]
|
||||
|
||||
DNS_DEFAULT = DEFAULT_DNS_DEFAULT.copy()
|
||||
DNS_META = DEFAULT_DNS_META.copy()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# helpers
|
||||
# --------------------------------------------------------------------
|
||||
def log(msg, trace_log=None):
|
||||
line = f"[resolver] {msg}"
|
||||
print(line, file=sys.stderr)
|
||||
if trace_log:
|
||||
try:
|
||||
with open(trace_log, "a") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def is_private_ipv4(ip: str) -> bool:
|
||||
"""
|
||||
ip может быть "A.B.C.D" или "A.B.C.D/nn".
|
||||
Возвращаем True, если адрес из приватных диапазонов.
|
||||
"""
|
||||
parts = ip.split("/")
|
||||
base = parts[0]
|
||||
try:
|
||||
o1, o2, o3, o4 = map(int, base.split("."))
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
if o1 == 10:
|
||||
return True
|
||||
if o1 == 127:
|
||||
return True
|
||||
if o1 == 0:
|
||||
return True
|
||||
if o1 == 192 and o2 == 168:
|
||||
return True
|
||||
if o1 == 172 and 16 <= o2 <= 31:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_list(path):
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
out = []
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def load_cache(path):
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(path, data):
|
||||
tmp = path + ".tmp"
|
||||
try:
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def split_dns(dns: str):
|
||||
"""
|
||||
Разбор записи вида:
|
||||
"1.2.3.4" -> ("1.2.3.4", None)
|
||||
"1.2.3.4#6053" -> ("1.2.3.4", "6053")
|
||||
"""
|
||||
if "#" in dns:
|
||||
host, port = dns.split("#", 1)
|
||||
host = host.strip()
|
||||
port = port.strip()
|
||||
if not host:
|
||||
host = "127.0.0.1"
|
||||
if not port:
|
||||
port = "53"
|
||||
return host, port
|
||||
return dns, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# dnspython-резолвы
|
||||
# --------------------------------------------------------------------
|
||||
def dig_a(host, dns_list, timeout=3):
|
||||
"""
|
||||
A-резолв через dnspython.
|
||||
dns_list: либо строка "IP[#PORT]", либо список таких строк.
|
||||
"""
|
||||
if isinstance(dns_list, str):
|
||||
dns_list = [dns_list]
|
||||
|
||||
ips = []
|
||||
|
||||
for entry in dns_list:
|
||||
server, port = split_dns(entry)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
r = dns.resolver.Resolver(configure=False)
|
||||
r.nameservers = [server]
|
||||
if port:
|
||||
try:
|
||||
r.port = int(port)
|
||||
except ValueError:
|
||||
r.port = 53
|
||||
r.timeout = timeout
|
||||
r.lifetime = timeout
|
||||
|
||||
try:
|
||||
answer = r.resolve(host, "A")
|
||||
except dns.exception.DNSException:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for rr in answer:
|
||||
s = rr.to_text().strip()
|
||||
parts = s.split(".")
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
if all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
||||
if not is_private_ipv4(s) and s not in ips:
|
||||
ips.append(s)
|
||||
|
||||
return ips
|
||||
|
||||
|
||||
def dig_ptr(ip, upstream, timeout=3):
|
||||
"""
|
||||
PTR-резолв: ip -> список имён.
|
||||
dns может быть "IP" или "IP#PORT".
|
||||
"""
|
||||
server, port = split_dns(upstream)
|
||||
if not server:
|
||||
return []
|
||||
|
||||
r = dns.resolver.Resolver(configure=False)
|
||||
r.nameservers = [server]
|
||||
if port:
|
||||
try:
|
||||
r.port = int(port)
|
||||
except ValueError:
|
||||
r.port = 53
|
||||
r.timeout = timeout
|
||||
r.lifetime = timeout
|
||||
|
||||
try:
|
||||
rev = dns.reversename.from_address(ip)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
try:
|
||||
answer = r.resolve(rev, "PTR")
|
||||
except dns.exception.DNSException:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
names = []
|
||||
for rr in answer:
|
||||
s = rr.to_text().strip()
|
||||
if s.endswith("."):
|
||||
s = s[:-1]
|
||||
if s:
|
||||
names.append(s.lower())
|
||||
return names
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Загрузка DNS-конфига
|
||||
# --------------------------------------------------------------------
|
||||
def load_dns_config(path=DNS_CONFIG_PATH, trace_log=None):
|
||||
"""
|
||||
Читает /etc/selective-vpn/dns-upstreams.conf и обновляет
|
||||
глобальные DNS_DEFAULT / DNS_META.
|
||||
|
||||
Формат строк:
|
||||
default 1.2.3.4 5.6.7.8
|
||||
meta 9.9.9.9 8.8.8.8
|
||||
Можно использовать "ip#port", например 127.0.0.1#6053.
|
||||
"""
|
||||
global DNS_DEFAULT, DNS_META
|
||||
|
||||
if not os.path.exists(path):
|
||||
DNS_DEFAULT = DEFAULT_DNS_DEFAULT.copy()
|
||||
DNS_META = DEFAULT_DNS_META.copy()
|
||||
log(
|
||||
f"dns-config: {path} not found, fallback to built-in defaults "
|
||||
f"(default={DNS_DEFAULT}, meta={DNS_META})",
|
||||
trace_log,
|
||||
)
|
||||
return
|
||||
|
||||
dflt = []
|
||||
meta = []
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
parts = s.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
key = parts[0].lower()
|
||||
addrs = parts[1:]
|
||||
if key == "default":
|
||||
dflt.extend(addrs)
|
||||
elif key == "meta":
|
||||
meta.extend(addrs)
|
||||
except Exception as e:
|
||||
DNS_DEFAULT = DEFAULT_DNS_DEFAULT.copy()
|
||||
DNS_META = DEFAULT_DNS_META.copy()
|
||||
log(
|
||||
f"dns-config: failed to read {path}: {e}, fallback to built-in defaults "
|
||||
f"(default={DNS_DEFAULT}, meta={DNS_META})",
|
||||
trace_log,
|
||||
)
|
||||
return
|
||||
|
||||
if not dflt:
|
||||
dflt = DEFAULT_DNS_DEFAULT.copy()
|
||||
log(
|
||||
"dns-config: no 'default' section, fallback to built-in for default",
|
||||
trace_log,
|
||||
)
|
||||
if not meta:
|
||||
meta = DEFAULT_DNS_META.copy()
|
||||
log("dns-config: no 'meta' section, fallback to built-in for meta", trace_log)
|
||||
|
||||
DNS_DEFAULT = dflt
|
||||
DNS_META = meta
|
||||
log(
|
||||
f"dns-config: accept {path}: "
|
||||
f"default={', '.join(DNS_DEFAULT)}; meta={', '.join(DNS_META)}",
|
||||
trace_log,
|
||||
)
|
||||
|
||||
|
||||
def resolve_host(host, meta_special, trace_log=None):
|
||||
"""
|
||||
Forward-резолв одного домена (A-записи).
|
||||
DNS берём из DNS_DEFAULT / DNS_META, которые загрузил load_dns_config().
|
||||
"""
|
||||
if host in meta_special:
|
||||
dns_list = DNS_META
|
||||
else:
|
||||
dns_list = DNS_DEFAULT
|
||||
|
||||
ips = dig_a(host, dns_list)
|
||||
|
||||
uniq = []
|
||||
for ip in ips:
|
||||
if ip not in uniq:
|
||||
uniq.append(ip)
|
||||
|
||||
if uniq:
|
||||
log(f"{host}: {', '.join(uniq)}", trace_log)
|
||||
else:
|
||||
log(f"{host}: no IPs", trace_log)
|
||||
return uniq
|
||||
|
||||
|
||||
def parse_static_entries(static_lines):
|
||||
"""
|
||||
static_lines — строки из static-ips.txt.
|
||||
Возвращаем список кортежей (ip_entry, base_ip, comment).
|
||||
"""
|
||||
entries = []
|
||||
for line in static_lines:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
|
||||
if "#" in s:
|
||||
ip_part, comment = s.split("#", 1)
|
||||
ip_part = ip_part.strip()
|
||||
comment = comment.strip()
|
||||
else:
|
||||
ip_part = s
|
||||
comment = ""
|
||||
|
||||
if not ip_part:
|
||||
continue
|
||||
if is_private_ipv4(ip_part):
|
||||
continue
|
||||
|
||||
base_ip = ip_part.split("/", 1)[0]
|
||||
entries.append((ip_part, base_ip, comment))
|
||||
return entries
|
||||
|
||||
|
||||
def resolve_static_entries(static_entries, ptr_cache, ttl_sec, trace_log=None):
|
||||
"""
|
||||
static_entries: список кортежей (ip_entry, base_ip, comment).
|
||||
ip_entry — как в static-ips.txt (может быть с /mask)
|
||||
base_ip — A.B.C.D (без маски)
|
||||
comment — текст после # или "".
|
||||
|
||||
Возвращаем dict: ip_entry -> список меток,
|
||||
уже с префиксом '*' (чтобы можно было искать).
|
||||
"""
|
||||
now = int(time.time())
|
||||
result = {}
|
||||
for ip_entry, base_ip, comment in static_entries:
|
||||
labels = []
|
||||
# 1) если есть комментарий — он главнее всего
|
||||
if comment:
|
||||
labels.append(f"*{comment}")
|
||||
# 2) если комментария нет, пробуем PTR (с кэшем)
|
||||
if not comment:
|
||||
cache_entry = ptr_cache.get(base_ip)
|
||||
names = []
|
||||
if (
|
||||
cache_entry
|
||||
and isinstance(cache_entry, dict)
|
||||
and isinstance(cache_entry.get("last_resolved"), (int, float))
|
||||
):
|
||||
age = now - cache_entry["last_resolved"]
|
||||
cached_names = cache_entry.get("names") or []
|
||||
if age <= ttl_sec and cached_names:
|
||||
names = cached_names
|
||||
if not names:
|
||||
# PTR через те же DNS, что и обычный трафик (используем первый из default)
|
||||
dns_for_ptr = DNS_DEFAULT[0] if DNS_DEFAULT else DEFAULT_DNS_DEFAULT[0]
|
||||
|
||||
try:
|
||||
names = dig_ptr(base_ip, dns_for_ptr) or []
|
||||
except Exception as e:
|
||||
log(
|
||||
f"PTR failed for {base_ip} (using {dns_for_ptr}): "
|
||||
f"{type(e).__name__}: {e}",
|
||||
trace_log,
|
||||
)
|
||||
names = []
|
||||
|
||||
uniq_names = []
|
||||
for n in names:
|
||||
if n not in uniq_names:
|
||||
uniq_names.append(n)
|
||||
names = uniq_names
|
||||
ptr_cache[base_ip] = {
|
||||
"names": names,
|
||||
"last_resolved": now,
|
||||
}
|
||||
for n in names:
|
||||
labels.append(f"*{n}")
|
||||
# 3) если вообще ничего нет — ставим общий тег
|
||||
if not labels:
|
||||
labels = ["*[STATIC-IP]"]
|
||||
result[ip_entry] = labels
|
||||
log(f"static {ip_entry}: labels={', '.join(labels)}", trace_log)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# API-слой: одна чистая функция, которую легко вызвать откуда угодно
|
||||
# --------------------------------------------------------------------
|
||||
def run_resolver_job(
|
||||
*,
|
||||
domains,
|
||||
meta_special,
|
||||
static_lines,
|
||||
cache_path,
|
||||
ptr_cache_path,
|
||||
ttl_sec,
|
||||
workers,
|
||||
trace_log=None,
|
||||
):
|
||||
"""
|
||||
Главный API резолвера.
|
||||
|
||||
Вход:
|
||||
domains — список доменов
|
||||
meta_special — set() доменов из meta-special.txt
|
||||
static_lines — строки из static-ips.txt
|
||||
cache_path — путь к domain-cache.json
|
||||
ptr_cache_path— путь к ptr-cache.json
|
||||
ttl_sec — TTL кэша доменов / PTR
|
||||
workers — число потоков
|
||||
trace_log — путь к trace.log (или None)
|
||||
|
||||
Выход: dict с ключами:
|
||||
ips — отсортированный список IP/подсетей
|
||||
ip_map — список (ip, label) пар (домен или *LABEL)
|
||||
domain_cache — обновлённый кэш доменов
|
||||
ptr_cache — обновлённый PTR-кэш
|
||||
summary — статистика (dict)
|
||||
"""
|
||||
# --- подгружаем DNS-конфиг ---
|
||||
load_dns_config(DNS_CONFIG_PATH, trace_log)
|
||||
|
||||
meta_special = set(meta_special or [])
|
||||
|
||||
log(f"domains to resolve: {len(domains)}", trace_log)
|
||||
|
||||
# --- кэши ---
|
||||
domain_cache = load_cache(cache_path)
|
||||
ptr_cache = load_cache(ptr_cache_path)
|
||||
now = int(time.time())
|
||||
|
||||
# --- разруливаем: что берём из domain_cache, что резолвим ---
|
||||
fresh_from_cache = {}
|
||||
to_resolve = []
|
||||
|
||||
for d in domains:
|
||||
entry = domain_cache.get(d)
|
||||
if entry and isinstance(entry, dict):
|
||||
ts = entry.get("last_resolved") or 0
|
||||
ips = entry.get("ips") or []
|
||||
if isinstance(ts, (int, float)) and isinstance(ips, list) and ips:
|
||||
if now - ts <= ttl_sec:
|
||||
valid_ips = [ip for ip in ips if not is_private_ipv4(ip)]
|
||||
if valid_ips:
|
||||
fresh_from_cache[d] = valid_ips
|
||||
continue
|
||||
|
||||
to_resolve.append(d)
|
||||
|
||||
log(
|
||||
f"from cache: {len(fresh_from_cache)}, to resolve: {len(to_resolve)}",
|
||||
trace_log,
|
||||
)
|
||||
|
||||
resolved = dict(fresh_from_cache)
|
||||
|
||||
total_domains = len(domains)
|
||||
cache_hits = len(fresh_from_cache)
|
||||
resolved_now = 0
|
||||
unresolved = 0
|
||||
|
||||
# --- параллельный резолв доменов ---
|
||||
if to_resolve:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
fut2host = {
|
||||
ex.submit(resolve_host, d, meta_special, trace_log): d
|
||||
for d in to_resolve
|
||||
}
|
||||
for fut in concurrent.futures.as_completed(fut2host):
|
||||
d = fut2host[fut]
|
||||
try:
|
||||
ips = fut.result()
|
||||
except Exception as e:
|
||||
log(f"{d}: resolver exception: {e}", trace_log)
|
||||
ips = []
|
||||
|
||||
if ips:
|
||||
resolved[d] = ips
|
||||
domain_cache[d] = {
|
||||
"ips": ips,
|
||||
"last_resolved": now,
|
||||
}
|
||||
resolved_now += 1
|
||||
else:
|
||||
unresolved += 1
|
||||
|
||||
# --- читаем static-ips и готовим список для PTR ---
|
||||
static_entries = parse_static_entries(static_lines)
|
||||
log(f"static entries: {len(static_entries)}", trace_log)
|
||||
|
||||
# --- PTR/labels для static-ips ---
|
||||
static_label_map = resolve_static_entries(
|
||||
static_entries, ptr_cache, ttl_sec, trace_log
|
||||
)
|
||||
|
||||
# --- собираем общий список IP и map ---
|
||||
ip_set = set()
|
||||
ip_to_domains = defaultdict(set)
|
||||
|
||||
# доменные IP
|
||||
for d, ips in resolved.items():
|
||||
for ip in ips:
|
||||
ip_set.add(ip)
|
||||
ip_to_domains[ip].add(d)
|
||||
|
||||
# статические IP / сети
|
||||
for ip_entry, _, _ in static_entries:
|
||||
ip_set.add(ip_entry)
|
||||
for label in static_label_map.get(ip_entry, []):
|
||||
ip_to_domains[ip_entry].add(label)
|
||||
|
||||
unique_ip_count = len(ip_set)
|
||||
if unique_ip_count == 0:
|
||||
log("no IPs resolved at all", trace_log)
|
||||
else:
|
||||
log(f"resolver done: {unique_ip_count} unique IPs", trace_log)
|
||||
|
||||
ips_sorted = sorted(ip_set)
|
||||
|
||||
# flatten ip_map
|
||||
ip_map_pairs = []
|
||||
for ip in ips_sorted:
|
||||
for dom in sorted(ip_to_domains[ip]):
|
||||
ip_map_pairs.append((ip, dom))
|
||||
|
||||
summary = {
|
||||
"domains_total": total_domains,
|
||||
"from_cache": cache_hits,
|
||||
"resolved_now": resolved_now,
|
||||
"unresolved": unresolved,
|
||||
"static_entries": len(static_entries),
|
||||
"unique_ips": unique_ip_count,
|
||||
}
|
||||
|
||||
log(
|
||||
"summary: domains=%d, from_cache=%d, resolved_now=%d, "
|
||||
"unresolved=%d, static_entries=%d, unique_ips=%d"
|
||||
% (
|
||||
summary["domains_total"],
|
||||
summary["from_cache"],
|
||||
summary["resolved_now"],
|
||||
summary["unresolved"],
|
||||
summary["static_entries"],
|
||||
summary["unique_ips"],
|
||||
),
|
||||
trace_log,
|
||||
)
|
||||
|
||||
return {
|
||||
"ips": ips_sorted,
|
||||
"ip_map": ip_map_pairs,
|
||||
"domain_cache": domain_cache,
|
||||
"ptr_cache": ptr_cache,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# CLI-обёртка вокруг API-функции (для bash-скрипта)
|
||||
# --------------------------------------------------------------------
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--domains", required=True, help="file with domains (one per line)")
|
||||
ap.add_argument("--output-ips", required=True, help="file to write unique IPs")
|
||||
ap.add_argument(
|
||||
"--output-map",
|
||||
required=True,
|
||||
help="file to write IP<TAB>domain map",
|
||||
)
|
||||
ap.add_argument("--meta-file", required=True, help="meta-special.txt path")
|
||||
ap.add_argument("--static-ips", required=True, help="static-ips.txt path")
|
||||
ap.add_argument("--cache", required=True, help="domain-cache.json path")
|
||||
ap.add_argument("--ptr-cache", required=True, help="ptr-cache.json path")
|
||||
ap.add_argument("--trace-log", default=None)
|
||||
ap.add_argument("--workers", type=int, default=40)
|
||||
ap.add_argument("--ttl-sec", type=int, default=24 * 3600)
|
||||
args = ap.parse_args()
|
||||
|
||||
trace_log = args.trace_log
|
||||
|
||||
try:
|
||||
# входные данные для API-функции
|
||||
domains = load_list(args.domains)
|
||||
meta_special = load_list(args.meta_file)
|
||||
|
||||
static_lines = []
|
||||
if os.path.exists(args.static_ips):
|
||||
with open(args.static_ips, "r") as f:
|
||||
static_lines = f.read().splitlines()
|
||||
|
||||
job_result = run_resolver_job(
|
||||
domains=domains,
|
||||
meta_special=meta_special,
|
||||
static_lines=static_lines,
|
||||
cache_path=args.cache,
|
||||
ptr_cache_path=args.ptr_cache,
|
||||
ttl_sec=args.ttl_sec,
|
||||
workers=args.workers,
|
||||
trace_log=trace_log,
|
||||
)
|
||||
|
||||
ips_sorted = job_result["ips"]
|
||||
ip_map_pairs = job_result["ip_map"]
|
||||
domain_cache = job_result["domain_cache"]
|
||||
ptr_cache = job_result["ptr_cache"]
|
||||
|
||||
# output-ips: по одному IP/подсети
|
||||
with open(args.output_ips, "w") as f:
|
||||
for ip in ips_sorted:
|
||||
f.write(ip + "\n")
|
||||
|
||||
# output-map: IP<TAB>домен/метка
|
||||
with open(args.output_map, "w") as f:
|
||||
for ip, dom in ip_map_pairs:
|
||||
f.write(f"{ip}\t{dom}\n")
|
||||
|
||||
# сохраняем кэши
|
||||
save_cache(args.cache, domain_cache)
|
||||
save_cache(args.ptr_cache, ptr_cache)
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
# настоящий фатал
|
||||
log(f"FATAL resolver error: {e}", trace_log)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user