procproxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- procproxy-0.1.0.dist-info/METADATA +489 -0
- procproxy-0.1.0.dist-info/RECORD +45 -0
- procproxy-0.1.0.dist-info/WHEEL +5 -0
- procproxy-0.1.0.dist-info/entry_points.txt +2 -0
- procproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- procproxy-0.1.0.dist-info/top_level.txt +1 -0
- proxycore/__init__.py +7 -0
- proxycore/__main__.py +65 -0
- proxycore/api.py +226 -0
- proxycore/backend/__init__.py +49 -0
- proxycore/backend/base.py +159 -0
- proxycore/backend/null.py +51 -0
- proxycore/backend/windivert/__init__.py +657 -0
- proxycore/backend/windivert/filter.py +123 -0
- proxycore/backend/windivert/pidmap.py +289 -0
- proxycore/backend/windivert/pkt.py +424 -0
- proxycore/canproxy.py +229 -0
- proxycore/checkroute.py +247 -0
- proxycore/cli.py +605 -0
- proxycore/config.py +1010 -0
- proxycore/dns/__init__.py +46 -0
- proxycore/dns/cache.py +209 -0
- proxycore/dns/modes.py +451 -0
- proxycore/dns/udp.py +606 -0
- proxycore/forward.py +572 -0
- proxycore/ipc.py +637 -0
- proxycore/log.py +298 -0
- proxycore/pathsetup.py +112 -0
- proxycore/probe.py +168 -0
- proxycore/proc.py +342 -0
- proxycore/rules.py +730 -0
- proxycore/runtime.py +654 -0
- proxycore/service/__init__.py +536 -0
- proxycore/service/__main__.py +124 -0
- proxycore/tunnel/__init__.py +8 -0
- proxycore/tunnel/relay.py +393 -0
- proxycore/tunnel/sessions.py +315 -0
- proxycore/tunnel/sink.py +325 -0
- proxycore/types.py +165 -0
- proxycore/upstream/__init__.py +52 -0
- proxycore/upstream/base.py +150 -0
- proxycore/upstream/chain.py +92 -0
- proxycore/upstream/http_connect.py +148 -0
- proxycore/upstream/socks4.py +106 -0
- proxycore/upstream/socks5.py +266 -0
proxycore/dns/modes.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""DNS 三模式(`profile.dns_mode`):`local` / `remote` / `bypass`。
|
|
2
|
+
|
|
3
|
+
* `remote`(默认):不本地解析,把域名字符串交给上游(SOCKS5 ATYP=3 / SOCKS4a /
|
|
4
|
+
CONNECT 传域名)。没有本地 DNS 泄漏。
|
|
5
|
+
* `local`:本地解析成 IP 再交给代理。本地 DNS 查询可能直连出去,是泄漏点。
|
|
6
|
+
* `dns_local_transport=proxy` 尚未实现(配置校验会拒绝),避免出现
|
|
7
|
+
"以为 DNS 走代理、实际直连"的静默泄漏。
|
|
8
|
+
* `bypass` + `dns_bypass`:列表内域名走 local,其余走 remote。
|
|
9
|
+
|
|
10
|
+
红线:
|
|
11
|
+
* `local` 模式下本地 DNS 服务器必须进守卫 G3 白名单,否则 DNS 查询本身会被代理并成环;
|
|
12
|
+
* `local` + `dns_local_transport=proxy` 时拿到的是解析结果而非域名,`dst_name` 规则不可用;
|
|
13
|
+
* `remote` 模式禁止在本地做 `getaddrinfo`。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import secrets
|
|
20
|
+
import socket
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..config import Profile
|
|
26
|
+
from ..log import EventLogger, get_logger
|
|
27
|
+
from .cache import DnsCache
|
|
28
|
+
|
|
29
|
+
LOG = EventLogger(get_logger("proxycore.dns"))
|
|
30
|
+
|
|
31
|
+
MODE_LOCAL = "local"
|
|
32
|
+
MODE_REMOTE = "remote"
|
|
33
|
+
MODE_BYPASS = "bypass"
|
|
34
|
+
VALID_MODES = {MODE_LOCAL, MODE_REMOTE, MODE_BYPASS}
|
|
35
|
+
|
|
36
|
+
TRANSPORT_DIRECT = "direct"
|
|
37
|
+
TRANSPORT_PROXY = "proxy"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_mode(value: str) -> str:
|
|
41
|
+
"""非法/未知模式落到 `remote`(最安全的一种:本地不查 DNS)。"""
|
|
42
|
+
return value if value in VALID_MODES else MODE_REMOTE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_transport(value: str) -> str:
|
|
46
|
+
return value if value in {TRANSPORT_DIRECT, TRANSPORT_PROXY} else TRANSPORT_DIRECT
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class ResolutionPlan:
|
|
51
|
+
"""一次连接的目标解析计划。"""
|
|
52
|
+
|
|
53
|
+
mode: str # 实际生效的模式(bypass 会在这里落到 local 或 remote)
|
|
54
|
+
hostname: str | None # 交给上游的域名(None = 只能用 IP)
|
|
55
|
+
addr: str # 上游最终要连的地址(domain 模式下就是域名)
|
|
56
|
+
used_local_dns: bool = False
|
|
57
|
+
reason: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DnsPolicy:
|
|
61
|
+
"""按 `dns_mode` 决定"这条连接要不要在本地解析"。"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
mode: str = MODE_REMOTE,
|
|
67
|
+
bypass: list[str] | None = None,
|
|
68
|
+
local_transport: str = TRANSPORT_DIRECT,
|
|
69
|
+
local_server: str = "",
|
|
70
|
+
cache: DnsCache | None = None,
|
|
71
|
+
timeout: float = 5.0,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.mode = normalize_mode(mode)
|
|
74
|
+
self.bypass = list(bypass or [])
|
|
75
|
+
self.local_transport = normalize_transport(local_transport)
|
|
76
|
+
self.local_server = local_server
|
|
77
|
+
self.cache = cache or DnsCache()
|
|
78
|
+
self.timeout = timeout
|
|
79
|
+
|
|
80
|
+
def configure(self, profile: Profile) -> None:
|
|
81
|
+
"""按 `Profile` 原地更新(热重载用)。
|
|
82
|
+
|
|
83
|
+
原地更新而不是新建对象:`Sink` 持有这个 policy 并可能在 worker 线程上
|
|
84
|
+
随时读它。换对象就要同步改 `Sink.dns`,漏掉一处就是"热重载后 DNS 配置
|
|
85
|
+
静默不生效"。缓存也刻意保留。
|
|
86
|
+
"""
|
|
87
|
+
self.mode = normalize_mode(str(getattr(profile, "dns_mode", MODE_REMOTE)))
|
|
88
|
+
self.bypass = [str(b) for b in (getattr(profile, "dns_bypass", None) or [])]
|
|
89
|
+
self.local_transport = normalize_transport(
|
|
90
|
+
str(getattr(profile, "dns_local_transport", TRANSPORT_DIRECT))
|
|
91
|
+
)
|
|
92
|
+
self.local_server = str(getattr(profile, "dns_local_server", "") or "")
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------ 判定 -----
|
|
95
|
+
def effective_mode(self, hostname: str | None) -> tuple[str, str]:
|
|
96
|
+
"""返回 `(生效模式, reason)`。"""
|
|
97
|
+
if self.mode != MODE_BYPASS:
|
|
98
|
+
return self.mode, f"dns_mode:{self.mode}"
|
|
99
|
+
if hostname and self.matches_bypass(hostname):
|
|
100
|
+
return MODE_LOCAL, "dns-bypass"
|
|
101
|
+
return MODE_REMOTE, "dns-bypass-default-remote"
|
|
102
|
+
|
|
103
|
+
def matches_bypass(self, hostname: str) -> bool:
|
|
104
|
+
from ..rules import wildcard_match
|
|
105
|
+
|
|
106
|
+
host = hostname.lower()
|
|
107
|
+
return any(wildcard_match(pattern.lower(), host) for pattern in self.bypass)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------- 运行时接线(Runtime)--
|
|
110
|
+
def plan_for_connection(self, addr: str) -> ResolutionPlan:
|
|
111
|
+
"""sink 在 dial 之前调用:这个目标地址该怎么交给上游。
|
|
112
|
+
|
|
113
|
+
`addr` 可能是 IP(应用自己解析过 → 路线 A/B 的常态),也可能是域名
|
|
114
|
+
(测试模式直接把域名给了 sink,或将来有别的名字来源)。区别在于:
|
|
115
|
+
|
|
116
|
+
* 是域名 → 直接按域名走 `plan()`(`local` 模式会在本地解析掉);
|
|
117
|
+
* 是 IP → 只能查 DNS 缓存找名字,且只用可信项(见 `trusted_name_for`)。
|
|
118
|
+
|
|
119
|
+
没有可用名字时返回"按 IP 上代理"。应用自己已经解析了,
|
|
120
|
+
这是应用行为决定的,无法改变,不是缺陷。
|
|
121
|
+
"""
|
|
122
|
+
if not _is_ip_literal(addr):
|
|
123
|
+
return self.plan(addr, addr)
|
|
124
|
+
return self.plan(addr, self.trusted_name_for(addr))
|
|
125
|
+
|
|
126
|
+
def trusted_name_for(self, addr: str) -> str | None:
|
|
127
|
+
"""取该 IP 的确定域名;推断出来的映射不返回(`None`)。
|
|
128
|
+
|
|
129
|
+
红线:DNS 嗅探得到的
|
|
130
|
+
映射只是近似,TTL 内有效且会因 CDN 误判。拿它去 dial 等于"猜一个域名
|
|
131
|
+
然后让上游连过去",猜错就是连到别人的站点,所以 dial 只用可信项,
|
|
132
|
+
推断项只喂给 `dst_name` 规则的近似匹配(并打 `name-inferred` 标记)。
|
|
133
|
+
"""
|
|
134
|
+
entry = self.cache.entry(addr)
|
|
135
|
+
if entry is None or entry.inferred:
|
|
136
|
+
return None
|
|
137
|
+
return entry.name
|
|
138
|
+
|
|
139
|
+
def inferred_names(self) -> set[str]:
|
|
140
|
+
"""未过期的推断域名(`GuardSet.inferred_names` 的来源)。"""
|
|
141
|
+
return self.cache.inferred_names()
|
|
142
|
+
|
|
143
|
+
def revision(self) -> int:
|
|
144
|
+
"""缓存内容版本号,供调用方判断要不要刷新 `inferred_names`。"""
|
|
145
|
+
return self.cache.revision
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------ 计划 -----
|
|
148
|
+
def plan(self, addr: str, hostname: str | None) -> ResolutionPlan:
|
|
149
|
+
"""决定交给上游的地址形态。
|
|
150
|
+
|
|
151
|
+
`hostname` 为 None 表示"应用自己已经解析过了"(我们只看到 IP,只能按 IP 走)。
|
|
152
|
+
"""
|
|
153
|
+
mode, reason = self.effective_mode(hostname)
|
|
154
|
+
|
|
155
|
+
if hostname is None:
|
|
156
|
+
return ResolutionPlan(
|
|
157
|
+
mode=mode, hostname=None, addr=addr, used_local_dns=False, reason=f"{reason};app-resolved"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if mode == MODE_REMOTE:
|
|
161
|
+
# 远端解析:域名原样透传(禁止本地 getaddrinfo)
|
|
162
|
+
return ResolutionPlan(mode=mode, hostname=hostname, addr=hostname, reason=reason)
|
|
163
|
+
|
|
164
|
+
# local / bypass:本地解析成 IP
|
|
165
|
+
resolved = self.resolve_local(hostname)
|
|
166
|
+
if resolved is None:
|
|
167
|
+
# 解析失败 → 退化为"按原地址 + 域名"交给上游(比直接失败更安全,不泄漏也不断流)
|
|
168
|
+
LOG.warn("dns_local_resolve_failed", hostname=hostname, fallback="remote")
|
|
169
|
+
return ResolutionPlan(
|
|
170
|
+
mode=MODE_REMOTE, hostname=hostname, addr=hostname, reason=f"{reason};local-failed->remote"
|
|
171
|
+
)
|
|
172
|
+
return ResolutionPlan(
|
|
173
|
+
mode=MODE_LOCAL, hostname=None, addr=resolved, used_local_dns=True, reason=reason
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------ 解析 -----
|
|
177
|
+
def resolve_local(self, hostname: str) -> str | None:
|
|
178
|
+
"""本地解析;`local_server` 非空时向指定 DNS 服务器查询,否则用系统解析器。
|
|
179
|
+
|
|
180
|
+
配了 `local_server` 却查询失败时**不回退系统解析器**:那会绕开用户指定的
|
|
181
|
+
服务器(可能正是为了不泄漏),把 DNS 查询发到本机默认 DNS。失败返回 None,
|
|
182
|
+
由 `plan()` 退化为"域名交给上游远端解析"(不泄漏,只是本模式没生效)。
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
ipaddress.ip_address(hostname)
|
|
186
|
+
return hostname # 已经是 IP 字面量
|
|
187
|
+
except ValueError:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
if self.local_server:
|
|
191
|
+
resolved = query_a_record(hostname, self.local_server, timeout=self.timeout)
|
|
192
|
+
if resolved:
|
|
193
|
+
self.cache.put(resolved, hostname, inferred=False, source="local-dns")
|
|
194
|
+
return resolved
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
infos = socket.getaddrinfo(hostname, None, socket.AF_INET, socket.SOCK_STREAM)
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
LOG.warn("getaddrinfo_failed", hostname=hostname, error=str(exc))
|
|
200
|
+
return None
|
|
201
|
+
if not infos:
|
|
202
|
+
return None
|
|
203
|
+
addr = str(infos[0][4][0])
|
|
204
|
+
self.cache.put(addr, hostname, inferred=False, source="getaddrinfo")
|
|
205
|
+
return addr
|
|
206
|
+
|
|
207
|
+
# --------------------------------------------------- 守卫 G3 白名单 ----
|
|
208
|
+
def guard_endpoints(self) -> set[tuple[str, int]]:
|
|
209
|
+
"""`local` 模式的 DNS 服务器必须进白名单(否则查询会被代理并成环)。"""
|
|
210
|
+
if not self.local_server:
|
|
211
|
+
return set()
|
|
212
|
+
return {(self.local_server, 53)}
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------ 嗅探 -----
|
|
215
|
+
def note_answer(
|
|
216
|
+
self, addr: str, name: str, *, ttl_s: float | None = None, inferred: bool = True
|
|
217
|
+
) -> None:
|
|
218
|
+
"""记录一条 IP→域名映射。
|
|
219
|
+
|
|
220
|
+
`inferred=True`(默认)= DNS 嗅探到的,只是近似:TTL 内有效、CDN 会误判,
|
|
221
|
+
所以它只喂 `dst_name` 规则的近似匹配,不参与 dial、不参与 BLOCK
|
|
222
|
+
。
|
|
223
|
+
|
|
224
|
+
`inferred=False` = 权威来源(应用真的连的就是这个域名),可以拿去 dial。
|
|
225
|
+
但抓包式后端给不出这种映射:内核不向用户态提供主机名,过滤器里只有地址,
|
|
226
|
+
里没有任何名字类字段,`ALE_AUTH_CONNECT` 的层数据结构 `FWPS_CONNECT_REQUEST0`
|
|
227
|
+
只有 `localAddressAndPort` / `remoteAddressAndPort`(2026-10-02 对着本机
|
|
228
|
+
WDK 10.0.26100.0 的 `fwpsk.h` 逐项核过)。所以 `inferred=False` 目前
|
|
229
|
+
没有任何 in-tree 生产者,只有单测和将来的手工/API 喂入口在用。
|
|
230
|
+
"""
|
|
231
|
+
self.cache.put(
|
|
232
|
+
addr,
|
|
233
|
+
name,
|
|
234
|
+
ttl_s=ttl_s,
|
|
235
|
+
inferred=inferred,
|
|
236
|
+
source="dns-sniff" if inferred else "authoritative",
|
|
237
|
+
)
|
|
238
|
+
LOG.debug("dns_answer_cached", addr=addr, name=name, inferred=inferred)
|
|
239
|
+
|
|
240
|
+
def name_for(self, addr: str) -> str | None:
|
|
241
|
+
return self.cache.get(addr)
|
|
242
|
+
|
|
243
|
+
def describe(self) -> dict[str, Any]:
|
|
244
|
+
return {
|
|
245
|
+
"mode": self.mode,
|
|
246
|
+
"bypass": list(self.bypass),
|
|
247
|
+
"local_transport": self.local_transport,
|
|
248
|
+
"local_server": self.local_server or "(system)",
|
|
249
|
+
"guard_endpoints": sorted(f"{h}:{p}" for h, p in self.guard_endpoints()),
|
|
250
|
+
"cache": self.cache.stats(),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _is_ip_literal(text: str) -> bool:
|
|
255
|
+
try:
|
|
256
|
+
ipaddress.ip_address(text)
|
|
257
|
+
except ValueError:
|
|
258
|
+
return False
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def build_dns_policy(profile: Profile, *, cache: DnsCache | None = None) -> DnsPolicy:
|
|
263
|
+
"""从 `Profile` 装配 `DnsPolicy`(`Runtime` 与 `api.make_runtime` 共用)。
|
|
264
|
+
|
|
265
|
+
`cache` 不传 = 新建一份。热重载时必须把旧缓存传进来复用
|
|
266
|
+
(见 `Runtime.apply_profile`):否则每次改配置都会把 DNS 记录扔光,
|
|
267
|
+
`dst_name` 规则会在重载后集体失效一段时间,而这与"改配置"毫无关系。
|
|
268
|
+
"""
|
|
269
|
+
policy = DnsPolicy(cache=cache)
|
|
270
|
+
policy.configure(profile)
|
|
271
|
+
return policy
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------ 最小 DNS 客户端 ----
|
|
275
|
+
def build_query(name: str, qtype: int = 1, *, txid: int | None = None) -> bytes:
|
|
276
|
+
"""构造一个 DNS 查询报文(A 记录)。
|
|
277
|
+
|
|
278
|
+
`txid` 默认随机:固定事务 ID 会让响应更容易被伪造。`query_a_record()`
|
|
279
|
+
会记住本次使用的 ID 并严格校验回包。
|
|
280
|
+
"""
|
|
281
|
+
import struct
|
|
282
|
+
|
|
283
|
+
if txid is None:
|
|
284
|
+
txid = secrets.randbits(16)
|
|
285
|
+
header = struct.pack("!HHHHHH", txid & 0xFFFF, 0x0100, 1, 0, 0, 0)
|
|
286
|
+
qname = b"".join(bytes([len(part)]) + part.encode("idna") for part in name.split(".")) + b"\x00"
|
|
287
|
+
return header + qname + struct.pack("!HH", qtype, 1)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _read_name(packet: bytes, pos: int) -> tuple[str, int]:
|
|
291
|
+
"""读取域名,返回 `(名字, 名字字段结束之后的偏移)`。
|
|
292
|
+
|
|
293
|
+
关键:压缩指针(`0xC0 xx`)表示"名字的其余部分在别处",名字字段本身只占 2 字节。
|
|
294
|
+
因此跟随指针之前必须先把"字段结束位置"记下来,否则后面的 rtype/ttl/rdata
|
|
295
|
+
会读到被指向的位置(踩过:ttl/rdata 全错位)。
|
|
296
|
+
"""
|
|
297
|
+
labels: list[str] = []
|
|
298
|
+
after_field = -1 # 名字字段在原始字节流里的结束偏移
|
|
299
|
+
visited: set[int] = set()
|
|
300
|
+
while 0 <= pos < len(packet):
|
|
301
|
+
length = packet[pos]
|
|
302
|
+
if length == 0:
|
|
303
|
+
pos += 1
|
|
304
|
+
if after_field < 0:
|
|
305
|
+
after_field = pos
|
|
306
|
+
break
|
|
307
|
+
if length & 0xC0 == 0xC0:
|
|
308
|
+
if pos + 1 >= len(packet):
|
|
309
|
+
break
|
|
310
|
+
pointer = ((length & 0x3F) << 8) | packet[pos + 1]
|
|
311
|
+
if after_field < 0:
|
|
312
|
+
after_field = pos + 2
|
|
313
|
+
if pointer in visited:
|
|
314
|
+
break # 真环,跳出
|
|
315
|
+
visited.add(pointer)
|
|
316
|
+
pos = pointer
|
|
317
|
+
continue
|
|
318
|
+
if pos + 1 + length > len(packet):
|
|
319
|
+
break
|
|
320
|
+
labels.append(packet[pos + 1 : pos + 1 + length].decode("latin1"))
|
|
321
|
+
pos += 1 + length
|
|
322
|
+
if after_field < 0: # 解析中途失败
|
|
323
|
+
after_field = pos
|
|
324
|
+
return ".".join(labels), after_field
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_answers(packet: bytes) -> list[tuple[str, str, int]]:
|
|
328
|
+
"""解析响应里的 A/AAAA 记录,返回 `[(name, ip, ttl)]`。"""
|
|
329
|
+
import struct
|
|
330
|
+
|
|
331
|
+
if len(packet) < 12:
|
|
332
|
+
return []
|
|
333
|
+
_id, _flags, qdcount, ancount, _ns, _ar = struct.unpack_from("!HHHHHH", packet, 0)
|
|
334
|
+
offset = 12
|
|
335
|
+
|
|
336
|
+
for _ in range(qdcount):
|
|
337
|
+
_qname, offset = _read_name(packet, offset)
|
|
338
|
+
offset += 4 # qtype + qclass
|
|
339
|
+
|
|
340
|
+
answers: list[tuple[str, str, int]] = []
|
|
341
|
+
for _ in range(ancount):
|
|
342
|
+
if offset >= len(packet):
|
|
343
|
+
break
|
|
344
|
+
name, offset = _read_name(packet, offset)
|
|
345
|
+
if offset + 10 > len(packet):
|
|
346
|
+
break
|
|
347
|
+
rtype, _rclass, ttl, rdlength = struct.unpack_from("!HHIH", packet, offset)
|
|
348
|
+
offset += 10
|
|
349
|
+
rdata = packet[offset : offset + rdlength]
|
|
350
|
+
offset += rdlength
|
|
351
|
+
if rtype == 1 and rdlength == 4:
|
|
352
|
+
answers.append((name, socket.inet_ntoa(rdata), int(ttl)))
|
|
353
|
+
elif rtype == 28 and rdlength == 16:
|
|
354
|
+
answers.append((name, socket.inet_ntop(socket.AF_INET6, rdata), int(ttl)))
|
|
355
|
+
return answers
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _is_query_response(packet: bytes, txid: int) -> bool:
|
|
359
|
+
"""回包是否属于本次查询:长度、事务 ID、QR 位三项都要对。"""
|
|
360
|
+
import struct
|
|
361
|
+
|
|
362
|
+
if len(packet) < 12:
|
|
363
|
+
return False
|
|
364
|
+
resp_id, flags = struct.unpack_from("!HH", packet, 0)
|
|
365
|
+
return resp_id == txid and bool(flags & 0x8000)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def query_a_record(name: str, server: str, *, timeout: float = 5.0, port: int = 53) -> str | None:
|
|
369
|
+
"""向指定 DNS 服务器发一次 A 查询(UDP)。失败返回 None。
|
|
370
|
+
|
|
371
|
+
安全约束:
|
|
372
|
+
* 每次查询随机事务 ID;
|
|
373
|
+
* socket 先 `connect()` 到服务器,内核只交付该源地址/端口的报文;
|
|
374
|
+
* 回包必须匹配事务 ID 与 QR 位,否则丢弃并继续等,直到总超时。
|
|
375
|
+
"""
|
|
376
|
+
if not server:
|
|
377
|
+
return None
|
|
378
|
+
txid = secrets.randbits(16)
|
|
379
|
+
deadline = time.monotonic() + max(0.05, timeout)
|
|
380
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
381
|
+
try:
|
|
382
|
+
sock.connect((server, port))
|
|
383
|
+
sock.send(build_query(name, txid=txid))
|
|
384
|
+
packet = b""
|
|
385
|
+
while True:
|
|
386
|
+
remaining = deadline - time.monotonic()
|
|
387
|
+
if remaining <= 0:
|
|
388
|
+
raise TimeoutError("DNS 查询超时")
|
|
389
|
+
sock.settimeout(remaining)
|
|
390
|
+
candidate = sock.recv(4096)
|
|
391
|
+
if _is_query_response(candidate, txid):
|
|
392
|
+
packet = candidate
|
|
393
|
+
break
|
|
394
|
+
except OSError as exc:
|
|
395
|
+
LOG.warn("dns_query_failed", server=server, name=name, error=str(exc))
|
|
396
|
+
return None
|
|
397
|
+
finally:
|
|
398
|
+
sock.close()
|
|
399
|
+
answers = parse_answers(packet)
|
|
400
|
+
for record_name, addr, _ttl in answers:
|
|
401
|
+
if record_name.lower().rstrip(".") == name.lower().rstrip("."):
|
|
402
|
+
return addr
|
|
403
|
+
return answers[0][1] if answers else None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def sniff_queries(payload: bytes) -> list[str]:
|
|
407
|
+
"""从 DNS 查询报文中提取被查询的域名(用于建立"即将解析"的映射)。"""
|
|
408
|
+
import struct
|
|
409
|
+
|
|
410
|
+
if len(payload) < 12:
|
|
411
|
+
return []
|
|
412
|
+
_id, flags, qdcount, _an, _ns, _ar = struct.unpack_from("!HHHHHH", payload, 0)
|
|
413
|
+
if flags & 0x8000: # 是响应而不是查询
|
|
414
|
+
return []
|
|
415
|
+
offset = 12
|
|
416
|
+
names: list[str] = []
|
|
417
|
+
for _ in range(qdcount):
|
|
418
|
+
labels: list[str] = []
|
|
419
|
+
while offset < len(payload):
|
|
420
|
+
length = payload[offset]
|
|
421
|
+
if length == 0:
|
|
422
|
+
offset += 1
|
|
423
|
+
break
|
|
424
|
+
if length & 0xC0:
|
|
425
|
+
offset += 2
|
|
426
|
+
break
|
|
427
|
+
labels.append(payload[offset + 1 : offset + 1 + length].decode("latin1"))
|
|
428
|
+
offset += 1 + length
|
|
429
|
+
if labels:
|
|
430
|
+
names.append(".".join(labels))
|
|
431
|
+
offset += 4
|
|
432
|
+
return names
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
__all__ = [
|
|
436
|
+
"MODE_BYPASS",
|
|
437
|
+
"MODE_LOCAL",
|
|
438
|
+
"MODE_REMOTE",
|
|
439
|
+
"TRANSPORT_DIRECT",
|
|
440
|
+
"TRANSPORT_PROXY",
|
|
441
|
+
"VALID_MODES",
|
|
442
|
+
"DnsPolicy",
|
|
443
|
+
"ResolutionPlan",
|
|
444
|
+
"build_dns_policy",
|
|
445
|
+
"build_query",
|
|
446
|
+
"normalize_mode",
|
|
447
|
+
"normalize_transport",
|
|
448
|
+
"parse_answers",
|
|
449
|
+
"query_a_record",
|
|
450
|
+
"sniff_queries",
|
|
451
|
+
]
|