feat(lite): merge TUN mode with IPv6 routing
This commit is contained in:
@@ -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]
|
||||
Reference in New Issue
Block a user