mihomo_tunnel.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. "proxies": [],
  39. "rules": []
  40. }
  41. # Deep copy to avoid modifying the original dictionary
  42. # in case it is reused by other instances.
  43. exit_node_config = dict(self.exit_node)
  44. # Handle proxy chaining using the new 'dialer-proxy' feature
  45. if self.relay_node:
  46. # 1. Add relay node to proxies
  47. config["proxies"].append(self.relay_node)
  48. # 2. Tell the exit node to dial through the relay node
  49. exit_node_config["dialer-proxy"] = self.relay_node["name"]
  50. # 3. Add exit node to proxies
  51. config["proxies"].append(exit_node_config)
  52. # 4. Route all traffic to the exit node
  53. config["rules"].append(f"MATCH,{exit_node_config['name']}")
  54. with open(self.config_path, 'w', encoding='utf-8') as f:
  55. yaml.dump(config, f, allow_unicode=False, sort_keys=False)
  56. def start(self):
  57. if not os.path.exists(self.mihomo_bin_path):
  58. raise FileNotFoundError(f"Mihomo binary not found: {self.mihomo_bin_path}")
  59. self._generate_config()
  60. if self.enable_log:
  61. self.log_file_obj = open(self.log_path, 'w', encoding='utf-8')
  62. stdout_target = self.log_file_obj
  63. else:
  64. stdout_target = subprocess.DEVNULL
  65. try:
  66. self.process = subprocess.Popen(
  67. [self.mihomo_bin_path, "-d", self.temp_dir, "-f", self.config_path],
  68. stdout=stdout_target,
  69. stderr=subprocess.STDOUT,
  70. cwd=self.temp_dir
  71. )
  72. time.sleep(1.5)
  73. if self.process.poll() is not None:
  74. error_msg = "Mihomo failed to start and exited immediately."
  75. if self.enable_log:
  76. self.log_file_obj.close()
  77. with open(self.log_path, 'r', encoding='utf-8') as f:
  78. error_msg += f"\nLog:\n{f.read()}"
  79. raise RuntimeError(error_msg)
  80. return f"127.0.0.1:{self.local_port}"
  81. except Exception as e:
  82. self.stop()
  83. raise e
  84. def stop(self):
  85. if self.process and self.process.poll() is None:
  86. self.process.terminate()
  87. try:
  88. self.process.wait(timeout=3)
  89. except subprocess.TimeoutExpired:
  90. self.process.kill()
  91. self.process = None
  92. if self.log_file_obj and not self.log_file_obj.closed:
  93. self.log_file_obj.close()
  94. if os.path.exists(self.temp_dir):
  95. try:
  96. shutil.rmtree(self.temp_dir, ignore_errors=True)
  97. except Exception:
  98. pass
  99. def __del__(self):
  100. self.stop()
  101. # ================= TEST SCRIPT =================
  102. if __name__ == "__main__":
  103. MIHOMO_EXE = "E:/coordinator/mihomo-windows-amd64-alpha-98aa7e6/mihomo-windows-amd64.exe" if os.name == 'nt' else "./mihomo"
  104. exit_node = {
  105. "name": "ExitNode",
  106. "type": "http",
  107. "server": "46.203.77.84",
  108. "port": 41588,
  109. "username": "nKX6cH1jvBWJlFZ",
  110. "password": "vySUmO3VMCKkYDS"
  111. }
  112. relay_node = {
  113. "name": "RelayNode",
  114. "type": "ss",
  115. "server": "odko6qmr.mobilfunk.top",
  116. "port": 12012,
  117. "cipher": "aes-128-gcm",
  118. "password": "haMLMXirByn6rGVh",
  119. "plugin": "obfs",
  120. "plugin-opts": {
  121. "mode": "http",
  122. "host": "c0f69828a7c1.microsoft.com"
  123. },
  124. "udp": True
  125. }
  126. print("Starting Mihomo Tunnel...")
  127. # Enable log to False for production (no disk writing, no console output)
  128. tunnel = MihomoTunnel(MIHOMO_EXE, exit_node, relay_node, enable_log=True)
  129. try:
  130. local_proxy = tunnel.start()
  131. print(f"Success! Proxy mapped to: {local_proxy}")
  132. while True:
  133. time.sleep(10)
  134. except KeyboardInterrupt:
  135. print("\nStopping tunnel...")
  136. except Exception as e:
  137. print(f"\nError: {e}")
  138. finally:
  139. tunnel.stop()
  140. print("Tunnel closed and temp files cleaned.")