ssh_tunnel.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import argparse
  4. import os
  5. import sys
  6. import subprocess
  7. import socket
  8. import time
  9. import shutil
  10. from pathlib import Path
  11. DEFAULT_HOST = "117.50.195.224"
  12. DEFAULT_USER = "root"
  13. DEFAULT_PORT = 29765
  14. DEFAULT_SOCKS_HOST = "127.0.0.1"
  15. DEFAULT_SOCKS_PORT = 1066
  16. # Windows creation flags for detached background process
  17. CREATE_NO_WINDOW = 0x08000000
  18. DETACHED_PROCESS = 0x00000008
  19. CREATE_NEW_PROCESS_GROUP = 0x00000200
  20. def which_ssh():
  21. ssh_path = shutil.which("ssh")
  22. if ssh_path is None:
  23. print("未找到 ssh 可执行文件。请在系统中安装 OpenSSH 客户端并确保 ssh 在 PATH 中。", file=sys.stderr)
  24. sys.exit(2)
  25. return ssh_path
  26. def check_port_free(host, port):
  27. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  28. s.settimeout(0.5)
  29. try:
  30. s.bind((host, port))
  31. s.close()
  32. return True
  33. except OSError:
  34. return False
  35. def wait_for_listen(host, port, timeout=10.0):
  36. # 等待本地端口开始监听(大概率说明隧道已建立)
  37. deadline = time.time() + timeout
  38. while time.time() < deadline:
  39. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  40. s.settimeout(0.5)
  41. try:
  42. if s.connect_ex((host, port)) == 0:
  43. return True
  44. except OSError:
  45. pass
  46. time.sleep(0.2)
  47. return False
  48. def ensure_dir(p: Path):
  49. p.mkdir(parents=True, exist_ok=True)
  50. if not os.access(p, os.W_OK):
  51. print(f"目录不可写: {p}", file=sys.stderr)
  52. sys.exit(2)
  53. def default_control_path(host, port):
  54. base = Path.home() / ".ssh"
  55. ensure_dir(base)
  56. # 控制套接字文件名尽量短
  57. name = f"cm-{host}-{port}"
  58. cpath = base / name
  59. # Windows AF_UNIX 路径不要太长
  60. if len(str(cpath)) > 100:
  61. print(f"警告:控制套接字路径过长,可能导致失败: {cpath}", file=sys.stderr)
  62. return str(cpath)
  63. def build_keepalive_opts(alive_interval=60, alive_count=3):
  64. return [
  65. "-o", f"ServerAliveInterval={alive_interval}",
  66. "-o", f"ServerAliveCountMax={alive_count}",
  67. ]
  68. def start_tunnel(args):
  69. ssh = which_ssh()
  70. # 提前检查端口是否被占用
  71. if not args.force and not check_port_free(args.socks_host, args.socks_port):
  72. print(f"本地端口 {args.socks_host}:{args.socks_port} 已被占用。使用 --force 忽略或更换端口。", file=sys.stderr)
  73. sys.exit(1)
  74. cmd = [
  75. ssh,
  76. "-N",
  77. "-D", f"{args.socks_host}:{args.socks_port}",
  78. "-p", str(args.port),
  79. ] + build_keepalive_opts(args.alive_interval, args.alive_count)
  80. if args.strict_no:
  81. cmd += ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
  82. target = f"{args.user}@{args.host}"
  83. cmd.append(target)
  84. print("启动本地 SSH 隧道(非复用模式):")
  85. print(" ".join(cmd))
  86. # 在 Windows 上用无窗口/脱离控制台方式运行
  87. if os.name == "nt":
  88. flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
  89. with open(os.devnull, "wb") as devnull:
  90. proc = subprocess.Popen(
  91. cmd,
  92. stdin=devnull,
  93. stdout=devnull,
  94. stderr=devnull,
  95. creationflags=flags,
  96. close_fds=True,
  97. )
  98. print(f"已启动,PID={proc.pid}。尝试等待端口监听...")
  99. ok = wait_for_listen(args.socks_host, args.socks_port, timeout=args.wait_timeout)
  100. if ok:
  101. print(f"SOCKS 已监听于 {args.socks_host}:{args.socks_port}")
  102. else:
  103. print("未检测到端口监听。若使用密码认证,后台无法输入密码,请改用密钥或前台启动。")
  104. # 记录 pid 文件
  105. pidfile = pid_file_path(args)
  106. ensure_dir(pidfile.parent)
  107. pidfile.write_text(str(proc.pid), encoding="utf-8")
  108. print(f"PID 已写入 {pidfile}")
  109. else:
  110. # 在非 Windows 系统,使用 -f 让 ssh 后台运行(如果你跨平台使用)
  111. cmd.insert(1, "-f")
  112. subprocess.run(cmd, check=True)
  113. print("已启动。")
  114. def pid_file_path(args):
  115. base = Path.home() / ".ssh" / "tunnels"
  116. fname = f"{args.host}-{args.port}-D{args.socks_host.replace('.', '_')}_{args.socks_port}.pid"
  117. return base / fname
  118. def stop_tunnel(args):
  119. # 通过 pidfile 强制停止非复用模式进程
  120. pidfile = pid_file_path(args)
  121. if not pidfile.exists():
  122. print(f"未找到 PID 文件:{pidfile}。如果你是用复用模式启动,请用 stop-mux。", file=sys.stderr)
  123. sys.exit(1)
  124. try:
  125. pid = int(pidfile.read_text(encoding="utf-8").strip())
  126. except Exception as e:
  127. print(f"读取 PID 文件失败:{e}", file=sys.stderr)
  128. sys.exit(1)
  129. if os.name == "nt":
  130. # 使用 taskkill 结束
  131. r = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True)
  132. if r.returncode == 0:
  133. print(f"已结束进程 PID={pid}")
  134. try:
  135. pidfile.unlink()
  136. except Exception:
  137. pass
  138. else:
  139. print(f"结束进程失败:{r.stdout}\n{r.stderr}", file=sys.stderr)
  140. sys.exit(r.returncode)
  141. else:
  142. try:
  143. os.kill(pid, 15)
  144. print(f"已发送 SIGTERM 给 PID={pid}")
  145. pidfile.unlink(missing_ok=True)
  146. except Exception as e:
  147. print(f"结束失败:{e}", file=sys.stderr)
  148. sys.exit(1)
  149. def status_tunnel(args):
  150. # 仅检查本地 SOCKS 端口是否在监听
  151. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  152. s.settimeout(0.3)
  153. try:
  154. if s.connect_ex((args.socks_host, args.socks_port)) == 0:
  155. print(f"监听中:{args.socks_host}:{args.socks_port}")
  156. else:
  157. print(f"未监听:{args.socks_host}:{args.socks_port}")
  158. finally:
  159. s.close()
  160. def start_mux(args):
  161. ssh = which_ssh()
  162. cpath = args.control_path or default_control_path(args.host, args.port)
  163. # 确保控制路径所在目录存在
  164. ensure_dir(Path(cpath).expanduser().resolve().parent)
  165. cmd = [
  166. ssh,
  167. "-M",
  168. "-S", cpath,
  169. "-N",
  170. "-D", f"{args.socks_host}:{args.socks_port}",
  171. "-p", str(args.port),
  172. ] + build_keepalive_opts(args.alive_interval, args.alive_count)
  173. if args.strict_no:
  174. cmd += ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
  175. target = f"{args.user}@{args.host}"
  176. cmd.append(target)
  177. print("启动本地 SSH 隧道(连接复用模式):")
  178. print(" ".join(cmd))
  179. if os.name == "nt":
  180. flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
  181. with open(os.devnull, "wb") as devnull:
  182. proc = subprocess.Popen(
  183. cmd,
  184. stdin=devnull,
  185. stdout=devnull,
  186. stderr=devnull,
  187. creationflags=flags,
  188. close_fds=True,
  189. )
  190. print(f"已启动(复用主进程),PID={proc.pid}。等待端口监听...")
  191. ok = wait_for_listen(args.socks_host, args.socks_port, timeout=args.wait_timeout)
  192. if ok:
  193. print(f"SOCKS 已监听于 {args.socks_host}:{args.socks_port}")
  194. else:
  195. print("未检测到端口监听。若使用密码认证,后台无法输入密码,请改用密钥或前台启动。")
  196. else:
  197. # 非 Windows 可以用 -f,但复用主连接一般不建议 -f,使用后台方式或系统化管理更稳妥
  198. subprocess.Popen(cmd)
  199. def stop_mux(args):
  200. ssh = which_ssh()
  201. cpath = args.control_path or default_control_path(args.host, args.port)
  202. cmd = [
  203. ssh,
  204. "-S", cpath,
  205. "-O", "exit",
  206. "-p", str(args.port),
  207. f"{args.user}@{args.host}",
  208. ]
  209. print("关闭复用主连接:")
  210. print(" ".join(cmd))
  211. r = subprocess.run(cmd, capture_output=True, text=True)
  212. if r.returncode == 0:
  213. print(r.stdout.strip() or "已请求关闭。")
  214. else:
  215. # 有的版本输出在 stderr
  216. out = (r.stdout + "\n" + r.stderr).strip()
  217. print(f"关闭失败:{out}", file=sys.stderr)
  218. sys.exit(r.returncode)
  219. def status_mux(args):
  220. ssh = which_ssh()
  221. cpath = args.control_path or default_control_path(args.host, args.port)
  222. cmd = [
  223. ssh,
  224. "-S", cpath,
  225. "-O", "check",
  226. "-p", str(args.port),
  227. f"{args.user}@{args.host}",
  228. ]
  229. r = subprocess.run(cmd, capture_output=True, text=True)
  230. if r.returncode == 0:
  231. print(r.stdout.strip() or "Master running")
  232. else:
  233. out = (r.stdout + "\n" + r.stderr).strip()
  234. print(out or "Not running")
  235. sys.exit(r.returncode)
  236. def parse_args():
  237. p = argparse.ArgumentParser(description="Windows 上启动/管理 SSH 本地 SOCKS 隧道的小工具")
  238. sub = p.add_subparsers(dest="cmd", required=True)
  239. def add_common(sp):
  240. sp.add_argument("-H", "--host", default=DEFAULT_HOST, help="SSH 服务器地址")
  241. sp.add_argument("-u", "--user", default=DEFAULT_USER, help="SSH 用户名")
  242. sp.add_argument("-p", "--port", type=int, default=DEFAULT_PORT, help="SSH 端口")
  243. sp.add_argument("--socks-host", default=DEFAULT_SOCKS_HOST, help="本地 SOCKS 监听地址")
  244. sp.add_argument("-D", "--socks-port", type=int, default=DEFAULT_SOCKS_PORT, help="本地 SOCKS 监听端口")
  245. sp.add_argument("--alive-interval", type=int, default=60, help="ServerAliveInterval")
  246. sp.add_argument("--alive-count", type=int, default=5, help="ServerAliveCountMax")
  247. sp.add_argument("--strict-no", action="store_true", help="临时关闭主机指纹检查(不安全)")
  248. sp.add_argument("--wait-timeout", type=float, default=10.0, help="启动后等待监听的秒数")
  249. return sp
  250. add_common(sub.add_parser("start", help="启动隧道(非复用)")).add_argument("--force", action="store_true", help="忽略本地端口占用检查")
  251. add_common(sub.add_parser("stop", help="停止隧道(通过 pid 文件)"))
  252. add_common(sub.add_parser("status", help="查看本地 SOCKS 端口是否在监听"))
  253. sp_mux_start = add_common(sub.add_parser("start-mux", help="启动隧道(连接复用模式)"))
  254. sp_mux_start.add_argument("-S", "--control-path", default=None, help="控制套接字路径(默认:~/.ssh/cm-<host>-<port>)")
  255. sp_mux_stop = add_common(sub.add_parser("stop-mux", help="关闭复用主连接"))
  256. sp_mux_stop.add_argument("-S", "--control-path", default=None, help="控制套接字路径")
  257. sp_mux_status = add_common(sub.add_parser("status-mux", help="查看复用主连接状态"))
  258. sp_mux_status.add_argument("-S", "--control-path", default=None, help="控制套接字路径")
  259. return p.parse_args()
  260. def main():
  261. args = parse_args()
  262. if args.cmd == "start":
  263. start_tunnel(args)
  264. elif args.cmd == "stop":
  265. stop_tunnel(args)
  266. elif args.cmd == "status":
  267. status_tunnel(args)
  268. elif args.cmd == "start-mux":
  269. start_mux(args)
  270. elif args.cmd == "stop-mux":
  271. stop_mux(args)
  272. elif args.cmd == "status-mux":
  273. status_mux(args)
  274. else:
  275. print("未知命令", file=sys.stderr)
  276. sys.exit(2)
  277. if __name__ == "__main__":
  278. main()
  279. # 使用示例
  280. # 服务器端需要先开通免密登录
  281. # 启动非复用隧道(相当于 ssh -N -D 127.0.0.1:1066 -p 29765 root@117.50.195.224,并包含存活探测):
  282. # python ssh_tunnel.py start -H 117.50.195.224 -u root -p 29765 -D 1066
  283. # python ssh_tunnel.py start -H 106.14.113.12 -u root -p 29765 -D 1067
  284. # python ssh_tunnel.py start -H 103.40.13.87 -u root -p 40268 -D 1061
  285. # 查看本地 SOCKS 端口是否监听: python ssh_tunnel.py status -D 1066
  286. # 停止非复用隧道(通过 PID 文件): python ssh_tunnel.py stop -H 117.50.195.224 -u root -p 29765 -D 1066