"""SSH 隧道引擎:基于 paramiko 的本地端口转发,含自动重连与状态事件。""" from __future__ import annotations import select import socket import threading import time from collections import deque import paramiko _POLL = 0.5 # 监听 socket 的 accept 轮询间隔 _KEEPALIVE = 15 # SSH 保活秒数 def friendly_error(e: Exception) -> str: if isinstance(e, paramiko.BadHostKeyException): return "主机密钥校验失败:该主机的主机密钥与已记录的不一致(主机可能被重装)。" if isinstance(e, paramiko.AuthenticationException): return "SSH 认证失败:请检查用户名、密码或私钥。" if isinstance(e, paramiko.SSHException): msg = str(e) if "negotiation" in msg or "timed out" in msg or "Timeout" in msg: return "SSH 握手超时:远程主机无响应。" if "not a valid" in msg.lower() or ("key" in msg.lower() and "invalid" in msg.lower()): return f"密钥无效:{msg}" return f"SSH 错误:{msg}" if isinstance(e, socket.gaierror): return f"无法解析主机名:{getattr(e, 'args', [''])[0]}" if isinstance(e, ConnectionRefusedError): return "连接被拒绝:远程主机的 SSH 端口不可达(服务未运行或防火墙拦截)。" if isinstance(e, (socket.timeout, TimeoutError)): return "连接超时:远程主机无响应。" winerr = getattr(e, "winerror", None) if winerr == 10048: return "本地端口绑定失败:该端口已被其他程序占用。" if winerr == 10013: return "本地端口绑定失败:权限不足(可换一个 >1024 的端口)。" return str(e) or e.__class__.__name__ class Tunnel: """一条本地转发隧道。生命周期:stopped → connecting → connected ⇄ error。""" def __init__(self, cfg: dict, host: dict | None, emit, known_hosts_path): self.cfg = dict(cfg) self.host = dict(host) if host else None self._emit = emit self.known_hosts_path = known_hosts_path self.status = "stopped" self.error = "" self.started_at: float | None = None self.conn_count = 0 self.logs: deque = deque(maxlen=200) self._desired = False self._thread: threading.Thread | None = None self._client: paramiko.SSHClient | None = None self._listener: socket.socket | None = None self._wakeup = threading.Event() # ---------- 状态 ---------- def snapshot(self) -> dict: return { "id": self.cfg["id"], "status": self.status, "error": self.error, "started_at": self.started_at, "conn_count": self.conn_count, } def _log(self, level: str, msg: str) -> None: entry = {"t": time.strftime("%H:%M:%S"), "level": level, "msg": msg} self.logs.append(entry) try: self._emit({"type": "log", "tunnel_id": self.cfg["id"], "entry": entry}) except Exception: pass def _set(self, status: str, error: str = "") -> None: self.status = status self.error = error try: self._emit({"type": "tunnel", **self.snapshot()}) except Exception: pass # ---------- 控制 ---------- def start(self) -> None: if self._desired: return if not self.host: self._set("error", "隧道未绑定有效的主机") return self._desired = True self._wakeup.clear() self._set("connecting") self._thread = threading.Thread( target=self._run, daemon=True, name=f"tunnel-{self.cfg['id']}" ) self._thread.start() def stop(self) -> None: self._desired = False self._wakeup.set() listener, self._listener = self._listener, None if listener: try: listener.close() except OSError: pass client, self._client = self._client, None if client: try: client.close() except Exception: pass thread = self._thread if thread and thread.is_alive() and thread is not threading.current_thread(): thread.join(timeout=3) self._set("stopped") self.started_at = None # ---------- 主循环 ---------- def _run(self) -> None: backoff = 1.0 while self._desired: try: self._serve_once() if not self._desired: return # 连接意外断开 self._log("warn", "SSH 连接已断开") if not self.cfg.get("autoreconnect", True): self._set("error", "连接已断开(未开启自动重连)") return self._set("connecting", "连接断开,正在重连") except Exception as e: # noqa: BLE001 if not self._desired: return msg = friendly_error(e) self._log("error", msg) if not self.cfg.get("autoreconnect", True): self._set("error", msg) return self._set("connecting", msg) self._wakeup.wait(backoff) backoff = min(backoff * 2, 15) self._set("stopped") def _serve_once(self) -> None: host = self.host cfg = self.cfg client = paramiko.SSHClient() self._client = client try: if self.known_hosts_path.exists(): client.load_host_keys(str(self.known_hosts_path)) else: self.known_hosts_path.write_text("", encoding="utf-8") client.load_host_keys(str(self.known_hosts_path)) client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) port = int(host.get("port") or 22) kw = dict( hostname=host["hostname"], port=port, username=host.get("username"), timeout=12, banner_timeout=12, auth_timeout=12, allow_agent=False, look_for_keys=False, ) if host.get("auth") == "key": kw["key_filename"] = host.get("key_path") or None if host.get("key_passphrase"): kw["passphrase"] = host["key_passphrase"] else: kw["password"] = host.get("password") self._log("info", f"正在连接 {host.get('username')}@{host['hostname']}:{port} ...") client.connect(**kw) try: client.save_host_keys(str(self.known_hosts_path)) except Exception: pass transport = client.get_transport() transport.set_keepalive(_KEEPALIVE) local_port = int(cfg["local_port"]) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", local_port)) listener.listen(32) listener.settimeout(_POLL) self._listener = listener self.started_at = time.time() self._set("connected") self._log("info", f"隧道已建立: 127.0.0.1:{local_port} → {cfg['remote_host']}:{cfg['remote_port']}") while self._desired and transport.is_active(): try: sock, _addr = listener.accept() except socket.timeout: continue threading.Thread(target=self._pipe, args=(sock, transport), daemon=True).start() finally: listener = self._listener self._listener = None if listener: try: listener.close() except OSError: pass try: client.close() except Exception: pass self._client = None def _pipe(self, sock: socket.socket, transport) -> None: cfg = self.cfg self.conn_count += 1 self._set("connected") # 顺带刷新连接计数 dest = (cfg["remote_host"], int(cfg["remote_port"])) try: try: chan = transport.open_channel("direct-tcpip", dest, sock.getpeername(), timeout=10) except Exception as e: self._log("warn", f"打开远程通道失败 ({dest[0]}:{dest[1]}): {friendly_error(e)}") return if chan is None: self._log("warn", f"远程拒绝了到 {dest[0]}:{dest[1]} 的转发请求") return self._pump(sock, chan) finally: for c in (sock,): try: c.close() except OSError: pass @staticmethod def _pump(sock: socket.socket, chan) -> None: try: while True: r, _, _ = select.select([sock, chan], [], [], 1.0) if not r: if chan.closed or chan.eof_received: break continue if sock in r: data = sock.recv(65536) if not data: break chan.sendall(data) if chan in r: data = chan.recv(65536) if not data: break sock.sendall(data) except Exception: pass finally: try: chan.close() except Exception: pass try: sock.close() except OSError: pass def _opsig(cfg: dict) -> tuple: return (cfg.get("host_id"), int(cfg.get("local_port") or 0), cfg.get("remote_host"), int(cfg.get("remote_port") or 0), bool(cfg.get("autoreconnect", True))) def _hostsig(h: dict | None) -> tuple: if not h: return () return (h.get("hostname"), h.get("port"), h.get("username"), h.get("auth"), h.get("password"), h.get("key_path"), h.get("key_passphrase")) class TunnelManager: def __init__(self, emit, data_dir): self._emit = emit self.known_hosts_path = data_dir / "known_hosts" self.tunnels: dict[str, Tunnel] = {} def apply_config(self, tunnels_cfg: list, hosts_by_id: dict) -> None: alive_ids = {t["id"] for t in tunnels_cfg} for tid in list(self.tunnels): if tid not in alive_ids: self.tunnels.pop(tid).stop() for cfg in tunnels_cfg: host = hosts_by_id.get(cfg.get("host_id")) cur = self.tunnels.get(cfg["id"]) if cur is None: tun = Tunnel(cfg, host, self._emit, self.known_hosts_path) self.tunnels[cfg["id"]] = tun if cfg.get("autostart"): tun.start() else: changed = (_opsig(cfg) != _opsig(cur.cfg) or _hostsig(host) != _hostsig(cur.host)) cur.cfg = dict(cfg) cur.host = dict(host) if host else None if changed and cur.status != "stopped": cur.stop() cur.start() def stop_all(self, *_args) -> None: for tun in list(self.tunnels.values()): tun.stop() def probe_host(host: dict, known_hosts_path) -> tuple[bool, str]: """测试一台主机的连通性与认证,不影响隧道。""" client = paramiko.SSHClient() try: if known_hosts_path.exists(): client.load_host_keys(str(known_hosts_path)) client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) port = int(host.get("port") or 22) kw = dict( hostname=host["hostname"], port=port, username=host.get("username"), timeout=8, banner_timeout=8, auth_timeout=8, allow_agent=False, look_for_keys=False, ) if host.get("auth") == "key": kw["key_filename"] = host.get("key_path") or None if host.get("key_passphrase"): kw["passphrase"] = host["key_passphrase"] else: kw["password"] = host.get("password") client.connect(**kw) try: client.save_host_keys(str(known_hosts_path)) except Exception: pass version = client.get_transport().remote_version return True, f"认证成功 · 服务端 {version}" except Exception as e: # noqa: BLE001 return False, friendly_error(e) finally: try: client.close() except Exception: pass