test_proxy_pool.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. from pathlib import Path
  2. from core.proxy_pool import (
  3. ManagedProxy,
  4. ProxyLease,
  5. ProxyNode,
  6. ProxyPool,
  7. parse_clash_ss_nodes,
  8. parse_direct_proxy_urls,
  9. )
  10. def test_parse_clash_ss_nodes_respects_excludes_and_preferred_patterns(tmp_path: Path) -> None:
  11. config = tmp_path / "clash.yaml"
  12. config.write_text(
  13. """
  14. proxies:
  15. - { name: "新加坡优化-2", type: ss, server: "sg.bad", port: 1234, cipher: "aes-256-gcm", password: "pw" }
  16. - { name: "新加坡原生解锁-1", type: ss, server: "sg.good", port: 2345, cipher: "aes-256-gcm", password: "pw" }
  17. - { name: "★三网-日本备用", type: ss, server: "jp.good", port: 3456, cipher: "aes-256-gcm", password: "pw" }
  18. - { name: "台湾-三网备用", type: ss, server: "tw.good", port: 4567, cipher: "aes-256-gcm", password: "pw" }
  19. """.strip(),
  20. encoding="utf-8",
  21. )
  22. nodes = parse_clash_ss_nodes(
  23. config,
  24. ("sg", "jp", "tw"),
  25. exclude_names=("新加坡优化-2",),
  26. preferred_name_patterns=("新加坡原生解锁", "★三网-日本备用"),
  27. )
  28. assert [node.name for node in nodes] == [
  29. "新加坡原生解锁-1",
  30. "★三网-日本备用",
  31. "台湾-三网备用",
  32. ]
  33. def test_proxy_pool_cooldowns_node_after_repeated_device_id_failures() -> None:
  34. node = ProxyNode(name="sg-node", server="sg.good", port=1234, cipher="aes-256-gcm", password="pw", region="sg")
  35. pool = ProxyPool(nodes=[node], size=1, executable="/bin/true")
  36. managed = ManagedProxy(node=node, local_port=17891)
  37. pool._managed = [managed]
  38. pool._started = True
  39. lease = ProxyLease(name="sg-node", local_port=17891, proxy_url="socks5://127.0.0.1:17891")
  40. pool.release(lease, success=False, stage="device_id")
  41. pool.release(lease, success=False, stage="device_id")
  42. snapshot = pool.snapshot()
  43. assert snapshot[0]["device_id_failures"] == 2
  44. assert snapshot[0]["device_id_consecutive_failures"] == 2
  45. assert snapshot[0]["cooldown_reason"] == "device_id_failures"
  46. assert pool._available() == []
  47. def test_proxy_pool_prefers_nodes_with_better_device_id_history() -> None:
  48. bad_node = ProxyNode(name="tw-bad", server="tw.bad", port=1234, cipher="aes-256-gcm", password="pw", region="tw")
  49. good_node = ProxyNode(name="sg-good", server="sg.good", port=2345, cipher="aes-256-gcm", password="pw", region="sg")
  50. pool = ProxyPool(nodes=[bad_node, good_node], size=2, executable="/bin/true")
  51. pool._managed = [
  52. ManagedProxy(node=bad_node, local_port=17891, device_id_failures=2, device_id_consecutive_failures=1),
  53. ManagedProxy(node=good_node, local_port=17892, device_id_successes=3),
  54. ]
  55. pool._started = True
  56. available = pool._available()
  57. assert [item.node.name for item in available] == ["sg-good", "tw-bad"]
  58. def test_direct_proxy_parse_and_pool_start() -> None:
  59. direct_nodes = parse_direct_proxy_urls("socks5://1.2.3.4:1080;http://5.6.7.8:8080")
  60. pool = ProxyPool(nodes=[], direct_nodes=direct_nodes, size=2, executable=None)
  61. pool.start()
  62. lease = pool.acquire()
  63. assert lease.proxy_url in {"socks5://1.2.3.4:1080", "http://5.6.7.8:8080"}
  64. assert lease.local_port >= 17891
  65. pool.release(lease, success=True)
  66. snapshot = pool.snapshot()
  67. assert len(snapshot) == 2
  68. assert {item["proxy_url"] for item in snapshot} == {"socks5://1.2.3.4:1080", "http://5.6.7.8:8080"}
  69. assert snapshot[0]["disabled"] is False
  70. def test_direct_proxy_mixed_with_ss(monkeypatch) -> None:
  71. class DummyProcess:
  72. def poll(self) -> None:
  73. return None
  74. def terminate(self) -> None:
  75. return None
  76. def wait(self, timeout: float | None = None) -> int:
  77. del timeout
  78. return 0
  79. monkeypatch.setattr("core.proxy_pool.subprocess.Popen", lambda *args, **kwargs: DummyProcess())
  80. ss_node = ProxyNode(name="sg-node", server="sg.good", port=1234, cipher="aes-256-gcm", password="pw", region="sg")
  81. direct_nodes = parse_direct_proxy_urls("http://5.6.7.8:8080")
  82. pool = ProxyPool(nodes=[ss_node], direct_nodes=direct_nodes, size=2, executable="/usr/bin/sslocal")
  83. pool.start()
  84. first = pool.acquire()
  85. second = pool.acquire()
  86. urls = {first.proxy_url, second.proxy_url}
  87. assert "http://5.6.7.8:8080" in urls
  88. assert any(url.startswith("socks5://127.0.0.1:") for url in urls)
  89. pool.release(first, success=True)
  90. pool.release(second, success=True)
  91. def test_direct_proxy_invalid_url_skipped(capsys) -> None:
  92. nodes = parse_direct_proxy_urls("ftp://1.2.3.4:21;not-a-url;https://good.example:8443")
  93. captured = capsys.readouterr()
  94. assert [node.proxy_url for node in nodes] == ["https://good.example:8443"]
  95. assert "invalid direct proxy url" in captured.err
  96. def test_proxy_pool_acquire_prefers_requested_region() -> None:
  97. us_node = ProxyNode(name="us-node", server="us.good", port=1234, cipher="aes-256-gcm", password="pw", region="us")
  98. tw_node = ProxyNode(name="tw-node", server="tw.good", port=2345, cipher="aes-256-gcm", password="pw", region="tw")
  99. pool = ProxyPool(nodes=[us_node, tw_node], size=2, executable="/bin/true")
  100. pool._managed = [
  101. ManagedProxy(node=us_node, local_port=17891),
  102. ManagedProxy(node=tw_node, local_port=17892),
  103. ]
  104. pool._started = True
  105. lease = pool.acquire(preferred_regions=("tw",))
  106. assert lease.name == "tw-node"
  107. def test_proxy_pool_acquire_prefers_requested_name_over_region() -> None:
  108. us_node = ProxyNode(name="us-special", server="us.good", port=1234, cipher="aes-256-gcm", password="pw", region="us")
  109. tw_node = ProxyNode(name="tw-node", server="tw.good", port=2345, cipher="aes-256-gcm", password="pw", region="tw")
  110. pool = ProxyPool(nodes=[us_node, tw_node], size=2, executable="/bin/true")
  111. pool._managed = [
  112. ManagedProxy(node=us_node, local_port=17891),
  113. ManagedProxy(node=tw_node, local_port=17892),
  114. ]
  115. pool._started = True
  116. lease = pool.acquire(preferred_name="us-special", preferred_regions=("tw",))
  117. assert lease.name == "us-special"
  118. def test_proxy_pool_prefers_configured_name_patterns_within_region() -> None:
  119. ordinary = ProxyNode(name="台湾-移动备用-2", server="tw1.good", port=1234, cipher="aes-256-gcm", password="pw", region="tw")
  120. preferred = ProxyNode(name="台湾♣备用-1", server="tw2.good", port=2345, cipher="aes-256-gcm", password="pw", region="tw")
  121. pool = ProxyPool(
  122. nodes=[ordinary, preferred],
  123. size=2,
  124. executable="/bin/true",
  125. preferred_name_patterns=("台湾♣备用-1",),
  126. )
  127. pool._managed = [
  128. ManagedProxy(node=ordinary, local_port=17891),
  129. ManagedProxy(node=preferred, local_port=17892),
  130. ]
  131. pool._started = True
  132. lease = pool.acquire(preferred_regions=("tw",))
  133. assert lease.name == "台湾♣备用-1"