proxy_pool.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. """SS-only proxy pool for zhuce6 registration workers."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from datetime import datetime
  5. from pathlib import Path
  6. import shutil
  7. import socket
  8. import subprocess
  9. import threading
  10. import time
  11. from typing import Any
  12. from urllib.parse import urlparse
  13. import yaml
  14. SKIP_NAME_MARKERS = (
  15. "流量",
  16. "续费",
  17. "到期",
  18. "订阅",
  19. "官网",
  20. "客服",
  21. "购买",
  22. "套餐",
  23. "说明",
  24. )
  25. REGION_ALIASES: dict[str, tuple[str, ...]] = {
  26. "sg": ("sg", "singapore", "新加坡"),
  27. "hk": ("hk", "hong kong", "香港"),
  28. "jp": ("jp", "japan", "日本"),
  29. "us": ("us", "usa", "united states", "美国"),
  30. "tw": ("tw", "taiwan", "台湾"),
  31. }
  32. DEVICE_ID_FAIL_COOLDOWN_SECONDS = 600
  33. @dataclass(frozen=True)
  34. class ProxyNode:
  35. name: str
  36. server: str
  37. port: int
  38. cipher: str
  39. password: str
  40. region: str
  41. @dataclass(frozen=True)
  42. class DirectProxyNode:
  43. name: str
  44. proxy_url: str
  45. region: str = "direct"
  46. @dataclass(frozen=True)
  47. class ProxyLease:
  48. name: str
  49. local_port: int
  50. proxy_url: str
  51. @dataclass
  52. class ManagedProxy:
  53. node: ProxyNode | DirectProxyNode
  54. local_port: int
  55. process: subprocess.Popen[Any] | None = None
  56. in_use: bool = False
  57. disabled: bool = False
  58. successes: int = 0
  59. failures: int = 0
  60. consecutive_failures: int = 0
  61. device_id_successes: int = 0
  62. device_id_failures: int = 0
  63. device_id_consecutive_failures: int = 0
  64. cooldown_until: float | None = None
  65. cooldown_reason: str = ""
  66. last_error: str = ""
  67. last_checked_at: float | None = None
  68. @property
  69. def proxy_url(self) -> str:
  70. if isinstance(self.node, DirectProxyNode):
  71. return self.node.proxy_url
  72. return f"socks5://127.0.0.1:{self.local_port}"
  73. def _normalize_region_name(raw: str) -> str:
  74. text = raw.strip().lower()
  75. for region, aliases in REGION_ALIASES.items():
  76. if any(alias in text for alias in aliases):
  77. return region
  78. return "other"
  79. def _should_skip_name(name: str) -> bool:
  80. lowered = name.strip().lower()
  81. return any(marker.lower() in lowered for marker in SKIP_NAME_MARKERS)
  82. def _matches_any_name(name: str, patterns: tuple[str, ...]) -> bool:
  83. lowered = name.strip().lower()
  84. return any(pattern.strip().lower() in lowered for pattern in patterns if pattern.strip())
  85. def parse_clash_ss_nodes(
  86. config_path: str | Path,
  87. preferred_regions: tuple[str, ...] = (),
  88. *,
  89. exclude_names: tuple[str, ...] = (),
  90. preferred_name_patterns: tuple[str, ...] = (),
  91. ) -> list[ProxyNode]:
  92. payload = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) or {}
  93. proxies = payload.get("proxies") if isinstance(payload, dict) else []
  94. items = proxies if isinstance(proxies, list) else []
  95. nodes: list[ProxyNode] = []
  96. for item in items:
  97. if not isinstance(item, dict):
  98. continue
  99. if str(item.get("type") or "").strip().lower() != "ss":
  100. continue
  101. name = str(item.get("name") or "").strip()
  102. if not name or _should_skip_name(name):
  103. continue
  104. if _matches_any_name(name, exclude_names):
  105. continue
  106. server = str(item.get("server") or "").strip()
  107. cipher = str(item.get("cipher") or "").strip()
  108. password = str(item.get("password") or "").strip()
  109. try:
  110. port = int(item.get("port") or 0)
  111. except (TypeError, ValueError):
  112. port = 0
  113. if not server or not cipher or not password or port <= 0:
  114. continue
  115. nodes.append(
  116. ProxyNode(
  117. name=name,
  118. server=server,
  119. port=port,
  120. cipher=cipher,
  121. password=password,
  122. region=_normalize_region_name(name),
  123. )
  124. )
  125. region_order = {region: index for index, region in enumerate(preferred_regions)}
  126. return sorted(
  127. nodes,
  128. key=lambda node: (
  129. 0 if _matches_any_name(node.name, preferred_name_patterns) else 1,
  130. region_order.get(node.region, 999),
  131. node.name.lower(),
  132. ),
  133. )
  134. def parse_direct_proxy_urls(raw: str) -> list[DirectProxyNode]:
  135. nodes: list[DirectProxyNode] = []
  136. seen_names: set[str] = set()
  137. for index, chunk in enumerate(str(raw or "").split(";"), start=1):
  138. proxy_url = chunk.strip()
  139. if not proxy_url:
  140. continue
  141. parsed = urlparse(proxy_url)
  142. if parsed.scheme not in {"http", "https", "socks4", "socks5"} or not parsed.hostname or parsed.port is None:
  143. print(f"[proxy_pool] invalid direct proxy url skipped: {proxy_url}", flush=True, file=__import__("sys").stderr)
  144. continue
  145. base_name = f"direct-{parsed.hostname}:{parsed.port}"
  146. name = base_name
  147. if name in seen_names:
  148. name = f"{base_name}-{index}"
  149. seen_names.add(name)
  150. nodes.append(DirectProxyNode(name=name, proxy_url=proxy_url))
  151. return nodes
  152. def _detect_ss_local_binary() -> str | None:
  153. return shutil.which("sslocal") or shutil.which("ss-local")
  154. def _find_open_port(start: int = 17891) -> int:
  155. port = start
  156. while port < 65535:
  157. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
  158. try:
  159. sock.bind(("127.0.0.1", port))
  160. return port
  161. except OSError:
  162. port += 1
  163. raise RuntimeError("no free local port available for proxy pool")
  164. class ProxyPool:
  165. def __init__(
  166. self,
  167. *,
  168. nodes: list[ProxyNode],
  169. direct_nodes: list[DirectProxyNode] | None = None,
  170. size: int = 6,
  171. preferred_regions: tuple[str, ...] = (),
  172. preferred_name_patterns: tuple[str, ...] = (),
  173. executable: str | None = None,
  174. ) -> None:
  175. self.nodes = list(nodes)
  176. self.direct_nodes = list(direct_nodes or [])
  177. self._all_nodes: list[ProxyNode | DirectProxyNode] = [*self.nodes, *self.direct_nodes]
  178. self.size = max(1, size)
  179. self.preferred_regions = preferred_regions
  180. self.preferred_name_patterns = tuple(
  181. pattern for pattern in preferred_name_patterns if str(pattern).strip()
  182. )
  183. self.executable = executable or _detect_ss_local_binary()
  184. self._managed: list[ManagedProxy] = []
  185. self._used_node_names: set[str] = set()
  186. self._next_local_port = 17891
  187. self._lock = threading.RLock()
  188. self._cond = threading.Condition(self._lock)
  189. self._started = False
  190. @classmethod
  191. def from_settings(cls, settings: Any) -> "ProxyPool" | None:
  192. config_path = getattr(settings, "proxy_pool_config", None)
  193. direct_urls = str(getattr(settings, "proxy_pool_direct_urls", "") or "").strip()
  194. if not config_path and not direct_urls:
  195. return None
  196. nodes: list[ProxyNode] = []
  197. if config_path:
  198. nodes = parse_clash_ss_nodes(
  199. config_path,
  200. getattr(settings, "proxy_pool_regions", ()),
  201. exclude_names=tuple(getattr(settings, "proxy_pool_exclude_names", ())),
  202. preferred_name_patterns=tuple(getattr(settings, "proxy_pool_preferred_patterns", ())),
  203. )
  204. direct_nodes = parse_direct_proxy_urls(direct_urls)
  205. if not nodes and not direct_nodes:
  206. return None
  207. return cls(
  208. nodes=nodes,
  209. direct_nodes=direct_nodes,
  210. size=int(getattr(settings, "proxy_pool_size", 6)),
  211. preferred_regions=tuple(getattr(settings, "proxy_pool_regions", ())),
  212. preferred_name_patterns=tuple(getattr(settings, "proxy_pool_preferred_patterns", ())),
  213. )
  214. def _command(self, node: ProxyNode, local_port: int) -> list[str]:
  215. if not self.executable:
  216. raise RuntimeError("ss-local executable not found")
  217. is_rust = self.executable.endswith("sslocal")
  218. if is_rust:
  219. return [
  220. self.executable,
  221. "-s", f"{node.server}:{node.port}",
  222. "-b", f"127.0.0.1:{local_port}",
  223. "-k", node.password,
  224. "-m", node.cipher,
  225. "-U",
  226. ]
  227. return [
  228. self.executable,
  229. "-s", node.server,
  230. "-p", str(node.port),
  231. "-l", str(local_port),
  232. "-k", node.password,
  233. "-m", node.cipher,
  234. "-b", "127.0.0.1",
  235. "-u",
  236. ]
  237. def start(self) -> None:
  238. with self._cond:
  239. if self._started:
  240. return
  241. self._managed = []
  242. self._used_node_names = set()
  243. self._next_local_port = 17891
  244. target = min(self.size, len(self._all_nodes))
  245. while len(self._managed) < target:
  246. if not self._spawn_next_node():
  247. break
  248. if not self._managed and self.nodes and not self.executable:
  249. raise RuntimeError("ss-local executable not found")
  250. self._started = True
  251. def _ensure_started(self) -> None:
  252. if not self._started:
  253. self.start()
  254. def _available(
  255. self,
  256. *,
  257. preferred_name: str | None = None,
  258. preferred_regions: tuple[str, ...] = (),
  259. ) -> list[ManagedProxy]:
  260. candidates: list[ManagedProxy] = []
  261. now = time.time()
  262. preferred_name_norm = str(preferred_name or "").strip().lower()
  263. region_order = {
  264. str(region or "").strip().lower(): index
  265. for index, region in enumerate(preferred_regions)
  266. if str(region or "").strip()
  267. }
  268. for item in self._managed:
  269. process = item.process
  270. if process is not None and process.poll() is not None:
  271. item.disabled = True
  272. item.last_error = f"process exited with code {process.poll()}"
  273. if item.cooldown_until is not None and item.cooldown_until <= now:
  274. item.cooldown_until = None
  275. item.cooldown_reason = ""
  276. if item.disabled or item.in_use:
  277. continue
  278. if item.cooldown_until is not None and item.cooldown_until > now:
  279. continue
  280. candidates.append(item)
  281. return sorted(
  282. candidates,
  283. key=lambda item: (
  284. 0 if preferred_name_norm and item.node.name.strip().lower() == preferred_name_norm else 1,
  285. 0 if _matches_any_name(item.node.name, self.preferred_name_patterns) else 1,
  286. region_order.get(str(item.node.region or "").strip().lower(), 999),
  287. item.device_id_consecutive_failures > 0,
  288. -(item.device_id_successes - item.device_id_failures),
  289. item.device_id_failures,
  290. item.failures >= 3,
  291. -(item.successes - item.failures),
  292. item.failures,
  293. item.node.name.lower(),
  294. ),
  295. )
  296. def _spawn_matching_node(
  297. self,
  298. *,
  299. preferred_name: str | None = None,
  300. preferred_regions: tuple[str, ...] = (),
  301. ) -> bool:
  302. preferred_name_norm = str(preferred_name or "").strip().lower()
  303. region_set = {
  304. str(region or "").strip().lower()
  305. for region in preferred_regions
  306. if str(region or "").strip()
  307. }
  308. for node in self._all_nodes:
  309. if node.name in self._used_node_names:
  310. continue
  311. node_name_norm = node.name.strip().lower()
  312. if preferred_name_norm and node_name_norm == preferred_name_norm:
  313. return self._spawn_specific_node(node)
  314. for node in self._all_nodes:
  315. if node.name in self._used_node_names:
  316. continue
  317. node_region_norm = str(node.region or "").strip().lower()
  318. if region_set and node_region_norm not in region_set:
  319. continue
  320. if _matches_any_name(node.name, self.preferred_name_patterns):
  321. return self._spawn_specific_node(node)
  322. for node in self._all_nodes:
  323. if node.name in self._used_node_names:
  324. continue
  325. node_region_norm = str(node.region or "").strip().lower()
  326. if region_set and node_region_norm in region_set:
  327. return self._spawn_specific_node(node)
  328. return False
  329. def _spawn_specific_node(self, node: ProxyNode | DirectProxyNode) -> bool:
  330. if node.name in self._used_node_names:
  331. return False
  332. if isinstance(node, DirectProxyNode):
  333. local_port = self._next_local_port
  334. self._next_local_port += 1
  335. process = None
  336. else:
  337. if not self.executable:
  338. return False
  339. local_port = _find_open_port(self._next_local_port)
  340. self._next_local_port = local_port + 1
  341. process = subprocess.Popen( # noqa: S603
  342. self._command(node, local_port),
  343. stdout=subprocess.DEVNULL,
  344. stderr=subprocess.DEVNULL,
  345. )
  346. self._managed.append(
  347. ManagedProxy(
  348. node=node,
  349. local_port=local_port,
  350. process=process,
  351. last_checked_at=time.time(),
  352. )
  353. )
  354. self._used_node_names.add(node.name)
  355. return True
  356. def _spawn_next_node(self) -> bool:
  357. for node in self._all_nodes:
  358. if self._spawn_specific_node(node):
  359. return True
  360. return False
  361. def acquire(
  362. self,
  363. timeout: float = 5.0,
  364. *,
  365. preferred_name: str | None = None,
  366. preferred_regions: tuple[str, ...] = (),
  367. ) -> ProxyLease:
  368. deadline = time.time() + timeout
  369. with self._cond:
  370. self._ensure_started()
  371. while True:
  372. available = self._available(
  373. preferred_name=preferred_name,
  374. preferred_regions=preferred_regions,
  375. )
  376. if available:
  377. item = available[0]
  378. item.in_use = True
  379. item.last_checked_at = time.time()
  380. return ProxyLease(
  381. name=item.node.name,
  382. local_port=item.local_port,
  383. proxy_url=item.proxy_url,
  384. )
  385. self._spawn_matching_node(
  386. preferred_name=preferred_name,
  387. preferred_regions=preferred_regions,
  388. )
  389. remaining = deadline - time.time()
  390. if remaining <= 0:
  391. raise RuntimeError("no proxy available in pool")
  392. self._cond.wait(timeout=min(0.2, remaining))
  393. def release(self, lease: ProxyLease, *, success: bool | None, stage: str | None = None) -> None:
  394. with self._cond:
  395. for item in self._managed:
  396. if item.node.name != lease.name or item.local_port != lease.local_port:
  397. continue
  398. item.in_use = False
  399. item.last_checked_at = time.time()
  400. stage_key = str(stage or "").strip().lower()
  401. if success is True:
  402. item.successes += 1
  403. item.consecutive_failures = 0
  404. item.device_id_successes += 1
  405. item.device_id_consecutive_failures = 0
  406. item.cooldown_until = None
  407. item.cooldown_reason = ""
  408. elif success is False:
  409. item.failures += 1
  410. item.consecutive_failures += 1
  411. if stage_key == "device_id":
  412. item.device_id_failures += 1
  413. item.device_id_consecutive_failures += 1
  414. if item.device_id_consecutive_failures >= 2:
  415. item.cooldown_until = time.time() + DEVICE_ID_FAIL_COOLDOWN_SECONDS
  416. item.cooldown_reason = "device_id_failures"
  417. item.last_error = "cooldown after repeated device_id failures"
  418. process = item.process
  419. if process is not None and process.poll() is not None:
  420. item.disabled = True
  421. item.last_error = f"process exited with code {process.poll()}"
  422. if (
  423. success is False
  424. and not item.disabled
  425. and item.successes == 0
  426. and item.consecutive_failures >= 3
  427. ):
  428. item.disabled = True
  429. item.last_error = "disabled after repeated proxy-stage failures"
  430. if process is not None and process.poll() is None:
  431. process.terminate()
  432. try:
  433. process.wait(timeout=2)
  434. except subprocess.TimeoutExpired:
  435. process.kill()
  436. process.wait(timeout=2)
  437. self._spawn_next_node()
  438. self._cond.notify_all()
  439. return
  440. def close(self) -> None:
  441. with self._cond:
  442. for item in self._managed:
  443. process = item.process
  444. if process is None:
  445. continue
  446. if process.poll() is None:
  447. process.terminate()
  448. try:
  449. process.wait(timeout=2)
  450. except subprocess.TimeoutExpired:
  451. process.kill()
  452. process.wait(timeout=2)
  453. item.in_use = False
  454. self._started = False
  455. self._cond.notify_all()
  456. def snapshot(self) -> list[dict[str, Any]]:
  457. with self._lock:
  458. return [
  459. {
  460. "name": item.node.name,
  461. "region": item.node.region,
  462. "proxy_url": item.proxy_url,
  463. "local_port": item.local_port,
  464. "in_use": item.in_use,
  465. "disabled": item.disabled,
  466. "successes": item.successes,
  467. "failures": item.failures,
  468. "consecutive_failures": item.consecutive_failures,
  469. "device_id_successes": item.device_id_successes,
  470. "device_id_failures": item.device_id_failures,
  471. "device_id_consecutive_failures": item.device_id_consecutive_failures,
  472. "cooldown_until": (
  473. datetime.fromtimestamp(item.cooldown_until).isoformat(timespec="seconds")
  474. if item.cooldown_until
  475. else None
  476. ),
  477. "cooldown_reason": item.cooldown_reason,
  478. "last_error": item.last_error,
  479. }
  480. for item in self._managed
  481. ]