feat(lite): merge TUN mode with IPv6 routing

This commit is contained in:
RoyirinRosmontis
2026-09-19 03:41:41 +08:00
parent 3e29012d9c
commit 50364dc17a
23 changed files with 2996 additions and 237 deletions
+152
View File
@@ -0,0 +1,152 @@
"""轻量 DNS 观察器,用于把 TUN 里的目标 IP 关联回解析域名。
TUN 送到 SOCKS5 的请求通常只带目标 IP。这里观察经 UoT 或直连的
UDP DNS 回包,保存短期的 ``IP -> 域名`` 映射供分流和日志使用。
"""
from __future__ import annotations
import ipaddress
import struct
import threading
import time
from typing import Callable
def _read_name(packet: bytes, offset: int) -> tuple[str, int]:
labels: list[str] = []
cursor = offset
next_offset = None
jumps = 0
while True:
if cursor >= len(packet):
raise ValueError("DNS 名称超出数据包")
length = packet[cursor]
if length == 0:
cursor += 1
if next_offset is None:
next_offset = cursor
break
if length & 0xC0 == 0xC0:
if cursor + 1 >= len(packet):
raise ValueError("DNS 压缩指针不完整")
pointer = ((length & 0x3F) << 8) | packet[cursor + 1]
if next_offset is None:
next_offset = cursor + 2
cursor = pointer
jumps += 1
if jumps > 16:
raise ValueError("DNS 名称压缩指针循环")
continue
if length > 63 or cursor + 1 + length > len(packet):
raise ValueError("DNS 标签不完整")
labels.append(packet[cursor + 1:cursor + 1 + length].decode("idna"))
cursor += 1 + length
return ".".join(labels).rstrip(".").lower(), int(next_offset)
def _question(packet: bytes) -> tuple[int, str, int]:
if len(packet) < 12:
raise ValueError("DNS 头不完整")
txid, flags, questions, _answers, _authority, _additional = struct.unpack(
"!HHHHHH", packet[:12]
)
if flags & 0x8000 or questions < 1:
raise ValueError("不是 DNS 查询")
name, offset = _read_name(packet, 12)
if offset + 4 > len(packet):
raise ValueError("DNS 问题段不完整")
return txid, name, offset + 4
class DnsObserver:
def __init__(self, log: Callable[[str], None] | None = None,
max_ttl: int = 300):
self.log = log or (lambda _message: None)
self.max_ttl = max(1, int(max_ttl))
self._lock = threading.RLock()
self._domain_to_ips: dict[str, tuple[float, tuple[str, ...]]] = {}
# 一个 CDN IP 可能同时服务多个域名;不能把最后一次 DNS 回包当成唯一身份。
self._ip_to_domains: dict[str, tuple[float, set[str]]] = {}
def observe_query(self, packet: bytes) -> str | None:
try:
_txid, domain, _ = _question(packet)
return domain or None
except (ValueError, UnicodeError):
return None
def observe_response(self, packet: bytes) -> tuple[str, tuple[str, ...]] | None:
try:
if len(packet) < 12:
raise ValueError("DNS 头不完整")
_txid, flags, questions, answers, _authority, _additional = struct.unpack(
"!HHHHHH", packet[:12]
)
if not (flags & 0x8000) or questions < 1:
raise ValueError("不是 DNS 回包")
domain, offset = _read_name(packet, 12)
if offset + 4 > len(packet):
raise ValueError("DNS 问题段不完整")
offset += 4
ips: list[str] = []
ttl = self.max_ttl
for _ in range(answers):
_owner, offset = _read_name(packet, offset)
if offset + 10 > len(packet):
raise ValueError("DNS 答案头不完整")
rtype, rclass, record_ttl, size = struct.unpack(
"!HHIH", packet[offset:offset + 10]
)
offset += 10
if offset + size > len(packet):
raise ValueError("DNS 答案数据不完整")
raw = packet[offset:offset + size]
offset += size
if rclass != 1 or rtype not in (1, 28):
continue
if (rtype == 1 and size != 4) or (rtype == 28 and size != 16):
continue
ips.append(str(ipaddress.ip_address(raw)))
ttl = min(ttl, max(1, int(record_ttl)))
if not domain or not ips:
return None
expires = time.monotonic() + min(ttl, self.max_ttl)
unique = tuple(dict.fromkeys(ips))
with self._lock:
self._domain_to_ips[domain] = (expires, unique)
for ip in unique:
previous = self._ip_to_domains.get(ip)
domains = set(previous[1]) if previous and previous[0] > time.monotonic() else set()
domains.add(domain)
self._ip_to_domains[ip] = (expires, domains)
return domain, unique
except (ValueError, UnicodeError, OSError):
return None
def lookup_ip(self, ip: str) -> str | None:
try:
normalized = str(ipaddress.ip_address(ip))
except ValueError:
return None
now = time.monotonic()
with self._lock:
item = self._ip_to_domains.get(normalized)
if not item:
return None
if item[0] <= now:
self._ip_to_domains.pop(normalized, None)
return None
return next(iter(item[1])) if len(item[1]) == 1 else None
def lookup_domain(self, domain: str) -> tuple[str, ...]:
key = str(domain).rstrip(".").lower()
now = time.monotonic()
with self._lock:
item = self._domain_to_ips.get(key)
if not item:
return ()
if item[0] <= now:
self._domain_to_ips.pop(key, None)
return ()
return item[1]