Files
elmprodvpn/selective-vpn-gui/agvpn-resolver.py
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

646 lines
19 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())