Files
MoviePilot/tests/test_doh_helper.py

530 lines
18 KiB
Python
Raw Permalink 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.
"""
验证加密 DNS 执行器的惰性创建、关闭和套接字恢复。
"""
import json
import socket
import struct
import threading
import time
import urllib.request
import pytest
from app.adapters.network import doh
from app.runtime.correlation import correlation_scope, get_correlation_id
from app.runtime.execution import OwnedThreadPoolExecutor
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
"""
DoH 线程池按需创建,并在模块关闭时恢复系统 DNS
"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try:
assert helper.shutdown() is True
assert doh._executor is None
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
executor = doh._executor
assert isinstance(executor, OwnedThreadPoolExecutor)
assert helper.shutdown() is True
assert doh._executor is None
assert socket.getaddrinfo is doh._orig_getaddrinfo
assert getattr(executor, "_shutdown", False)
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_doh_shutdown_is_bounded_and_retryable(monkeypatch):
"""
阻塞查询超时时保留同一 owner,释放后可重试并安全重新启用。
"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
entered = threading.Event()
release = threading.Event()
future = None
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
def blocked_query() -> None:
"""
模拟底层网络栈未按 DoH 请求超时返回的同步查询。
"""
entered.set()
release.wait()
try:
assert helper.shutdown(timeout=1) is True
assert doh.enable_doh(True) is True
with doh._executor_lock:
executor = doh._get_executor_locked()
future = executor.submit(blocked_query)
assert entered.wait(timeout=1)
started_at = time.monotonic()
assert helper.shutdown(timeout=0.01) is False
assert time.monotonic() - started_at < 1
assert doh._executor is executor
assert socket.getaddrinfo is doh._orig_getaddrinfo
assert executor.accepting is False
# 未收敛 owner 不得被新 executor 覆盖,否则旧查询会脱离生命周期追踪。
assert doh.enable_doh(True) is False
assert doh._executor is executor
release.set()
future.result(timeout=1)
assert helper.shutdown(timeout=1) is True
assert doh._executor is None
assert doh.enable_doh(True) is True
finally:
release.set()
if future is not None:
future.result(timeout=1)
helper.shutdown(timeout=1)
socket.getaddrinfo = original_getaddrinfo
def test_doh_config_reload_disables_and_closes_executor(monkeypatch):
"""
热更新关闭 DoH 时恢复系统 DNS 并释放已创建的线程池
"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
executor = doh._executor
assert executor is not None
monkeypatch.setattr("app.runtime.config.settings.DOH_ENABLE", False)
helper.on_config_changed()
assert doh._executor is None
assert getattr(executor, "_shutdown", False)
assert socket.getaddrinfo is doh._orig_getaddrinfo
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
"""
同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。
"""
query_calls = []
resolved_hosts = []
def fake_query(resolver: str, host: str) -> str:
query_calls.append((resolver, host))
return "203.0.113.7"
def fake_getaddrinfo(host: str, *args, **kwargs):
resolved_hosts.append(host)
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (host, 0))]
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", fake_query)
monkeypatch.setattr(doh, "_orig_getaddrinfo", fake_getaddrinfo)
original_getaddrinfo = socket.getaddrinfo
with doh._doh_lock:
doh._doh_cache.clear()
try:
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
socket.getaddrinfo("example.com", None)
finally:
object.__new__(doh.DohHelper).shutdown()
socket.getaddrinfo = original_getaddrinfo
with doh._doh_lock:
doh._doh_cache.clear()
assert query_calls == [("resolver.test", "example.com")]
assert resolved_hosts == ["203.0.113.7", "203.0.113.7"]
def test_doh_queries_use_each_request_context(monkeypatch):
"""
复用的 DoH worker 应按查询恢复关联 ID,不能丢失或粘住首个请求。
"""
original_getaddrinfo = socket.getaddrinfo
observed = []
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr(
"app.runtime.config.settings.DOH_DOMAINS",
"first.example,second.example",
)
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(
doh,
"_doh_query",
lambda _resolver, _host: observed.append(get_correlation_id()) or "203.0.113.7",
)
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda _host, *_args, **_kwargs: [])
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
with correlation_scope("doh-first"):
socket.getaddrinfo("first.example", None)
with correlation_scope("doh-second"):
socket.getaddrinfo("second.example", None)
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
assert observed == ["doh-first", "doh-second"]
def _dns_name(name: str) -> bytes:
"""
按 RFC 1035 编码未压缩的域名。
"""
return b"".join(bytes([len(label)]) + label.encode() for label in name.split(".")) + b"\x00"
def _dns_response(host: str, answers: list[tuple[bytes, int, bytes]], rcode: int = 0) -> bytes:
"""
构造单问题 DNS 应答报文。
:param host: 问题区的域名
:param answers: 依次排列的应答记录,每项为 (编码后的记录名, 记录类型, rdata)
:param rcode: 响应码,0 表示成功
:return: 完整报文
"""
header = struct.pack(">HHHHHH", 0, 0x8180 | rcode, 1, len(answers), 0, 0)
question = _dns_name(host) + struct.pack(">HH", 1, 1)
records = b"".join(
name + struct.pack(">HHIH", record_type, 1, 60, len(rdata)) + rdata
for name, record_type, rdata in answers
)
return header + question + records
def _cname_then_a_response() -> bytes:
"""
构造 image.tmdb.org 先返回 CNAME、再返回 CDN A 记录的真实形态应答。
第一条记录名压缩指向问题区,第二条记录名压缩指向 CNAME 的目标域名。
"""
host = "image.tmdb.org"
question_end = 12 + len(_dns_name(host)) + 4
cname_rdata_offset = question_end + 2 + 10
return _dns_response(
host,
[
(b"\xc0\x0c", 5, _dns_name("tmdb-image-prod.b-cdn.net")),
(struct.pack(">H", 0xC000 | cname_rdata_offset), 1, socket.inet_aton("169.150.249.164")),
],
)
class _FakeDohResponse:
"""
模拟 urllib 返回的 DoH 响应上下文。
"""
def __init__(self, body: bytes, status: int = 200) -> None:
"""
:param body: 响应正文
:param status: HTTP 状态码
"""
self.status = status
self._body = body
def __enter__(self) -> "_FakeDohResponse":
return self
def __exit__(self, *_exc: object) -> None:
return None
def read(self) -> bytes:
"""
返回完整响应正文。
"""
return self._body
def test_doh_query_follows_cname_to_a_record(monkeypatch):
"""
应答先给 CNAME 时应跳过别名记录取 A 记录,而不是把别名字节当成 IP。
"""
monkeypatch.setattr(
urllib.request,
"urlopen",
lambda _request, timeout: _FakeDohResponse(_cname_then_a_response()),
)
# 旧实现按固定偏移读取,会把 "\x0ftmd" 误解析为 15.116.109.100
assert doh._doh_query("resolver.test", "image.tmdb.org") == "169.150.249.164"
@pytest.mark.parametrize(
"message",
[
_dns_response("example.com", [], rcode=3),
_dns_response("example.com", [(b"\xc0\x0c", 5, _dns_name("alias.example.net"))]),
_dns_response("example.com", [(b"\xc0\x0c", 1, socket.inet_aton("203.0.113.7"))])[:-2],
_dns_response("example.com", [(b"\x40", 1, socket.inet_aton("203.0.113.7"))]),
b"\x00\x00\x81\x80",
],
ids=["nxdomain", "cname-only", "truncated-rdata", "reserved-label", "short-header"],
)
def test_parse_dns_a_record_rejects_unusable_answers(message):
"""
失败响应码、没有 A 记录、截断报文和保留标签类型都不应产生地址。
"""
assert doh._parse_dns_a_record(message) is None
def test_parse_dns_a_record_reads_plain_a_record():
"""
直接返回 A 记录的应答应取第一条地址。
"""
message = _dns_response(
"example.com",
[
(b"\xc0\x0c", 1, socket.inet_aton("203.0.113.7")),
(b"\xc0\x0c", 1, socket.inet_aton("203.0.113.8")),
],
)
assert doh._parse_dns_a_record(message) == "203.0.113.7"
def test_doh_query_json_skips_cname(monkeypatch):
"""
JSON 应答同样应跳过 CNAME 链,只返回 A 记录地址。
"""
body = json.dumps({
"Status": 0,
"Answer": [
{"name": "image.tmdb.org.", "type": 5, "data": "tmdb-image-prod.b-cdn.net."},
{"name": "tmdb-image-prod.b-cdn.net.", "type": 1, "data": "169.150.249.164"},
],
}).encode()
monkeypatch.setattr(
urllib.request,
"urlopen",
lambda _request, timeout: _FakeDohResponse(body),
)
assert doh.doh_query_json("resolver.test", "image.tmdb.org") == "169.150.249.164"
def test_doh_query_logs_resolver_failure_at_debug(monkeypatch):
"""
单个解析器超时只记调试日志,不再逐条输出错误。
"""
errors = []
def timed_out(_request, timeout):
raise OSError("timed out")
monkeypatch.setattr(urllib.request, "urlopen", timed_out)
monkeypatch.setattr(doh.logger, "error", errors.append)
assert doh._doh_query("resolver.test", "example.com") is None
assert doh.doh_query_json("resolver.test", "example.com") is None
assert errors == []
def test_enable_doh_cools_down_after_all_resolvers_fail(monkeypatch):
"""
所有解析器都失败后,冷却期内直接走系统 DNS 且只告警一次;冷却结束后重新查询。
"""
clock = [1000.0]
query_calls = []
query_result = [None]
resolved_hosts = []
warnings = []
def fake_query(resolver: str, host: str):
query_calls.append((resolver, host))
return query_result[0]
def fake_getaddrinfo(host: str, *args, **kwargs):
resolved_hosts.append(host)
return []
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", fake_query)
monkeypatch.setattr(doh, "_orig_getaddrinfo", fake_getaddrinfo)
monkeypatch.setattr(doh, "monotonic", lambda: clock[0])
monkeypatch.setattr(doh.logger, "warning", warnings.append)
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
assert query_calls == [("resolver.test", "example.com")]
assert resolved_hosts == ["example.com"]
assert len(warnings) == 1
clock[0] += doh._DOH_FAILURE_COOLDOWN_SECONDS - 1
socket.getaddrinfo("example.com", None)
assert len(query_calls) == 1
assert resolved_hosts == ["example.com", "example.com"]
assert len(warnings) == 1
clock[0] += 2
query_result[0] = "203.0.113.7"
socket.getaddrinfo("example.com", None)
assert len(query_calls) == 2
assert resolved_hosts[-1] == "203.0.113.7"
assert "example.com" not in doh._doh_failure_until
assert doh._doh_cache["example.com"] == "203.0.113.7"
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
assert doh._doh_cache == {}
assert doh._doh_failure_until == {}
def test_doh_config_reload_clears_failure_cooldown(monkeypatch):
"""
修改 DoH 配置后应清除失败冷却,让新的解析器立即生效。
"""
query_calls = []
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr("app.runtime.config.settings.DOH_ENABLE", True)
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: query_calls.append(resolver))
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
monkeypatch.setattr(doh.logger, "warning", lambda _message: None)
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
assert "example.com" in doh._doh_failure_until
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "fixed.test")
helper.on_config_changed()
assert doh._doh_failure_until == {}
socket.getaddrinfo("example.com", None)
assert query_calls == ["resolver.test", "fixed.test"]
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_doh_cooldown_falls_back_without_holding_executor_lock(monkeypatch):
"""
冷却期回退系统 DNS 时不得持有执行器锁,否则系统 DNS 阻塞会拖住有界关闭。
"""
lock_held = []
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: None)
monkeypatch.setattr(
doh,
"_orig_getaddrinfo",
lambda host, *args, **kwargs: lock_held.append(doh._executor_lock.locked()) or [],
)
monkeypatch.setattr(doh.logger, "warning", lambda _message: None)
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
socket.getaddrinfo("example.com", None)
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
assert lock_held == [False, False]
@pytest.mark.parametrize("stale_result", [None, "203.0.113.7"])
def test_doh_reload_discards_in_flight_query_result(stale_result, monkeypatch):
"""
配置重载期间仍在进行的旧查询结果不得写回缓存或失败冷却。
"""
helper = object.__new__(doh.DohHelper)
query_calls = []
warnings = []
def reload_during_query(resolver: str, host: str):
query_calls.append(resolver)
if len(query_calls) == 1:
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "fixed.test")
helper.on_config_changed()
return stale_result
return None
original_getaddrinfo = socket.getaddrinfo
monkeypatch.setattr("app.runtime.config.settings.DOH_ENABLE", True)
monkeypatch.setattr("app.runtime.config.settings.DOH_DOMAINS", "example.com")
monkeypatch.setattr("app.runtime.config.settings.DOH_RESOLVERS", "resolver.test")
monkeypatch.setattr(doh, "_doh_query", reload_during_query)
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
monkeypatch.setattr(doh.logger, "warning", warnings.append)
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None)
assert doh._doh_cache == {}
assert doh._doh_failure_until == {}
assert warnings == []
socket.getaddrinfo("example.com", None)
assert query_calls == ["resolver.test", "fixed.test"]
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
def test_doh_concurrent_failures_warn_once(monkeypatch):
"""
同一代次的并发查询全部失败时,只有首个记录冷却的查询输出告警。
"""
warnings = []
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
monkeypatch.setattr(doh.logger, "warning", warnings.append)
try:
assert helper.shutdown() is True
assert doh.enable_doh(True) is True
generation = doh._doh_generation
doh._remember_doh_result("example.com", None, generation)
doh._remember_doh_result("example.com", None, generation)
finally:
helper.shutdown()
socket.getaddrinfo = original_getaddrinfo
assert len(warnings) == 1