From 8ec9db69b538c2f3566ab69a35623fc457931f71 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 20 Sep 2026 03:39:56 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20jump=20=E8=B7=B3=E6=9D=BF=E6=9C=BA?= =?UTF-8?q?=E3=80=81=E6=8E=A2=E9=92=88=E2=80=9C=E6=9C=AA=E7=9F=A5=E2=80=9D?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=B8=8E=20SSH=20=E5=91=BD=E4=BB=A4=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这几处改动在源码里互相咬合——core.py 的命令构建重构既服务于 jump,也服务于改写成 有界读取的探针;health/serve/daemon/doctor 的“未知”语义又依赖 core 抛出的 ProbeError ——拆成多个提交只会得到“新开头 + 旧结尾”的半成品,因此一并提交。 - [ssh] jump:堡垒机原样交给 ssh -J,跳板机身份复用 ~/.ssh/config;与 options 里的 ProxyJump/ProxyCommand 同时出现会被直接拒绝;doctor 只探测本机能直连的第一跳。 - [ssh] user / identity_file 可省略:省略即不再强制 user@ 与 -i;新增 ponte config --ssh-command 打印实际执行命令行。 - ponte reload:只重启设置真的变过的隧道,坏配置在本地就被拒绝,POSIX 下 kill -HUP 等价。 - 探针没跑成 = 未知:不再报成“端口未监听”,也不再计入假死强杀阈值;/healthz 回 200 unverified,status / doctor / 看板显示未知并附原因;确凿失败则回推原因。 - 探针在 Windows 上不再永久挂死:SSH 子进程恢复 close_fds,探针读取双重限时。 - stop/status/logs/watch 在配置写坏时仍可用;restart 仍先校验再动手。 - 启动失败不再只有“未写 PID 文件”:捕获子进程首行输出,包内旧配置的工作目录也修正。 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- _smoke_test.py | 16 +- ponte/config.example.toml | 19 +- ponte/config.py | 227 ++++++++++++++++++- ponte/core.py | 224 +++++++++++++------ ponte/daemon.py | 446 +++++++++++++++++++++++++++++++++++--- ponte/doctor.py | 109 +++++++++- ponte/health.py | 71 +++++- ponte/main.py | 168 ++++++++++++-- ponte/serve.py | 131 +++++++++-- tests/test_config.py | 200 +++++++++++++++++ tests/test_core.py | 232 +++++++++++++++++--- tests/test_daemon.py | 378 +++++++++++++++++++++++++++++++- tests/test_doctor.py | 106 ++++++++- tests/test_health.py | 71 +++++- tests/test_main.py | 187 ++++++++++++++++ tests/test_serve.py | 97 +++++++++ 16 files changed, 2483 insertions(+), 199 deletions(-) diff --git a/_smoke_test.py b/_smoke_test.py index 22f9010..29904f6 100644 --- a/_smoke_test.py +++ b/_smoke_test.py @@ -40,6 +40,10 @@ def check_remote_ports(self): ... def build_args(self): ... pkg3.TunnelManager = _TM +class ProbeError(RuntimeError): + """探针没跑成 → 状态未知(stub:名字必须与 ponte.core 保持同步)。""" +pkg3.ProbeError = ProbeError + sys.modules["ponte"] = pkg sys.modules["ponte.config"] = pkg2 sys.modules["ponte.core"] = pkg3 @@ -143,15 +147,17 @@ def __init__(self, alive): self.alive = alive def poll(self): return None if self.alive else 1 class TM2: - def __init__(self, alive=True, ports="dict", fail_ports=False): + def __init__(self, alive=True, ports="dict", fail_ports=False, probe_error=None): self._proc = Proc(alive) self.ports = ports self.fail_ports = fail_ports + self.probe_error = probe_error self._timeout = None @property def process(self): return self._proc def check_remote_ports(self, **kw): self._timeout = kw.get("timeout") + if self.probe_error is not None: raise ProbeError(self.probe_error) if self.fail_ports: raise ConnectionError("refused") if self.ports == "dict": return {23334: True, 17897: False} if self.ports == "list": return [23334, 17897] @@ -182,6 +188,14 @@ def check_remote_ports(self, **kw): assert s4.all_healthy is False and s4.error is not None and "ConnectionError" in s4.error print("health: port check failure ->", s4.error) +# 探测连接失败 = 未知:不得报成“端口未监听”,且必须可判定为“不确定”。 +hc6 = health.HealthChecker(TM2(probe_error="ssh 退出码 255"), _HC(60, True, 10)) +s6 = hc6.check() +assert s6.remote_ports == {} and s6.all_healthy is False, s6 +assert s6.remote_probe_error and "255" in s6.remote_probe_error +assert s6.conclusive is False and "unknown" in str(s6) +print("health: unanswered probe is unknown OK ->", s6.remote_probe_error) + hc5 = health.HealthChecker(TM2(alive=True, ports="bad"), _HC(60, False, 10)) s5 = hc5.check() assert s5.remote_ports == {} and s5.all_healthy is True, s5 diff --git a/ponte/config.example.toml b/ponte/config.example.toml index e701686..53f2c93 100644 --- a/ponte/config.example.toml +++ b/ponte/config.example.toml @@ -16,13 +16,29 @@ # 所有路径支持 ~ 与环境变量展开。 [ssh] +# host 是唯一必填项。user / identity_file / port 留空(或删掉)时,ponte +# 不再强行拼出 user@ 与 -i,而是交给 OpenSSH 自己解析——也就是你 ~/.ssh/config +# 里的 Host 别名、User、IdentityFile、Port,或者 ssh-agent 里的身份。 +# 所以“ssh myserver 已经能用”的机器,这里只写 host = "myserver" 就够了。 host = "YOUR_SERVER_IP" port = 22 user = "YOUR_SSH_USER" -# 按各平台 SSH 惯例放密钥即可(~ 自动展开);如需自定义路径请直接改这里 +# 按各平台 SSH 惯例放密钥即可(~ 自动展开);如需自定义路径请直接改这里。 +# 若 ~/.ssh/config 已配置 IdentityFile,删掉这一行即可复用,不必重复一遍。 identity_file = "~/.ssh/id_rsa" known_hosts_file = "~/.ssh/known_hosts" +# 跳板机 / 堡垒机:服务器只能从跳板机那侧(内网)访问时填写。 +# 写法就是 ssh 命令行的 ProxyJump,ponte 原样交给 ssh -J,不自己转发: +# jump = "bastion.example.com" 只用主机名 +# jump = "ops@bastion.example.com" 指定跳板机上的登录用户 +# jump = "ops@bastion.example.com:2222" 跳板机 SSH 不是 22 端口 +# jump = "ops@hop1, ops@hop2" 多跳(逗号分隔),挨个排下去 +# jump = "ops@bastion.example.com" +# 跳板机自己的密钥与用户同样交给 ~/.ssh/config(给堡垒机写一个 Host 块即可), +# 所以这里不需要也不提供第二份 identity_file。列了 jump 就别再在 [ssh.options] +# 里写 ProxyJump / ProxyCommand,两者会被拒绝(它们说的是同一件事)。 + [ssh.options] StrictHostKeyChecking = "accept-new" ServerAliveInterval = 30 @@ -84,6 +100,7 @@ description = "示例:把远端 23334 转发到本地 2222" # host = "YOUR_SERVER_IP" # user = "YOUR_SSH_USER" # identity_file = "~/.ssh/id_rsa" +# jump = "ops@bastion.example.com" # 可选,每条 profile 各自的跳板机 # [[profiles.tunnels]] # remote_port = 8080 # local_host = "127.0.0.1" diff --git a/ponte/config.py b/ponte/config.py index 8753fb6..5b7563c 100644 --- a/ponte/config.py +++ b/ponte/config.py @@ -41,6 +41,7 @@ "TunnelConfig", "SSHConfig", "SSHOptions", + "JumpHop", "Tunnel", "TUNNEL_FLAGS", "DEFAULT_BIND_HOST", @@ -57,6 +58,7 @@ "is_loopback_host", "get_config", "load_config", + "daemon_paths_from_file", "set_config_path", "clear_config_cache", "config_search_paths", @@ -260,21 +262,97 @@ def destination(self) -> str: return self.ssh.destination +@dataclass(frozen=True) +class JumpHop: + """One hop of a jump chain — a machine ssh connects *through*. + + Reaching a server that only a bastion can see used to mean hand-writing + ``ProxyJump`` into ``[ssh.options]``: undiscoverable, unvalidated, and + invisible to ``ponte doctor``. A hop is a first-class setting now, but a + *parsed* one rather than a raw string, because naming the first hop is what + lets doctor probe it (and report "the bastion is down" instead of "login + failed"). + """ + + host: str + user: str = "" + port: int = 22 + + @property + def destination(self) -> str: + """The hop as ``user@host``, or just ``host`` when no user is set.""" + return f"{self.user}@{self.host}" if self.user else self.host + + def render(self) -> str: + """This hop in OpenSSH ``ProxyJump`` syntax (``[user@]host[:port]``). + + The port is omitted when it is 22 (the default ssh assumes) and IPv6 + literals are bracketed, so the string handed to ``-J`` is what the user + would have typed themselves. + """ + host = f"[{self.host}]" if ":" in self.host else self.host + rendered = f"{self.user}@{host}" if self.user else host + return rendered if self.port == 22 else f"{rendered}:{self.port}" + + @dataclass(frozen=True) class SSHConfig: - """Connection parameters for the SSH endpoint.""" + """Connection parameters for the SSH endpoint. + + ``host`` is the only required field. ``user`` and ``identity_file`` are + optional *on purpose*: when they are omitted ponte stops forcing ``-i`` and + ``user@`` onto the command line, so OpenSSH resolves them itself from + ``~/.ssh/config``, an ssh-agent identity, or its default key locations. + That means a machine with a working ``ssh myserver`` does not have to + duplicate Host/User/IdentityFile into ponte's config, and ponte stops + overriding an ``IdentityFile`` the user set in their SSH config. + + ``jumps`` describes how to *reach* ``host`` when it is not directly + reachable — the private-network case, where a bastion sits in front of the + machine you actually want. It is passed to OpenSSH as ``-J`` (the same + ``ProxyJump`` the command line takes), so the hop's own credentials are + resolved by ssh from ``~/.ssh/config`` too: give the bastion a ``Host`` + block instead of a second key path here. + """ host: str - user: str - identity_file: str + user: str = "" + identity_file: str | None = None port: int = 22 known_hosts_file: str | None = None options: SSHOptions = field(default_factory=SSHOptions) + jumps: tuple[JumpHop, ...] = () + """Hosts to reach :attr:`host` through, first hop first (``[ssh] jump``).""" @property def destination(self) -> str: - """The ``user@host`` target passed to ``ssh``.""" - return f"{self.user}@{self.host}" + """The target passed to ``ssh``: ``user@host``, or just ``host``. + + Dropping the ``user@`` half when no user is configured is what lets an + SSH ``Host`` alias (whose ``User`` lives in ``~/.ssh/config``) work. + """ + return f"{self.user}@{self.host}" if self.user else self.host + + @property + def proxy_jump(self) -> str | None: + """The value ssh's ``-J`` expects, or ``None`` without a jump chain. + + Rendered from :attr:`jumps` (not kept as the raw string) so the command + line is always the canonical spelling, whatever the config wrote. + """ + if not self.jumps: + return None + return ",".join(hop.render() for hop in self.jumps) + + @property + def first_hop(self) -> JumpHop | None: + """The one hop this machine must be able to reach itself. + + Only the first hop is reachable from here: every later one is reached + through its predecessor, so a local connection attempt to it would fail + on a perfectly healthy chain. + """ + return self.jumps[0] if self.jumps else None @dataclass(frozen=True) @@ -718,6 +796,44 @@ def load_config(path: _Path) -> TunnelConfig: return _parse_config(data, config_path) +def daemon_paths_from_file(path: _Path | None = None) -> tuple[str, str]: + """Best-effort ``(pid_file, log_file)`` for a possibly-invalid config file. + + The control commands (``stop`` / ``status`` / ``logs`` / ``watch``) need + these two paths and nothing else, and they must keep working when the rest + of the config no longer validates — otherwise a single typo in a tunnel + rule locks the user out of stopping the very daemon they need to stop. + + So this reads *only* ``[daemon]`` with a raw TOML parse, ignores every other + problem in the file, and never raises: a missing, unreadable or syntactically + broken file yields the platform defaults. + """ + default = _parse_daemon({}) + try: + resolved = _resolve_config_path(path) + with open(resolved, "rb") as handle: + data = tomllib.load(handle) + except (ConfigError, OSError, tomllib.TOMLDecodeError): + return default.pid_file, default.log_file + + section = data.get("daemon") + if not isinstance(section, Mapping): + return default.pid_file, default.log_file + try: + pid_file = _optional_str(section, "pid_file", default="") + log_file = _optional_str(section, "log_file", default="") + except ConfigError: + # A non-string pid_file is exactly the kind of typo that broke the + # strict load; treat it as "unset" rather than losing the whole path. + return default.pid_file, default.log_file + + state_dir = _default_state_dir() + return ( + _expand(pid_file) if pid_file else os.path.join(state_dir, "ponte.pid"), + _expand(log_file) if log_file else os.path.join(state_dir, "ponte.log"), + ) + + def _parse_config(data: Mapping[str, Any], config_path: str) -> TunnelConfig: warnings: list[str] = [] _warn_unknown_keys(data, _KNOWN_TOP_LEVEL, "", warnings) @@ -766,8 +882,21 @@ def _parse_config(data: Mapping[str, Any], config_path: str) -> TunnelConfig: } ) _KNOWN_PROFILE = frozenset({"name", "ssh", "tunnels"}) +#: Accepted spellings of the jump-host key. ``jump`` is the short one shipped +#: in the template; ``proxy_jump`` is what an OpenSSH user reaches for first. +_JUMP_KEYS = ("jump", "proxy_jump") + _KNOWN_SSH = frozenset( - {"host", "port", "user", "identity_file", "known_hosts_file", "options"} + { + "host", + "port", + "user", + "identity_file", + "known_hosts_file", + "options", + "jump", + "proxy_jump", + } ) _KNOWN_TUNNEL = frozenset( {"kind", "remote_port", "remote_host", "local_host", "local_port", "description"} @@ -878,23 +1007,103 @@ def _parse_ssh( _expect_table(section, where) _warn_unknown_keys(section, _KNOWN_SSH, where, warnings) host = _require_str(section, "host", where) - user = _require_str(section, "user", where) - identity_file = _require_str(section, "identity_file", where) + # ``user`` / ``identity_file`` are optional: leaving them out defers to + # ``~/.ssh/config`` / ssh-agent instead of forcing ``user@`` and ``-i``. + user = _optional_str(section, "user", default="") + identity_file = _optional_str(section, "identity_file", default=None) port = _optional_int( section, "port", default=22, minimum=1, maximum=65535, where=where ) known_hosts = _optional_str(section, "known_hosts_file", None) options = _parse_ssh_options(section.get("options", {}), where=f"{where}.options") + jumps = _parse_jump(section, where) + if jumps: + # Both spellings land on the command line as the *same* connection + # setting, and ssh resolves duplicates by precedence rather than + # complaint — so a profile that ends up with -J *and* -o ProxyJump would + # silently ignore one of them. Refuse instead of guessing. + conflicting = [ + key + for key, _ in options.extra + if key.lower() in {"proxyjump", "proxycommand"} + ] + if conflicting: + raise ConfigValidationError( + f"Field '{where}.jump' cannot be combined with " + f"'{where}.options.{conflicting[0]}': both say how to reach the " + "server, and ssh would silently apply only one. Keep one of them." + ) return SSHConfig( host=host, user=user, - identity_file=_expand(identity_file), + identity_file=_expand(identity_file) if identity_file else None, port=port, known_hosts_file=_expand(known_hosts) if known_hosts else None, options=options, + jumps=jumps, ) +#: One hop of a ProxyJump chain: ``[user@]host[:port]``. IPv6 literals have to +#: be bracketed, because ``::1:2222`` cannot be split back into host and port. +_JUMP_HOP_PATTERN = re.compile( + r"(?:(?P[^@\s:,]+)@)?(?P\[[^\]]+\]|[^@\s:,]+)(?::(?P\d+))?" +) + + +def _parse_jump(section: Mapping[str, Any], where: str) -> tuple[JumpHop, ...]: + """Parse ``[ssh] jump`` (or its ``proxy_jump`` spelling) into hops. + + The value keeps OpenSSH's own ``ProxyJump`` syntax — ``[user@]host[:port]``, + comma separated for a chain — because it is handed to ``-J`` verbatim. + Accepting the spelling people already type on the command line means there + is nothing new to learn, and nothing to translate on the way out. + """ + present = [key for key in _JUMP_KEYS if section.get(key)] + if not present: + return () + if len(present) > 1: + raise ConfigValidationError( + f"Fields '{where}.jump' and '{where}.proxy_jump' are two spellings " + "of the same setting; keep one" + ) + key = present[0] + raw = section[key] + if isinstance(raw, bool) or not isinstance(raw, str): + raise ConfigValidationError( + f"Field '{where}.{key}' must be a string, got {type(raw).__name__}" + ) + + hops: list[JumpHop] = [] + for chunk in raw.split(","): + spec = chunk.strip() + if not spec: + raise ConfigValidationError( + f"Field '{where}.{key}' has an empty hop in {raw!r}; " + "hops are separated by a single comma" + ) + match = _JUMP_HOP_PATTERN.fullmatch(spec) + if match is None: + raise ConfigValidationError( + f"Field '{where}.{key}' has an invalid hop {spec!r}; expected " + "'[user@]host[:port]' (bracket IPv6 literals, e.g. 'ops@[::1]:2222')" + ) + host = match.group("host") + if host.startswith("["): + host = host[1:-1] + if not host: + raise ConfigValidationError( + f"Field '{where}.{key}' has an empty host in hop {spec!r}" + ) + port = 22 + if match.group("port") is not None: + port = _check_int( + match.group("port"), "port", minimum=1, maximum=65535, where=f"{where}.{key}" + ) + hops.append(JumpHop(host=host, user=match.group("user") or "", port=port)) + return tuple(hops) + + def _parse_ssh_options(section: Any, where: str = "ssh.options") -> SSHOptions: dft = SSHOptions() if not section: diff --git a/ponte/core.py b/ponte/core.py index acb22fe..e848011 100644 --- a/ponte/core.py +++ b/ponte/core.py @@ -17,10 +17,27 @@ from ponte.config import WILDCARD_HOSTS, Profile, TunnelConfig, get_config -__all__ = ["TunnelManager", "creation_flags"] +__all__ = ["ProbeError", "TunnelManager", "port_is_open", "creation_flags"] logger = logging.getLogger(__name__) +#: Seconds a killed probe gets to release its stdout pipe before we give up on +#: reading it (see :func:`_run_capture`). +_PROBE_KILL_GRACE = 2.0 + + +class ProbeError(RuntimeError): + """A probe could not be completed, so what it inspects is *unknown*. + + The distinction this exception exists for is "the probe ran and the answer + was no" versus "the probe never got to ask". Only the first is evidence + that a tunnel is down, and callers must not turn the second into a down + verdict: a probe *connection* fails for reasons that say nothing about the + tunnel (a shared/NATed uplink, provider-side connection rate limiting, a + session reset), and reporting those as a closed port is how a health + monitor ends up killing perfectly healthy tunnels. + """ + def creation_flags() -> int: """Return subprocess creation flags that suppress a console window. @@ -56,6 +73,58 @@ def creation_flags() -> int: ) +def _run_capture(args: list[str], timeout: float) -> tuple[int | None, str]: + """Run *args* and return ``(returncode, stdout)`` without ever stalling. + + Deliberately not ``subprocess.run``. On Windows that helper, once its + *timeout* fires, kills the child and then blocks in ``communicate()`` + joining the stdout reader thread — and that join only returns when *every* + write end of the pipe is closed. A long-lived process holding an inherited + duplicate of the handle keeps the pipe open forever, so the call never + comes back and neither does its timeout. + + That is not hypothetical: it froze the daemon's health monitor for hours + while the tunnel itself was fine, because the first probe's pipe had been + inherited by the SSH tunnel child (see :meth:`TunnelManager.connect`). The + status file then sat on its last reading, ``ponte status`` kept saying + "异常", and zombie-session recovery — which runs off health ticks — never + fired at all. + + So the read is bounded twice: ``communicate(timeout)`` for the normal path, + then a *short* second ``communicate`` after the kill. If even that fails to + return (the handle is genuinely wedged elsewhere), give up on the output + rather than on the caller: ``(None, "")`` reads as "probe failed", which is + the conservative answer for both callers. + """ + proc = subprocess.Popen( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + # stderr is not read by either caller; DEVNULL avoids a second pipe. + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + close_fds=True, + creationflags=creation_flags(), + ) + try: + out, _ = proc.communicate(timeout=timeout) + return proc.returncode, out or "" + except subprocess.TimeoutExpired: + logger.debug("probe timed out after %ss; killing %s", timeout, args[0]) + proc.kill() + try: + out, _ = proc.communicate(timeout=_PROBE_KILL_GRACE) + except subprocess.TimeoutExpired: + logger.warning( + "probe pipe never closed after kill (leaked handle?); " + "reporting the probe as failed", + ) + return None, "" + return proc.returncode, out or "" + + def _windows_ssh_fallbacks() -> list[str]: """Return Windows ssh.exe candidates, including a per-user Git install.""" candidates = list(_WINDOWS_SSH_FALLBACKS) @@ -148,9 +217,15 @@ def connect(self) -> int: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, - # POSIX defaults to closing fds on exec; Windows deliberately keeps - # the inherited console handles so CREATE_NO_WINDOW keeps working. - close_fds=(sys.platform != "win32"), + # ``close_fds`` must stay on *even on Windows*. This child is + # long-lived (hours), and on Windows an inheriting child keeps a + # copy of every handle that is inheritable at spawn time. Windows + # has no close-on-exec, so a sibling's pipe write end inherited + # here outlives that sibling: whoever reads the pipe (e.g. the + # health probe's ``communicate()``) then never sees EOF and blocks + # forever. Suppressing a console window is the job of + # ``creationflags`` below, not of handle inheritance. + close_fds=True, creationflags=creation_flags(), ) self._connected_at = time.monotonic() @@ -230,17 +305,16 @@ def last_session_duration(self) -> float | None: # -- Argument building -------------------------------------------------- - def build_args(self) -> list[str]: - """Construct the full SSH command line as a list of strings. - - Every configured :class:`~ponte.config.Tunnel` contributes exactly one - forwarding flag followed by its spec, so a mixed ``-R``/``-L``/``-D`` - set becomes a single connection. Example:: + def _connection_args(self, *, connect_timeout: int | None = None) -> list[str]: + """Return the ``ssh`` invocation prefix that reaches the server. - ["ssh", "-o", "ServerAliveInterval=30", "-N", - "-R", "23334:localhost:2222", - "-L", "127.0.0.1:8080:db.internal:5432", - "-D", "127.0.0.1:1080", "user@server-ip"] + Every path that talks to the server — the tunnel itself, the login test + the health loop and ``ponte test`` use, and the server-side port probe — + starts from this one list. They each used to build it themselves, which + is how a setting could reach the tunnel but not the checks that + supervise it; ``[ssh] jump`` (``-J``) would have been exactly such a + setting: the tunnel would come up through the bastion while every health + check declared it dead. """ cfg = self.profile.ssh args = [self.ssh_exe] @@ -253,13 +327,41 @@ def build_args(self) -> list[str]: if cfg.known_hosts_file: args.extend(["-o", f"UserKnownHostsFile={cfg.known_hosts_file}"]) - # Identity file - args.extend(["-i", cfg.identity_file]) + # Identity file. Optional: without it OpenSSH falls back to + # ~/.ssh/config, ssh-agent and its default key locations, which is how + # a machine whose plain ``ssh host`` already works stays that way. + if cfg.identity_file: + args.extend(["-i", cfg.identity_file]) + + # Jump host(s). Handed to OpenSSH as -J rather than tunnelled by ponte: + # ssh opens (and authenticates) the hop itself, so the bastion's own + # user/key come from ~/.ssh/config just like the destination's do. + if cfg.proxy_jump: + args.extend(["-J", cfg.proxy_jump]) # Port if cfg.port != 22: args.extend(["-p", str(cfg.port)]) + if connect_timeout is not None: + args.extend(["-o", f"ConnectTimeout={connect_timeout}"]) + + return args + + def build_args(self) -> list[str]: + """Construct the full SSH command line as a list of strings. + + Every configured :class:`~ponte.config.Tunnel` contributes exactly one + forwarding flag followed by its spec, so a mixed ``-R``/``-L``/``-D`` + set becomes a single connection. Example:: + + ["ssh", "-o", "ServerAliveInterval=30", "-N", + "-R", "23334:localhost:2222", + "-L", "127.0.0.1:8080:db.internal:5432", + "-D", "127.0.0.1:1080", "user@server-ip"] + """ + args = self._connection_args() + # No shell, just forwarding args.append("-N") @@ -268,41 +370,26 @@ def build_args(self) -> list[str]: args.extend([tunnel.flag, tunnel.spec]) # Destination - args.append(cfg.destination) + args.append(self.profile.ssh.destination) return args # -- Health / diagnostics ----------------------------------------------- def test_connection(self, timeout: int = 10) -> bool: - """Run a quick ``ssh … echo OK`` to verify connectivity. Returns - ``True`` if the server responds with "OK". + """Run a quick ``ssh … echo OK`` to verify connectivity. + + Returns ``True`` if the server responds with "OK". Goes through the + configured jump host, so this is the check that validates a *chain* + rather than only its last link. """ - cfg = self.profile.ssh - args = [self.ssh_exe] - for key, value in cfg.options.as_pairs(): - args.extend(["-o", f"{key}={value}"]) - if cfg.known_hosts_file: - args.extend(["-o", f"UserKnownHostsFile={cfg.known_hosts_file}"]) - args.extend(["-i", cfg.identity_file]) - if cfg.port != 22: - args.extend(["-p", str(cfg.port)]) - args.extend([ - "-o", f"ConnectTimeout={timeout}", - cfg.destination, - "echo OK", - ]) + args = self._connection_args(connect_timeout=timeout) + args.extend([self.profile.ssh.destination, "echo OK"]) try: - result = subprocess.run( - args, - capture_output=True, - text=True, - timeout=timeout + 5, - creationflags=creation_flags(), - ) - return result.returncode == 0 and "OK" in result.stdout + code, output = _run_capture(args, timeout=timeout + 5) except (subprocess.SubprocessError, OSError) as exc: logger.debug("Connection test failed: %s", exc) return False + return code == 0 and "OK" in output def check_remote_ports(self, timeout: int = 10) -> dict[int, bool]: """Connect to the server and check which ``-R`` ports are listening. @@ -315,6 +402,12 @@ def check_remote_ports(self, timeout: int = 10) -> dict[int, bool]: ``-L``/``-D`` rules are skipped here (their local end is covered by the far cheaper :meth:`check_local_ports`). Returns ``{}`` — never a probe connection — when no remote tunnel is configured. + + Raises: + ProbeError: the probe connection itself failed (ssh could not be + spawned, exited non-zero, or its output never arrived), so the + ports' state is *unknown* rather than closed. Every port in the + returned mapping was actually observed on the server. """ cfg = self.profile.ssh ports = { @@ -349,32 +442,25 @@ def check_remote_ports(self, timeout: int = 10) -> dict[int, bool]: "fi" ) - args = [self.ssh_exe] - for key, value in cfg.options.as_pairs(): - args.extend(["-o", f"{key}={value}"]) - if cfg.known_hosts_file: - args.extend(["-o", f"UserKnownHostsFile={cfg.known_hosts_file}"]) - args.extend(["-i", cfg.identity_file]) - if cfg.port != 22: - args.extend(["-p", str(cfg.port)]) - args.extend([ - "-o", f"ConnectTimeout={timeout}", - cfg.destination, - remote_cmd, - ]) + args = self._connection_args(connect_timeout=timeout) + args.extend([cfg.destination, remote_cmd]) try: - result = subprocess.run( - args, - capture_output=True, - text=True, - timeout=timeout + 5, - creationflags=creation_flags(), - ) + code, output = _run_capture(args, timeout=timeout + 5) except (subprocess.SubprocessError, OSError) as exc: - logger.debug("Remote port check failed: %s", exc) - return {p: False for p in ports} + logger.debug("Remote port check could not run: %s", exc) + raise ProbeError( + f"探测连接失败({exc}):{port_literal} 的状态未知" + ) from exc + if code != 0: + # ssh itself failed — auth, a connection reset, provider rate + # limiting, or our own timeout kill. The check command never ran, + # so nothing was learned about the ports. Reporting them closed + # here is what turns a flaky probe into a forced reconnect. + logger.debug("Remote port check: ssh exited %s", code) + raise ProbeError( + f"探测连接失败(ssh 退出码 {code}):{port_literal} 的状态未知" + ) - output = result.stdout or "" # python3 branch prints the open ports space-separated on one line. python_ports = {int(p) for p in output.split() if p.isdigit()} status: dict[int, bool] = {} @@ -404,12 +490,16 @@ def check_local_ports(self, timeout: float = 1.0) -> dict[int, bool]: host = tunnel.local_host if host in WILDCARD_HOSTS: host = "127.0.0.1" - status[tunnel.local_port] = _port_is_open(host, tunnel.local_port, timeout) + status[tunnel.local_port] = port_is_open(host, tunnel.local_port, timeout) return status -def _port_is_open(host: str, port: int, timeout: float) -> bool: - """Return ``True`` if a TCP connect to ``host:port`` succeeds.""" +def port_is_open(host: str, port: int, timeout: float) -> bool: + """Return ``True`` if a TCP connect to ``host:port`` succeeds. + + Public because ``ponte doctor`` probes a jump host with exactly this — the + one question a bastion check can answer locally. + """ try: with socket.create_connection((host, port), timeout=timeout): return True diff --git a/ponte/daemon.py b/ponte/daemon.py index a5043d3..68fc139 100644 --- a/ponte/daemon.py +++ b/ponte/daemon.py @@ -36,7 +36,15 @@ from collections.abc import Callable, Iterator from xml.sax.saxutils import escape as xml_escape -from ponte.config import DEFAULT_PROFILE_NAME, Profile, TunnelConfig, get_config +from ponte.config import ( + DEFAULT_PROFILE_NAME, + ConfigError, + Profile, + TunnelConfig, + get_config, + load_config, + package_dir, +) from ponte.core import TunnelManager, creation_flags from ponte.health import HealthChecker, HealthStatus from ponte.notify import Notification, Notifier @@ -69,6 +77,8 @@ { "process_alive", "healthy", + "health_conclusive", + "probe_error", "remote_ports", "local_ports", "health_error", @@ -127,6 +137,37 @@ def _derive_stop_marker(pid_file: str) -> str: return base + ".stop" +def _derive_reload_marker(pid_file: str) -> str: + """Derive the config-reload marker path from a ``.pid`` file path.""" + base, _ext = os.path.splitext(pid_file) + return base + ".reload" + + +def _is_package_dir(path: str) -> bool: + """Return ``True`` when *path* is the installed ``ponte`` package itself.""" + return os.path.normcase(os.path.abspath(path)) == os.path.normcase( + os.path.abspath(package_dir()) + ) + + +def _spawn_log_path(pid_file: str) -> str: + """Where a background spawn's first output is captured.""" + return f"{pid_file}.spawn.log" + + +def _spawn_tail(path: str, limit: int = 600) -> str: + """Tail of a spawn log for an error message (empty when there is nothing).""" + try: + with open(path, "rb") as handle: + data = handle.read() + except OSError: + return "" + text = _decode_console(data).strip() + if not text: + return "" + return f"\n---- child output ({path}) ----\n" + text[-limit:] + + def _encode_ps(script: str) -> str: """Base64 UTF-16LE encode a PowerShell snippet for ``-EncodedCommand``. @@ -152,6 +193,110 @@ def _decode_console(data: bytes) -> str: return data.decode("gbk", errors="replace") +def _windows_exit_code(pid: int) -> int | None: + """Return *pid*'s exit code, or ``None`` when the handle cannot be opened. + + ``None`` means *unknown* — typically ACCESS_DENIED for a process owned by + another account — and not "dead"; :func:`_windows_pid_alive` decides what to + do about that. Split out into its own function so the liveness logic is + testable on any platform: ``ctypes.windll`` only exists on Windows. + """ + import ctypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)) + if not handle: + return None + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return None + return int(code.value) + finally: + kernel32.CloseHandle(handle) + + +def _windows_process_listed(pid: int) -> bool | None: + """Return whether *pid* is in the process list, or ``None`` if unknown. + + A Toolhelp32 snapshot rather than ``tasklist`` on purpose: this runs on the + ``status`` / ``stop`` path, and spawning a console program there is exactly + how a harmless status check ends up flashing a black window. Enumeration + needs no rights at all, which is the point — it works for a process owned by + another account, where ``OpenProcess`` is refused outright. + """ + import ctypes + from ctypes import wintypes + + TH32CS_SNAPPROCESS = 0x00000002 + + class PROCESSENTRY32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.c_void_p), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", ctypes.c_char * 260), + ] + + kernel32 = ctypes.windll.kernel32 + # Without these, a 64-bit snapshot handle is truncated to 32 bits on return. + kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.Process32First.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + kernel32.Process32Next.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + + snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if not snapshot or snapshot == wintypes.HANDLE(-1).value: + return None + try: + entry = PROCESSENTRY32() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32) + found = bool(kernel32.Process32First(snapshot, ctypes.byref(entry))) + while found: + if int(entry.th32ProcessID) == int(pid): + return True + found = bool(kernel32.Process32Next(snapshot, ctypes.byref(entry))) + return False + finally: + kernel32.CloseHandle(snapshot) + + +def _windows_pid_alive(pid: int) -> bool: + """Windows liveness check that survives being run by a lesser account. + + ``OpenProcess`` is the accurate answer — its exit code also proves the pid + was not recycled — but a standard user cannot open a process owned by + another account, and that is precisely the setup ponte recommends on + Windows: the daemon runs as SYSTEM so the tunnel exists before anyone logs + in, while ``status`` / ``stop`` are typed by the logged-in user. There + ``OpenProcess`` fails with ACCESS_DENIED, and calling a healthy daemon "not + running" is a lie with consequences: ``ponte start`` would launch a second + daemon that fights for the same server-side ports, and ``ponte stop`` would + refuse to stop the real one. + + So an unopenable pid falls back to the process list, which needs no rights. + That cannot distinguish a recycled pid from the original — hence the + fallback, not the primary check. + """ + code = _windows_exit_code(pid) + if code is not None: + return code == _STILL_ACTIVE + listed = _windows_process_listed(pid) + if listed is None: + # No answer at all: assume alive. Failing this way only costs a refused + # ``start``; failing the other way starts a duplicate daemon. + return True + return listed + + def _run_tool( args: list[str], *, @@ -198,6 +343,12 @@ class ProfileStatus: local_ports: dict[int, bool] = dataclasses.field(default_factory=dict) """``-L``/``-D`` ports this machine listens on, ``{port: listening}``.""" health_error: str | None = None + #: ``False`` when the last check could not be completed (the probe + #: connection failed), so ``healthy is False`` is *not* a verdict about the + #: tunnel. ``None`` for a status file written before this field existed. + health_conclusive: bool | None = None + #: The failed probe's message, when the remote ports could not be observed. + probe_error: str | None = None #: Set when this profile's retry loop died of an unexpected exception. error: str | None = None @@ -330,6 +481,7 @@ def _ports(key: str) -> dict[int, bool]: raw_healthy = section.get("healthy") raw_alive = section.get("process_alive") + raw_conclusive = section.get("health_conclusive") return ProfileStatus( name=name, destination=destination, @@ -338,6 +490,8 @@ def _ports(key: str) -> dict[int, bool]: remote_ports=_ports("remote_ports"), local_ports=_ports("local_ports"), health_error=section.get("health_error"), + health_conclusive=raw_conclusive if isinstance(raw_conclusive, bool) else None, + probe_error=section.get("probe_error"), error=section.get("error"), connect_attempts_total=_count("connect_attempts_total"), sessions_total=_count("sessions_total"), @@ -443,6 +597,27 @@ def begin(self, names: list[str], started_at: float) -> None: section.setdefault("recent_events", []) self._write({"started_at": started_at, "profiles": sections}) + def remove(self, names: list[str]) -> None: + """Drop the sections of *names* — profiles that left the config. + + Used by a reload: the daemon no longer supervises them, so leaving + their stale health behind would make ``ponte status`` show a tunnel + that does not exist any more. + """ + if not names: + return + with self._lock: + data = self._read() + sections = self._sections(data) + for name in names: + sections.pop(name, None) + self._write( + { + "started_at": data.get("started_at", time.time()), + "profiles": sections, + } + ) + @contextlib.contextmanager def edit(self, name: str) -> Iterator[dict]: """Yield one profile's section for mutation, then persist the file. @@ -634,8 +809,14 @@ def __init__(self, config: TunnelConfig | None = None) -> None: self.log_file = self.config.daemon.log_file self.status_file = _derive_status_file(self.pid_file) self.stop_marker = _derive_stop_marker(self.pid_file) + self.reload_marker = _derive_reload_marker(self.pid_file) self._store = _StatusStore(self.status_file) self._shutdown = threading.Event() + #: Serializes config reloads; also the "one reload at a time" guard. + self._reload_lock = threading.Lock() + #: Set while ``_reconcile`` swaps runners, so the supervisor does not + #: mistake the swap for "every profile died" and shut the daemon down. + self._reconciling = threading.Event() #: Shared by every profile: the channels and the rate limit are policy, #: not per-connection state. self.notifier = Notifier(self.config.notify) @@ -661,12 +842,25 @@ def work_dir(self) -> str: user's home. It used to be the package's *parent* directory, which is wrong once the package is installed: ``site-packages`` is not a meaningful working directory and may not even be writable. + + One case has to stay the parent, though: when the config file *is* the + one inside the package (the pre-0.3 layout, still supported). The daemon + and the generated service are started as ``python -m ponte.main``, and + from inside the package that import cannot resolve — Python finds the + package's parent on ``sys.path``, not the package itself. The child died + instantly, so ``ponte start`` reported only "daemon did not write its + PID file within 10 s" and ``ponte install`` would register a task that + can never start. """ source = self.config.source_path if source: directory = os.path.dirname(os.path.abspath(source)) if os.path.isdir(directory): - return directory + if not _is_package_dir(directory): + return directory + # Legacy in-package config: the package's parent is where + # ``python -m ponte.main`` resolves from, and it is writable. + return os.path.dirname(os.path.abspath(package_dir())) return os.path.expanduser("~") # -- PID helpers ----------------------------------------------------------- @@ -687,22 +881,7 @@ def read_pid(self) -> int | None: def _pid_alive(pid: int) -> bool: """Return ``True`` if *pid* names a live process on this OS.""" if sys.platform == "win32": - import ctypes - - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - handle = ctypes.windll.kernel32.OpenProcess( - PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid) - ) - if not handle: - return False - try: - code = ctypes.c_ulong() - ok = ctypes.windll.kernel32.GetExitCodeProcess( - handle, ctypes.byref(code) - ) - return bool(ok and code.value == _STILL_ACTIVE) - finally: - ctypes.windll.kernel32.CloseHandle(handle) + return _windows_pid_alive(pid) # POSIX: signal 0 just probes for existence. try: os.kill(pid, 0) @@ -736,6 +915,14 @@ def _on_health( loop's blocking ``connect()`` return so the tunnel is re-established instead of being left down forever. + Only *conclusive* unhealthy checks count towards that threshold. A + check that could not be completed (``status.conclusive`` is False — the + probe connection itself failed, so nothing was learned about the ports) + is neither a failure nor a recovery: it leaves the counter untouched and + can never trigger a forced reconnect. Otherwise a path that drops + probe connections — a shared uplink, provider rate limiting — would + let the monitor kill a healthy tunnel. + ``manager`` may be ``None`` (e.g. in unit tests, or before ``run()``), in which case the forced-reconnect path is skipped — the health data is still persisted and logged as usual. @@ -747,6 +934,8 @@ def _on_health( "checked_at": time.time(), "process_alive": status.process_alive, "healthy": status.all_healthy, + "health_conclusive": status.conclusive, + "probe_error": status.remote_probe_error, "remote_ports": { str(p): ok for p, ok in status.remote_ports.items() }, @@ -764,8 +953,24 @@ def _on_health( self._health_failures[profile] = 0 return - # Unhealthy check: count it, and if the SSH process is still alive - # (i.e. a zombie rather than a cleanly-exited process) force reconnect. + # Inconclusive check: the tunnel's state is unknown, so this is not + # evidence of a zombie — and killing the session on it would be a + # self-inflicted outage. Note the counter is deliberately *left alone* + # rather than reset, so a real zombie is still caught after + # _HEALTH_FAILURE_THRESHOLD conclusive failures even when inconclusive + # checks are interleaved. + if not status.conclusive: + logger.warning( + "health[%s]: 状态未知 — 本次检查没有得出结论(%s)," + "不计入强制重连", + profile, + status.remote_probe_error or status.error or "原因未知", + ) + return + + # Conclusive unhealthy check: count it, and if the SSH process is still + # alive (i.e. a zombie rather than a cleanly-exited process) force + # reconnect. failures = self._health_failures.get(profile, 0) + 1 self._health_failures[profile] = failures if ( @@ -886,6 +1091,9 @@ def run(self) -> int: """ self._setup_logging() log = logging.getLogger("ponte.daemon") + # The spawn log exists only to explain a startup that never got this + # far; now that logging works it would sit next to the pid file forever. + self._safe_remove(_spawn_log_path(self.pid_file)) import ponte log.info( @@ -896,6 +1104,9 @@ def run(self) -> int: ) self.write_pid() self._safe_remove(self.stop_marker) + # A reload request left behind by a previous, already-dead daemon must + # not fire on this one's first sweep. + self._safe_remove(self.reload_marker) # Prime the status file with a start time before the first health tick. # Merge, don't overwrite: the cumulative tunnel statistics must survive @@ -907,7 +1118,6 @@ def run(self) -> int: ProfileRunner(profile, self.config, self) for profile in self.config.profiles ] - runners = self._runners def request_stop(reason: str) -> None: """Request shutdown from any thread. Idempotent, never raises.""" @@ -915,9 +1125,19 @@ def request_stop(reason: str) -> None: return log.info("shutdown requested: %s", reason) self._shutdown.set() - for runner in runners: + for runner in self._runners: runner.abort() + def request_reload() -> None: + """Apply a new config on a worker thread, leaving the caller free. + + Reconciling can block while it joins a retired profile's thread, so + it must not run on the marker watcher (which also has to notice a + stop request).""" + threading.Thread( + target=self._reload_worker, name="ponte-reload", daemon=True + ).start() + # SIGINT (Ctrl+C) and, where catchable, SIGTERM. try: signal.signal(signal.SIGINT, lambda *_a: request_stop("SIGINT")) @@ -925,6 +1145,15 @@ def request_stop(reason: str) -> None: except (ValueError, OSError): pass # SIGTERM may be uncatchable on some Windows builds + # SIGHUP is the POSIX convention for "re-read your config"; the marker + # file covers every platform, including Windows where SIGHUP is not + # deliverable. + if sys.platform != "win32": + try: + signal.signal(signal.SIGHUP, lambda *_a: request_reload()) + except (ValueError, OSError, AttributeError): + pass + # Stop-marker watcher gives cross-process graceful stop on Windows. threading.Thread( target=self._watch_stop_marker, @@ -932,25 +1161,36 @@ def request_stop(reason: str) -> None: daemon=True, name="ponte-stop-watch", ).start() + threading.Thread( + target=self._watch_reload_marker, + args=(request_reload,), + daemon=True, + name="ponte-reload-watch", + ).start() log.info( "starting SSH retry loop(s) (max_retries=%s, %d profile(s))", self.config.retry.max_retries, - len(runners), + len(self._runners), ) try: - for runner in runners: + for runner in self._runners: runner.start() while not self._shutdown.is_set(): self._shutdown.wait(_SUPERVISOR_INTERVAL) - if not any(runner.is_alive() for runner in runners): + # ``self._runners`` is rebound (not mutated) by a reload, and + # during that swap it can briefly hold only retired runners; + # treating that as "all profiles died" would kill the daemon. + if self._reconciling.is_set(): + continue + if not any(runner.is_alive() for runner in self._runners): log.warning("every profile loop has exited") break except KeyboardInterrupt: # pragma: no cover - must reload to trigger request_stop("KeyboardInterrupt") finally: request_stop("daemon shutdown") - for runner in runners: + for runner in self._runners: runner.finish() self._cleanup() log.info("daemon exited cleanly") @@ -974,6 +1214,134 @@ def _watch_stop_marker(self, request_stop: Callable[[str], None]) -> None: return self._shutdown.wait(_STOP_POLL_INTERVAL) + # -- Config reload (hot) --------------------------------------------------- + + def request_reload(self) -> None: + """Ask a *running* daemon (another process) to re-read its config. + + Drops the reload marker the daemon's watcher polls — the same + cross-process mechanism as the stop marker, so it works on Windows + where SIGHUP cannot be delivered. Returns as soon as the request is on + disk; the caller cannot observe the outcome synchronously, which is why + ``ponte reload`` validates the config *before* writing the marker. + """ + directory = os.path.dirname(self.reload_marker) + if directory: + os.makedirs(directory, exist_ok=True) + with open(self.reload_marker, "w", encoding="utf-8") as handle: + handle.write(str(time.time())) + + def _watch_reload_marker(self, request_reload: Callable[[], None]) -> None: + """Watch for a reload marker and apply the new config when it appears.""" + while not self._shutdown.is_set(): + if os.path.exists(self.reload_marker): + # Remove before reconciling: a request that lands mid-reload is + # then kept for the next sweep instead of being lost. + self._safe_remove(self.reload_marker) + request_reload() + self._shutdown.wait(_STOP_POLL_INTERVAL) + + def _reload_worker(self) -> None: + """Run :meth:`_reconcile` and log its summary.""" + summary = self._reconcile() + logging.getLogger("ponte.daemon").info("reload: %s", summary) + + def _retire(self, runner: ProfileRunner) -> None: + """Stop a profile's loops and wait for its thread to end, best effort.""" + try: + runner.finish() + except Exception as exc: # noqa: BLE001 - one bad runner must not abort a reload + logger.warning( + "[%s] could not stop cleanly during reload: %s", runner.profile.name, exc + ) + + def _reconcile(self) -> str: + """Re-read the config file and apply it in place; returns a summary. + + Only profiles whose configuration actually changed are restarted, so + adding a tunnel or fixing one profile's key no longer drops every other + connection. ``[retry]`` and ``[health]`` are baked into each runner at + construction time, so a change to either rebuilds every profile (the + connection must be re-established to pick up new policy). + + Never raises: a broken or unreadable config leaves the running tunnels + exactly as they are and comes back as a message, because a reload that + can kill a working tunnel on a typo is worse than no reload at all. + """ + source = self.config.source_path + if not source: + return "配置没有来源文件,未重载" + try: + new_config = load_config(source) + except ConfigError as exc: + logger.error("reload rejected, keeping the running config: %s", exc) + return f"重载失败,继续沿用旧配置:{exc}" + + if not self._reload_lock.acquire(blocking=False): + return "已有一次重载在进行,忽略本次请求" + try: + self._reconciling.set() + policy_changed = ( + new_config.retry != self.config.retry + or new_config.health != self.config.health + ) + + previous = {runner.profile.name: runner for runner in self._runners} + latest: list[ProfileRunner] = [] + started: list[str] = [] + kept: list[str] = [] + for profile in new_config.profiles: + existing = previous.pop(profile.name, None) + unchanged = ( + existing is not None + and not policy_changed + and existing.profile == profile + ) + if unchanged and existing is not None: + latest.append(existing) + kept.append(profile.name) + continue + if existing is not None: + self._retire(existing) + runner = ProfileRunner(profile, new_config, self) + runner.start() + latest.append(runner) + started.append(profile.name) + + removed = sorted(previous) + for runner in previous.values(): + self._retire(runner) + + self._runners = latest + self.config = new_config + # Notify policy is global: rebuild the notifier and hand it to every + # runner, including the ones that were left running. + self.notifier = Notifier(new_config.notify) + for runner in latest: + runner.notifier = self.notifier + + # Merge (don't overwrite) so the counters of surviving tunnels keep + # their history; the start time is preserved so daemon uptime does + # not reset on a reload. + self._store.begin( + new_config.profile_names, self._store.started_at() or time.time() + ) + self._store.remove(removed) + finally: + self._reconciling.clear() + self._reload_lock.release() + + parts: list[str] = [] + if kept: + parts.append(f"保持 {len(kept)} 条({', '.join(kept)})") + if started: + parts.append(f"重启/新增 {len(started)} 条({', '.join(started)})") + if removed: + parts.append(f"移除 {len(removed)} 条({', '.join(removed)})") + if policy_changed: + parts.append("retry/health 策略有变,全部重建") + return "配置已重载:" + (";".join(parts) if parts else "无变化") + # -- Start / background ---------------------------------------------------- def _daemon_args(self) -> list[str]: @@ -1009,6 +1377,15 @@ def start(self, foreground: bool = False) -> int: def _spawn_background(self) -> int: """Re-launch this CLI as a detached background process.""" cmd = [sys.executable, *self._daemon_args()] + # Capture the child's first words instead of dropping them: if it cannot + # even import ponte (a wrong working directory, a broken install) it dies + # before it can log anything, and "did not write its PID file" on its own + # sends the user looking in the wrong place. + captured = _spawn_log_path(self.pid_file) + try: + handle = open(captured, "wb") + except OSError: + handle = None # diagnostics are optional; never fail the spawn for it if sys.platform == "win32": flags = ( subprocess.DETACHED_PROCESS @@ -1020,8 +1397,8 @@ def _spawn_background(self) -> int: cwd=self.work_dir, creationflags=flags, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=handle or subprocess.DEVNULL, + stderr=subprocess.STDOUT if handle else subprocess.DEVNULL, ) else: # POSIX: start a new session so the child detaches from the @@ -1031,16 +1408,22 @@ def _spawn_background(self) -> int: cwd=self.work_dir, start_new_session=True, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=handle or subprocess.DEVNULL, + stderr=subprocess.STDOUT if handle else subprocess.DEVNULL, ) + if handle is not None: + handle.close() # the child holds its own handle # Wait for the child to write its PID file (up to 10 s). for _ in range(100): pid = self.read_pid() if pid is not None: + if captured: + self._safe_remove(captured) return pid time.sleep(0.1) - raise RuntimeError("daemon did not write its PID file within 10 s") + raise RuntimeError( + "daemon did not write its PID file within 10 s" + _spawn_tail(captured) + ) # -- Stop ------------------------------------------------------------------ @@ -1492,6 +1875,7 @@ def _cleanup(self) -> None: try: self._safe_remove(self.pid_file) self._safe_remove(self.stop_marker) + self._safe_remove(self.reload_marker) finally: self._shutdown.set() diff --git a/ponte/doctor.py b/ponte/doctor.py index 812a67a..95a3a16 100644 --- a/ponte/doctor.py +++ b/ponte/doctor.py @@ -4,9 +4,10 @@ act on. That is the difference between "it does not work" and a support thread. Doctor is strictly read-only: it never installs, restarts or changes anything, -so it is always safe to run on a machine you are debugging. The one exception -worth knowing about is the connectivity check, which opens a short-lived SSH -connection exactly like ``ponte test``; ``--offline`` skips it. +so it is always safe to run on a machine you are debugging. The exceptions worth +knowing about are the connectivity check, which opens a short-lived SSH +connection exactly like ``ponte test``, and the jump-host probe, which opens one +TCP connection; ``--offline`` skips both. """ from __future__ import annotations @@ -20,7 +21,7 @@ from typing import TYPE_CHECKING, Protocol from ponte.config import NotifyConfig, Profile, TunnelConfig -from ponte.core import TunnelManager +from ponte.core import ProbeError, TunnelManager, port_is_open if TYPE_CHECKING: # pragma: no cover - typing only, avoids importing the world from ponte.daemon import DaemonStatus @@ -188,6 +189,11 @@ def label(what: str) -> str: ] if sys.platform != "win32": results.append(_key_permission_check(profile, label)) + jump = _jump_check(profile, label, offline, timeout) + if jump is not None: + # Reported *before* connectivity on purpose: when the bastion is down + # the login failure that follows is a consequence, not a second problem. + results.append(jump) # Connectivity is worth testing even when nothing is running: it is the one # thing that tells apart "my config is wrong" from "the daemon is down". @@ -227,8 +233,15 @@ def _ssh_client_check( def _identity_check(profile: Profile, label: Callable[[str], str]) -> CheckResult: path = profile.ssh.identity_file if not path: + # Not an error any more: without ``identity_file`` ponte omits ``-i`` + # and lets ssh resolve the key from ~/.ssh/config, ssh-agent or its + # default locations. Only warn that this may not survive a rebooted + # service with no agent. return CheckResult( - label("密钥文件"), FAIL, "未配置 identity_file", "在配置里把 identity_file 指到私钥" + label("密钥文件"), + OK, + "未配置,交由 ~/.ssh/config / ssh-agent / 默认密钥", + "后台服务读不到 ssh-agent 时,建议显式设置 [ssh] identity_file", ) if not os.path.isfile(path): return CheckResult( @@ -243,6 +256,8 @@ def _identity_check(profile: Profile, label: Callable[[str], str]) -> CheckResul def _key_permission_check(profile: Profile, label: Callable[[str], str]) -> CheckResult: """POSIX only: a world/group readable private key is refused by OpenSSH.""" path = profile.ssh.identity_file + if not path: + return CheckResult(label("密钥权限"), SKIP, "未显式配置 identity_file") try: mode = os.stat(path).st_mode except OSError as exc: @@ -257,6 +272,59 @@ def _key_permission_check(profile: Profile, label: Callable[[str], str]) -> Chec return CheckResult(label("密钥权限"), OK, oct(mode & 0o777)) +def _jump_check( + profile: Profile, + label: Callable[[str], str], + offline: bool, + timeout: int, +) -> CheckResult | None: + """Reachability of the first jump hop (``None`` without a jump chain). + + A tunnel through a bastion fails in one of two places — the hop, or the + server behind it — and ssh reports both with the same unhelpful login + failure. A plain TCP connect to the hop tells the two apart in one line. + + Only the *first* hop is probed: every later one is reached through its + predecessor, so a connect attempt from here would fail on a perfectly + healthy chain and turn the report into a lie. + """ + hops = profile.ssh.jumps + if not hops: + return None + chain = " → ".join(hop.destination for hop in hops) + head = hops[0] + name = label("跳板机") + if offline: + return CheckResult(name, SKIP, f"{chain}(已跳过,--offline)") + if port_is_open(head.host, head.port, timeout): + detail = f"{chain}(第一跳 {head.host}:{head.port} 可达)" + if len(hops) > 1: + detail += ";后续跳只能经前一跳验证,以 SSH 连通性为准" + return CheckResult(name, OK, detail) + return CheckResult( + name, + FAIL, + f"连不上第一跳 {head.host}:{head.port}(完整链路:{chain})", + f"先在终端单独执行 ssh {head.destination},确认地址、端口、密钥与" + "authorized_keys;堡垒机不可达时,后面的隧道一定起不来", + ) + + +def _connectivity_hint(profile: Profile) -> str: + """Hint for a failed login, naming the bastion when there is one. + + "Check that the server is reachable" is misleading advice when the server + is *supposed* to be unreachable from here — the thing to verify is the hop. + """ + head = profile.ssh.first_hop + if head is None: + return _SSH_HINT + return ( + f"链路是 {profile.ssh.proxy_jump},本机只直连第一跳:先单独 ssh " + f"{head.destination} 验证跳板机,再用 ssh -J … 验证整条链路" + ) + + def _connectivity_check( daemon: DoctorDaemon | None, profile: Profile, @@ -269,13 +337,17 @@ def _connectivity_check( return CheckResult(name, SKIP, "已跳过(--offline)") if daemon is None: return CheckResult(name, SKIP, "没有可用的守护进程对象") + hint = _connectivity_hint(profile) try: reachable = daemon.test_connection(timeout=timeout, profile=profile.name) except Exception as exc: # noqa: BLE001 - return CheckResult(name, FAIL, f"{type(exc).__name__}: {exc}", _SSH_HINT) + return CheckResult(name, FAIL, f"{type(exc).__name__}: {exc}", hint) if reachable: - return CheckResult(name, OK, f"能在 {timeout}s 内登录 {profile.destination}") - return CheckResult(name, FAIL, f"{timeout}s 内未能登录 {profile.destination}", _SSH_HINT) + detail = f"能在 {timeout}s 内登录 {profile.destination}" + if profile.ssh.jumps: + detail += f"(经 {profile.ssh.proxy_jump})" + return CheckResult(name, OK, detail) + return CheckResult(name, FAIL, f"{timeout}s 内未能登录 {profile.destination}", hint) def _remote_ports_check( @@ -299,6 +371,10 @@ def _remote_ports_check( return CheckResult(name, SKIP, "守护进程未运行,端口状态没有参考价值") try: state = daemon.check_remote_ports(timeout=timeout, profile=profile.name) + except ProbeError as exc: + # The probe's own connection failed: this is "cannot tell", not "the + # ports are closed" — and it must not read as a failure to the user. + return CheckResult(name, WARN, str(exc)) except Exception as exc: # noqa: BLE001 return CheckResult(name, WARN, f"探测失败:{type(exc).__name__}: {exc}") down = [port for port, is_open in sorted(state.items()) if not is_open] @@ -354,7 +430,15 @@ def _daemon_check(status: DaemonStatus | None) -> CheckResult: profiles = list(status.profiles) detail = f"pid {status.pid if status.pid is not None else '?'},已运行 {status.uptime}" - broken = [p.name for p in profiles if p.healthy is False] + # ``healthy is False`` alone is not a verdict: a check the daemon could not + # complete (``health_conclusive is False``) reports False too, and calling + # that "异常" here is the false alarm this check exists to avoid. + broken = [ + p.name for p in profiles if p.healthy is False and p.health_conclusive is not False + ] + unverified = [ + p.name for p in profiles if p.healthy is False and p.health_conclusive is False + ] unknown = [p.name for p in profiles if p.healthy is None] if broken: return CheckResult( @@ -365,6 +449,13 @@ def _daemon_check(status: DaemonStatus | None) -> CheckResult: ) if unknown: return CheckResult("守护进程", WARN, f"{detail},尚未上报健康状态:{', '.join(unknown)}") + if unverified: + return CheckResult( + "守护进程", + WARN, + f"{detail},无法判定(探测连接没建起来):{', '.join(unverified)}", + "隧道本身可能在正常转发;ponte logs -n 50 看探测失败原因", + ) return CheckResult("守护进程", OK, f"{detail},{len(profiles)} 条隧道全部健康") diff --git a/ponte/health.py b/ponte/health.py index d3ebe61..817672d 100644 --- a/ponte/health.py +++ b/ponte/health.py @@ -9,15 +9,18 @@ from __future__ import annotations import dataclasses +import logging import threading import time from collections.abc import Callable from ponte.config import HealthConfig -from ponte.core import TunnelManager +from ponte.core import ProbeError, TunnelManager __all__ = ["HealthChecker", "HealthStatus"] +logger = logging.getLogger(__name__) + #: Type of a user-supplied run-loop callback: ``Callable[[HealthStatus], None]``. HealthCallback = Callable[["HealthStatus"], None] @@ -40,9 +43,16 @@ class HealthStatus: ``-L``/``-D`` listeners probed on this machine. all_healthy: Overall health — the process is alive, every checked remote *and* local port is listening, and the check did not error. + Note that ``all_healthy is False`` does **not** by itself mean the + tunnel is down: a check that could not be completed is also not + healthy (see ``conclusive``). timestamp: Unix time (``time.time()``) when the check was performed. error: Human-readable error message if the check partially failed, else ``None``. + remote_probe_error: Set when the *server-side* probe connection itself + failed, so the ``-R`` ports could not be observed at all. Their + state is then unknown — ``remote_ports`` stays empty rather than + claiming every port is closed. """ process_alive: bool @@ -51,20 +61,47 @@ class HealthStatus: timestamp: float error: str | None = None local_ports: dict[int, bool] = dataclasses.field(default_factory=dict) + remote_probe_error: str | None = None + + @property + def conclusive(self) -> bool: + """Whether this snapshot is a verdict about the tunnel at all. + + A dead SSH process, or a port that a probe *did* reach and found + closed, is a verdict. A probe that never ran is not: it says the + inspector failed, not the tunnel. Callers use this to keep the two + apart — only a conclusive unhealthy reading may escalate (the daemon + force-reconnects after ``_HEALTH_FAILURE_THRESHOLD`` of them), because + a probe connection fails for reasons that leave the tunnel untouched. + """ + if not self.process_alive: + return True + return self.error is None and self.remote_probe_error is None def __str__(self) -> str: # human-friendly one-liner for logs - ports = { - port: ("ok" if ok else "down") for port, ok in self.remote_ports.items() - } + remote: object + if self.remote_probe_error is not None: + remote = "unknown" + else: + remote = { + port: ("ok" if ok else "down") + for port, ok in self.remote_ports.items() + } local = { port: ("ok" if ok else "down") for port, ok in self.local_ports.items() } return ( f"process={'alive' if self.process_alive else 'dead'}, " - f"remote_ports={ports}, " + f"remote_ports={remote}, " f"local_ports={local}," f" healthy={self.all_healthy}" + + ("" if self.conclusive else ", conclusive=False") + (f", error={self.error!r}" if self.error else "") + + ( + f", probe_error={self.remote_probe_error!r}" + if self.remote_probe_error + else "" + ) ) @@ -115,9 +152,16 @@ def check(self) -> HealthStatus: # 2. Are the remote forwarding ports listening? remote_ports: dict[int, bool] = {} + remote_probe_error: str | None = None if self.remote_check_enabled: try: remote_ports = self.check_remote_ports() + except ProbeError as exc: + # The probe never ran. The ports are *unknown*, not down: + # leaving them out entirely means no display layer can turn a + # failed probe connection into "未监听". + remote_probe_error = str(exc) + logger.debug("remote port probe could not run: %s", exc) except Exception as exc: # noqa: BLE001 error_messages.append( f"remote port check failed: {type(exc).__name__}: {exc}" @@ -136,12 +180,13 @@ def check(self) -> HealthStatus: f"local port check failed: {type(exc).__name__}: {exc}" ) - # 4. Aggregate. An error on any sub-check makes the result unhealthy — - # a failed probe is indistinguishable from a down tunnel, so be - # conservative. + # 4. Aggregate. An unexpected error on any sub-check makes the result + # unhealthy — not healthy is the safe default — but it is *not* a + # verdict, so callers must read ``conclusive`` before escalating. error = "; ".join(error_messages) if error_messages else None all_healthy = ( error is None + and remote_probe_error is None and process_alive and all(remote_ports.values()) and all(local_ports.values()) @@ -153,6 +198,7 @@ def check(self) -> HealthStatus: timestamp=snapshot_time, error=error, local_ports=local_ports, + remote_probe_error=remote_probe_error, ) def check_remote_ports(self) -> dict[int, bool]: @@ -160,7 +206,9 @@ def check_remote_ports(self) -> dict[int, bool]: Returns a ``{port: bool}`` mapping of which configured remote ports are listening. Tolerates both a ``dict[int, bool]`` and a simple iterable of - open ports as return values. + open ports as return values. A :class:`~ponte.core.ProbeError` from the + manager propagates untouched: it means the probe connection failed and + the ports were never observed. """ method = getattr(self.manager, "check_remote_ports", None) if not callable(method): @@ -236,7 +284,10 @@ def run_loop( Every remote-port check opens a new SSH connection, so a prolonged outage must not hammer the server hard enough to trip ``MaxStartups``. The interval returns to the base ``interval`` as soon as a check is - healthy again. + healthy again. An inconclusive check (a probe connection that failed) + counts as "not healthy" here on purpose: backing off is exactly what a + rate-limiting path wants, even though the daemon will not treat it as + evidence that the tunnel is down. """ if interval is None: interval = self.check_interval diff --git a/ponte/main.py b/ponte/main.py index eaa3a74..c5d2ece 100644 --- a/ponte/main.py +++ b/ponte/main.py @@ -13,6 +13,7 @@ import dataclasses import json import os +import shlex import sys import time import webbrowser @@ -29,11 +30,16 @@ from ponte import __version__ from ponte.config import ( ConfigError, + DaemonConfig, ServeConfig, + TunnelConfig, + daemon_paths_from_file, get_config, init_config, + load_config, set_config_path, ) +from ponte.core import ProbeError from ponte.daemon import _format_duration from ponte.doctor import FAIL, OK, SKIP, WARN, counts, run_checks from ponte.serve import create_server, serve_url @@ -117,6 +123,40 @@ def _daemon() -> TunnelDaemon: return TunnelDaemon(config=get_config()) +def _control_daemon() -> TunnelDaemon: + """A daemon handle for the control/observe commands (stop/status/logs/watch). + + Those commands need only the pid/log paths, so they must survive a config + that no longer validates: one bad line in a tunnel rule should never leave + a user unable to stop the daemon they need to stop. When the strict load + fails we recover just ``[daemon]`` from the raw TOML and carry on, saying + so on stderr (stdout stays clean for ``status --json``). + + ``start``/``restart``/``install`` deliberately do *not* use this: starting + from a broken config should still fail, and ``restart`` must fail *before* + it stops anything. + """ + from ponte.daemon import TunnelDaemon + + try: + # Delegating to ``_daemon()`` keeps a single injection point: tests and + # callers that replace ``ponte.main._daemon`` still control this path. + return _daemon() + except ConfigError as exc: + pid_file, log_file = daemon_paths_from_file() + first_line = str(exc).strip().splitlines()[0] if str(exc).strip() else "配置无效" + err_console.print( + f"[yellow]配置无法解析({escape(first_line)})," + "已仅按 [daemon] 路径定位守护进程[/yellow]" + ) + return TunnelDaemon( + config=TunnelConfig( + profiles=[], + daemon=DaemonConfig(pid_file=pid_file, log_file=log_file), + ) + ) + + def _fail(message: str) -> NoReturn: """Print a red ``错误:`` message to stderr and exit with status 1.""" err_console.print(f"[bold red]错误:{escape(message)}[/bold red]") @@ -173,7 +213,7 @@ def start( def stop() -> None: """停止反向隧道守护进程。""" try: - daemon = _daemon() + daemon = _control_daemon() if not daemon.status().running: console.print("[grey]未运行[/grey]") raise typer.Exit(code=0) @@ -188,6 +228,35 @@ def stop() -> None: _fail(str(exc)) +@app.command() +def reload() -> None: + """重载配置:只重启真的改过的隧道,其它连接不中断。""" + try: + daemon = _daemon() + # 先在本进程把新配置解析一遍:写错了要立刻告诉用户, + # 而不是让守护进程读到坏配置后把正在跑的隧道一起拆掉。 + source = daemon.config.source_path + if source: + try: + fresh = load_config(source) + except ConfigError as exc: + _fail(f"配置有问题,未发送重载请求(守护进程继续用旧配置):{exc}") + for warning in fresh.warnings: + console.print(f"[yellow]警告:{escape(warning)}[/yellow]") + + if not daemon.status().running: + console.print("[grey]未运行(可 ponte start 启动)[/grey]") + raise typer.Exit(code=0) + + daemon.request_reload() + console.print("[green]已请求重载[/green]") + console.print("[dim]配置未变的隧道不会中断;结果见 ponte logs -f[/dim]") + except typer.Exit: + raise + except Exception as exc: + _fail(str(exc)) + + @app.command() def restart() -> None: """重启反向隧道守护进程(先停止,再以后台模式启动)。""" @@ -224,7 +293,7 @@ def status( ) -> None: """查看守护进程与各隧道健康状态(每条隧道一行)。""" try: - s = _daemon().status() + s = _control_daemon().status() if not s.running: if json_output: console.print_json(json.dumps({"running": False})) @@ -270,13 +339,20 @@ def _status_table(title: str) -> Table: return table -def _markup_health(healthy: bool | None, error: str | None) -> str: - """Render a health flag, with the probe error when there is one.""" +def _markup_health( + healthy: bool | None, error: str | None, conclusive: bool | None = None +) -> str: + """Render a health flag, with the probe error when there is one. + + A check that never got to ask (``conclusive is False``) is *未知*, not + 异常: a failed probe connection says nothing about the tunnel, and painting + it red is how a healthy tunnel gets "fixed" until it breaks. + """ if healthy is True: return "[green]健康[/green]" - if healthy is None: - return "[yellow]未知[/yellow]" detail = escape(error or "") + if healthy is None or conclusive is False: + return "[yellow]未知[/yellow]" + (f"({detail})" if detail else "") return "[red]异常[/red]" + (f"({detail})" if detail else "") @@ -295,7 +371,14 @@ def _add_profile_rows(table: Table, profile) -> None: # noqa: ANN001 - cycle gu # 目标放在第一行:多条隧道时,最先要说清的是“这张表是哪个服务器”。 if profile.destination: table.add_row("目标", escape(profile.destination)) - table.add_row("健康状态", _markup_health(profile.healthy, profile.health_error)) + table.add_row( + "健康状态", + _markup_health( + profile.healthy, + profile.probe_error or profile.health_error, + profile.health_conclusive, + ), + ) # 会话时长是区分“守护进程活了多久”与“隧道活了多久”的那一列。 if profile.current_session_at is not None: @@ -339,6 +422,11 @@ def _profile_payload(profile) -> dict: # noqa: ANN001 - ProfileStatus cycle gua "healthy": profile.healthy, "process_alive": profile.process_alive, "health_error": profile.health_error, + # ``healthy: false`` with ``health_conclusive: false`` means the check + # could not be completed — the ports below are empty because they were + # never observed, not because they were found closed. + "health_conclusive": profile.health_conclusive, + "probe_error": profile.probe_error, "error": profile.error, "remote_ports": {str(p): ok for p, ok in profile.remote_ports.items()}, "local_ports": {str(p): ok for p, ok in profile.local_ports.items()}, @@ -399,7 +487,7 @@ def logs( ) -> None: """查看守护进程日志(默认只看尾部,-f 跟随)。""" try: - log_file = _daemon().log_file + log_file = _control_daemon().log_file if not os.path.isfile(log_file): console.print("[yellow]尚无日志(daemon 从未启动?)[/yellow]") raise typer.Exit(code=0) @@ -471,7 +559,14 @@ def _render_profile_feed(profile) -> RenderableType: # noqa: ANN001 - cycle gua def _render_profile_watch(profile) -> RenderableType: # noqa: ANN001 - cycle guard """One profile's dashboard block: statistics grid + event feed.""" table = Table.grid(padding=(0, 2)) - table.add_row("健康状态", _markup_health(profile.healthy, profile.health_error)) + table.add_row( + "健康状态", + _markup_health( + profile.healthy, + profile.probe_error or profile.health_error, + profile.health_conclusive, + ), + ) if profile.current_session_at is not None: table.add_row( @@ -551,7 +646,7 @@ def watch( ), ) -> None: """实时看板:在终端里持续刷新隧道健康与会话统计。""" - daemon = _daemon() + daemon = _control_daemon() try: with Live( _render_watch(daemon.status()), @@ -705,7 +800,14 @@ def check( any_port = False for name in names: label = "" if len(names) == 1 else f"{name} " - remote = daemon.check_remote_ports(timeout=timeout, profile=name) + try: + remote = daemon.check_remote_ports(timeout=timeout, profile=name) + except ProbeError as exc: + # 探测连接没建起来 → 未知,不是“未监听”;也不能因为一条 profile + # 探不到就让其余 profile 的结果看不到。 + console.print(f"{label}远程端口: [yellow]未知[/yellow]({escape(str(exc))})") + any_port = True + remote = {} local = daemon.check_local_ports(profile=name) for port, ok in sorted(remote.items()): any_port = True @@ -838,11 +940,39 @@ def uninstall() -> None: _fail(str(exc)) +def _print_ssh_commands(cfg: TunnelConfig) -> None: + """Print the exact ``ssh`` argv ponte runs, one line per profile. + + The config is a description; this is the command. It answers "what does + ponte actually execute?" without guessing — especially useful now that + omitting ``user``/``identity_file`` hands those decisions to OpenSSH. + """ + from ponte.core import TunnelManager + + multiple = len(cfg.profiles) > 1 + for profile in cfg.profiles: + if multiple: + console.print(f"{escape(profile.name)}:", markup=False) + console.print( + shlex.join(TunnelManager(cfg, profile).build_args()), + markup=False, + highlight=False, + soft_wrap=True, + ) + + @app.command() -def config() -> None: +def config( + ssh_command: bool = typer.Option( + False, "--ssh-command", help="只打印实际会执行的 ssh 命令(逐条隧道)" + ), +) -> None: """打印当前生效的配置关键项。""" try: cfg = get_config() + if ssh_command: + _print_ssh_commands(cfg) + return retry = cfg.retry health = cfg.health @@ -853,10 +983,15 @@ def config() -> None: multiple = len(cfg.profiles) > 1 for profile in cfg.profiles: prefix = f"{profile.name} · " if multiple else "" - table.add_row( - f"{prefix}服务器", f"{profile.ssh.user}@{profile.ssh.host}" - ) + # ``destination`` (not ``user@host``) so an omitted user prints as + # the bare host it actually passes to ssh. + table.add_row(f"{prefix}服务器", escape(profile.destination)) table.add_row(f"{prefix}SSH 端口", str(profile.ssh.port)) + if profile.ssh.proxy_jump: + table.add_row( + f"{prefix}跳板机", + escape(profile.ssh.proxy_jump) + "(ssh -J,逐跳由 OpenSSH 自己建立)", + ) tunnel_lines = [ t.summary + (f" ({escape(t.description)})" if t.description else "") for t in profile.tunnels @@ -925,7 +1060,8 @@ def init( console.print( "[dim]请填写 " + escape("[ssh]") - + " 的 host / user / identity_file,然后运行 ponte test[/dim]" + + " 的 host(user / identity_file 可省略,省略即复用 ~/.ssh/config)," + "然后运行 ponte test[/dim]" ) console.print( "[dim]换其它配置文件:ponte --config … 或设置 PONTE_CONFIG[/dim]" diff --git a/ponte/serve.py b/ponte/serve.py index 5c0e24f..3759990 100644 --- a/ponte/serve.py +++ b/ponte/serve.py @@ -102,6 +102,53 @@ def _profiles(payload: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: } +def _health_detail(section: Mapping[str, Any]) -> str | None: + """The reason attached to a profile's health mark, if any. + + ``probe_error`` is listed as well as ``health_error`` because a failed + probe reports itself separately — that is what makes "we could not ask" + distinguishable from "we asked and the answer was no". + """ + return section.get("health_error") or section.get("probe_error") or None + + +def _failure_reason(section: Mapping[str, Any]) -> str | None: + """Why a profile is unhealthy, when the daemon recorded no reason of its own. + + A conclusive failure often carries no error string: the probe ran fine and + simply found a port closed, so the reason has to be read back out of the + observed port states. Without this, ``/healthz`` answers ``503 degraded`` + and leaves the reader with nothing to act on. + """ + detail = _health_detail(section) + if detail: + return detail + if section.get("process_alive") is False: + return "SSH process is not running" + reasons: list[str] = [] + for key, kind in (("remote_ports", "remote"), ("local_ports", "local")): + ports = section.get(key) + if not isinstance(ports, Mapping): + continue + for port, listening in sorted(ports.items(), key=lambda item: str(item[0])): + if listening is False: + label = "port" if kind == "remote" else "local port" + reasons.append(f"{label} {port} is not listening") + return "; ".join(reasons) or None + + +def _inconclusive(section: Mapping[str, Any]) -> bool: + """Whether a profile's last check failed to reach a verdict. + + The daemon marks this explicitly (``health_conclusive``): ``healthy`` is + False either way, but only a *conclusive* failure is evidence that the + tunnel is broken. A shared/NATed uplink drops the probe's own connection + often enough that reporting every such tick as degraded turns a monitoring + endpoint into noise nobody reads. + """ + return section.get("healthy") is False and section.get("health_conclusive") is False + + def _as_float(value: Any, default: float | None = None) -> float | None: """Coerce a JSON number, returning *default* for anything else.""" if isinstance(value, bool) or not isinstance(value, (int, float)): @@ -128,10 +175,14 @@ def health_response(payload: Mapping[str, Any]) -> tuple[int, dict[str, Any]]: The code reports whether the tunnel works, which is the whole reason to have this endpoint instead of pinging the PID file: - * ``503 down`` — the daemon is not running: nothing is being forwarded. - * ``503 degraded`` — running, but at least one profile is unhealthy. - * ``200 ok`` — running, and every profile is healthy. - * ``200 starting`` — running, but no health check has reported yet. A fresh + * ``503 down`` — the daemon is not running: nothing is being forwarded. + * ``503 degraded`` — running, but at least one profile conclusively failed. + * ``200 unverified`` — running, but every recent check failed to reach a + verdict: the probe's own connection could not be made, so the state is + unknown rather than broken. Kept out of ``degraded`` on purpose — a + flaky path to the server would otherwise page on every restart. + * ``200 ok`` — running, and every profile is healthy. + * ``200 starting`` — running, but no health check has reported yet. A fresh ``ponte start`` waits up to ``check_interval`` (60 s by default) for its first answer; calling that "down" would fire a false alert on every restart, and "no data yet" is not evidence of failure. @@ -143,21 +194,36 @@ def health_response(payload: Mapping[str, Any]) -> tuple[int, dict[str, Any]]: if not profiles: return 200, {"status": "starting", "reason": "no profile has reported yet"} + unknown = sorted(name for name, s in profiles.items() if _inconclusive(s)) unhealthy = sorted( - name for name, section in profiles.items() if section.get("healthy") is False + name + for name, section in profiles.items() + if section.get("healthy") is False and not _inconclusive(section) ) if unhealthy: - errors = { - name: profiles[name].get("health_error") - for name in unhealthy - if profiles[name].get("health_error") - } - return 503, { + errors: dict[str, Any] = {} + for name in unhealthy: + detail = _failure_reason(profiles[name]) + if detail: + errors[name] = detail + body: dict[str, Any] = { "status": "degraded", "profiles": len(profiles), "unhealthy": unhealthy, "errors": errors, } + if unknown: + body["unknown"] = unknown + return 503, body + + if unknown: + return 200, { + "status": "unverified", + "profiles": len(profiles), + "unknown": unknown, + "reason": "health checks could not be completed; the tunnel state " + "is unknown, not broken", + } if all(section.get("healthy") is True for section in profiles.values()): return 200, {"status": "ok", "profiles": len(profiles)} @@ -294,7 +360,7 @@ def render_metrics(payload: Mapping[str, Any], *, now: float | None = None) -> s families.add( "ponte_profiles_unhealthy", "gauge", - "Profiles that are currently unhealthy.", + "Profiles that conclusively failed their last health check.", [ _series( "ponte_profiles_unhealthy", @@ -303,10 +369,24 @@ def render_metrics(payload: Mapping[str, Any], *, now: float | None = None) -> s 1 for section in profiles.values() if section.get("healthy") is False + and not _inconclusive(section) ), ) ], ) + families.add( + "ponte_profiles_unknown", + "gauge", + "Profiles whose last health check could not be completed " + "(unknown state, not necessarily broken).", + [ + _series( + "ponte_profiles_unknown", + {}, + sum(1 for section in profiles.values() if _inconclusive(section)), + ) + ], + ) # Identity lives in one info metric instead of being repeated as a label on # every series: the samples stay keyed by profile (what a legend wants), and # a scrape does not re-encode the same destination a dozen times. @@ -344,8 +424,10 @@ def add( add( "ponte_profile_healthy", "gauge", - "1 when every health check of the profile passes; absent until the first check.", - lambda section: section.get("healthy"), + "1 when every health check of the profile passes; absent until the first " + "check, and absent while a check cannot be completed (see " + "ponte_profiles_unknown) — an unanswered probe is not a failed tunnel.", + lambda section: None if _inconclusive(section) else section.get("healthy"), ) add( "ponte_profile_process_alive", @@ -541,6 +623,8 @@ def _health_pill(section: Mapping[str, Any]) -> str: if healthy is True: return _pill("健康", "ok") if healthy is False: + if _inconclusive(section): + return _pill("未知", "unknown") return _pill("异常", "bad") return _pill("未知", "unknown") @@ -609,7 +693,12 @@ def _feed(section: Mapping[str, Any]) -> str: def _profile_card(name: str, section: Mapping[str, Any], *, now: float) -> str: """One profile's card: identity, statistics, ports and event feed.""" healthy = section.get("healthy") - tone = "ok" if healthy is True else ("bad" if healthy is False else "unknown") + if healthy is True: + tone = "ok" + elif healthy is False and not _inconclusive(section): + tone = "bad" + else: + tone = "unknown" rows: list[str] = [] if section.get("destination"): @@ -670,6 +759,9 @@ def _profile_card(name: str, section: Mapping[str, Any], *, now: float) -> str: if notified is not None: rows.append(_row("上次通知", f"{_format_duration(now - notified)}前")) + if section.get("probe_error"): + rows.append(_row("探测失败(端口状态未知)", section["probe_error"])) + if section.get("health_error"): rows.append(_row("检查错误", section["health_error"])) if section.get("error"): @@ -689,10 +781,17 @@ def _summary(payload: Mapping[str, Any], profiles: Mapping[str, Any]) -> str: if not payload.get("running"): return _pill("守护进程未运行", "bad") + '可执行 ponte start 启动' unhealthy = [ - name for name, section in profiles.items() if section.get("healthy") is False + name + for name, section in profiles.items() + if section.get("healthy") is False and not _inconclusive(section) ] + unknown = [name for name, section in profiles.items() if _inconclusive(section)] if unhealthy: overall = _pill(f"{len(unhealthy)}/{len(profiles)} 条隧道异常", "bad") + if unknown: + overall += _pill(f"{len(unknown)} 条状态未知", "unknown") + elif unknown: + overall = _pill(f"{len(unknown)}/{len(profiles)} 条隧道状态未知", "unknown") elif profiles and all( section.get("healthy") is True for section in profiles.values() ): diff --git a/tests/test_config.py b/tests/test_config.py index 528948f..1f4adc6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,7 +11,9 @@ ConfigNotFoundError, ConfigParseError, ConfigValidationError, + JumpHop, TunnelConfig, + daemon_paths_from_file, ensure_bindable, get_config, is_loopback_host, @@ -1060,3 +1062,201 @@ def test_ensure_bindable_treats_an_empty_host_as_exposed() -> None: with pytest.raises(ConfigValidationError, match="token"): ensure_bindable("", "") ensure_bindable("", "s3cret") + + +# --------------------------------------------------------------------------- +# [ssh] —— 省略 user / identity_file 即复用 ~/.ssh/config +# --------------------------------------------------------------------------- + + +def test_ssh_host_only_defers_to_ssh_config(tmp_path) -> None: + """只写 host 必须能解析:user/identity_file 交给 ~/.ssh/config 与 ssh-agent。""" + body = """ +[ssh] +host = "myserver" + +[[tunnels]] +remote_port = 23334 +local_host = "localhost" +local_port = 2222 +""" + cfg = load_config(_write_toml(tmp_path, body)) + assert cfg.ssh.host == "myserver" + assert cfg.ssh.user == "" + assert cfg.ssh.identity_file is None + # 只有主机名,ssh 才能套用 Host 别名里的 User / IdentityFile + assert cfg.ssh.destination == "myserver" + assert cfg.ssh.port == 22 + + +# --------------------------------------------------------------------------- +# [ssh] jump —— 经跳板机连接(ProxyJump / -J) +# --------------------------------------------------------------------------- + +_JUMP_TUNNEL = """ +[[tunnels]] +remote_port = 23334 +local_host = "localhost" +local_port = 2222 +""" + + +def test_ssh_jump_single_hop(tmp_path) -> None: + """jump 解析成 hop,之后原样交给 ssh -J;目标侧不受影响。""" + body = '[ssh]\nhost = "10.0.0.9"\njump = "ops@bastion.example.com"\n' + _JUMP_TUNNEL + cfg = load_config(_write_toml(tmp_path, body)) + assert cfg.ssh.proxy_jump == "ops@bastion.example.com" + assert cfg.ssh.first_hop == JumpHop(host="bastion.example.com", user="ops") + assert cfg.ssh.destination == "10.0.0.9" + + +def test_ssh_jump_accepts_the_openssh_spelling_and_port(tmp_path) -> None: + """proxy_jump 是同一个设置;host:port 写法保留端口(非 22 必须留在 -J 里)。""" + body = ( + '[ssh]\nhost = "10.0.0.9"\nproxy_jump = "ops@bastion.example.com:2222"\n' + + _JUMP_TUNNEL + ) + cfg = load_config(_write_toml(tmp_path, body)) + assert cfg.ssh.proxy_jump == "ops@bastion.example.com:2222" + assert cfg.ssh.first_hop is not None + assert cfg.ssh.first_hop.port == 2222 + + +def test_ssh_jump_chain_keeps_every_hop(tmp_path) -> None: + """多跳:逗号分隔,顺序保留,IPv6 字面量加回刮号。""" + body = ( + '[ssh]\nhost = "10.0.0.9"\n' + 'jump = "ops@hop1:2222, root@[::1], hop3"\n' + _JUMP_TUNNEL + ) + cfg = load_config(_write_toml(tmp_path, body)) + assert [hop.render() for hop in cfg.ssh.jumps] == [ + "ops@hop1:2222", + "root@[::1]", + "hop3", + ] + assert cfg.ssh.proxy_jump == "ops@hop1:2222,root@[::1],hop3" + # 本机只可能直连第一跳,doctor 探测的就是它 + assert cfg.ssh.first_hop == JumpHop(host="hop1", user="ops", port=2222) + + +def test_ssh_jump_absent_is_none(tmp_path) -> None: + """没写 jump 时为 None,且不会输出空的 -J。""" + body = '[ssh]\nhost = "example.com"\n' + _JUMP_TUNNEL + cfg = load_config(_write_toml(tmp_path, body)) + assert cfg.ssh.jumps == () + assert cfg.ssh.proxy_jump is None + assert cfg.ssh.first_hop is None + + +@pytest.mark.parametrize( + "jump", + [ + "ops@", # 有空 user 没 host + "bastion:", # 冒号后没端口 + "a,,b", # 空 hop + "ops@bastion:0", # 端口越界 + "ops@bastion:70000", + "bastion example.com", + ], +) +def test_ssh_jump_rejects_bad_hop(tmp_path, jump: str) -> None: + body = f'[ssh]\nhost = "10.0.0.9"\njump = "{jump}"\n' + _JUMP_TUNNEL + with pytest.raises(ConfigValidationError): + load_config(_write_toml(tmp_path, body)) + + +def test_ssh_jump_rejects_both_spellings(tmp_path) -> None: + """两种写法同时出现是有歧义的,不能猜。""" + body = ( + '[ssh]\nhost = "10.0.0.9"\n' + 'jump = "hop1"\nproxy_jump = "hop2"\n' + _JUMP_TUNNEL + ) + with pytest.raises(ConfigValidationError, match="keep one"): + load_config(_write_toml(tmp_path, body)) + + +def test_ssh_jump_rejects_conflicting_options(tmp_path) -> None: + """jump 与 [ssh.options] ProxyJump/ProxyCommand 说的是同一件事,直接拒绝。""" + body = ( + '[ssh]\nhost = "10.0.0.9"\njump = "bastion"\n' + '[ssh.options]\nProxyJump = "other"\n' + _JUMP_TUNNEL + ) + with pytest.raises(ConfigValidationError, match="ProxyJump"): + load_config(_write_toml(tmp_path, body)) + + +def test_ssh_jump_works_inside_a_profile(tmp_path) -> None: + """profiles 布局里同样可用,而每个 profile 各有自己的跳板机。""" + body = ( + '[[profiles]]\nname = "web"\n' + '[profiles.ssh]\nhost = "10.0.0.9"\njump = "bastion"\n' + '[[profiles.tunnels]]\nremote_port = 23334\n' + 'local_host = "localhost"\nlocal_port = 2222\n' + ) + cfg = load_config(_write_toml(tmp_path, body)) + assert cfg.profiles[0].ssh.proxy_jump == "bastion" + + +def test_jumphop_render_and_destination() -> None: + """render() 是给 ssh 看的:省略默认端口、给 IPv6 加刮号。""" + assert JumpHop(host="bastion", user="ops").render() == "ops@bastion" + assert JumpHop(host="bastion", user="ops").destination == "ops@bastion" + assert JumpHop(host="bastion").render() == "bastion" + assert JumpHop(host="bastion", port=2200).render() == "bastion:2200" + assert JumpHop(host="::1", user="root", port=2200).render() == "root@[::1]:2200" + + +def test_ssh_user_without_identity_file(tmp_path) -> None: + """写了 user 但不写 identity_file:目标带 user@,密钥仍由 ssh 解析。""" + body = """ +[ssh] +host = "myserver" +user = "deploy" + +[[tunnels]] +remote_port = 23334 +local_host = "localhost" +local_port = 2222 +""" + cfg = load_config(_write_toml(tmp_path, body)) + assert cfg.ssh.destination == "deploy@myserver" + assert cfg.ssh.identity_file is None + + +# --------------------------------------------------------------------------- +# daemon_paths_from_file —— 配置坏掉时控制命令的兼底 +# --------------------------------------------------------------------------- + + +def test_daemon_paths_from_file_recovers_a_config_that_fails_validation(tmp_path) -> None: + """严格加载失败(缺隧道)仍要能取回 [daemon] 的 pid/log,否则 stop 被锁死。""" + pid = tmp_path / "custom.pid" + log = tmp_path / "custom.log" + body = ( + "[daemon]\n" + f'pid_file = "{pid.as_posix()}"\n' + f'log_file = "{log.as_posix()}"\n' + '\n[ssh]\nhost = "example.com"\n' + ) + path = _write_toml(tmp_path, body) + + with pytest.raises(ConfigValidationError): + load_config(path) + + assert daemon_paths_from_file(path) == (pid.as_posix(), log.as_posix()) + + +def test_daemon_paths_from_file_falls_back_when_toml_is_broken(tmp_path) -> None: + """连 TOML 都不合法时退回平台默认路径,而不是把异常抛给 stop。""" + pid_file, log_file = daemon_paths_from_file( + _write_toml(tmp_path, "not = toml = =\n") + ) + assert os.path.isabs(pid_file) and os.path.basename(pid_file) == "ponte.pid" + assert os.path.isabs(log_file) and os.path.basename(log_file) == "ponte.log" + + +def test_daemon_paths_from_file_falls_back_when_the_file_is_missing(tmp_path) -> None: + """配置文件被删掉也一样:返回默认路径,不报错。""" + pid_file, log_file = daemon_paths_from_file(tmp_path / "nope.toml") + assert os.path.isabs(pid_file) and os.path.basename(pid_file) == "ponte.pid" + assert os.path.isabs(log_file) and os.path.basename(log_file) == "ponte.log" diff --git a/tests/test_core.py b/tests/test_core.py index fb2ee04..ab91f26 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,16 +1,20 @@ """pytest tests for :mod:`ponte.core` (SSH arg building + port probe parsing). -No real SSH is spawned: ``subprocess.run`` is patched and the probe/connect -command parsing is exercised directly. +No real SSH is spawned: ``_run_capture`` (or ``Popen`` underneath it) is patched +and the probe/connect command parsing is exercised directly. """ from __future__ import annotations +import dataclasses import subprocess import sys import time +import pytest + from ponte.config import ( + JumpHop, Profile, SSHConfig, SSHOptions, @@ -18,7 +22,13 @@ TunnelConfig, WindowsConfig, ) -from ponte.core import TunnelManager, _find_ssh, creation_flags +from ponte.core import ( + ProbeError, + TunnelManager, + _find_ssh, + _run_capture, + creation_flags, +) # ``CREATE_NO_WINDOW`` is a Windows-only constant missing from ``subprocess`` # on POSIX. Referencing it directly would make the Windows-flag tests fail at @@ -99,29 +109,20 @@ def test_find_ssh_windows_config(monkeypatch) -> None: def test_test_connection_ok(monkeypatch) -> None: - monkeypatch.setattr( - "ponte.core.subprocess.run", - lambda *a, **k: __import__("types").SimpleNamespace(returncode=0, stdout="OK"), - ) + monkeypatch.setattr("ponte.core._run_capture", lambda *a, **k: (0, "OK")) tm = TunnelManager(_cfg()) assert tm.test_connection(timeout=5) is True def test_test_connection_fail(monkeypatch) -> None: - monkeypatch.setattr( - "ponte.core.subprocess.run", - lambda *a, **k: __import__("types").SimpleNamespace(returncode=1, stdout=""), - ) + monkeypatch.setattr("ponte.core._run_capture", lambda *a, **k: (1, "")) tm = TunnelManager(_cfg()) assert tm.test_connection(timeout=5) is False def test_check_remote_ports_python_probe(monkeypatch) -> None: # 服务器端 python3 分支:输出空格分隔的开放端口 - monkeypatch.setattr( - "ponte.core.subprocess.run", - lambda *a, **k: __import__("types").SimpleNamespace(returncode=0, stdout="23334\n"), - ) + monkeypatch.setattr("ponte.core._run_capture", lambda *a, **k: (0, "23334\n")) tm = TunnelManager(_cfg()) assert tm.check_remote_ports(timeout=5) == {23334: True, 17897: False} @@ -129,23 +130,117 @@ def test_check_remote_ports_python_probe(monkeypatch) -> None: def test_check_remote_ports_tool_fallback(monkeypatch) -> None: # 回退分支:ss 风格输出 ``*:23334 `` token monkeypatch.setattr( - "ponte.core.subprocess.run", - lambda *a, **k: __import__("types").SimpleNamespace( - returncode=0, - stdout="tcp LISTEN 0 128 0.0.0.0:23334 users:(())\n", - ), + "ponte.core._run_capture", + lambda *a, **k: (0, "tcp LISTEN 0 128 0.0.0.0:23334 users:(())\n"), ) tm = TunnelManager(_cfg()) assert tm.check_remote_ports(timeout=5) == {23334: True, 17897: False} -def test_check_remote_ports_error(monkeypatch) -> None: - monkeypatch.setattr( - "ponte.core.subprocess.run", - lambda *a, **k: (_ for _ in ()).throw(subprocess.SubprocessError("boom")), - ) +def test_check_remote_ports_error_is_unknown_not_down(monkeypatch) -> None: + """探针自己建不起来 → 状态未知,绝不能报成“端口未监听”。 + + 这条区别就是“没问到”和“问了、答案是没在听”的区别:把前者报成后者, + 健康监视器连续几次就会把一条完全正常的隧道强行重连。 + """ + + def _boom(*_args, **_kwargs): + raise subprocess.SubprocessError("boom") + + monkeypatch.setattr("ponte.core._run_capture", _boom) tm = TunnelManager(_cfg()) - assert tm.check_remote_ports(timeout=5) == {23334: False, 17897: False} + with pytest.raises(ProbeError) as excinfo: + tm.check_remote_ports(timeout=5) + assert "23334" in str(excinfo.value) and "未知" in str(excinfo.value) + + +def test_check_remote_ports_nonzero_exit_is_unknown(monkeypatch) -> None: + """ssh 以非 0 退出(认证失败/被重置/被限速/被超时 kill)→ 检查命令压根没跑。""" + monkeypatch.setattr("ponte.core._run_capture", lambda *a, **k: (255, "")) + with pytest.raises(ProbeError) as excinfo: + TunnelManager(_cfg()).check_remote_ports(timeout=5) + assert "255" in str(excinfo.value) + + +def test_check_remote_ports_wedged_pipe_is_unknown(monkeypatch) -> None: + """``_run_capture`` 放弃读取时返回 ``(None, "")`` —— 同样只能算未知。""" + monkeypatch.setattr("ponte.core._run_capture", lambda *a, **k: (None, "")) + with pytest.raises(ProbeError): + TunnelManager(_cfg()).check_remote_ports(timeout=5) + + +def test_check_remote_ports_probe_that_ran_may_still_report_closed(monkeypatch) -> None: + """探针跑通了、输出里没有这个端口 → 这才是真正的“未监听”(可据以告警)。""" + monkeypatch.setattr("ponte.core._run_capture", lambda *a, **k: (0, "23334\n")) + assert TunnelManager(_cfg()).check_remote_ports(timeout=5) == { + 23334: True, + 17897: False, + } + + +# --------------------------------------------------------------------------- +# _run_capture —— 探针读取必须永远有界 +# --------------------------------------------------------------------------- + + +def _fake_popen(monkeypatch, *, stdout: str = "", returncode: int = 0, + timeouts: int = 0, captured: dict | None = None): + """Patch ``Popen`` with a probe stand-in; *timeouts* is how many + ``communicate`` calls raise :class:`subprocess.TimeoutExpired` first.""" + state = {"timeouts": timeouts, "killed": False} + + class _Proc: + def __init__(self) -> None: + self.returncode = returncode + + def communicate(self, timeout=None): + if state["timeouts"] > 0: + state["timeouts"] -= 1 + raise subprocess.TimeoutExpired(cmd="ssh", timeout=timeout) + return stdout, "" + + def kill(self) -> None: + state["killed"] = True + + def _popen(args, **kwargs): + if captured is not None: + captured["args"] = args + captured.update(kwargs) + return _Proc() + + monkeypatch.setattr("ponte.core.subprocess.Popen", _popen) + return state + + +def test_run_capture_returns_stdout(monkeypatch) -> None: + _fake_popen(monkeypatch, stdout="23334\n", returncode=0) + assert _run_capture(["ssh", "host"], timeout=5) == (0, "23334\n") + + +def test_run_capture_never_inherits_handles(monkeypatch) -> None: + """探针不得继承/泄漏句柄:stdin 丢弃、close_fds 打开。 + + 继承来的管道写端会让 ``communicate()`` 永远等不到 EOF,这正是健康监视器 + 卡死数小时的成因。 + """ + captured: dict = {} + _fake_popen(monkeypatch, captured=captured) + _run_capture(["ssh", "host"], timeout=5) + assert captured["close_fds"] is True + assert captured["stdin"] is subprocess.DEVNULL + + +def test_run_capture_hangs_up_after_killing_a_wedged_probe(monkeypatch) -> None: + """超时被 kill 后若管道仍不关闭,必须放弃输出而不是永远阻塞调用方。""" + state = _fake_popen(monkeypatch, timeouts=2) + assert _run_capture(["ssh", "host"], timeout=0.1) == (None, "") + assert state["killed"] is True + + +def test_run_capture_keeps_output_when_the_killed_probe_still_reports(monkeypatch) -> None: + """超时后能读到的输出仍然有用(例如 ssh 已经把端口列表写出来了)。""" + _fake_popen(monkeypatch, stdout="23334", returncode=255, timeouts=1) + assert _run_capture(["ssh", "host"], timeout=0.1) == (255, "23334") # --------------------------------------------------------------------------- @@ -206,7 +301,7 @@ def test_check_remote_ports_skips_non_remote_kinds(monkeypatch) -> None: monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") calls: list[tuple] = [] monkeypatch.setattr( - "ponte.core.subprocess.run", lambda *a, **k: calls.append(a) + "ponte.core._run_capture", lambda args, **k: calls.append(args) ) tm = TunnelManager( _cfg( @@ -391,23 +486,92 @@ def poll(self): return self.returncode def _popen(*args, **kwargs): - captured["creationflags"] = kwargs.get("creationflags") + captured.update(kwargs) return _Proc() monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr("ponte.core.subprocess.Popen", _popen) TunnelManager(_cfg()).connect() assert captured["creationflags"] == _CREATE_NO_WINDOW_VALUE + # 长命 ssh 绝不能继承兄弟进程的管道:Windows 没有 close-on-exec, + # 被继承的写端会让持有读端的一方永远等不到 EOF。 + assert captured["close_fds"] is True def test_test_connection_passes_creationflags(monkeypatch) -> None: captured: dict = {} - - def _run(*args, **kwargs): - captured["creationflags"] = kwargs.get("creationflags") - return __import__("types").SimpleNamespace(returncode=0, stdout="OK") + _fake_popen(monkeypatch, stdout="OK", returncode=0, captured=captured) monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr("ponte.core.subprocess.run", _run) - TunnelManager(_cfg()).test_connection() + assert TunnelManager(_cfg()).test_connection() is True assert captured["creationflags"] == _CREATE_NO_WINDOW_VALUE + + +# --------------------------------------------------------------------------- +# [ssh] jump —— -J 必须出现在每一条通往服务器的命令里 +# --------------------------------------------------------------------------- + +_JUMP_HOPS = (JumpHop(host="bastion.example.com", user="ops"),) + + +def _jump_cfg() -> TunnelConfig: + """单一 profile,服务器只能从跳板机那一侧访问。""" + cfg = _cfg() + profile = dataclasses.replace( + cfg.profiles[0], + ssh=dataclasses.replace(cfg.profiles[0].ssh, jumps=_JUMP_HOPS), + ) + return dataclasses.replace(cfg, profiles=[profile]) + + +def test_build_args_passes_the_jump_host_to_ssh(monkeypatch) -> None: + monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") + args = TunnelManager(_jump_cfg()).build_args() + assert _flag_pairs(args, "-J") == [("-J", "ops@bastion.example.com")] + # 隧道仍然指向真正的服务器,跳板机只用来过路 + assert args[-1] == "testuser@example.com" + + +def test_the_login_test_goes_through_the_jump_host(monkeypatch) -> None: + """健康检查/ponte test 必须走同一条链路,否则隧道通了却报“登录失败”。""" + captured: dict = {} + + def _run(args, **_kwargs): + captured["args"] = args + return 0, "OK" + + monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") + monkeypatch.setattr("ponte.core._run_capture", _run) + assert TunnelManager(_jump_cfg()).test_connection(timeout=7) is True + assert _flag_pairs(captured["args"], "-J") == [("-J", "ops@bastion.example.com")] + assert captured["args"][-2:] == ["testuser@example.com", "echo OK"] + assert ("-o", "ConnectTimeout=7") in _flag_pairs(captured["args"], "-o") + + +def test_the_remote_port_probe_goes_through_the_jump_host(monkeypatch) -> None: + """服务端端口探测也要过跳板机,否则它连不上服务器、把健康的隧道报成异常。""" + captured: dict = {} + + def _run(args, **_kwargs): + captured["args"] = args + return 0, "23334" + + monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") + monkeypatch.setattr("ponte.core._run_capture", _run) + assert TunnelManager(_jump_cfg()).check_remote_ports(timeout=7)[23334] is True + assert _flag_pairs(captured["args"], "-J") == [("-J", "ops@bastion.example.com")] + + +def test_build_args_without_key_or_user_defers_to_ssh(monkeypatch) -> None: + """没有 identity_file / user 时不传 -i、也不拼 user@,交给 ssh 自己解析。""" + monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") + cfg = _cfg() + profile = dataclasses.replace( + cfg.profiles[0], + ssh=dataclasses.replace(cfg.profiles[0].ssh, identity_file=None, user=""), + ) + tm = TunnelManager(dataclasses.replace(cfg, profiles=[profile])) + args = tm.build_args() + assert "-i" not in args + assert args[-1] == "example.com" + assert "-p" not in args diff --git a/tests/test_daemon.py b/tests/test_daemon.py index af119ab..34f837b 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -16,17 +16,28 @@ import pytest -from ponte.config import Profile, SSHConfig, Tunnel, TunnelConfig, WindowsConfig +import ponte.daemon as daemon +from ponte.config import ( + Profile, + SSHConfig, + Tunnel, + TunnelConfig, + WindowsConfig, + load_config, +) from ponte.core import creation_flags from ponte.daemon import ( DaemonStatus, ProfileRunner, TunnelDaemon, _decode_console, + _derive_reload_marker, _derive_status_file, _derive_stop_marker, _encode_ps, _run_tool, + _windows_pid_alive, + _windows_process_listed, ) from ponte.health import HealthStatus from ponte.retry import RetryEvent @@ -107,6 +118,92 @@ def test_read_pid_missing(tmp_path) -> None: assert d.read_pid() is None +# --------------------------------------------------------------------------- +# Windows 进程存活判定 —— 非提权用户看不到 SYSTEM 守护进程 +# --------------------------------------------------------------------------- + + +def test_pid_alive_win32_uses_the_exit_code_when_openable(monkeypatch) -> None: + """能拿到句柄时以退出码为准:259 是 STILL_ACTIVE,其它就是已退出。""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr("ponte.daemon._windows_exit_code", lambda _pid: 259) + assert TunnelDaemon._pid_alive(4321) is True + + monkeypatch.setattr("ponte.daemon._windows_exit_code", lambda _pid: 1) + assert TunnelDaemon._pid_alive(4321) is False + + +def test_pid_alive_win32_falls_back_when_openprocess_is_denied(monkeypatch) -> None: + """ACCESS_DENIED(SYSTEM 身份跑着的守护进程)不能当成“没在跑”。 + + 误报“未运行”的代价很具体:ponte start 会再拉一个守护进程抢同一对服务器端口, + ponte stop 则拒绝停掉真正在跑的那个。 + """ + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr("ponte.daemon._windows_exit_code", lambda _pid: None) + + monkeypatch.setattr("ponte.daemon._windows_process_listed", lambda _pid: True) + assert TunnelDaemon._pid_alive(4321) is True + + # 进程真的不在了:同样拿不到句柄,但进程列表里找不到。 + monkeypatch.setattr("ponte.daemon._windows_process_listed", lambda _pid: False) + assert TunnelDaemon._pid_alive(4321) is False + + +def test_pid_alive_win32_assumes_alive_when_it_cannot_tell(monkeypatch) -> None: + """连进程列表都拿不到时宁可报“活着”:多拦一次 start 好过起两个守护进程。""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr("ponte.daemon._windows_exit_code", lambda _pid: None) + monkeypatch.setattr("ponte.daemon._windows_process_listed", lambda _pid: None) + assert TunnelDaemon._pid_alive(4321) is True + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_windows_process_listed_sees_a_real_process() -> None: + """真实调用 Toolhelp32(64 位下快照句柄很容易被截断,这里守住)。""" + assert _windows_process_listed(os.getpid()) is True + assert _windows_process_listed(2**31 - 1) is False + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_windows_pid_alive_reports_a_dead_pid_as_dead() -> None: + """不存在的 pid 必须判定为已退出(否则 ponte start 永远拒绝启动)。""" + assert _windows_pid_alive(os.getpid()) is True + assert _windows_pid_alive(2**31 - 1) is False + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_windows_pid_alive_sees_an_account_it_cannot_open() -> None: + """SYSTEM 跑着的进程,本用户拿不到句柄,也必须判为“活着”。 + + 这正是 ponte 在 Windows 推荐的部署:守护进程以 SYSTEM 开机即起, + status / stop 却由登录用户执行——拿不到句柄就报“未运行”的话, + ponte start 会再起一个守护进程抢同一对端口。 + """ + import subprocess + + import ponte.daemon as daemon_module + + out = subprocess.run( + ["tasklist", "/FI", "USERNAME eq SYSTEM", "/NH", "/FO", "CSV"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ).stdout + pid = None + for line in out.splitlines(): + parts = [part.strip('"') for part in line.split('","')] + if len(parts) >= 2 and parts[1].isdigit(): + pid = int(parts[1]) + break + if pid is None: + pytest.skip("no SYSTEM process to inspect") + if daemon_module._windows_exit_code(pid) is not None: + pytest.skip("this session can open SYSTEM processes (elevated?)") + assert _windows_pid_alive(pid) is True + + def test_status_not_running(tmp_path) -> None: d = TunnelDaemon(_cfg(tmp_path)) s = d.status() @@ -209,9 +306,12 @@ def test_cleanup_removes_pid_and_marker(tmp_path) -> None: d.write_pid() with open(d.stop_marker, "w", encoding="utf-8") as fh: fh.write("x") + with open(d.reload_marker, "w", encoding="utf-8") as fh: + fh.write("x") d._cleanup() assert not os.path.exists(cfg.daemon.pid_file) assert not os.path.exists(d.stop_marker) + assert not os.path.exists(d.reload_marker) def _windows_path_semantics(monkeypatch, *, executable: str, existing: set[str]) -> None: @@ -433,6 +533,17 @@ def _healthy_status() -> HealthStatus: ) +def _unknown_status() -> HealthStatus: + """探测连接失败:没观察到端口,因此也没有“未监听”的结论。""" + return HealthStatus( + process_alive=True, + remote_ports={}, + all_healthy=False, + timestamp=time.time(), + remote_probe_error="探测连接失败(ssh 退出码 255):23334 的状态未知", + ) + + def test_on_health_forces_reconnect_after_threshold(tmp_path) -> None: """连续 N 次 unhealthy 且进程活着 → manager.stop() 被调用,触发后计数归零。""" cfg = _cfg(tmp_path) @@ -486,6 +597,66 @@ def test_on_health_does_not_force_reconnect_when_process_dead(tmp_path) -> None: assert manager.stop_calls == 0 +def test_on_health_unknown_never_forces_reconnect(tmp_path) -> None: + """未知状态无论多少次都不触发强制重连。 + + 共享/NAT 出口下探测连接失败率很高(实测约 30%),若把它计入阈值, + 监视器会在一条完全健康的隧道上反复开工。“没问到”永远不是证据。 + """ + cfg = _cfg(tmp_path) + d = TunnelDaemon(cfg) + manager = _FakeManager() + + for _ in range(20): + d._on_health(_unknown_status(), manager) + assert manager.stop_calls == 0 + assert d._health_failures.get("default", 0) == 0 + + +def test_on_health_unknown_does_not_reset_conclusive_failures(tmp_path) -> None: + """未知不清零计数器:真假死在夹杂探测失败时仍要被抓到。""" + cfg = _cfg(tmp_path) + d = TunnelDaemon(cfg) + manager = _FakeManager() + + d._on_health(_unhealthy_status(), manager) + d._on_health(_unhealthy_status(), manager) + d._on_health(_unknown_status(), manager) # 不计入,也不清零 + assert manager.stop_calls == 0 + assert d._health_failures["default"] == 2 + + d._on_health(_unhealthy_status(), manager) # 第三次“确凿”失败 → 触发 + assert manager.stop_calls == 1 + + +def test_on_health_persists_the_unknown_mark(tmp_path) -> None: + """显示层靠这两个字段区分“未知”与“异常”,所以它们必须落到状态文件。""" + from ponte.daemon import _profile_status + + d = TunnelDaemon(_cfg(tmp_path)) + d._on_health(_unknown_status()) + section = _section(d) + assert section["healthy"] is False + assert section["health_conclusive"] is False + assert "255" in section["probe_error"] + assert section["remote_ports"] == {}, "没观察到的端口不得写成“未监听”" + + # 读回来同样保留这两个字段("未知"必须能穿过状态文件活到 CLI)。 + status = _profile_status("default", section) + assert status.healthy is False + assert status.health_conclusive is False + assert status.probe_error and "255" in status.probe_error + + +def test_on_health_persists_conclusive_mark(tmp_path) -> None: + """确凿失败仍然写成“可判定”,否则重连逻辑就永远不会触发。""" + d = TunnelDaemon(_cfg(tmp_path)) + d._on_health(_unhealthy_status()) + section = _section(d) + assert section["health_conclusive"] is True + assert section["probe_error"] is None + + # --------------------------------------------------------------------------- # 重连统计(tunnel statistics) # --------------------------------------------------------------------------- @@ -869,6 +1040,31 @@ def test_work_dir_is_config_directory_not_package_parent(tmp_path) -> None: assert TunnelDaemon(cfg).work_dir == str(tmp_path) +def test_work_dir_leaves_the_package_directory(monkeypatch, tmp_path) -> None: + """旧布局(配置就放在包目录里)不能拿包目录当工作目录。 + + 那里 `python -m ponte.main` 根本导入不到包:sys.path 上需要的是包的**父目录**。 + 子进程会秒死, ponte start 只报“未写 PID 文件”,ponte install 则注册一个 + 永远起不来的任务。 + """ + pkg = tmp_path / "pkg" + pkg.mkdir() + monkeypatch.setattr("ponte.daemon.package_dir", lambda: str(pkg)) + cfg = dataclasses.replace(_cfg(tmp_path), source_path=str(pkg / "config.toml")) + assert TunnelDaemon(cfg).work_dir == str(tmp_path) + + +def test_spawn_failure_includes_what_the_child_said(tmp_path) -> None: + """子进程什么也没说时,报错要把它残留的输出带上,而不是只给一个超时。""" + d = TunnelDaemon(_cfg(tmp_path)) + os.makedirs(os.path.dirname(d.pid_file), exist_ok=True) + with open(d.pid_file + ".spawn.log", "wb") as handle: + handle.write(b"ModuleNotFoundError: No module named 'ponte'\n") + + assert "ModuleNotFoundError" in daemon._spawn_tail(d.pid_file + ".spawn.log") + assert daemon._spawn_tail(str(tmp_path / "missing.log")) == "" + + def test_work_dir_falls_back_to_home(monkeypatch, tmp_path) -> None: monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) @@ -1105,3 +1301,183 @@ def test_status_leaves_the_target_unknown_for_a_dropped_profile(tmp_path) -> Non assert ghost is not None assert ghost.destination is None + + +# --------------------------------------------------------------------------- +# 配置热重载:ponte reload / SIGHUP +# --------------------------------------------------------------------------- + + +class _RecordingRunner: + """``ProfileRunner`` stand-in for reload tests: records lifecycle, no SSH.""" + + instances: list[_RecordingRunner] = [] + + def __init__(self, profile, config, daemon, **_kwargs) -> None: + self.profile = profile + self.notifier = daemon.notifier + self.started = False + self.finished = False + _RecordingRunner.instances.append(self) + + def start(self) -> None: + self.started = True + + def abort(self) -> None: + pass + + def finish(self) -> None: + self.finished = True + + def is_alive(self) -> bool: + return self.started and not self.finished + + +def _write_profiles( + tmp_path, specs, *, extra: str = "" +) -> TunnelConfig: + """Write a profiles-layout config (real key file) and return it loaded. + + *specs* is an iterable of ``(name, host)`` pairs, so a test can change just + one endpoint and prove only that profile is restarted. + """ + key = tmp_path / "id_rsa" + key.write_text("x", encoding="utf-8") + blocks = "" + for name, host in specs: + blocks += f""" +[[profiles]] +name = "{name}" + [profiles.ssh] + host = "{host}" + user = "u" + identity_file = "{key.as_posix()}" + [[profiles.tunnels]] + remote_port = 23334 + local_host = "localhost" + local_port = 2222 +""" + # pid/log must live in tmp_path: the platform default state dir belongs to + # the real user, and these tests would otherwise read each other's status + # files (and touch the operator's own). + daemon_section = ( + "[daemon]\n" + f'pid_file = "{(tmp_path / "ponte.pid").as_posix()}"\n' + f'log_file = "{(tmp_path / "ponte.log").as_posix()}"\n' + ) + path = tmp_path / "config.toml" + path.write_text(extra + daemon_section + blocks, encoding="utf-8") + return load_config(str(path)) + + +def _reload_daemon(tmp_path, monkeypatch, specs): + """A daemon whose runners are recording stubs, one per *specs* entry.""" + _RecordingRunner.instances = [] + monkeypatch.setattr("ponte.daemon.ProfileRunner", _RecordingRunner) + cfg = _write_profiles(tmp_path, specs) + d = TunnelDaemon(cfg) + d._runners = [_RecordingRunner(p, cfg, d) for p in cfg.profiles] + for runner in d._runners: + runner.start() + return d + + +def test_derive_reload_marker_from_pid() -> None: + assert _derive_reload_marker(r"C:\x\ponte.pid") == r"C:\x\ponte.reload" + + +def test_request_reload_writes_marker(tmp_path) -> None: + """请求重载只写标记文件(跨进程),由守护进程自己消费。""" + d = TunnelDaemon(_cfg(tmp_path)) + assert not os.path.exists(d.reload_marker) + d.request_reload() + assert os.path.exists(d.reload_marker) + + +def test_reconcile_keeps_unchanged_profiles(tmp_path, monkeypatch) -> None: + """配置没变的隧道重载后还是同一个 runner——连接不会被打断。""" + d = _reload_daemon( + tmp_path, monkeypatch, (("web", "web.example.com"), ("db", "db.example.com")) + ) + before = list(d._runners) + + summary = d._reconcile() + + assert d._runners == before + assert all(not runner.finished for runner in before) + assert "保持 2 条" in summary + + +def test_reconcile_restarts_only_the_changed_profile(tmp_path, monkeypatch) -> None: + """改一台服务器只重启那一条,其余(含新增)各归各位。""" + d = _reload_daemon( + tmp_path, monkeypatch, (("web", "web.example.com"), ("db", "db.example.com")) + ) + old = {runner.profile.name: runner for runner in d._runners} + + # web 换了一台机器,db 原样,另加一条 cache。 + _write_profiles( + tmp_path, + ( + ("web", "web2.example.com"), + ("db", "db.example.com"), + ("cache", "cache.example.com"), + ), + ) + summary = d._reconcile() + + current = {runner.profile.name: runner for runner in d._runners} + assert list(current) == ["web", "db", "cache"] # 顺序跟随配置 + assert current["db"] is old["db"] and not old["db"].finished + assert current["web"] is not old["web"] and old["web"].finished + assert current["cache"].started + assert "重启/新增" in summary + assert set(d._store.read_profiles()) == {"web", "db", "cache"} + + +def test_reconcile_removes_profiles_that_left_the_config(tmp_path, monkeypatch) -> None: + """配置里删掉的隧道要停掉,状态文件里也不再残留它的旧健康数据。""" + d = _reload_daemon( + tmp_path, monkeypatch, (("web", "web.example.com"), ("db", "db.example.com")) + ) + old = {runner.profile.name: runner for runner in d._runners} + + _write_profiles(tmp_path, (("web", "web.example.com"),)) + summary = d._reconcile() + + assert [runner.profile.name for runner in d._runners] == ["web"] + assert old["db"].finished + assert "移除 1 条" in summary + assert set(d._store.read_profiles()) == {"web"} + + +def test_reconcile_rebuilds_everything_when_policy_changes(tmp_path, monkeypatch) -> None: + """retry/health 是写进 runner 的策略,改了就必须整条重建。""" + d = _reload_daemon( + tmp_path, monkeypatch, (("web", "web.example.com"), ("db", "db.example.com")) + ) + old = {runner.profile.name: runner for runner in d._runners} + + _write_profiles( + tmp_path, + (("web", "web.example.com"), ("db", "db.example.com")), + extra="[retry]\nbase_delay = 7\n", + ) + summary = d._reconcile() + + assert all(old[name].finished for name in old) + assert all(runner is not old[runner.profile.name] for runner in d._runners) + assert "retry/health" in summary + + +def test_reconcile_keeps_running_config_on_a_broken_file(tmp_path, monkeypatch) -> None: + """配置文件写坏了必须原地保留:一个笔误不能把正在跑的隧道拆掉。""" + d = _reload_daemon(tmp_path, monkeypatch, (("web", "web.example.com"),)) + before = list(d._runners) + + (tmp_path / "config.toml").write_text("not = toml = =", encoding="utf-8") + summary = d._reconcile() + + assert "重载失败" in summary + assert d._runners == before + assert not before[0].finished diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 687bbaa..ba4e839 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -6,14 +6,16 @@ import sys from ponte.config import load_config +from ponte.core import ProbeError from ponte.daemon import DaemonStatus, ProfileStatus from ponte.doctor import FAIL, OK, SKIP, WARN, counts, run_checks -def _config(tmp_path, extra: str = "", tunnel: str | None = None): +def _config(tmp_path, extra: str = "", tunnel: str | None = None, jump: str | None = None): """A valid single-profile config; *extra* is appended as-is.""" key = tmp_path / "id_rsa" key.write_text("x", encoding="utf-8") + jump_line = f'jump = "{jump}"\n' if jump else "" rules = tunnel if tunnel is not None else """ [[tunnels]] remote_port = 23334 @@ -27,7 +29,7 @@ def _config(tmp_path, extra: str = "", tunnel: str | None = None): host = "example.com" user = "u" identity_file = "{key.as_posix()}" - +{jump_line} {rules} [daemon] pid_file = "{(tmp_path / 'ponte.pid').as_posix()}" @@ -52,9 +54,13 @@ def __init__( local: dict[int, bool] | None = None, service: bool | None = False, notifier: object | None = None, + conclusive: bool | None = True, + probe_error: str | None = None, ) -> None: self._running = running self._healthy = healthy + self._conclusive = conclusive + self._probe_error = probe_error self._reachable = reachable self._remote = remote if remote is not None else {23334: True} self._local = local if local is not None else {} @@ -70,7 +76,12 @@ def status(self) -> DaemonStatus: pid=4242, uptime_seconds=3600, profiles=[ - ProfileStatus(name="default", healthy=self._healthy, remote_ports=self._remote) + ProfileStatus( + name="default", + healthy=self._healthy, + health_conclusive=self._conclusive, + remote_ports=self._remote, + ) ], ) @@ -82,6 +93,8 @@ def check_remote_ports( self, timeout: int = 10, profile: str | None = None ) -> dict[int, bool]: self.calls.append(("remote", profile)) + if self._probe_error is not None: + raise ProbeError(self._probe_error) return self._remote def check_local_ports( @@ -167,6 +180,68 @@ def test_key_permission_row_exists_on_posix(tmp_path) -> None: assert "密钥权限" in names +# --------------------------------------------------------------------------- +# 跳板机 / ProxyJump +# --------------------------------------------------------------------------- + + +def test_without_a_jump_there_is_no_hop_row(tmp_path) -> None: + names = [result.name for result in run_checks(_config(tmp_path), None)] + assert "跳板机" not in names + + +def test_jump_row_passes_when_the_bastion_answers(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("ponte.doctor.port_is_open", lambda *_args: True) + result = _find(run_checks(_config(tmp_path, jump="ops@bastion:2222"), None), "跳板机") + assert result.status == OK + assert "ops@bastion" in result.detail + assert "bastion:2222" in result.detail + + +def test_jump_probes_the_first_hop_only(tmp_path, monkeypatch) -> None: + """只探测第一跳:后面几跳只能经前一跳到达,本机直接连它们会误报失败。""" + seen: list[tuple] = [] + + def _probe(host, port, timeout): # noqa: ANN001, ANN202 - 记录探测目标即可 + seen.append((host, port, timeout)) + return True + + monkeypatch.setattr("ponte.doctor.port_is_open", _probe) + config = _config(tmp_path, jump="ops@hop1:2222, root@hop2") + result = _find(run_checks(config, None, timeout=3), "跳板机") + assert seen == [("hop1", 2222, 3)] + assert result.status == OK + assert "后续跳" in result.detail + + +def test_unreachable_bastion_is_a_failure_with_the_command_to_try(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("ponte.doctor.port_is_open", lambda *_args: False) + result = _find(run_checks(_config(tmp_path, jump="ops@bastion"), None), "跳板机") + assert result.status == FAIL + assert "bastion:22" in result.detail + assert "ssh ops@bastion" in result.hint + + +def test_jump_row_is_skipped_offline(tmp_path, monkeypatch) -> None: + def _probe(*_args): # noqa: ANN202 - 离线时不该被调用 + raise AssertionError("offline 不该发起探测") + + monkeypatch.setattr("ponte.doctor.port_is_open", _probe) + config = _config(tmp_path, jump="ops@bastion") + result = _find(run_checks(config, None, offline=True), "跳板机") + assert result.status == SKIP + + +def test_connectivity_hint_names_the_bastion(tmp_path, monkeypatch) -> None: + """登录失败时提示的是跳板机,而不是再次叫人去确认服务器可达。""" + monkeypatch.setattr("ponte.doctor.port_is_open", lambda *_args: True) + config = _config(tmp_path, jump="ops@bastion") + connectivity = _find(run_checks(config, _FakeDaemon(reachable=False)), "SSH 连通性") + assert connectivity.status == FAIL + assert "ops@bastion" in connectivity.hint + assert "authorized_keys" not in connectivity.hint, "有跳板机时别再说去查服务器" + + def test_rows_are_prefixed_per_profile_when_there_are_several(tmp_path) -> None: """多隧道时每行都要带 profile 名,否则不知道是哪条连接出的问题。""" key = (tmp_path / "id_rsa").as_posix() @@ -217,6 +292,17 @@ def test_remote_port_down_is_a_failure(tmp_path) -> None: assert "ss -tlnp" in result.hint +def test_remote_port_probe_failure_is_not_a_failure(tmp_path) -> None: + """探测连接没建起来 = 无法判定:WARN 并说清原因,不能报成“未监听”。""" + daemon = _FakeDaemon( + running=True, probe_error="探测连接失败(ssh 退出码 255):23334 的状态未知" + ) + result = _find(run_checks(_config(tmp_path), daemon), "远程端口") + assert result.status == WARN + assert "255" in result.detail and "未知" in result.detail + assert not result.hint, "未知不是故障:不该给出查端口/看日志的修复提示" + + def test_local_port_down_is_a_warning(tmp_path) -> None: config = _config( tmp_path, @@ -301,6 +387,20 @@ def test_daemon_row_when_a_tunnel_is_broken(tmp_path) -> None: assert "ponte watch" in result.hint +def test_daemon_row_when_the_check_could_not_be_completed(tmp_path) -> None: + """healthy=False 但 health_conclusive=False → 无法判定,不是“异常”。""" + result = _find( + run_checks( + _config(tmp_path), + _FakeDaemon(running=True, healthy=False, conclusive=False), + ), + "守护进程", + ) + assert result.status == WARN + assert "无法判定" in result.detail + assert "default" in result.detail + + def test_daemon_row_when_health_is_unknown(tmp_path) -> None: result = _find( run_checks(_config(tmp_path), _FakeDaemon(running=True, healthy=None)), diff --git a/tests/test_health.py b/tests/test_health.py index 7c4dd94..5e1df05 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -8,6 +8,7 @@ import pytest from ponte.config import HealthConfig +from ponte.core import ProbeError from ponte.health import HealthChecker, HealthStatus @@ -22,10 +23,17 @@ def poll(self) -> int | None: class _TM: """A TunnelManager stand-in exposing ``process`` + ``check_remote_ports``.""" - def __init__(self, alive: bool = True, ports: str = "dict", fail_ports: bool = False) -> None: + def __init__( + self, + alive: bool = True, + ports: str = "dict", + fail_ports: bool = False, + probe_error: str | None = None, + ) -> None: self._proc = _Proc(alive) self.ports = ports self.fail_ports = fail_ports + self.probe_error = probe_error self._timeout: int | None = None @property @@ -34,6 +42,8 @@ def process(self) -> _Proc: def check_remote_ports(self, **kw) -> object: self._timeout = kw.get("timeout") + if self.probe_error is not None: + raise ProbeError(self.probe_error) if self.fail_ports: raise ConnectionError("refused") if self.ports == "dict": @@ -80,6 +90,65 @@ def test_port_check_failure() -> None: assert s.error is not None and "ConnectionError" in s.error +# --------------------------------------------------------------------------- +# 探测失败 = 未知,而不是“端口挂了” +# --------------------------------------------------------------------------- + + +def test_failed_probe_connection_reports_unknown_ports() -> None: + """探针连接建不起来 → ``remote_ports`` 为空(没观察到),并标记为不确定。 + + 以前这里会得到 ``{23334: False, 17897: False}``:同一份快照既骗显示 + (“未监听”),又让守护进程在连续三次后强杀一条健康的隧道。 + """ + hc = HealthChecker( + _TM(alive=True, probe_error="ssh 退出码 255:23334 的状态未知"), _hc() + ) + s = hc.check() + assert s.remote_ports == {} + assert s.remote_probe_error is not None and "255" in s.remote_probe_error + assert s.all_healthy is False + assert s.conclusive is False + assert "unknown" in str(s) + assert "conclusive=False" in str(s) + + +def test_a_probe_that_ran_still_speaks_definitively() -> None: + """探针跑通了并看到端口关着 → 这是判定(可告警、可触发重连),不是未知。""" + s = HealthChecker(_TM(alive=True, ports="dict"), _hc()).check() + assert s.remote_ports == {23334: True, 17897: False} + assert s.remote_probe_error is None + assert s.conclusive is True + + +def test_a_dead_process_is_conclusive_even_without_a_probe() -> None: + """进程已经退出:无需探针也知道隧道是断的,不能因为探针失败而含糊。""" + s = HealthStatus( + process_alive=False, + remote_ports={}, + all_healthy=False, + timestamp=time.time(), + remote_probe_error="探测连接失败(refused)", + ) + assert s.conclusive is True + + +def test_an_unexpected_subcheck_failure_is_not_conclusive() -> None: + """非 ProbeError 的意外异常同样不构成“隧道挂了”的证据。""" + s = HealthChecker(_TM(alive=True, ports="dict", fail_ports=True), _hc()).check() + assert s.all_healthy is False + assert s.error is not None and "ConnectionError" in s.error + assert s.conclusive is False + + +def test_an_inconclusive_check_still_backs_off() -> None: + """未知同样算“没健康”:退避是对的(路径限速时最不该做的是继续猛探)。""" + assert HealthChecker._backoff_interval(60.0, 1, 300.0) == 120.0 + hc = HealthChecker(_TM(alive=True, probe_error="probe down"), _hc()) + s = hc.check() + assert s.all_healthy is False # 因此 run_loop 会退避 + + def test_remote_check_disabled() -> None: cfg = HealthConfig(check_interval=60, remote_check_enabled=False, remote_check_timeout=10) hc = HealthChecker(_TM(alive=True, ports="bad"), cfg) diff --git a/tests/test_main.py b/tests/test_main.py index 909e5ce..e2974a9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -11,6 +11,7 @@ from ponte import __version__ from ponte.config import ( HealthConfig, + JumpHop, Profile, RetryConfig, ServeConfig, @@ -178,6 +179,42 @@ def status(self) -> DaemonStatus: ) +def test_markup_health_separates_unknown_from_broken() -> None: + """显示层:无法判定的检查是黄色“未知”,不是红色“异常”。""" + from ponte.main import _markup_health + + assert "未知" in _markup_health(False, "探测连接失败", False) + assert "异常" in _markup_health(False, "port closed", True) + # 旧状态文件没有这个字段:保持旧语义(宁可按异常提醒)。 + assert "异常" in _markup_health(False, "port closed") + assert "健康" in _markup_health(True, None, True) + assert "未知" in _markup_health(None, None, None) + + +def test_check_reports_an_unanswered_probe_as_unknown(monkeypatch) -> None: + """``ponte check`` 探不到时说“未知”,且不阻断其余 profile 的结果。""" + from ponte.core import ProbeError + + class _Daemon: + profile_names = ["default", "offsite"] + + def check_remote_ports(self, timeout=10, profile=None): + if profile == "default": + raise ProbeError("探测连接失败(ssh 退出码 255):23334 的状态未知") + return {23335: True} + + def check_local_ports(self, timeout=1.0, profile=None): + return {} + + monkeypatch.setattr("ponte.main._daemon", lambda: _Daemon()) + result = CliRunner().invoke(app, ["check"]) + + assert result.exit_code == 0 + assert "未知" in result.output and "255" in result.output + assert "23335" in result.output + assert "未监听" not in result.output + + def test_status_table_shows_tunnel_stats(monkeypatch) -> None: """默认表格输出包含会话统计与上次断线原因(信息缺口修复)。""" s = _status( @@ -207,6 +244,35 @@ def status(self) -> DaemonStatus: assert "ssh exited with code 255" in result.output +def test_stop_survives_a_config_that_no_longer_validates(tmp_path) -> None: + """一个笔误不该锁死 stop:配置校验失败时仍按 [daemon] 路径定位守护进程。""" + pid = tmp_path / "ponte.pid" + # 肯定不存在的 pid:确保不会误杀测试进程(stop 必须在“未运行”分支返回)。 + pid.write_text("999999999", encoding="utf-8") + path = tmp_path / "config.toml" + path.write_text( + "[daemon]\n" + f'pid_file = "{pid.as_posix()}"\n' + '\n[ssh]\nhost = "example.com"\n', # 缺 [[tunnels]] → 严格加载必失败 + encoding="utf-8", + ) + + result = CliRunner().invoke(app, ["--config", str(path), "stop"]) + + assert result.exit_code == 0 + assert "未运行" in result.output + + +def test_restart_still_refuses_a_broken_config(tmp_path) -> None: + """restart 必须校验成功再动手:否则坏配置会先把隧道停掉、再启动失败。""" + path = tmp_path / "config.toml" + path.write_text('[ssh]\nhost = "example.com"\n', encoding="utf-8") + + result = CliRunner().invoke(app, ["--config", str(path), "restart"]) + + assert result.exit_code == 1 + + def test_watch_renders_dashboard(monkeypatch) -> None: """watch 看板:一帧渲染包含健康、会话与事件流(不进入死循环)。""" from ponte.main import _render_watch, console @@ -354,6 +420,90 @@ def test_restart(monkeypatch) -> None: assert fake.started +def _write_reload_config(tmp_path) -> str: + """A minimal, valid single-tunnel config, for ``ponte reload`` tests.""" + key = tmp_path / "id_rsa" + key.write_text("x", encoding="utf-8") + path = tmp_path / "config.toml" + path.write_text( + "[ssh]\n" + 'host = "example.com"\n' + 'user = "u"\n' + f'identity_file = "{key.as_posix()}"\n' + "\n[[tunnels]]\n" + "remote_port = 23334\n" + 'local_host = "localhost"\n' + "local_port = 2222\n", + encoding="utf-8", + ) + return str(path) + + +def test_reload_command_requests_a_reload(monkeypatch, tmp_path) -> None: + """reload 只发出重载请求,不重启进程。""" + cfg = dataclasses.replace(_cfg(), source_path=_write_reload_config(tmp_path)) + + class _Daemon: + def __init__(self) -> None: + self.config = cfg + self.reloaded = False + + def status(self) -> DaemonStatus: + return DaemonStatus(running=True, pid=1, uptime_seconds=1) + + def request_reload(self) -> None: + self.reloaded = True + + fake = _Daemon() + monkeypatch.setattr("ponte.main._daemon", lambda: fake) + result = CliRunner().invoke(app, ["reload"]) + assert result.exit_code == 0 + assert fake.reloaded is True + assert "已请求重载" in result.output + + +def test_reload_command_refuses_a_broken_config(monkeypatch, tmp_path) -> None: + """写坏的配置必须在本地就被拦住,绝不能发给守护进程。""" + path = tmp_path / "config.toml" + path.write_text("not = toml = =", encoding="utf-8") + cfg = dataclasses.replace(_cfg(), source_path=str(path)) + + class _Daemon: + def __init__(self) -> None: + self.config = cfg + + def status(self) -> DaemonStatus: # pragma: no cover - must not be reached + raise AssertionError("坏配置不该走到读取状态这一步") + + def request_reload(self) -> None: # pragma: no cover + raise AssertionError("坏配置不得发出重载请求") + + monkeypatch.setattr("ponte.main._daemon", lambda: _Daemon()) + result = CliRunner().invoke(app, ["reload"]) + assert result.exit_code == 1 + assert "配置有问题" in result.output + + +def test_reload_when_not_running(monkeypatch, tmp_path) -> None: + """守护进程没跑就别留下孤零零的标记文件。""" + cfg = dataclasses.replace(_cfg(), source_path=_write_reload_config(tmp_path)) + + class _Daemon: + def __init__(self) -> None: + self.config = cfg + + def status(self) -> DaemonStatus: + return DaemonStatus(running=False) + + def request_reload(self) -> None: # pragma: no cover + raise AssertionError("未运行时不该写标记") + + monkeypatch.setattr("ponte.main._daemon", lambda: _Daemon()) + result = CliRunner().invoke(app, ["reload"]) + assert result.exit_code == 0 + assert "未运行" in result.output + + def test_test_command_ok(monkeypatch) -> None: fake = _FakeDaemonWithActions(test_ok=True) monkeypatch.setattr("ponte.main._daemon", lambda: fake) @@ -749,3 +899,40 @@ def status(self) -> DaemonStatus: assert result.exit_code == 0 payload = _json.loads(result.output) assert payload["profiles"]["default"]["destination"] == "testuser@example.com:22" + + +def test_config_ssh_command_prints_the_real_argv(monkeypatch) -> None: + """--ssh-command 要把 ponte 真正会执行的命令行原样吐出来。""" + monkeypatch.setattr("ponte.main.get_config", lambda: _cfg()) + monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") + result = CliRunner().invoke(app, ["config", "--ssh-command"]) + assert result.exit_code == 0 + assert "/usr/bin/ssh" in result.output + assert "-R 23334:localhost:2222" in result.output + assert "testuser@example.com" in result.output + + +def test_config_shows_the_jump_host(monkeypatch) -> None: + """跳板机要在 config 里看得见,--ssh-command 也要真的把 -J 拼进去。""" + cfg = _cfg() + profile = dataclasses.replace( + cfg.profiles[0], + ssh=dataclasses.replace( + cfg.profiles[0].ssh, jumps=(JumpHop(host="bastion", user="ops"),) + ), + ) + monkeypatch.setattr( + "ponte.main.get_config", lambda: dataclasses.replace(cfg, profiles=[profile]) + ) + monkeypatch.setattr("ponte.core._find_ssh", lambda _cfg: "/usr/bin/ssh") + + result = CliRunner().invoke(app, ["config"]) + assert result.exit_code == 0 + assert "跳板机" in result.output + assert "ops@bastion" in result.output + + argv = CliRunner().invoke(app, ["config", "--ssh-command"]) + assert argv.exit_code == 0 + assert "-J ops@bastion" in argv.output + + diff --git a/tests/test_serve.py b/tests/test_serve.py index ddf9cac..d205446 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -39,6 +39,8 @@ def _profile(**overrides) -> dict: "healthy": True, "process_alive": True, "health_error": None, + "health_conclusive": True, + "probe_error": None, "error": None, "remote_ports": {"23334": True}, "local_ports": {"1080": False}, @@ -178,6 +180,101 @@ def test_healthz_degraded_names_the_broken_profile() -> None: assert body["errors"] == {"db": "port 23335 is not listening"} +# --------------------------------------------------------------------------- +# 探测没跑成 ≠ 隧道挂了 +# --------------------------------------------------------------------------- + + +def _unanswered(**overrides) -> dict: + """一份“本次检查没有得出结论”的 section(守护进程写出来的样子)。""" + return _profile( + healthy=False, + health_conclusive=False, + probe_error="探测连接失败(ssh 退出码 255):23334 的状态未知", + remote_ports={}, + **overrides, + ) + + +def test_healthz_unverified_is_not_degraded() -> None: + """探针自己建不起来 → 200 unverified,而不是 503 degraded。 + + 否则一条共享/NAT 出口(探测连接失败率实测约 30%)会把监控变成噪声。 + """ + code, body = health_response(_payload(profiles={"web": _unanswered()})) + assert code == 200 + assert body["status"] == "unverified" + assert body["unknown"] == ["web"] + assert "unhealthy" not in body + + +def test_healthz_degraded_still_wins_and_names_the_unknown_ones() -> None: + """确凿失败优先:一个真坏 + 一个未知 → 503,且两者都点出来。""" + code, body = health_response( + _payload( + profiles={ + "web": _unanswered(), + "db": _profile(healthy=False, health_error="port 23335 is not listening"), + } + ) + ) + assert code == 503 + assert body["unhealthy"] == ["db"] + assert body["unknown"] == ["web"] + assert body["errors"] == {"db": "port 23335 is not listening"} + + +def test_metrics_do_not_call_an_unanswered_probe_a_failure() -> None: + """``ponte_profile_healthy`` 在未知时“缺样本”,而不是报 0。""" + families = _parse_families( + render_metrics( + _payload(profiles={"web": _unanswered(), "db": _profile()}), now=_NOW + ) + ) + assert _sample(families, "ponte_profile_healthy", profile="web") is None + assert _sample(families, "ponte_profile_healthy", profile="db") == ( + 'ponte_profile_healthy{profile="db"} 1' + ) + assert _sample(families, "ponte_profiles_unhealthy") == "ponte_profiles_unhealthy 0" + assert _sample(families, "ponte_profiles_unknown") == "ponte_profiles_unknown 1" + + +def test_dashboard_shows_unknown_with_its_reason() -> None: + """看板卡片必须是“未知”,不能是红色“异常”。""" + page = dashboard_html(_payload(profiles={"web": _unanswered()}), now=_NOW) + assert "未知" in page + assert "异常" not in page + assert "探测失败(端口状态未知)" in page + assert 'class="card unknown"' in page + + +def test_healthz_degraded_explains_a_closed_port_without_an_error_string() -> None: + """探针跑通了、看到端口关着:状态文件里没有 error 字符串,/healthz 也要说清原因。""" + code, body = health_response( + _payload( + profiles={ + "web": _profile( + healthy=False, remote_ports={"23334": False}, health_error=None + ) + } + ) + ) + assert code == 503 + assert body["unhealthy"] == ["web"] + # 远程和本地端口都点到:一条“为什么”比一个括号更有用。 + assert body["errors"] == { + "web": "port 23334 is not listening; local port 1080 is not listening" + } + + +def test_healthz_degraded_when_the_ssh_process_is_gone() -> None: + code, body = health_response( + _payload(profiles={"web": _profile(healthy=False, process_alive=False)}) + ) + assert code == 503 + assert body["errors"] == {"web": "SSH process is not running"} + + def test_healthz_starting_is_not_reported_as_down() -> None: """No health check yet must not fire a false alert on every restart. From b942b0b3594c0f1701fee6bbe765ef358a77ebbb Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 20 Sep 2026 03:40:40 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(cli):=20doctor=20--json=20=E4=B8=8E=20?= =?UTF-8?q?shell=20=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doctor 的体检表多一个 --json 形态(ok / counts / checks),退出码照样“有失败就非 0”, 这样 CI 在同一次运行里既能存下报告又能直接据它设门禁。typer 的 add_completion 打开, ponte --install-completion 可安装 bash / zsh / fish / PowerShell 补全。 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- ponte/main.py | 34 +++++++++++++++++++++++++-- tests/test_main.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/ponte/main.py b/ponte/main.py index c5d2ece..3a44f25 100644 --- a/ponte/main.py +++ b/ponte/main.py @@ -73,7 +73,8 @@ def _configure_utf8_stdio() -> None: no_args_is_help=True, invoke_without_command=True, help="管理 SSH 反向隧道守护进程的命令行工具", - add_completion=False, + # 生成 shell 补全(bash/zsh/fish/PowerShell):ponte --install-completion + add_completion=True, ) console = Console() err_console = Console(stderr=True) @@ -832,6 +833,9 @@ def check( def doctor( offline: bool = typer.Option(False, "--offline", help="跳过需要网络/SSH 的检查"), timeout: int = typer.Option(5, "--timeout", help="SSH 连通性测试超时(秒)"), + json_output: bool = typer.Option( + False, "--json", help="以 JSON 输出体检结果(供脚本 / CI 消费)" + ), ) -> None: """一键体检:配置、密钥、连通性、端口、自启与通知,逐项给结论与修法。""" try: @@ -849,6 +853,33 @@ def doctor( daemon = None checks = run_checks(cfg, daemon, offline=offline, timeout=timeout) + tally = counts(checks) + + if json_output: + # 与表格同一份结论,只是给脚本一个稳定的结构;退出码照样反映失败, + # 这样 CI 里既能把报告存下来,又可以直接用 if 判断。 + console.print_json( + json.dumps( + { + "ok": tally[FAIL] == 0, + "counts": tally, + "checks": [ + { + "name": check.name, + "status": check.status, + "detail": check.detail, + "hint": check.hint, + } + for check in checks + ], + }, + ensure_ascii=False, + ) + ) + if tally[FAIL]: + raise typer.Exit(code=1) + return + table = Table(title="ponte doctor", header_style="bold cyan") table.add_column("检查", no_wrap=True, style="cyan") table.add_column("结论", no_wrap=True) @@ -869,7 +900,6 @@ def doctor( ) console.print(table) - tally = counts(checks) console.print( f"通过 {tally[OK]} · 注意 {tally[WARN]} · " f"失败 {tally[FAIL]} · 跳过 {tally[SKIP]}" diff --git a/tests/test_main.py b/tests/test_main.py index e2974a9..2686fc0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -179,6 +179,38 @@ def status(self) -> DaemonStatus: ) +def test_status_json_marks_an_unanswered_check_as_unknown(monkeypatch) -> None: + """``healthy=false`` 但 ``health_conclusive=false``:脚本能分清“未知”与“坏了”。""" + import json as _json + + s = _status( + ProfileStatus( + name="default", + healthy=False, + health_conclusive=False, + probe_error="探测连接失败(ssh 退出码 255):23334 的状态未知", + remote_ports={}, + ), + running=True, + pid=4321, + uptime_seconds=60.0, + ) + + class _Daemon: + def status(self) -> DaemonStatus: + return s + + monkeypatch.setattr("ponte.main._daemon", lambda: _Daemon()) + result = CliRunner().invoke(app, ["status", "--json"]) + + payload = _json.loads(result.output) + profile = payload["profiles"]["default"] + assert profile["healthy"] is False + assert profile["health_conclusive"] is False + assert "255" in profile["probe_error"] + assert profile["remote_ports"] == {}, "没观察到的端口不得写成“未监听”" + + def test_markup_health_separates_unknown_from_broken() -> None: """显示层:无法判定的检查是黄色“未知”,不是红色“异常”。""" from ponte.main import _markup_health @@ -936,3 +968,29 @@ def test_config_shows_the_jump_host(monkeypatch) -> None: assert "-J ops@bastion" in argv.output +def test_doctor_json_reports_failures_and_exits_nonzero(monkeypatch) -> None: + """doctor --json 既给结构化报告,又保留能直接 if 判断的退出码。""" + import json as _json + + # _cfg() 的 identity_file 指向一个不存在的路径 → 必然有 FAIL。 + monkeypatch.setattr("ponte.main.get_config", lambda: _cfg()) + monkeypatch.setattr("ponte.main._daemon", lambda: _FakeDaemon(running=False)) + result = CliRunner().invoke(app, ["doctor", "--offline", "--json"]) + + payload = _json.loads(result.output) + assert set(payload) == {"ok", "counts", "checks"} + assert payload["ok"] is False + assert payload["counts"]["fail"] >= 1 + assert result.exit_code == 1 + first = payload["checks"][0] + assert set(first) == {"name", "status", "detail", "hint"} + assert payload["counts"]["fail"] == sum( + 1 for check in payload["checks"] if check["status"] == "fail" + ) + + +def test_shell_completion_script_is_available() -> None: + """add_completion=True:--show-completion 真的能生成补全脚本。""" + result = CliRunner().invoke(app, ["--show-completion", "bash"]) + assert result.exit_code == 0 + assert "completion" in result.output.lower() From 7379b384e7120bf1e89275cc016ca06b8112c7f8 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 20 Sep 2026 03:40:55 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20README=20?= =?UTF-8?q?=E4=B8=8E=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补上 jump 跳板机、reload 热重载、config --ssh-command、doctor --json 与 shell 补全, 以及“未知不是挂了”的说明、排查表条目与中文对照。 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0acf3..d20af85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A failed health probe was reported as a dead port — and could kill a healthy + tunnel.** The server-side probe is an SSH connection of its own, and when that + connection failed (a reset, provider-side rate limiting, our own timeout kill) + every configured port was still reported as *not listening*. Two things + followed: `ponte status` showed `未监听`, and `_on_health` counted the tick + towards its three-consecutive-failures threshold, so the monitor force-killed + a session that was forwarding traffic perfectly well and made the retry loop + reconnect it — the flakier the path, the more often. Measured on a shared + uplink, roughly a third of probe ticks failed that way. "The probe never got + to ask" is now kept apart from "the probe asked and the answer was no": + `TunnelManager.check_remote_ports()` raises `ProbeError`, and the health + snapshot marks the result inconclusive (`HealthStatus.conclusive`) with the + reason instead of inventing port states. An inconclusive tick is not counted + towards the forced-reconnect threshold (the counter is left untouched, so a + real zombie is still caught across interleaved probe failures), `ponte status` + and the dashboard show `未知` with the probe's reason, `ponte doctor` warns + instead of failing, `ponte check` says `未知` for the affected profile without + hiding the others, and `/healthz` answers `200 unverified` with an `unknown` + list (a new `ponte_profiles_unknown` metric) rather than `503 degraded`. + `healthy: false` with `health_conclusive: false` in `status --json` is the + machine-readable form of that distinction. +- **The health monitor could freeze forever on Windows — which disabled the one + recovery that saves a dead tunnel.** The SSH child was spawned with + `close_fds=False` (Windows has no close-on-exec, so this was meant to keep + `CREATE_NO_WINDOW` working), which handed that long-lived process a copy of + every inheritable handle — including the stdout pipe of the *following* health + probe. When the probe hit its timeout, `subprocess.run` killed it and then + blocked in `communicate()` waiting for a pipe whose write end the live SSH + session still held open, so the health thread never came back: `ponte status` + sat on its last reading ("异常") for hours while the tunnel was fine, and the + zombie-session force-reconnect — which runs off health ticks — never fired at + all. The SSH child now closes descriptors as on POSIX, and probes read through + a helper that bounds the wait twice and reports a wedged probe as failed + rather than hanging its caller. +- **A broken config could leave you unable to stop the daemon.** `ponte stop`, + `status`, `logs` and `watch` all built their daemon handle from the validated + config, so one typo in a tunnel rule made every one of them fail with a + config error — including `stop`, which is exactly the command you need when + something is wrong. They now fall back to a minimal handle carrying only the + `[daemon]` pid/log paths, recovered from the raw TOML when the file still + parses and from the platform defaults when it does not; the fallback is + announced on stderr, so `status --json` still emits clean JSON. `start`, + `restart` and `install` keep requiring a valid config on purpose — and + `restart` validates *before* stopping anything, so a bad edit can no longer + leave a tunnel stopped and unrestartable. - **A console window could still flash on the stop path.** Every external control tool (`taskkill`, `systemctl`, `launchctl`) now goes through a single helper that applies `creation_flags()`. `taskkill` was the Windows offender: @@ -20,6 +65,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Every SSH path now builds its connection flags in one place.** The tunnel, + the login test behind `ponte test` / the health loop / `doctor`, and the + server-side port probe each assembled their own `-o`/`-i`/`-p` list, so a + setting could reach the tunnel but not the checks that supervise it — a + mismatch that would have reported a perfectly healthy tunnel as dead. They now + share one builder, which is what makes adding `-J` safe. +- **`[ssh]` may now defer to `~/.ssh/config`.** `user` and `identity_file` are + optional; only `host` is required. When either is omitted ponte no longer + forces `user@` / `-i` onto the command line, so OpenSSH resolves the user and + the key itself — from a `Host` alias, `User`, `IdentityFile` or an ssh-agent + identity. A machine whose plain `ssh myserver` already works no longer has to + duplicate that into ponte's config, and ponte stops overriding an + `IdentityFile` set in the SSH config. An explicitly configured + `identity_file` must still exist (unchanged), so a typo cannot silently fall + back to a different key. - **`ponte status --json` is now keyed by profile.** That contract was added in this same unreleased cycle, so nothing released depends on it: instead of one flat object of tunnel statistics the payload is @@ -47,6 +107,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Jump hosts are a first-class setting: `[ssh] jump`.** "The server is only + reachable through the bastion" is the most common real topology this kind of + tool is pointed at, and it used to be unsupported in any discoverable way: + hand-writing `ProxyJump` into `[ssh.options]` worked, but nothing validated + it, `ponte config` did not show it, and `ponte doctor` could not tell "the + bastion is down" from "your key is wrong". The value keeps OpenSSH's own + `ProxyJump` syntax — `[user@]host[:port]`, comma separated for a chain — and + is passed to `ssh -J` verbatim, so there is nothing new to learn and the hop's + identity comes from `~/.ssh/config` just like the destination's (no second + `identity_file` to keep in sync). `proxy_jump` is accepted as an alias, hops + are validated at load time (bad port, empty hop, stray whitespace), and a + `jump` combined with a `ProxyJump`/`ProxyCommand` in `[ssh.options]` is + rejected — those describe the same hop, and ssh would silently apply only one. + `ponte config` shows the chain, `ponte config --ssh-command` shows the `-J` + it produces, and `ponte doctor` gained a jump-host row that TCP-probes the *first* + hop (the only one reachable from here — probing later hops would fail on a + healthy chain) before reporting connectivity, so a dead bastion is named as + the cause rather than surfacing as a login failure. When a jump is configured, + the connectivity hint tells you to test `ssh ` instead of sending + you to `authorized_keys` on a server you cannot reach anyway. +- **`ponte reload` — apply a new config without dropping healthy tunnels.** + Re-reads the config file and restarts *only* the profiles whose settings + actually changed: a new profile is started, a removed one is stopped (and its + stale status section dropped), an unchanged one is left running. Because + `[retry]`/`[health]` are baked into a runner at construction time, a change to + either rebuilds every profile — but a pure tunnel edit no longer costs the + other connections. The request travels through a reload marker file (the same + cross-process mechanism as the stop marker), so it works on Windows too, with + `kill -HUP` as the POSIX equivalent. A config that fails to parse is rejected + *before* the daemon sees it — `ponte reload` validates it locally and reports + the error, and the running tunnels keep going. +- **`ponte doctor --json`.** The same checkup as the table, as a stable JSON + object (`ok`, `counts`, `checks`), with the exit code still non-zero when + anything failed — so CI can store the report and gate on it in the same run. +- **`ponte config --ssh-command`.** Prints the exact `ssh` argv ponte will + execute, one shell-quoted line per profile — the fastest way to answer "what + is it actually running?", especially now that `user`/`identity_file` may be + left to OpenSSH. +- **Shell completion.** `ponte --install-completion` now installs completion + for bash / zsh / fish / PowerShell (`add_completion` was off before). - **Multiple SSH endpoints in one config: `[[profiles]]`.** A profile is a named SSH endpoint with its own key and its own `[[profiles.tunnels]]`, and a single daemon supervises every one of them concurrently — each with its own diff --git a/README.md b/README.md index 8d6fd74..6f60232 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,24 @@ jitter (`max_retries=0` = retry forever), so a drop never becomes a dead tunnel. A session that stays up ≥ `stable_after` seconds resets the retry budget, so a long-running tunnel is never abandoned after a few flaky drops. +- 🔄 **Hot reload** — `ponte reload` re-reads the config and restarts *only* the + profiles whose settings actually changed, so adding a tunnel (or fixing one + server) no longer drops every other connection. `kill -HUP` does the same on + POSIX. A config with a typo in it is rejected before the daemon sees it and the + running tunnels are left untouched. +- 🔑 **Your `~/.ssh/config` still counts** — only `host` is required in `[ssh]`. + Leave out `user` and/or `identity_file` and ponte stops forcing `user@` and + `-i`, so OpenSSH resolves them itself: `Host` aliases, `User`, `IdentityFile`, + ssh-agent. A machine where `ssh myserver` already works needs no duplication, + and ponte no longer overrides an `IdentityFile` you set there. + `ponte config --ssh-command` prints the exact command line it will run. +- 🧱 **Jump hosts are first-class** — `jump = "ops@bastion"` in `[ssh]` reaches a + server only the bastion can see. It is handed to `ssh -J` verbatim (comma + separated for a chain, `[user@]host[:port]` per hop), so OpenSSH builds the + hop itself and the bastion's user/key come from `~/.ssh/config` as well — no + second key path to keep in sync. `ponte doctor` probes the one hop that is + actually reachable from here and says "the bastion is unreachable" instead of + leaving you with a login failure, while `ponte test` validates the whole chain. - 🛟 **Crash recovery** — `install` registers an OS auto-start service: boot-or-logon Scheduled Task (Windows), systemd user unit (Linux), launchd agent (macOS). - 💚 **Health checks** — periodic local-process + remote-port probing, with @@ -53,6 +71,11 @@ session that stays up ≥ `stable_after` seconds. `ponte notify-test` proves the channel works *before* the outage. Off by default: nothing leaves your machine unless you enable it. +- 💚 **Health checks that distinguish "broken" from "couldn't ask"** — each + tick probes the SSH process locally and the forwarding ports on the server. + A probe connection that fails is reported as *unknown* (yellow, with the + reason) rather than as a dead port, and never counts towards the + zombie-session reconnect. - 🩺 **`ponte doctor`** — one command that checks the config, the key file and its permissions, SSH reachability, listening ports, auto-start status and the notify channel, each row ending in a concrete fix instead of a black box. @@ -88,20 +111,23 @@ import package stay `ponte`; a checkout installs the same way (`pipx install .`) | `init [--path P] [--force]` | write a config file from the template (never overwrites without `--force`) | | `start` / `start --foreground` | start daemon in background / foreground (debug) | | `stop` / `restart` | graceful stop / stop-then-start | +| `reload` | re-read the config and restart only the tunnels whose settings changed | | `status [--json]` | per-profile health, ports and tunnel statistics (`--json` for scripts) | | `watch [--interval S]` | live dashboard: per-profile health, session uptime, reconnects, event feed | | `logs [-n N] [--follow]` | view / tail the daemon log | | `test [--profile NAME]` | quick SSH connectivity check (every profile by default) | | `check [--profile NAME]` | verify tunnel ports are listening (`-R` on the server, `-L`/`-D` locally) | -| `doctor [--offline] [--timeout S]` | one-shot checkup of config, key, connectivity, ports, auto-start and notifications, each row with a fix | +| `doctor [--offline] [--timeout S] [--json]` | one-shot checkup of config, key, connectivity, ports, auto-start and notifications, each row with a fix (`--json` for scripts) | | `notify-test [--profile NAME]` | send a test alert through the configured ntfy / webhook channels | | `serve [--host H] [--port P] [--token T] [--open]` | local HTTP dashboard, `/healthz` probe, Prometheus `/metrics`, `/status.json` snapshot | | `install` / `uninstall` | register / remove the OS auto-start service | -| `config` | print the effective configuration, its source file and any warnings | +| `config [--ssh-command]` | print the effective configuration, its source file and any warnings (`--ssh-command` prints the exact `ssh` argv) | Global options (before the command): `--config/-c PATH` pin a config file, `--version/-V` print the version. Unknown/typo'd config keys are reported by -`ponte config` instead of being silently ignored. +`ponte config` instead of being silently ignored. Run +`ponte --install-completion` once to add shell completion +(bash / zsh / fish / PowerShell). ## 📊 Web dashboard & monitoring @@ -126,6 +152,16 @@ because a scrape failure would hide *why* a tunnel went down — which is exactl what a graph exists to show. And `/healthz` reports `starting` (with `200`) until the first health check completes, so restarting the daemon does not page you. +**"Unknown" is not "down".** The remote probe is an SSH connection of its own, +and on a shared/NATed uplink it fails on its own often enough (roughly a third +of ticks in our measurements). So a check that could not be *completed* is kept +apart from a check that produced a verdict: `/healthz` answers `200 unverified` +(not `503`), `ponte status` shows `未知` with the probe's reason instead of +`异常`, unobserved ports are simply not rendered, and `ponte_profiles_unknown` +counts them. Only a conclusive failure — the SSH process gone, or a port a probe +*did* reach and found closed — pages you or triggers the zombie-session +reconnect. + **Security.** The dashboard names your servers, users and forwarded ports — it is a map of your infrastructure, not a status line. So `ponte serve` binds `127.0.0.1` and nothing else. Binding a LAN or public address is possible, but @@ -210,7 +246,20 @@ environment-variable expansion. Resolution order (first existing file wins): Sections: - `[ssh]` — `host` / `port` / `user` / `identity_file` / `known_hosts_file` / - `options` (any extra key there is passed through verbatim as `-o key=value`) + `jump` / `options` (any extra key there is passed through verbatim as + `-o key=value`). + Only `host` is required: leave out `user` and/or `identity_file` and ponte + stops forcing `user@` and `-i`, so OpenSSH resolves them from your + `~/.ssh/config` (`Host` alias, `User`, `IdentityFile`) or ssh-agent. + `ponte config --ssh-command` shows the resulting command line. +- `[ssh] jump` — the bastion in front of `host`, in OpenSSH `ProxyJump` syntax: + `"bastion"`, `"ops@bastion"`, `"ops@bastion:2222"`, or a chain such as + `"ops@hop1, root@hop2"`. ponte passes it to `ssh -J` and never talks to the + hop itself, so the bastion's identity comes from its own `Host` block in + `~/.ssh/config` — there is deliberately no per-hop `identity_file` here. + `proxy_jump` is accepted as an alias (set only one). Combining `jump` with a + `ProxyJump`/`ProxyCommand` in `[ssh.options]` is rejected: those describe the + same hop and ssh would silently apply only one. - `[[profiles]]` — an alternative to the single-tunnel layout: each entry has `name`, its own `[profiles.ssh]` and its own `[[profiles.tunnels]]`. Mixed with a top-level `[ssh]`/`[[tunnels]]` it is rejected rather than guessed at; @@ -247,11 +296,13 @@ Sections: |---------|---------------| | `Permission denied (publickey)` | public key on server `~/.ssh/authorized_keys`; on Windows strip inherited ACLs (`icacls id_rsa /inheritance:r /grant:r :(R)`) | | Connection rejected after key change | delete `known_hosts`, reconnect (`StrictHostKeyChecking=accept-new` default) | +| Server only reachable through a bastion | set `[ssh] jump = "ops@bastion"` — ponte hands it to `ssh -J`; `ponte doctor` then probes that hop and says whether the bastion itself is down | | Process alive but remote port down | cloud security-group inbound rules; check server with `ss -tlnp` / `lsof -nP -iTCP -sTCP:LISTEN` — the daemon now force-reconnects a "zombie" tunnel after 3 consecutive failed checks | | Console window flashes at logon, or while stopping | the Scheduled Task must run `pythonw.exe` — check `[windows] pythonw_exe`; `ponte stop` also force-kills through a hidden `taskkill` | | `ponte serve` exits with "cannot bind" / port busy | another process holds the port — `ponte serve --port 8788`; the refused non-loopback bind is a *token* problem, and the message says so | | `/healthz` returns `401` | a `[serve].token` is set: pass `?token=...` or `Authorization: Bearer ...` | | `/healthz` returns `503` while the tunnel looks fine | it reports the *tunnel*, not the process: read `unhealthy` / `errors` in the body, then `ponte check` | +| `/healthz` returns `200` with `"status": "unverified"` | the probe's own connection failed, so ponte cannot confirm the ports — read `unknown`, and check `ponte logs` if it persists | | Logs | `ponte logs -n 100 --follow` | ## 🧪 Development & testing @@ -306,9 +357,25 @@ again. See [CONTRIBUTING.md](CONTRIBUTING.md). - 🔁 **自愈** — 无限重连 + 指数退避 + 全抖动(`max_retries=0` = 永远重试), 掉线不会变成死隧道。会话稳定运行 ≥ `stable_after` 秒后重试预算归零, 长跑隧道不会因前期几次抖动被永久放弃。 +- 🔄 **配置热重载** — `ponte reload` 重新读取配置,**只重启真的改过的那几条** + 隧道:新增一条隧道、修好一台服务器,不再把其它正在跑的连接一起拆掉。 + POSIX 下 `kill -HUP` 等价。配置写错时它会在守护进程看到之前就被拒绝, + 正在跑的隧道不受影响。 +- 🔑 **复用你已有的 `~/.ssh/config`** — `[ssh]` 里 `host` 是唯一必填项。 + 省略 `user` 和/或 `identity_file` 后,ponte 不再强行拼出 `user@` 与 `-i`, + 交给 OpenSSH 自己解析:`Host` 别名、`User`、`IdentityFile`、ssh-agent。 + “`ssh myserver` 已经能用”的机器无需重复一份配置,ponte 也不会再覆盖你 + 写在 SSH 配置里的 `IdentityFile`。`ponte config --ssh-command` 打印最终命令。 +- 🧱 **跳板机是一等公民** — `[ssh]` 里写 `jump = "ops@bastion"` 就能连上只有堡垒机 + 看得到的服务器。它被原样交给 `ssh -J`(逐跳写 `[user@]host[:port]`,多跳用 + 逗号分隔),跳板机这一段由 OpenSSH 自己建立,它的用户与密钥同样来自 + `~/.ssh/config`,不需要再维护第二份密钥路径。`ponte doctor` 只探测本机真正 + 能直连的那一跳,直接告诉你“堡垒机连不上”,而不是丢一个登录失败给你; + `ponte test` 则验证整条链路。 - 🛟 **崩溃兜底** — `install` 注册系统级开机自启服务:Windows 计划任务(开机或登录) / Linux systemd user / macOS launchd。 -- 💚 **健康检查** — 周期探测本地进程存活 + 远程端口,异常给出明确诊断。 +- 💚 **健康检查** — 周期探测本地进程存活 + 远程端口,异常给出明确诊断; + “探针自己没连上”会被报成**未知**而不是异常,也不会据此强杀一条健康隧道。 SSH 进程假死(活着但端口全掉)时连续 3 次检查失败即强制重连;检查失败 指数退避,不会高频新开 SSH 触发服务器 `MaxStartups`。 - 🔔 **真断了会主动告诉你** — 连续 `[notify].on_consecutive_failures` 次失败后, @@ -347,20 +414,22 @@ ponte install # 注册开机自启 + 崩溃重启 | `init [--path P] [--force]` | 从模板生成配置文件(不加 `--force` 不覆盖) | | `start` / `start --foreground` | 后台启动 / 前台启动(调试) | | `stop` / `restart` | 优雅停止 / 停旧起新 | +| `reload` | 重读配置,只重启设置真的变了的隧道 | | `status [--json]` | 逐条隧道的健康、端口与统计(`--json` 供脚本消费) | | `watch [--interval S]` | 实时看板:每条隧道一栏,含会话时长、重连次数与事件流 | | `logs [-n N] [--follow]` | 查看 / 跟读日志 | | `test [--profile NAME]` | 快速测 SSH 连通性(默认逐条测试) | | `check [--profile NAME]` | 检查隧道端口(`-R` 在服务器上,`-L`/`-D` 在本机) | -| `doctor [--offline] [--timeout S]` | 一键体检配置、密钥、连通性、端口、自启与通知,每项给出修法 | +| `doctor [--offline] [--timeout S] [--json]` | 一键体检配置、密钥、连通性、端口、自启与通知,每项给出修法(`--json` 供脚本消费) | | `notify-test [--profile NAME]` | 通过已配置的 ntfy / webhook 通道发一条测试通知 | | `serve [--host H] [--port P] [--token T] [--open]` | 本地 HTTP 看板、`/healthz` 探活、Prometheus `/metrics`、`/status.json` 快照 | | `install` / `uninstall` | 注册 / 移除开机自启服务 | -| `config` | 打印生效配置、来源文件与配置告警 | +| `config [--ssh-command]` | 打印生效配置、来源文件与配置告警(`--ssh-command` 打印实际执行的 ssh 命令) | 全局选项(写在子命令之前):`--config/-c PATH` 指定配置文件, `--version/-V` 打印版本。拼错/未知的配置项会由 `ponte config` 报出来, -不再被静默忽略。 +不再被静默忽略。运行一次 `ponte --install-completion` 即可启用 shell 补全 +(bash / zsh / fish / PowerShell)。 ## 📊 网页看板与监控接入 @@ -384,6 +453,13 @@ ponte serve --open # 顺手在浏览器里打开 “它为何挂了”,而那正是画图的目的。另外首次健康检查完成前,`/healthz` 报的是 `starting`(`200`),所以重启守护进程不会造成误报。 +**“未知”不是“挂了”。** 远程端口探测本身就是一条独立的 SSH 连接,在共享/NAT +出口上它自己就会失败(我们实测约三分之一的检查如此)。所以 ponte 把“没问成” +和“问了、答案是坏的”分开:`/healthz` 回 `200 unverified`(而不是 `503`), +`ponte status` 显示黄色的 `未知` 并附上探测失败原因(而不是 `异常`),没被观察到的 +端口干脆不显示,`ponte_profiles_unknown` 单独计数。只有确凿的失败——SSH 进程没了, +或探针**确实**连上并看到端口没在听——才会报警或触发假死强制重连。 + **安全模型。** 看板会列出你的服务器地址、登录用户与转发端口——这是一张 内网拓扑图,不是一行状态。所以 `ponte serve` 只绑 `127.0.0.1`。绑到局域网或 公网是可以的,但**必须**同时给令牌:ponte 对“对外 + 无令牌”的组合是直接 @@ -468,7 +544,18 @@ ponte(本地守护进程,Python) 各段含义: - `[ssh]` — `host` / `port` / `user` / `identity_file` / `known_hosts_file` / - `options`(该表内未列出的键会原样透传为 `-o key=value`) + `jump` / `options`(该表内未列出的键会原样透传为 `-o key=value`)。只有 + `host` 必填:省略 `user` 和/或 `identity_file` 后,ponte 不再强行拼出 + `user@` 与 `-i`,由 OpenSSH 从 `~/.ssh/config`(`Host` 别名、`User`、 + `IdentityFile`)或 ssh-agent 解析。`ponte config --ssh-command` 可查看 + 最终命令行。 +- `[ssh] jump` — 目标服务器前面的跳板机,写法就是 OpenSSH 的 `ProxyJump`: + `"bastion"`、`"ops@bastion"`、`"ops@bastion:2222"`,或 `"ops@hop1, root@hop2"` + 这样的多跳链路。ponte 把它交给 `ssh -J`,自己从不接触跳板机,所以跳板机的 + 身份来自它自己的 `Host` 块——这里**刻意不提供**第二份 `identity_file`。 + `proxy_jump` 是等价的别名(只能写一个)。与 `[ssh.options]` 里的 + `ProxyJump`/`ProxyCommand` 同时出现会被直接拒绝:它们说的是同一跳, + 而 ssh 遇到重复设置只会静默采用其中一个。 - `[[profiles]]` — 单隧道写法的替代品:每个条目有 `name`、自己的 `[profiles.ssh]` 与 `[[profiles.tunnels]]`。与顶层 `[ssh]`/`[[tunnels]]` 混用会被拒绝(而不是猜你的意图);`retry`/`health`/`daemon`/`service` @@ -503,11 +590,13 @@ ponte(本地守护进程,Python) |------|----------| | 「Permission denied (publickey)」 | 公钥是否加入服务器 `~/.ssh/authorized_keys`;Windows 下私钥去掉继承 ACL(`icacls id_rsa /inheritance:r /grant:r <用户名>:(R)`) | | 换 key 后连接被拒 | 删除 `known_hosts` 重连(默认 `StrictHostKeyChecking=accept-new`) | +| 服务器只能经堡垒机访问 | 配 `[ssh] jump = "ops@bastion"`——ponte 原样交给 `ssh -J`;之后 `ponte doctor` 会探测那一跳,直接说明堡垒机自身是否可达 | | 进程活着但远程端口不通 | 云安全组入方向规则;服务器上 `ss -tlnp` / `lsof -nP -iTCP -sTCP:LISTEN` 确认监听 —— 守护进程已支持假死检测:连续 3 次检查失败自动强制重连 | | 登录时(或 `stop` 时)闪出黑色控制台窗口 | 计划任务必须跑 `pythonw.exe`——检查 `[windows] pythonw_exe`;`ponte stop` 的强杀也已隐藏控制台 | | `ponte serve` 报绑定失败 / 端口占用 | 换端口:`ponte serve --port 8788`;若报的是非回环地址,那是**令牌**问题,报错里写了 | | `/healthz` 返回 `401` | 配了 `[serve].token`:带上 `?token=...` 或 `Authorization: Bearer ...` | | 隧道看着正常,`/healthz` 却回 `503` | 它报的是**隧道**不是进程:看响应体里的 `unhealthy` / `errors`,再用 `ponte check` 复核 | +| `/healthz` 回 `200` 且 `"status": "unverified"` | 探测连接自己没建起来,ponte 无法确认端口状态:看 `unknown` 字段;持续如此就看 `ponte logs` | | 排查日志 | `ponte logs -n 100 --follow` | ## 🧪 开发与测试 From 0618ede6d38320c32f814ba54a0e810cbf9d589d Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 20 Sep 2026 03:41:11 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test(serve):=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E8=B7=91=E9=80=9A=E2=80=9C=E5=AE=88=E6=8A=A4=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=96=87=E4=BB=B6=20=E2=86=92=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20HTTP=20=E6=9C=8D=E5=8A=A1=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 其余测试都直接喂手工拼的 payload,所以守护进程写出来的字段名与看板读的字段名一旦 不一致,两边各自的单元测试都发现不了。这里用真实 HealthChecker 与 TunnelDaemon 的 落盘路径,经真实回环套接字断言 /healthz、/status.json、/metrics 与看板:未知 ≠ 坏了、 只有确凿失败才 503、没观察到的端口不会被写成“未监听”。 它当场抓到一处真实缺口:探针跑通但看到端口关闭时,状态文件里并没有 error 字符串, /healthz 便只回 503 degraded 而说不出原因。现已从观测到的端口状态回推出原因, 并补上对应的单元测试。 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- tests/test_integration_serve.py | 189 ++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/test_integration_serve.py diff --git a/tests/test_integration_serve.py b/tests/test_integration_serve.py new file mode 100644 index 0000000..ad61735 --- /dev/null +++ b/tests/test_integration_serve.py @@ -0,0 +1,189 @@ +"""端到端:真实的守护进程状态文件经过真实的 HTTP 服务(回环套接字)。 + +其余测试都是直接喂手工拼的 payload,所以“守护进程写出来的字段名”与“看板读的 +字段名”一旦不一致,两边各自的单元测试都照样通过。这里跑完整链路: + + HealthChecker → TunnelDaemon._on_health 落盘 → main._status_payload + → serve.create_server 真套接字 → 看板 /healthz /status.json /metrics + +因此它守的是跨进程的*契约*,而不是任何一个函数的实现细节。 +""" + +from __future__ import annotations + +import json +import os +import socket +import threading +import urllib.error +import urllib.request +from pathlib import Path + +from ponte.config import load_config +from ponte.core import ProbeError +from ponte.daemon import TunnelDaemon +from ponte.health import HealthChecker +from ponte.main import _status_payload +from ponte.serve import create_server + +#: 探针自己没连上时,守护进程实际记录下来的那种原因串。 +PROBE_ERROR = "探测连接失败(ssh 退出码 255):23334 的状态未知" + + +class _FakeManager: + """``TunnelManager`` 的最小替身:健康检查只用到这两个入口。""" + + process = None + + def __init__(self, probe: object) -> None: + self._probe = probe + + def check_remote_ports(self, **_kwargs: object) -> object: + if isinstance(self._probe, Exception): + raise self._probe + return self._probe + + def check_local_ports(self, **_kwargs: object) -> dict[int, bool]: + return {} + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _config_path(tmp_path: Path, port: int) -> Path: + key = tmp_path / "id_rsa" + key.write_text("x", encoding="utf-8") + path = tmp_path / "config.toml" + path.write_text( + "[ssh]\n" + 'host = "example.com"\n' + 'user = "u"\n' + f'identity_file = "{key.as_posix()}"\n' + "\n[[tunnels]]\n" + "remote_port = 23334\n" + 'local_host = "localhost"\n' + "local_port = 2222\n" + "\n[daemon]\n" + f'pid_file = "{(tmp_path / "ponte.pid").as_posix()}"\n' + "\n[serve]\n" + 'host = "127.0.0.1"\n' + f"port = {port}\n", + encoding="utf-8", + ) + return path + + +def _daemon_with_snapshot(tmp_path: Path, probe: object) -> TunnelDaemon: + """跑一次真实的健康检查,并经守护进程自己的写入路径落盘。""" + cfg = load_config(str(_config_path(tmp_path, _free_port()))) + daemon = TunnelDaemon(config=cfg) + # 用测试进程自己的 pid:这样 status() 认为守护进程在跑(不调用 stop())。 + (tmp_path / "ponte.pid").write_text(str(os.getpid()), encoding="utf-8") + status = HealthChecker(_FakeManager(probe), cfg.health).check() + daemon._on_health(status, manager=None, profile="default") + return daemon + + +def _start(daemon: TunnelDaemon, token: str = "s3cret"): + server = create_server( + lambda: _status_payload(daemon.status()), + host="127.0.0.1", + port=_free_port(), + token=token, + refresh=5, + ) + thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True + ) + thread.start() + return server, int(server.server_address[1]), token + + +def _get(port: int, path: str): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=5) as resp: + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: # 503 等也要能读到响应体 + return exc.code, exc.read().decode("utf-8") + + +def _metrics(port: int, token: str) -> list[str]: + _, body = _get(port, f"/metrics?token={token}") + return body.splitlines() + + +def test_unanswered_probe_stays_unknown_all_the_way_to_the_dashboard(tmp_path) -> None: + """探针没连上 → 一路都是“未知”,绝不是“端口没在听”。""" + daemon = _daemon_with_snapshot(tmp_path, ProbeError(PROBE_ERROR)) + server, port, token = _start(daemon) + try: + code, body = _get(port, f"/healthz?token={token}") + assert code == 200, "未知不是故障:不该用 503 报警" + payload = json.loads(body) + assert payload["status"] == "unverified" + assert payload["unknown"] == ["default"] + assert "unhealthy" not in payload + + code, body = _get(port, f"/status.json?token={token}") + assert code == 200 + section = json.loads(body)["profiles"]["default"] + assert section["health_conclusive"] is False + assert "255" in section["probe_error"] + assert section["remote_ports"] == {}, "没观察到的端口不得写成“未监听”" + + lines = _metrics(port, token) + assert "ponte_profiles_unknown 1" in lines + assert not [ln for ln in lines if ln.startswith("ponte_profile_healthy")], ( + "未知时 ponte_profile_healthy 应当是“没有样本”,而不是 0" + ) + + _, page = _get(port, f"/?token={token}") + assert "未知" in page + assert "异常" not in page + assert 'class="card unknown"' in page + finally: + server.shutdown() + server.server_close() + + +def test_a_conclusive_pass_is_reported_as_healthy(tmp_path) -> None: + """探针确实跑通了并看到端口在听 → 健康,且真的采到了样本。""" + daemon = _daemon_with_snapshot(tmp_path, {23334: True}) + server, port, token = _start(daemon) + try: + code, body = _get(port, f"/healthz?token={token}") + assert code == 200 + assert json.loads(body)["status"] == "ok" + + lines = _metrics(port, token) + assert 'ponte_profile_healthy{profile="default"} 1' in lines + assert "ponte_profiles_unknown 0" in lines + + _, page = _get(port, f"/?token={token}") + assert "健康" in page + finally: + server.shutdown() + server.server_close() + + +def test_a_conclusive_failure_is_degraded_and_names_the_port(tmp_path) -> None: + """探针连上了、答案是端口没在听 → 这才是判定:503 并点名端口。""" + daemon = _daemon_with_snapshot(tmp_path, {23334: False}) + server, port, token = _start(daemon) + try: + code, body = _get(port, f"/healthz?token={token}") + assert code == 503 + payload = json.loads(body) + assert payload["status"] == "degraded" + assert payload["unhealthy"] == ["default"] + assert "23334" in payload["errors"]["default"] + + lines = _metrics(port, token) + assert "ponte_profiles_unhealthy 1" in lines + assert "ponte_profiles_unknown 0" in lines + finally: + server.shutdown() + server.server_close() From 66c048b734adfd2b86471759d71196e5d858e2dd Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 20 Sep 2026 10:01:12 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(serve):=20=E7=9C=8B=E6=9D=BF=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=B8=80=E8=A1=8C=E4=B8=80=E9=9A=A7=E9=81=93=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8E=9F=E5=9C=B0=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一条隧道一张卡片时,3 条就要滚动,而状态页的第一要求是“一眼看全”;刷新又是整页 meta refresh,每 5 秒把你正在读的页面扔掉一次——展开的行合上、滚动位置归零。这次 把这两件事一起改掉,并且把跳板机补上看板。 - 跳板机随 destination 一起成为状态的一部分(ProfileStatus.jump / status --json 的 jump 字段,两者都取自配置):看板在目标下方用琥珀色写出“↳ 经 ops@bastion:2222”。 堡垒机是链路里最先断的一环,也是配置里其它地方都说不出来的那一环。 - 一行一条隧道:端口成了带 ✓/✗ 与“远程/本地”标签的 chips,统计表与事件流收进该行 的
(无脚本照样能展开),异常原因直接写在收起的那一行上——需要点开才知道 原因,等于没有原因;探针没探到的端口组写“未观测”,而不是画成红色的“未监听”。 - 刷新不再整页重载: