mihomo_tunnel.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import os
  2. import time
  3. import socket
  4. import subprocess
  5. import yaml
  6. import tempfile
  7. import shutil
  8. import urllib.request
  9. class MihomoTunnel:
  10. """
  11. Manage local proxy tunnels using Mihomo (Clash Meta).
  12. Supports multi-instance concurrency with isolated temp directories.
  13. Adapted for the new 'dialer-proxy' chain routing mechanism.
  14. """
  15. def __init__(self, mihomo_bin_path, exit_node, relay_node=None, enable_log=False):
  16. self.mihomo_bin_path = os.path.abspath(mihomo_bin_path)
  17. self.exit_node = exit_node
  18. self.relay_node = relay_node
  19. self.enable_log = enable_log
  20. self.process = None
  21. self.local_port = 0
  22. self.api_port = 0
  23. self.log_file_obj = None
  24. self.temp_dir = tempfile.mkdtemp(prefix="mihomo_tunnel_")
  25. self.config_path = os.path.join(self.temp_dir, "config.yaml")
  26. self.log_path = os.path.join(self.temp_dir, "run.log")
  27. def _get_free_port(self):
  28. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  29. s.bind(('127.0.0.1', 0))
  30. return s.getsockname()[1]
  31. def _generate_config(self):
  32. self.local_port = self._get_free_port()
  33. self.api_port = self._get_free_port()
  34. config = {
  35. "allow-lan": False,
  36. "bind-address": "127.0.0.1",
  37. "log-level": "warning" if self.enable_log else "silent",
  38. "mixed-port": self.local_port,
  39. "external-controller": f"127.0.0.1:{self.api_port}",
  40. "mode": "rule",
  41. "ipv6": False,
  42. "dns": {
  43. "enable": False
  44. },
  45. "proxies": [],
  46. "rules": []
  47. }
  48. # Deep copy to avoid modifying the original dictionary
  49. # in case it is reused by other instances.
  50. exit_node_config = dict(self.exit_node)
  51. # Handle proxy chaining using the new 'dialer-proxy' feature
  52. if self.relay_node:
  53. # 1. Add relay node to proxies
  54. config["proxies"].append(self.relay_node)
  55. # 2. Tell the exit node to dial through the relay node
  56. exit_node_config["dialer-proxy"] = self.relay_node["name"]
  57. # 3. Add exit node to proxies
  58. config["proxies"].append(exit_node_config)
  59. # 4. Route all traffic to the exit node
  60. config["rules"].append(f"MATCH,{exit_node_config['name']}")
  61. with open(self.config_path, 'w', encoding='utf-8') as f:
  62. yaml.dump(config, f, allow_unicode=False, sort_keys=False)
  63. def start(self):
  64. if not os.path.exists(self.mihomo_bin_path):
  65. raise FileNotFoundError(f"Mihomo binary not found: {self.mihomo_bin_path}")
  66. self._generate_config()
  67. if self.enable_log:
  68. self.log_file_obj = open(self.log_path, 'w', encoding='utf-8')
  69. stdout_target = self.log_file_obj
  70. else:
  71. stdout_target = subprocess.DEVNULL
  72. try:
  73. self.process = subprocess.Popen(
  74. [self.mihomo_bin_path, "-d", self.temp_dir, "-f", self.config_path],
  75. stdout=stdout_target,
  76. stderr=subprocess.STDOUT,
  77. cwd=self.temp_dir
  78. )
  79. time.sleep(1.5)
  80. if self.process.poll() is not None:
  81. error_msg = "Mihomo failed to start and exited immediately."
  82. if self.enable_log:
  83. self.log_file_obj.close()
  84. with open(self.log_path, 'r', encoding='utf-8') as f:
  85. error_msg += f"\nLog:\n{f.read()}"
  86. raise RuntimeError(error_msg)
  87. return f"127.0.0.1:{self.local_port}"
  88. except Exception as e:
  89. self.stop()
  90. raise e
  91. def cut_all_connections(self):
  92. """
  93. [新增方法]
  94. 通过 Mihomo API 强制切断当前代理池的所有活跃连接。
  95. 常用于检测到 IP 被封锁或切换代理线路时,立即打断正在卡死/挂起的旧请求。
  96. """
  97. if not self.process or self.process.poll() is not None:
  98. return False
  99. try:
  100. url = f"http://127.0.0.1:{self.api_port}/connections"
  101. req = urllib.request.Request(url, method="DELETE")
  102. with urllib.request.urlopen(req, timeout=3) as response:
  103. return response.status in (200, 204)
  104. except Exception as e:
  105. if self.enable_log:
  106. print(f"[MihomoTunnel] Failed to cut connections: {e}")
  107. return False
  108. def stop(self):
  109. if self.process and self.process.poll() is None:
  110. self.process.terminate()
  111. try:
  112. self.process.wait(timeout=3)
  113. except subprocess.TimeoutExpired:
  114. self.process.kill()
  115. self.process = None
  116. if self.log_file_obj and not self.log_file_obj.closed:
  117. self.log_file_obj.close()
  118. if os.path.exists(self.temp_dir):
  119. try:
  120. shutil.rmtree(self.temp_dir, ignore_errors=True)
  121. except Exception:
  122. pass
  123. def __del__(self):
  124. self.stop()
  125. # ================= TEST SCRIPT =================
  126. if __name__ == "__main__":
  127. MIHOMO_EXE = "E:/coordinator/mihomo-windows-amd64-alpha-98aa7e6/mihomo-windows-amd64.exe" if os.name == 'nt' else "./mihomo"
  128. exit_node = {
  129. "name": "ExitNode",
  130. "type": "http",
  131. "server": "46.203.77.84",
  132. "port": 41588,
  133. "username": "nKX6cH1jvBWJlFZ",
  134. "password": "vySUmO3VMCKkYDS"
  135. }
  136. relay_node = {
  137. "name": "RelayNode",
  138. "type": "ss",
  139. "server": "odko6qmr.mobilfunk.top",
  140. "port": 12012,
  141. "cipher": "aes-128-gcm",
  142. "password": "haMLMXirByn6rGVh",
  143. "plugin": "obfs",
  144. "plugin-opts": {
  145. "mode": "http",
  146. "host": "c0f69828a7c1.microsoft.com"
  147. },
  148. "udp": True
  149. }
  150. print("Starting Mihomo Tunnel...")
  151. # Enable log to False for production (no disk writing, no console output)
  152. tunnel = MihomoTunnel(MIHOMO_EXE, exit_node, relay_node, enable_log=True)
  153. try:
  154. local_proxy = tunnel.start()
  155. print(f"Success! Proxy mapped to: {local_proxy}")
  156. while True:
  157. time.sleep(10)
  158. except KeyboardInterrupt:
  159. print("\nStopping tunnel...")
  160. except Exception as e:
  161. print(f"\nError: {e}")
  162. finally:
  163. tunnel.stop()
  164. print("Tunnel closed and temp files cleaned.")