mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
535 lines
19 KiB
Python
535 lines
19 KiB
Python
"""受控外部网络探测应用服务。"""
|
||
|
||
from collections.abc import Callable, Mapping
|
||
from dataclasses import dataclass
|
||
from time import monotonic
|
||
from typing import Any, Optional, Protocol
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
from app.foundation.url import UrlUtils
|
||
|
||
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
||
_MAX_REDIRECTS = 3
|
||
|
||
|
||
class NetworkTestResponse(Protocol):
|
||
"""网络探测所需的最小异步响应合同。"""
|
||
|
||
status_code: int
|
||
headers: Mapping[str, str]
|
||
text: str
|
||
|
||
async def aclose(self) -> None:
|
||
"""释放响应占用的传输资源。"""
|
||
|
||
|
||
class NetworkTestTransport(Protocol):
|
||
"""网络探测使用的受限 HTTPS GET 端口。"""
|
||
|
||
async def get(
|
||
self,
|
||
url: str,
|
||
*,
|
||
proxy: Any = None,
|
||
headers: Optional[Mapping[str, str]] = None,
|
||
user_agent: Optional[str] = None,
|
||
) -> Optional[NetworkTestResponse]:
|
||
"""关闭自动重定向并校验证书后请求指定 HTTPS 地址。"""
|
||
|
||
|
||
class NetworkTestLogger(Protocol):
|
||
"""网络探测所需的最小日志端口。"""
|
||
|
||
def debug(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
||
"""记录不影响探测结果的诊断信息。"""
|
||
|
||
def warning(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
||
"""记录被安全策略阻断的网络行为。"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class NetworkTestTarget:
|
||
"""可安全返回给客户端的网络测试目标投影。"""
|
||
|
||
id: str
|
||
name: str
|
||
icon: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class NetworkTestRule:
|
||
"""仅由宿主构造、不会向客户端暴露的完整网络测试规则。"""
|
||
|
||
id: str
|
||
name: str
|
||
icon: str
|
||
url: str
|
||
proxy: bool
|
||
allowed_redirect_prefixes: tuple[str, ...]
|
||
expected_text: Optional[str] = None
|
||
invalid_message: Optional[str] = None
|
||
proxy_name: Optional[str] = None
|
||
headers: tuple[tuple[str, str], ...] = ()
|
||
|
||
def public_target(self) -> NetworkTestTarget:
|
||
"""投影客户端展示字段,避免泄露 URL、凭据和准入规则。"""
|
||
return NetworkTestTarget(id=self.id, name=self.name, icon=self.icon)
|
||
|
||
def request_headers(self) -> Optional[dict[str, str]]:
|
||
"""为单次请求创建与冻结规则隔离的请求头字典。"""
|
||
return dict(self.headers) or None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class NetworkTestResult:
|
||
"""网络测试应用结果,不包含 HTTP 响应层对象。"""
|
||
|
||
success: bool
|
||
message: Optional[str] = None
|
||
elapsed_ms: Optional[int] = None
|
||
|
||
|
||
SettingsReader = Callable[[str, Any], Any]
|
||
|
||
|
||
class NetworkTestService:
|
||
"""维护目标目录、安全准入并执行服务端受控连通性测试。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
transport: NetworkTestTransport,
|
||
settings: SettingsReader,
|
||
logger: NetworkTestLogger,
|
||
) -> None:
|
||
"""注入通用传输、部署设置读取和运行日志端口。"""
|
||
self._transport = transport
|
||
self._settings = settings
|
||
self._logger = logger
|
||
|
||
def list_targets(self) -> tuple[NetworkTestTarget, ...]:
|
||
"""返回不含请求细节的当前网络测试目标目录。"""
|
||
return tuple(rule.public_target() for rule in self._build_rules())
|
||
|
||
async def execute(
|
||
self,
|
||
*,
|
||
target_id: Optional[str] = None,
|
||
url: Optional[str] = None,
|
||
include: Optional[str] = None,
|
||
) -> NetworkTestResult:
|
||
"""解析内置目标、执行安全校验并返回连通性测试结果。"""
|
||
target = self._find_rule(target_id=target_id, url=url)
|
||
if target is None:
|
||
return NetworkTestResult(success=False, message="测试目标不存在")
|
||
invalid_message = self._validate_rule(target)
|
||
if invalid_message:
|
||
self._logger.warning(f"拦截不安全的网络测试地址: {target.url}")
|
||
return NetworkTestResult(success=False, message=invalid_message)
|
||
if include:
|
||
self._logger.debug("nettest include 参数已忽略,改为服务端固定校验")
|
||
return await self._request(target)
|
||
|
||
async def _request(self, target: NetworkTestRule) -> NetworkTestResult:
|
||
"""手动处理受控重定向并校验最终响应。"""
|
||
started_at = monotonic()
|
||
current_url = target.url
|
||
redirect_count = 0
|
||
response: Optional[NetworkTestResponse] = None
|
||
while redirect_count <= _MAX_REDIRECTS:
|
||
response = await self._transport.get(
|
||
current_url,
|
||
proxy=self._settings("PROXY", None) if target.proxy else None,
|
||
headers=target.request_headers(),
|
||
user_agent=self._settings("NORMAL_USER_AGENT", None),
|
||
)
|
||
if response is None or response.status_code not in _REDIRECT_STATUS_CODES:
|
||
break
|
||
location = response.headers.get("location")
|
||
if not location:
|
||
break
|
||
next_url = urljoin(current_url, location)
|
||
if not self._is_allowed_redirect(next_url, target):
|
||
await self._close_response(response)
|
||
self._logger.warning(
|
||
f"拦截网络测试重定向: {current_url} -> {next_url}"
|
||
)
|
||
return NetworkTestResult(
|
||
success=False,
|
||
message="测试目标发生了未授权跳转",
|
||
)
|
||
await self._close_response(response)
|
||
response = None
|
||
current_url = next_url
|
||
redirect_count += 1
|
||
|
||
elapsed_ms = round((monotonic() - started_at) * 1000)
|
||
if redirect_count > _MAX_REDIRECTS:
|
||
await self._close_response(response)
|
||
return NetworkTestResult(success=False, message="测试目标重定向次数过多")
|
||
if response is None:
|
||
label = target.proxy_name or target.name
|
||
return NetworkTestResult(
|
||
success=False,
|
||
message=f"{label}无法连接",
|
||
elapsed_ms=elapsed_ms,
|
||
)
|
||
|
||
try:
|
||
return self._build_response_result(target, response, elapsed_ms)
|
||
finally:
|
||
await self._close_response(response)
|
||
|
||
@staticmethod
|
||
def _build_response_result(
|
||
target: NetworkTestRule,
|
||
response: NetworkTestResponse,
|
||
elapsed_ms: int,
|
||
) -> NetworkTestResult:
|
||
"""把传输响应归一为稳定的应用结果。"""
|
||
if response.status_code == 200:
|
||
if target.expected_text and target.expected_text.lower() not in (
|
||
response.text or ""
|
||
).lower():
|
||
return NetworkTestResult(
|
||
success=False,
|
||
message=target.invalid_message or "无效响应",
|
||
elapsed_ms=elapsed_ms,
|
||
)
|
||
return NetworkTestResult(success=True, elapsed_ms=elapsed_ms)
|
||
if target.proxy_name:
|
||
message = f"{target.proxy_name}已失效,错误码:{response.status_code}"
|
||
else:
|
||
message = f"错误码:{response.status_code}"
|
||
if "github" in target.url:
|
||
if response.status_code == 401:
|
||
message = "Github Token已失效,请检查配置"
|
||
elif response.status_code in {403, 429}:
|
||
message = "触发限流,请配置Github Token"
|
||
return NetworkTestResult(
|
||
success=False,
|
||
message=message,
|
||
elapsed_ms=elapsed_ms,
|
||
)
|
||
|
||
def _find_rule(
|
||
self,
|
||
*,
|
||
target_id: Optional[str],
|
||
url: Optional[str],
|
||
) -> Optional[NetworkTestRule]:
|
||
"""优先按正式 target_id、否则按旧 URL 精确匹配内置规则。"""
|
||
rules = self._build_rules()
|
||
if target_id:
|
||
return next((rule for rule in rules if rule.id == target_id), None)
|
||
if url:
|
||
return next((rule for rule in rules if rule.url == url), None)
|
||
return None
|
||
|
||
def _validate_rule(self, target: NetworkTestRule) -> Optional[str]:
|
||
"""兜底校验服务端规则未生成非 HTTPS、带凭据或目录外地址。"""
|
||
parsed = urlparse(target.url)
|
||
if parsed.scheme.lower() != "https":
|
||
return "测试地址仅支持 HTTPS"
|
||
if not parsed.netloc:
|
||
return "测试地址无效"
|
||
if parsed.username or parsed.password:
|
||
return "测试地址不支持携带账号信息"
|
||
if not any(rule == target for rule in self._build_rules()):
|
||
return "测试地址不在允许的测试目标列表中"
|
||
return None
|
||
|
||
@classmethod
|
||
def _is_allowed_redirect(cls, url: str, target: NetworkTestRule) -> bool:
|
||
"""只允许重定向到当前测试项声明的协议、主机、端口和路径。"""
|
||
parsed = urlparse(url)
|
||
if parsed.scheme.lower() != "https" or not parsed.netloc:
|
||
return False
|
||
if parsed.username or parsed.password:
|
||
return False
|
||
return any(
|
||
cls._matches_prefix(url, prefix)
|
||
for prefix in target.allowed_redirect_prefixes
|
||
)
|
||
|
||
@staticmethod
|
||
def _matches_prefix(url: str, prefix: str) -> bool:
|
||
"""按协议、主机、有效端口和路径前缀匹配允许的跳转范围。"""
|
||
parsed_url = urlparse(url)
|
||
parsed_prefix = urlparse(prefix)
|
||
if parsed_url.scheme.lower() != parsed_prefix.scheme.lower():
|
||
return False
|
||
if (parsed_url.hostname or "").lower() != (
|
||
parsed_prefix.hostname or ""
|
||
).lower():
|
||
return False
|
||
try:
|
||
url_port = parsed_url.port or (
|
||
443 if parsed_url.scheme.lower() == "https" else 80
|
||
)
|
||
prefix_port = parsed_prefix.port or (
|
||
443 if parsed_prefix.scheme.lower() == "https" else 80
|
||
)
|
||
except ValueError:
|
||
return False
|
||
if url_port != prefix_port:
|
||
return False
|
||
prefix_path = parsed_prefix.path or "/"
|
||
if prefix_path.endswith("/"):
|
||
return parsed_url.path.startswith(prefix_path)
|
||
return parsed_url.path == prefix_path or parsed_url.path.startswith(
|
||
f"{prefix_path}/"
|
||
)
|
||
|
||
async def _close_response(
|
||
self,
|
||
response: Optional[NetworkTestResponse],
|
||
) -> None:
|
||
"""安静释放已读取响应,关闭失败不覆盖真实探测结果。"""
|
||
if response is None or not hasattr(response, "aclose"):
|
||
return
|
||
try:
|
||
await response.aclose()
|
||
except Exception as err: # noqa: BLE001 - 资源回收失败仅记录诊断
|
||
self._logger.debug(f"关闭网络测试响应失败: {err}")
|
||
|
||
def _build_rules(self) -> tuple[NetworkTestRule, ...]:
|
||
"""根据当前部署设置构建唯一网络测试目录和安全准入规则。"""
|
||
github_proxy = UrlUtils.standardize_base_url(
|
||
self._settings("GITHUB_PROXY", None) or ""
|
||
)
|
||
pip_proxy = UrlUtils.standardize_base_url(
|
||
self._settings("PIP_PROXY", None) or "https://pypi.org/simple/"
|
||
)
|
||
tmdb_key = self._settings("TMDB_API_KEY", None)
|
||
tmdb_domain = (
|
||
self._settings("TMDB_API_DOMAIN", None) or "api.themoviedb.org"
|
||
)
|
||
github_headers = self._freeze_headers(
|
||
self._settings("GITHUB_HEADERS", None)
|
||
)
|
||
github_readme_url = "https://github.com/jxxghp/MoviePilot/blob/v2/README.md"
|
||
raw_readme_url = (
|
||
"https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md"
|
||
)
|
||
rules = [
|
||
NetworkTestRule(
|
||
id="tmdb_api",
|
||
name="api.themoviedb.org",
|
||
icon="tmdb",
|
||
url=f"https://api.themoviedb.org/3/movie/550?api_key={tmdb_key}",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=("https://api.themoviedb.org/3/",),
|
||
),
|
||
NetworkTestRule(
|
||
id="tmdb_api_alt",
|
||
name="api.tmdb.org",
|
||
icon="tmdb",
|
||
url=f"https://api.tmdb.org/3/movie/550?api_key={tmdb_key}",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=("https://api.tmdb.org/3/",),
|
||
),
|
||
NetworkTestRule(
|
||
id="tmdb_web",
|
||
name="www.themoviedb.org",
|
||
icon="tmdb",
|
||
url="https://www.themoviedb.org",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=("https://www.themoviedb.org/",),
|
||
),
|
||
NetworkTestRule(
|
||
id="tvdb_api",
|
||
name="api.thetvdb.com",
|
||
icon="tvdb",
|
||
url="https://api.thetvdb.com/series/81189",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=("https://api.thetvdb.com/",),
|
||
),
|
||
NetworkTestRule(
|
||
id="fanart_api",
|
||
name="webservice.fanart.tv",
|
||
icon="fanart",
|
||
url="https://webservice.fanart.tv",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=("https://webservice.fanart.tv/",),
|
||
),
|
||
NetworkTestRule(
|
||
id="telegram_api",
|
||
name="api.telegram.org",
|
||
icon="telegram",
|
||
url="https://api.telegram.org",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=(
|
||
"https://api.telegram.org/",
|
||
"https://core.telegram.org/",
|
||
),
|
||
),
|
||
NetworkTestRule(
|
||
id="wechat_api",
|
||
name="qyapi.weixin.qq.com",
|
||
icon="wechat",
|
||
url="https://qyapi.weixin.qq.com/cgi-bin/gettoken",
|
||
proxy=False,
|
||
allowed_redirect_prefixes=("https://qyapi.weixin.qq.com/",),
|
||
),
|
||
NetworkTestRule(
|
||
id="douban_api",
|
||
name="frodo.douban.com",
|
||
icon="douban",
|
||
url="https://frodo.douban.com",
|
||
proxy=False,
|
||
allowed_redirect_prefixes=(
|
||
"https://frodo.douban.com/",
|
||
"https://www.douban.com/doubanapp/frodo",
|
||
),
|
||
),
|
||
NetworkTestRule(
|
||
id="slack_api",
|
||
name="slack.com",
|
||
icon="slack",
|
||
url="https://slack.com",
|
||
proxy=False,
|
||
allowed_redirect_prefixes=(
|
||
"https://slack.com/",
|
||
"https://www.slack.com/",
|
||
),
|
||
),
|
||
NetworkTestRule(
|
||
id="pip_proxy",
|
||
name="pypi.org",
|
||
icon="python",
|
||
url=f"{pip_proxy}rsa/",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=(
|
||
pip_proxy,
|
||
"https://pypi.org/simple/",
|
||
),
|
||
expected_text="pypi:repository-version",
|
||
invalid_message="PIP加速代理已失效,请检查配置",
|
||
proxy_name="PIP加速代理",
|
||
),
|
||
NetworkTestRule(
|
||
id="github_proxy_web",
|
||
name="github.com",
|
||
icon="github",
|
||
url=(
|
||
f"{github_proxy}{github_readme_url}"
|
||
if github_proxy
|
||
else github_readme_url
|
||
),
|
||
proxy=True,
|
||
allowed_redirect_prefixes=(
|
||
"https://github.com/",
|
||
*(
|
||
(f"{github_proxy}https://github.com/",)
|
||
if github_proxy
|
||
else ()
|
||
),
|
||
),
|
||
expected_text="MoviePilot",
|
||
invalid_message=(
|
||
"Github加速代理已失效,请检查配置"
|
||
if github_proxy
|
||
else "无效响应"
|
||
),
|
||
proxy_name="Github加速代理" if github_proxy else None,
|
||
headers=github_headers,
|
||
),
|
||
NetworkTestRule(
|
||
id="github_api",
|
||
name="api.github.com",
|
||
icon="github",
|
||
url="https://api.github.com",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=("https://api.github.com/",),
|
||
headers=github_headers,
|
||
),
|
||
NetworkTestRule(
|
||
id="github_codeload",
|
||
name="codeload.github.com",
|
||
icon="github",
|
||
url="https://codeload.github.com",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=(
|
||
"https://codeload.github.com/",
|
||
"https://github.com/",
|
||
),
|
||
headers=github_headers,
|
||
),
|
||
NetworkTestRule(
|
||
id="github_proxy_raw",
|
||
name="raw.githubusercontent.com",
|
||
icon="github",
|
||
url=(
|
||
f"{github_proxy}{raw_readme_url}"
|
||
if github_proxy
|
||
else raw_readme_url
|
||
),
|
||
proxy=True,
|
||
allowed_redirect_prefixes=(
|
||
"https://raw.githubusercontent.com/",
|
||
*(
|
||
(f"{github_proxy}https://raw.githubusercontent.com/",)
|
||
if github_proxy
|
||
else ()
|
||
),
|
||
),
|
||
expected_text="MoviePilot",
|
||
invalid_message=(
|
||
"Github加速代理已失效,请检查配置"
|
||
if github_proxy
|
||
else "无效响应"
|
||
),
|
||
proxy_name="Github加速代理" if github_proxy else None,
|
||
headers=github_headers,
|
||
),
|
||
]
|
||
if tmdb_domain not in {"api.themoviedb.org", "api.tmdb.org"}:
|
||
rules.insert(
|
||
2,
|
||
NetworkTestRule(
|
||
id="tmdb_api_configured",
|
||
name=str(tmdb_domain),
|
||
icon="tmdb",
|
||
url=f"https://{tmdb_domain}/3/movie/550?api_key={tmdb_key}",
|
||
proxy=True,
|
||
allowed_redirect_prefixes=(
|
||
f"https://{tmdb_domain}/3/",
|
||
),
|
||
),
|
||
)
|
||
return tuple(rules)
|
||
|
||
@staticmethod
|
||
def _freeze_headers(value: Any) -> tuple[tuple[str, str], ...]:
|
||
"""把动态配置中的请求头复制为不可变、可比较的规则字段。"""
|
||
if not isinstance(value, Mapping):
|
||
return ()
|
||
return tuple(
|
||
(str(key), str(item))
|
||
for key, item in value.items()
|
||
if item is not None
|
||
)
|
||
|
||
|
||
_configured_network_test_service: Optional[NetworkTestService] = None
|
||
|
||
|
||
def configure_network_test_service(service: NetworkTestService) -> None:
|
||
"""由启动组合根登记唯一的网络测试应用服务。"""
|
||
global _configured_network_test_service
|
||
_configured_network_test_service = service
|
||
|
||
|
||
def reset_network_test_service() -> None:
|
||
"""清除当前 lifespan 的网络测试应用服务。"""
|
||
global _configured_network_test_service
|
||
_configured_network_test_service = None
|
||
|
||
|
||
def get_configured_network_test_service() -> NetworkTestService:
|
||
"""返回启动阶段登记的网络测试应用服务。"""
|
||
if _configured_network_test_service is None:
|
||
raise RuntimeError("网络测试服务尚未装配")
|
||
return _configured_network_test_service
|