mihomo_tunnel.py 5.5 KB

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