diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9032fd4..2a0acf3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,8 +36,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`ponte`) and the repository name are unchanged, so an install from a
checkout behaves exactly as before.
- **The coverage gate moved from 70% to 80%.** The `[[profiles]]`, notify and
- doctor work pushed the suite past it (~83%), so the threshold in `pyproject.toml`
+ doctor work pushed the suite past it (~84%), so the threshold in `pyproject.toml`
now matches the codebase instead of trailing it by ten points.
+- **The tunnel target is part of the status now.** Each profile's status
+ carries the SSH `destination` it connects to (`ProfileStatus.destination`,
+ taken from the config rather than the status file), shown as the first row of
+ a `ponte status` table and included in `ponte status --json`. A table of
+ numbers is useless if you cannot tell which server the broken one is; the
+ dashboard labels every card with it.
### Added
@@ -105,6 +111,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
is non-zero when anything failed, so it is usable from a script.
- `[windows] pythonw_exe` — pin the windowless interpreter for the Scheduled
Task, for installs where `pythonw.exe` does not sit next to `python.exe`.
+- **`ponte serve` — a local HTTP surface: dashboard, health probe and
+ Prometheus metrics.** `/` renders a self-contained dashboard (inline CSS, no
+ CDN, no JavaScript) with a card per tunnel: health, session age, availability,
+ forwarded-port state, the last disconnect and its reason, and the event feed.
+ `/healthz` is the endpoint to point a monitor at — it returns `503` when the
+ daemon is down *or* a tunnel is broken, and `200` (with `"status":
+ "starting"`) until the first health check completes, so a restart does not
+ page anyone. `/metrics` speaks the Prometheus text format (session age,
+ cumulative up/down time, availability, reconnect and port-listening state)
+ and deliberately always answers `200`, because a scrape failure would hide
+ *why* a tunnel went down — a graph's whole job. `/status.json` is exactly the
+ `ponte status --json` payload, so the page, the probe and the metrics can
+ never disagree with the CLI. Everything is read-only and re-read per request
+ (`Cache-Control: no-store`), and the whole thing is stdlib `http.server` — no
+ new dependency. Binds `127.0.0.1` by default; `[serve] host`/`port`/
+ `token`/`refresh` configure it, and a non-loopback bind **without** a token is
+ refused at config load and by `ponte serve` alike (the dashboard names your
+ servers, users and ports), with clients then passing `?token=` or
+ `Authorization: Bearer`.
### Planned
diff --git a/README.md b/README.md
index c7f9aa6..8d6fd74 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,11 @@
- 🩺 **`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.
+- 📊 **A dashboard and a metrics endpoint** — `ponte serve` puts the same status
+ on HTTP: `/` is a self-contained dashboard (no CDN, no JavaScript), `/healthz`
+ answers `503` when a tunnel is actually broken, `/metrics` speaks Prometheus
+ and `/status.json` is exactly `ponte status --json`. Loopback-only by default;
+ exposing it needs an explicit token.
- 🖥️ **Cross-platform** — resolves `ssh` automatically, per-platform runtime
paths, and portable remote-port probing (`socket` → `ss`/`lsof`/`netstat`).
@@ -90,6 +95,7 @@ import package stay `ponte`; a checkout installs the same way (`pipx install .`)
| `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 |
| `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 |
@@ -97,6 +103,61 @@ 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.
+## 📊 Web dashboard & monitoring
+
+`ponte watch` is for the machine you are sitting at; `ponte serve` is for
+everything else — a browser, a phone on the same host, Uptime Kuma, Prometheus.
+
+```bash
+ponte serve # http://127.0.0.1:8787/ (loopback only by default)
+ponte serve --open # ...and open it in your browser
+```
+
+| Endpoint | What it answers |
+|----------|-----------------|
+| `/` | the dashboard: per-tunnel health, session age, availability, port state, last disconnect with its reason, event feed |
+| `/healthz` | `200` while the tunnels work, `503` as soon as one is broken — the endpoint to point a monitor at |
+| `/metrics` | Prometheus text exposition: session age, cumulative up/down time, availability, reconnects, port-listening state |
+| `/status.json` | exactly the payload of `ponte status --json` |
+
+**Why `/healthz` and `/metrics` disagree on purpose.** `/healthz` fails, so a
+monitor can alert; `/metrics` always answers `200` and reports state as numbers,
+because a scrape failure would hide *why* a tunnel went down — which is exactly
+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.
+
+**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
+only together with a token; ponte *refuses* the combination of "exposed" and
+"no token" instead of warning about it:
+
+```toml
+[serve]
+host = "0.0.0.0" # opt in, deliberately
+port = 8787
+token = "a-long-random-string" # required for any non-loopback host
+refresh = 5 # dashboard auto-refresh, seconds
+# ipv6 hosts are fine too: host = "::1"
+```
+
+Clients then pass `?token=...` (handy for scrapers) or `Authorization: Bearer
+...`. All four endpoints are read-only, re-read the daemon status per request and
+send `Cache-Control: no-store`, so a page can never show a stale "healthy" for a
+tunnel that has since died.
+
+```yaml
+# prometheus.yml
+scrape_configs:
+ - job_name: ponte
+ static_configs:
+ - targets: ["127.0.0.1:8787"]
+ # with a token: metrics_path: /metrics?token=a-long-random-string
+```
+
+Alert on `ponte_profile_port_listening == 0` for the signal that matters most:
+a live process whose forwarded port is gone is the classic silent failure.
+
## 🛠️ Cross-platform service management
| Platform | Mechanism | Generated artifact |
@@ -128,6 +189,8 @@ ponte (local daemon, Python)
- `health.py` — periodic liveness + remote-port checks
- `notify.py` — ntfy / webhook alerts on repeated failures
- `doctor.py` — one-shot diagnostics used by `ponte doctor`
+- `serve.py` — read-only HTTP surface (dashboard / health probe / metrics) over
+ the same payload `ponte status --json` emits
- `config.py` — TOML load/validate (built-in `tomllib` on 3.11+)
## ⚙️ Configuration
@@ -186,6 +249,9 @@ Sections:
| Connection rejected after key change | delete `known_hosts`, reconnect (`StrictHostKeyChecking=accept-new` default) |
| 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` |
| Logs | `ponte logs -n 100 --follow` |
## 🧪 Development & testing
@@ -251,6 +317,10 @@ again. See [CONTRIBUTING.md](CONTRIBUTING.md).
让你在真出事**之前**就验证通道可用。默认关闭:不开启就绝不会外发任何数据。
- 🩺 **`ponte doctor`** — 一条命令逐项体检:配置、密钥及其权限、SSH 连通性、
监听端口、开机自启状态、通知通道,每行都给出具体修法而不是留个黑箱。
+- 📊 **看板与指标接口** — `ponte serve` 把同一份状态摆到 HTTP 上:`/` 是自包含的
+ 看板(不依赖 CDN、不用 JavaScript),`/healthz` 在隧道真的断时回 `503`,
+ `/metrics` 说 Prometheus 格式,`/status.json` 就是 `ponte status --json`。
+ 默认只监听本机;要对外必须先给令牌。
- 🖥️ **跨平台** — 自动查找 `ssh`、按平台落盘运行时文件、可移植的远程端口探测
(`socket` → `ss`/`lsof`/`netstat`)。
@@ -284,6 +354,7 @@ ponte install # 注册开机自启 + 崩溃重启
| `check [--profile NAME]` | 检查隧道端口(`-R` 在服务器上,`-L`/`-D` 在本机) |
| `doctor [--offline] [--timeout S]` | 一键体检配置、密钥、连通性、端口、自启与通知,每项给出修法 |
| `notify-test [--profile NAME]` | 通过已配置的 ntfy / webhook 通道发一条测试通知 |
+| `serve [--host H] [--port P] [--token T] [--open]` | 本地 HTTP 看板、`/healthz` 探活、Prometheus `/metrics`、`/status.json` 快照 |
| `install` / `uninstall` | 注册 / 移除开机自启服务 |
| `config` | 打印生效配置、来源文件与配置告警 |
@@ -291,6 +362,58 @@ ponte install # 注册开机自启 + 崩溃重启
`--version/-V` 打印版本。拼错/未知的配置项会由 `ponte config` 报出来,
不再被静默忽略。
+## 📊 网页看板与监控接入
+
+`ponte watch` 给坐在机器前的你看,`ponte serve` 给其它一切:浏览器、手机
+(同机)、Uptime Kuma、Prometheus。
+
+```bash
+ponte serve # http://127.0.0.1:8787/(默认只监听本机)
+ponte serve --open # 顺手在浏览器里打开
+```
+
+| 接口 | 回答什么问题 |
+|------|--------------|
+| `/` | 看板:逐条隧道的健康、当前会话时长、在线率、端口状态、上次断线原因与事件流 |
+| `/healthz` | 隧道正常时 `200`,任一条断开立即 `503` —— 监控就探这个 |
+| `/metrics` | Prometheus 文本格式:会话时长、累计在线/离线、在线率、重连次数、端口监听状态 |
+| `/status.json` | 与 `ponte status --json` 完全一致的载荷 |
+
+**为什么 `/healthz` 与 `/metrics` 故意不一致。** `/healthz` 会失败,监控才能
+报警;`/metrics` 永远回 `200`,把状态当数字报出来——因为采挂掉会盖住
+“它为何挂了”,而那正是画图的目的。另外首次健康检查完成前,`/healthz`
+报的是 `starting`(`200`),所以重启守护进程不会造成误报。
+
+**安全模型。** 看板会列出你的服务器地址、登录用户与转发端口——这是一张
+内网拓扑图,不是一行状态。所以 `ponte serve` 只绑 `127.0.0.1`。绑到局域网或
+公网是可以的,但**必须**同时给令牌:ponte 对“对外 + 无令牌”的组合是直接
+拒绝,而不是警告一句了事。
+
+```toml
+[serve]
+host = "0.0.0.0" # 显式选择对外
+port = 8787
+token = "一个足够长的随机串" # 非回环地址必需
+refresh = 5 # 看板自动刷新秒数
+# 也支持 IPv6:host = "::1"
+```
+
+客户端用 `?token=...`(脚本/采集器方便)或 `Authorization: Bearer ...`。
+四个接口全是只读、每次请求都重新读取守护进程状态,并带
+`Cache-Control: no-store`——所以页面不会拿旧的“健康”去骗一个已经挂了的隧道。
+
+```yaml
+# prometheus.yml
+scrape_configs:
+ - job_name: ponte
+ static_configs:
+ - targets: ["127.0.0.1:8787"]
+ # 带令牌时:metrics_path: /metrics?token=一个足够长的随机串
+```
+
+最值得拿来报警的一条是 `ponte_profile_port_listening == 0`:进程活着、
+转发端口却没了,正是那种悄无声息的典型故障。
+
## 🛠️ 跨平台服务管理
| 平台 | 机制 | 生成物 |
@@ -325,6 +448,8 @@ ponte(本地守护进程,Python)
- `health.py` — 周期存活 + 远程端口检查
- `notify.py` — 连续失败时的 ntfy / webhook 告警
- `doctor.py` — `ponte doctor` 使用的体检项
+- `serve.py` — 只读 HTTP 接口(看板 / 探活 / 指标),渲染的就是
+ `ponte status --json` 那份载荷
- `config.py` — TOML 加载/校验(3.11+ 内置 `tomllib`)
## ⚙️ 配置
@@ -363,6 +488,8 @@ ponte(本地守护进程,Python)
`cooldown`(同一 profile 两条告警之间的最小秒数),以及通道:
`ntfy_topic`(可选 `ntfy_server` / `ntfy_token`)和/或 `webhook_url`
(以 JSON 形式收到告警)
+- `[serve]` — 本地看板:`host`(默认 `127.0.0.1`)、`port`(默认 `8787`)、
+ `token`(绑定非回环地址时必填,否则拒绝启动)、`refresh`(看板刷新秒数)
- `[service]` — 服务名、自启、POSIX 强杀等待
- `[windows]` — 仅 Windows 使用(`task_name`、`ssh_exe`、`pythonw_exe`、
`run_as`)。`run_as` 默认 `user`(登录后以你本人身份运行、能读 `~/.ssh`)或
@@ -378,6 +505,9 @@ ponte(本地守护进程,Python)
| 换 key 后连接被拒 | 删除 `known_hosts` 重连(默认 `StrictHostKeyChecking=accept-new`) |
| 进程活着但远程端口不通 | 云安全组入方向规则;服务器上 `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` 复核 |
| 排查日志 | `ponte logs -n 100 --follow` |
## 🧪 开发与测试
diff --git a/ponte/config.example.toml b/ponte/config.example.toml
index bee1b62..e701686 100644
--- a/ponte/config.example.toml
+++ b/ponte/config.example.toml
@@ -143,6 +143,16 @@ cooldown = 900
# webhook_url = "https://example.com/hooks/ponte"
# 两个通道可以同时配。配好后用 `ponte notify-test` 验证真能收到。
+[serve]
+# 本地 HTTP 看板 / 指标(ponte serve)。默认只监听本机:看板会列出服务器
+# 地址、登录用户与转发端口,等于一张内网拓扑图,因此默认不对外。
+host = "127.0.0.1"
+port = 8787
+# 想让局域网或反向代理访问就必须设一个令牌,否则 ponte serve 直接拒绝启动。
+# 客户端用 ?token=... 或 Authorization: Bearer ... 带上它。
+# token = "一个足够长的随机串"
+refresh = 5 # 看板自动刷新间隔(秒);接口本身从不缓存
+
[service]
# 开机自启服务身份(Linux systemd 单元名 / macOS launchd label 后缀)
name = "ponte"
diff --git a/ponte/config.py b/ponte/config.py
index cbd1950..8753fb6 100644
--- a/ponte/config.py
+++ b/ponte/config.py
@@ -52,6 +52,9 @@
"HealthConfig",
"WindowsConfig",
"ServiceConfig",
+ "ServeConfig",
+ "ensure_bindable",
+ "is_loopback_host",
"get_config",
"load_config",
"set_config_path",
@@ -357,6 +360,36 @@ def channels(self) -> tuple[str, ...]:
return tuple(found)
+@dataclass(frozen=True)
+class ServeConfig:
+ """The optional local HTTP surface (``ponte serve``).
+
+ ``host`` defaults to loopback because every page under it names your
+ servers, users and forwarded ports — that is a map of your infrastructure,
+ not a status line. Binding anywhere else is allowed but *demands* a
+ ``token``: :func:`ensure_bindable` refuses rather than warns, for the same
+ reason the daemon refuses to install a task that would flash a console.
+
+ ``refresh`` is the dashboard's self-refresh interval in seconds. The JSON,
+ health and metrics endpoints are never cached, so this only affects how
+ often a browser redraws itself.
+ """
+
+ host: str = "127.0.0.1"
+ port: int = 8787
+ token: str = ""
+ refresh: int = 5
+
+ @property
+ def loopback(self) -> bool:
+ """``True`` when this bind address is reachable from this machine only."""
+ return is_loopback_host(self.host)
+
+ def check_bind(self) -> None:
+ """Raise :class:`ConfigValidationError` for an exposing configuration."""
+ ensure_bindable(self.host, self.token)
+
+
@dataclass(frozen=True)
class WindowsConfig:
"""Platform specific knobs used only on Windows.
@@ -579,6 +612,7 @@ class TunnelConfig:
notify: NotifyConfig = field(default_factory=NotifyConfig)
windows: WindowsConfig = field(default_factory=WindowsConfig)
service: ServiceConfig = field(default_factory=ServiceConfig)
+ serve: ServeConfig = field(default_factory=ServeConfig)
source_path: str = ""
"""Absolute path of the TOML file this configuration was loaded from."""
warnings: tuple[str, ...] = ()
@@ -695,6 +729,7 @@ def _parse_config(data: Mapping[str, Any], config_path: str) -> TunnelConfig:
notify = _parse_notify(data.get("notify", {}), warnings)
windows = _parse_windows(data.get("windows", {}), warnings)
service = _parse_service(data.get("service", {}), warnings)
+ serve = _parse_serve(data.get("serve", {}), warnings)
cfg = TunnelConfig(
profiles=profiles,
@@ -704,6 +739,7 @@ def _parse_config(data: Mapping[str, Any], config_path: str) -> TunnelConfig:
notify=notify,
windows=windows,
service=service,
+ serve=serve,
source_path=config_path,
warnings=tuple(warnings),
)
@@ -726,6 +762,7 @@ def _parse_config(data: Mapping[str, Any], config_path: str) -> TunnelConfig:
"notify",
"windows",
"service",
+ "serve",
}
)
_KNOWN_PROFILE = frozenset({"name", "ssh", "tunnels"})
@@ -757,6 +794,7 @@ def _parse_config(data: Mapping[str, Any], config_path: str) -> TunnelConfig:
)
_KNOWN_WINDOWS = frozenset({"task_name", "ssh_exe", "pythonw_exe", "run_as"})
_KNOWN_SERVICE = frozenset({"name", "autostart", "kill_timeout"})
+_KNOWN_SERVE = frozenset({"host", "port", "token", "refresh"})
def _warn_unknown_keys(
@@ -1051,6 +1089,26 @@ def _parse_notify(section: Any, warnings: list[str] | None = None) -> NotifyConf
return notify
+def _parse_serve(section: Any, warnings: list[str] | None = None) -> ServeConfig:
+ if not section:
+ return ServeConfig()
+ _expect_table(section, "serve")
+ _warn_unknown_keys(section, _KNOWN_SERVE, "serve", warnings)
+ dft = ServeConfig()
+ serve = ServeConfig(
+ host=_optional_str(section, "host", default=dft.host),
+ port=_optional_int(
+ section, "port", default=dft.port, minimum=1, maximum=65535, where="serve"
+ ),
+ token=_optional_str(section, "token", default=""),
+ refresh=_optional_int(
+ section, "refresh", default=dft.refresh, minimum=1, where="serve"
+ ),
+ )
+ serve.check_bind()
+ return serve
+
+
def _parse_windows(section: Any, warnings: list[str] | None = None) -> WindowsConfig:
if not section:
return WindowsConfig()
@@ -1088,6 +1146,33 @@ def _parse_service(section: Any, warnings: list[str] | None = None) -> ServiceCo
)
+def is_loopback_host(host: str) -> bool:
+ """Return ``True`` when *host* binds to this machine only.
+
+ Deliberately conservative: anything not obviously loopback is treated as
+ exposed. That includes ``""``, which ``http.server`` reads as *all
+ interfaces*, so an odd spelling cannot sneak past the token requirement in
+ :func:`ensure_bindable`.
+ """
+ name = host.strip().strip("[]").lower()
+ return name in ("localhost", "::1") or name.startswith("127.")
+
+
+def ensure_bindable(host: str, token: str) -> None:
+ """Refuse to expose the status surface to the network without a token.
+
+ Raises:
+ ConfigValidationError: when *host* is not loopback and *token* is empty.
+ """
+ if is_loopback_host(host) or token:
+ return
+ raise ConfigValidationError(
+ f"serve.host = {host!r} 会把看板暴露到网络上,必须同时设置 serve.token"
+ "(或命令行 --token):看板会列出服务器地址、登录用户与转发端口,"
+ "没有令牌等于把内网拓扑摊开给同网段的任何人。"
+ )
+
+
def _validate(cfg: TunnelConfig) -> None:
"""Cross-field validation that runs after every section is parsed."""
if not cfg.profiles:
diff --git a/ponte/daemon.py b/ponte/daemon.py
index 4d2fd28..a5043d3 100644
--- a/ponte/daemon.py
+++ b/ponte/daemon.py
@@ -185,6 +185,11 @@ class ProfileStatus:
"""
name: str
+ #: ``user@host:port`` this profile connects to, taken from the
+ #: configuration (not the status file). It is what makes a multi-profile
+ #: ``status --json`` / dashboard row self-describing: a table of numbers is
+ #: useless if you cannot tell which server is the broken one.
+ destination: str | None = None
#: ``None`` until the first health check of this profile reports in.
healthy: bool | None = None
process_alive: bool | None = None
@@ -289,7 +294,9 @@ def get_profile(self, name: str) -> ProfileStatus | None:
return None
-def _profile_status(name: str, section: dict) -> ProfileStatus:
+def _profile_status(
+ name: str, section: dict, *, destination: str | None = None
+) -> ProfileStatus:
"""Build a :class:`ProfileStatus` from one status-file section.
Tolerant on purpose: the file is written by whichever daemon version is
@@ -325,6 +332,7 @@ def _ports(key: str) -> dict[int, bool]:
raw_alive = section.get("process_alive")
return ProfileStatus(
name=name,
+ destination=destination,
healthy=raw_healthy if isinstance(raw_healthy, bool) else None,
process_alive=raw_alive if isinstance(raw_alive, bool) else None,
remote_ports=_ports("remote_ports"),
@@ -1129,13 +1137,19 @@ def status(self) -> DaemonStatus:
# daemon that still supervises it is stopped.
names = list(self.profile_names)
names += [name for name in sections if name not in names]
+ destinations = {
+ profile.name: profile.destination for profile in self.config.profiles
+ }
return DaemonStatus(
running=True,
pid=pid,
started_at=started,
uptime_seconds=max(0.0, uptime),
profiles=[
- _profile_status(name, sections.get(name, {})) for name in names
+ _profile_status(
+ name, sections.get(name, {}), destination=destinations.get(name)
+ )
+ for name in names
],
)
diff --git a/ponte/main.py b/ponte/main.py
index 63d8eb1..eaa3a74 100644
--- a/ponte/main.py
+++ b/ponte/main.py
@@ -10,10 +10,12 @@
from __future__ import annotations
+import dataclasses
import json
import os
import sys
import time
+import webbrowser
from pathlib import Path
from typing import TYPE_CHECKING, NoReturn
@@ -25,9 +27,16 @@
from rich.table import Table
from ponte import __version__
-from ponte.config import ConfigError, get_config, init_config, set_config_path
+from ponte.config import (
+ ConfigError,
+ ServeConfig,
+ get_config,
+ init_config,
+ set_config_path,
+)
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
if TYPE_CHECKING: # pragma: no cover - import cycle guard, runtime import is lazy
from ponte.daemon import TunnelDaemon
@@ -283,6 +292,9 @@ def _add_daemon_rows(table: Table, s) -> None: # noqa: ANN001 - DaemonStatus cy
def _add_profile_rows(table: Table, profile) -> None: # noqa: ANN001 - cycle guard
"""Append one profile's health, statistics and port states to *table*."""
+ # 目标放在第一行:多条隧道时,最先要说清的是“这张表是哪个服务器”。
+ if profile.destination:
+ table.add_row("目标", escape(profile.destination))
table.add_row("健康状态", _markup_health(profile.healthy, profile.health_error))
# 会话时长是区分“守护进程活了多久”与“隧道活了多久”的那一列。
@@ -323,6 +335,7 @@ def _round1(value: float | None) -> float | None:
def _profile_payload(profile) -> dict: # noqa: ANN001 - ProfileStatus cycle guard
"""Machine-readable snapshot of one profile (the ``--json`` contract)."""
return {
+ "destination": profile.destination,
"healthy": profile.healthy,
"process_alive": profile.process_alive,
"health_error": profile.health_error,
@@ -556,6 +569,96 @@ def watch(
_fail(str(exc))
+# ---------------------------------------------------------------------------
+# serve(本地 HTTP 看板 / 指标)
+# ---------------------------------------------------------------------------
+
+
+def _serve_config(
+ base: ServeConfig,
+ *,
+ host: str | None,
+ port: int | None,
+ token: str | None,
+ refresh: int | None,
+) -> ServeConfig:
+ """Apply ``ponte serve``'s command-line overrides to the ``[serve]`` section."""
+ return dataclasses.replace(
+ base,
+ host=base.host if host is None else host,
+ port=base.port if port is None else port,
+ token=base.token if token is None else token,
+ refresh=base.refresh if refresh is None else refresh,
+ )
+
+
+@app.command()
+def serve(
+ host: str | None = typer.Option(
+ None, "--host", help="监听地址(默认取 [serve].host,也就是只监听本机)"
+ ),
+ port: int | None = typer.Option(
+ None, "--port", min=1, max=65535, help="监听端口(默认取 [serve].port)"
+ ),
+ token: str | None = typer.Option(
+ None,
+ "--token",
+ help="访问令牌;绑定非回环地址时必须提供(写在命令行上会进 shell 历史,"
+ "长期使用建议写进 [serve].token)",
+ ),
+ refresh: int | None = typer.Option(
+ None, "--refresh", min=1, help="看板自动刷新间隔(秒)"
+ ),
+ open_browser: bool = typer.Option(False, "--open", help="启动后在浏览器里打开看板"),
+) -> None:
+ """启动本地 HTTP 服务:看板 / 、探活 /healthz、指标 /metrics、快照 /status.json。"""
+ try:
+ daemon = _daemon()
+ effective = _serve_config(
+ daemon.config.serve, host=host, port=port, token=token, refresh=refresh
+ )
+ # 与配置文件走同一条校验:绑定非回环地址却没有令牌时直接拒绝,
+ # 不提供“先跑起来再说”的选项。
+ effective.check_bind()
+ except typer.Exit:
+ raise
+ except Exception as exc:
+ _fail(str(exc))
+
+ try:
+ server = create_server(
+ lambda: _status_payload(daemon.status()),
+ host=effective.host,
+ port=effective.port,
+ token=effective.token,
+ refresh=effective.refresh,
+ )
+ except OSError as exc:
+ _fail(
+ f"无法监听 {effective.host}:{effective.port}({exc});"
+ "换一个端口:ponte serve --port 8788"
+ )
+
+ url = serve_url(effective.host, effective.port)
+ console.print(f"[green]ponte 看板已启动:{url}[/green]")
+ console.print(
+ f"[dim]指标 {url}metrics · 探活 {url}healthz · 快照 {url}status.json[/dim]"
+ )
+ if not effective.loopback:
+ console.print(
+ "[yellow]警告:已绑定非回环地址,同网段里拿到令牌的人都能看到你的服务器、"
+ "用户与端口;不要暴露到公网[/yellow]"
+ )
+ if open_browser:
+ webbrowser.open(url)
+ try:
+ server.serve_forever(poll_interval=0.5)
+ except KeyboardInterrupt:
+ console.print("\n[yellow]已停止看板[/yellow]")
+ finally:
+ server.server_close()
+
+
# ---------------------------------------------------------------------------
# test / check
# ---------------------------------------------------------------------------
@@ -781,6 +884,14 @@ def config() -> None:
f"on_consecutive_failures={notify.on_consecutive_failures}, "
f"cooldown={notify.cooldown}s",
)
+ # 令牌只报“有没有”,绝不回显:ponte config 的输出经常被粘进 issue。
+ serve = cfg.serve
+ table.add_row(
+ "serve",
+ f"host={serve.host}, port={serve.port}, "
+ f"token={'已设置' if serve.token else '(无)'}, "
+ f"refresh={serve.refresh}s",
+ )
table.add_row("pid_file", cfg.daemon.pid_file or "(默认)")
table.add_row("log_file", cfg.daemon.log_file or "(默认)")
table.add_row("ssh_exe", cfg.windows.ssh_exe or "ssh(PATH)")
diff --git a/ponte/serve.py b/ponte/serve.py
new file mode 100644
index 0000000..5c0e24f
--- /dev/null
+++ b/ponte/serve.py
@@ -0,0 +1,985 @@
+"""A local HTTP surface for ponte: dashboard, health probe and metrics.
+
+``ponte watch`` answers "is my tunnel up?" for a human sitting at the machine.
+This module answers it for everything else:
+
+* ``/`` a self-contained HTML dashboard — no CDN, no JavaScript, no
+ external assets, so it renders from ``curl``, a phone
+ browser on the same host, or an air-gapped box;
+* ``/healthz`` a probe for uptime monitors, whose status code reports
+ whether the *tunnel* works, not whether a process exists;
+* ``/metrics`` Prometheus text exposition, for a Prometheus / Grafana /
+ VictoriaMetrics scrape;
+* ``/status.json`` byte-for-byte the payload of ``ponte status --json``.
+
+Three rules shape the implementation:
+
+* **Loopback by default, and a token to leave it.** Everything under ``/``
+ names your servers, users and forwarded ports. Binding a non-loopback address
+ without a token is refused up front by :func:`ponte.config.ensure_bindable`
+ rather than served with a warning nobody reads.
+* **Standard library only.** ``http.server``, not a web framework: ponte exists
+ to be dropped on a machine that has nothing but SSH and Python, and a
+ dependency would cost more than this feature is worth.
+* **Read-only, and always fresh.** Every request re-reads the daemon status, so
+ a page can never show a cached "healthy" for a tunnel that has since died.
+ Nothing here can start, stop or reconfigure anything — the worst a leaked
+ token buys is a read of your port numbers.
+
+The one thing this module deliberately does *not* do is invent numbers: the
+dashboard, the JSON, the health probe and the metrics are all rendered from the
+same payload, so the web page can never disagree with ``ponte status``.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import hmac
+import html
+import json
+import logging
+import socket
+import time
+from collections.abc import Callable, Iterable, Mapping
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from string import Template
+from typing import Any, cast
+from urllib.parse import parse_qs, urlparse
+
+from ponte import __version__
+from ponte.config import ensure_bindable
+from ponte.daemon import _format_duration
+
+__all__ = [
+ "PonteHTTPServer",
+ "create_server",
+ "dashboard_html",
+ "health_response",
+ "render_metrics",
+ "serve_url",
+]
+
+logger = logging.getLogger(__name__)
+
+#: How many recent retry-loop events a dashboard card shows.
+_FEED_LIMIT = 8
+
+#: The routes this server answers. Anything else is a 404 — an explicit list
+#: rather than a fallback, so a typo never silently serves something.
+_ROUTES = ("/", "/healthz", "/metrics", "/status.json")
+
+#: Event type → (glyph, tone). Mirrors ``ponte watch`` so the two views teach
+#: the same visual language.
+_EVENT_GLYPHS: dict[str, tuple[str, str]] = {
+ "connecting": ("→", "dim"),
+ "connected": ("●", "ok"),
+ "disconnected": ("●", "bad"),
+ "retrying": ("↻", "warn"),
+ "max_retries_reached": ("✗", "bad"),
+}
+
+
+# ---------------------------------------------------------------------------
+# Payload accessors
+#
+# The payload is the ``ponte status --json`` contract. These helpers are
+# tolerant on purpose: the JSON is written by whichever daemon version is
+# installed, so a missing or unexpected field degrades to "unknown" instead of
+# turning the dashboard into a stack trace while the user is trying to read
+# their status.
+# ---------------------------------------------------------------------------
+
+
+def _profiles(payload: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
+ """Return the ``name → section`` map of a status payload."""
+ raw = payload.get("profiles")
+ if not isinstance(raw, Mapping):
+ return {}
+ return {
+ str(name): section
+ for name, section in raw.items()
+ if isinstance(section, Mapping)
+ }
+
+
+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)):
+ return default
+ return float(value)
+
+
+def _session_age(section: Mapping[str, Any], now: float) -> float | None:
+ """Seconds the current session has been up, ``None`` when disconnected."""
+ started = _as_float(section.get("current_session_at"))
+ if started is None:
+ return None
+ return max(0.0, now - started)
+
+
+# ---------------------------------------------------------------------------
+# /healthz
+# ---------------------------------------------------------------------------
+
+
+def health_response(payload: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
+ """Build ``(status_code, body)`` for ``/healthz``.
+
+ 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
+ ``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.
+ """
+ if not payload.get("running"):
+ return 503, {"status": "down", "reason": "ponte daemon is not running"}
+
+ profiles = _profiles(payload)
+ if not profiles:
+ return 200, {"status": "starting", "reason": "no profile has reported yet"}
+
+ unhealthy = sorted(
+ name for name, section in profiles.items() if section.get("healthy") is False
+ )
+ if unhealthy:
+ errors = {
+ name: profiles[name].get("health_error")
+ for name in unhealthy
+ if profiles[name].get("health_error")
+ }
+ return 503, {
+ "status": "degraded",
+ "profiles": len(profiles),
+ "unhealthy": unhealthy,
+ "errors": errors,
+ }
+
+ if all(section.get("healthy") is True for section in profiles.values()):
+ return 200, {"status": "ok", "profiles": len(profiles)}
+ return 200, {
+ "status": "starting",
+ "profiles": len(profiles),
+ "reason": "no health check has completed yet",
+ }
+
+
+# ---------------------------------------------------------------------------
+# /metrics
+# ---------------------------------------------------------------------------
+
+
+def _escape_label(value: Any) -> str:
+ """Escape a Prometheus label value (backslash, newline, double quote)."""
+ return (
+ str(value).replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
+ )
+
+
+def _metric_value(value: Any) -> str | None:
+ """Format *value* as a Prometheus sample value, ``None`` when unknown.
+
+ A missing number is *omitted* rather than exported as NaN: a gap in a graph
+ says "no data", while a NaN line invites the reader to guess.
+ """
+ if isinstance(value, bool):
+ return "1" if value else "0"
+ if isinstance(value, int):
+ return str(value)
+ if isinstance(value, float):
+ return f"{value:.3f}"
+ return None
+
+
+def _series(name: str, labels: Mapping[str, Any], value: Any) -> str | None:
+ """One sample line, or ``None`` when the value is unknown."""
+ formatted = _metric_value(value)
+ if formatted is None:
+ return None
+ if not labels:
+ return f"{name} {formatted}"
+ rendered = ",".join(
+ f'{key}="{_escape_label(val)}"' for key, val in labels.items()
+ )
+ return f"{name}{{{rendered}}} {formatted}"
+
+
+class _Families:
+ """Accumulates metric families so samples come out grouped.
+
+ The text exposition format requires every sample of a metric to be one
+ uninterrupted group, which is why this is not simply a list of lines. A
+ family whose samples are all unknown is dropped entirely — an empty
+ ``# HELP`` header with no samples is noise in every graph it reaches.
+ """
+
+ def __init__(self) -> None:
+ self._order: list[str] = []
+ self._families: dict[str, list[Any]] = {}
+
+ def add(
+ self,
+ name: str,
+ kind: str,
+ help_text: str,
+ samples: Iterable[str | None],
+ ) -> None:
+ """Register *samples* under the family *name* (``gauge``/``counter``)."""
+ kept = [sample for sample in samples if sample]
+ if not kept:
+ return
+ if name not in self._families:
+ self._order.append(name)
+ # HELP text stays English: it is read by Prometheus/Grafana
+ # tooling, not by the operator's terminal.
+ self._families[name] = [kind, help_text, []]
+ entry = self._families[name]
+ entry[2].extend(kept)
+
+ def render(self) -> str:
+ """The complete exposition text, ending with a newline."""
+ lines: list[str] = []
+ for name in self._order:
+ kind, help_text, samples = self._families[name]
+ lines.append(f"# HELP {name} {help_text}")
+ lines.append(f"# TYPE {name} {kind}")
+ lines.extend(samples)
+ return "\n".join(lines) + "\n"
+
+
+def render_metrics(payload: Mapping[str, Any], *, now: float | None = None) -> str:
+ """Render a status payload as Prometheus text exposition (``0.0.4``).
+
+ Deliberately *not* the same signal as ``/healthz``: that one fails when the
+ tunnel is broken, so a monitor can alert. This one always answers ``200``
+ and reports the state as numbers, because a scrape failure would hide
+ *why* the tunnel went down — exactly the question a graph exists to answer.
+ """
+ moment = time.time() if now is None else now
+ families = _Families()
+
+ running = bool(payload.get("running"))
+ families.add(
+ "ponte_up",
+ "gauge",
+ "1 when the ponte daemon is running.",
+ [_series("ponte_up", {}, running)],
+ )
+ families.add(
+ "ponte_build_info",
+ "gauge",
+ "ponte version, always 1 (join on this to graph deploys).",
+ [_series("ponte_build_info", {"version": __version__}, True)],
+ )
+ families.add(
+ "ponte_daemon_uptime_seconds",
+ "gauge",
+ "Seconds since the ponte daemon process started.",
+ [_series("ponte_daemon_uptime_seconds", {}, payload.get("uptime_seconds"))],
+ )
+ if not running:
+ return families.render()
+
+ profiles = _profiles(payload)
+ families.add(
+ "ponte_profiles_configured",
+ "gauge",
+ "Profiles the daemon supervises.",
+ [_series("ponte_profiles_configured", {}, len(profiles))],
+ )
+ families.add(
+ "ponte_profiles_unhealthy",
+ "gauge",
+ "Profiles that are currently unhealthy.",
+ [
+ _series(
+ "ponte_profiles_unhealthy",
+ {},
+ sum(
+ 1
+ for section in profiles.values()
+ if section.get("healthy") is False
+ ),
+ )
+ ],
+ )
+ # 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.
+ families.add(
+ "ponte_profile_info",
+ "gauge",
+ "Static identity of a profile: the SSH destination it connects to.",
+ [
+ _series(
+ "ponte_profile_info",
+ {"profile": name, "destination": section.get("destination") or ""},
+ True,
+ )
+ for name, section in profiles.items()
+ ],
+ )
+
+ def add(
+ name: str,
+ kind: str,
+ help_text: str,
+ getter: Callable[[Mapping[str, Any]], Any],
+ ) -> None:
+ """Register one numeric family, one sample per profile."""
+ families.add(
+ name,
+ kind,
+ help_text,
+ [
+ _series(name, {"profile": profile}, getter(section))
+ for profile, section in profiles.items()
+ ],
+ )
+
+ add(
+ "ponte_profile_healthy",
+ "gauge",
+ "1 when every health check of the profile passes; absent until the first check.",
+ lambda section: section.get("healthy"),
+ )
+ add(
+ "ponte_profile_process_alive",
+ "gauge",
+ "1 while the profile's SSH process is alive.",
+ lambda section: section.get("process_alive"),
+ )
+ add(
+ "ponte_profile_session_uptime_seconds",
+ "gauge",
+ "Age of the current SSH session; absent while the tunnel is down.",
+ lambda section: _session_age(section, moment),
+ )
+ add(
+ "ponte_profile_availability_ratio",
+ "gauge",
+ "Completed uptime / observed time for the profile (0..1), the same ratio ponte status prints.",
+ lambda section: section.get("availability"),
+ )
+ add(
+ "ponte_profile_connect_attempts_total",
+ "counter",
+ "SSH launch attempts (cumulative; survives daemon restarts).",
+ lambda section: section.get("connect_attempts_total"),
+ )
+ add(
+ "ponte_profile_sessions_total",
+ "counter",
+ "SSH sessions actually established.",
+ lambda section: section.get("sessions_total"),
+ )
+ add(
+ "ponte_profile_reconnects_total",
+ "counter",
+ "Scheduled reconnects after a drop.",
+ lambda section: section.get("reconnects_total"),
+ )
+ add(
+ "ponte_profile_tunnel_uptime_seconds",
+ "counter",
+ "Cumulative seconds the tunnel has been up.",
+ lambda section: section.get("tunnel_uptime_seconds"),
+ )
+ add(
+ "ponte_profile_tunnel_downtime_seconds",
+ "counter",
+ "Cumulative seconds the tunnel has been down.",
+ lambda section: section.get("tunnel_downtime_seconds"),
+ )
+ add(
+ "ponte_profile_last_disconnect_timestamp_seconds",
+ "gauge",
+ "Unix time of the most recent disconnect.",
+ lambda section: section.get("last_disconnect_at"),
+ )
+ add(
+ "ponte_profile_last_notification_timestamp_seconds",
+ "gauge",
+ "Unix time the most recent failure alert was delivered.",
+ lambda section: section.get("last_notification_at"),
+ )
+
+ # The strongest signal of the lot: whether the port is *actually* forwarded.
+ # A healthy-looking process with a dead port is the classic silent failure.
+ port_samples: list[str | None] = []
+ for name, section in profiles.items():
+ for key, kind in (("remote_ports", "remote"), ("local_ports", "local")):
+ ports = section.get(key)
+ if not isinstance(ports, Mapping):
+ continue
+ for port, listening in ports.items():
+ port_samples.append(
+ _series(
+ "ponte_profile_port_listening",
+ {"profile": name, "kind": kind, "port": port},
+ listening,
+ )
+ )
+ families.add(
+ "ponte_profile_port_listening",
+ "gauge",
+ "1 when a forwarded port is listening (remote: on the server, local: on this host).",
+ port_samples,
+ )
+ return families.render()
+
+
+# ---------------------------------------------------------------------------
+# The dashboard
+# ---------------------------------------------------------------------------
+
+#: The dashboard shell. A :class:`string.Template` (``$name`` placeholders)
+#: rather than ``str.format`` because the stylesheet is full of braces;
+#: ``substitute`` is used on purpose so a stray placeholder fails loudly in
+#: tests instead of rendering as literal text.
+_PAGE = Template(
+ """
+
+
+
+
+
+
+ponte · $title
+
+
+
+
+
ponte v$version
$summary
+$cards
+
+
+
+"""
+)
+
+
+def _esc(value: Any) -> str:
+ """Escape a value for HTML text or attribute context.
+
+ Everything that reaches the page goes through this: profile names come from
+ the config, and disconnect reasons come from SSH's stderr — neither is
+ trusted input.
+ """
+ return html.escape(str(value), quote=True)
+
+
+def _pill(text: str, tone: str) -> str:
+ """A coloured status pill (``tone`` is ``ok``/``bad``/``unknown``)."""
+ return f'{_esc(text)}'
+
+
+def _health_pill(section: Mapping[str, Any]) -> str:
+ """The health pill of one profile."""
+ healthy = section.get("healthy")
+ if healthy is True:
+ return _pill("健康", "ok")
+ if healthy is False:
+ return _pill("异常", "bad")
+ return _pill("未知", "unknown")
+
+
+def _kv(label: str, value: str) -> str:
+ """One table row. *value* must already be escaped or be trusted markup."""
+ return f"
{_esc(label)}
{value}
"
+
+
+def _row(label: str, value: Any) -> str:
+ """One table row from a plain (untrusted) value."""
+ return _kv(label, _esc(value))
+
+
+def _port_rows(section: Mapping[str, Any]) -> str:
+ """Rows for the forwarded ``-R``/``-L``/``-D`` ports of one profile."""
+ rows: list[str] = []
+ for key, label in (("remote_ports", "远程端口"), ("local_ports", "本地端口")):
+ ports = section.get(key)
+ if not isinstance(ports, Mapping) or not ports:
+ continue
+ for port, listening in sorted(ports.items(), key=_port_order):
+ rows.append(
+ _kv(
+ f"{label} {port}",
+ _pill("监听中", "ok") if listening else _pill("未监听", "bad"),
+ )
+ )
+ return "".join(rows)
+
+
+def _port_order(item: tuple[Any, Any]) -> int:
+ """Sort key putting ports in numeric order (the JSON keys are strings)."""
+ try:
+ return int(item[0])
+ except (TypeError, ValueError): # pragma: no cover - defensive
+ return 0
+
+
+def _feed(section: Mapping[str, Any]) -> str:
+ """The recent-event feed of one profile, newest first."""
+ events = section.get("recent_events")
+ if not isinstance(events, list) or not events:
+ return '
暂无事件
'
+ lines: list[str] = []
+ for event in reversed(events[-_FEED_LIMIT:]):
+ if not isinstance(event, Mapping): # pragma: no cover - defensive
+ continue
+ etype = str(event.get("type", "?"))
+ glyph, tone = _EVENT_GLYPHS.get(etype, ("·", "dim"))
+ at = _as_float(event.get("at"), 0.0) or 0.0
+ stamp = time.strftime("%H:%M:%S", time.localtime(at))
+ detail = etype
+ if event.get("reason"):
+ detail = f"{etype}: {event['reason']}"
+ elif event.get("attempt"):
+ delay = _as_float(event.get("delay"), 0.0) or 0.0
+ detail = f"{etype}: 第 {event['attempt']} 次,{delay:.1f}s 后重试"
+ lines.append(
+ f'
{_esc(stamp)}'
+ f'{_esc(glyph)}{_esc(detail)}
'
+ )
+ return "".join(lines)
+
+
+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")
+ rows: list[str] = []
+
+ if section.get("destination"):
+ rows.append(_row("目标", section["destination"]))
+ if section.get("process_alive") is not None:
+ rows.append(
+ _kv(
+ "SSH 进程",
+ _pill("运行中", "ok")
+ if section.get("process_alive")
+ else _pill("已退出", "bad"),
+ )
+ )
+
+ started = _as_float(section.get("current_session_at"))
+ if started is None:
+ rows.append(_kv("当前会话", _pill("已断开", "bad")))
+ else:
+ rows.append(_row("当前会话", _format_duration(now - started)))
+
+ availability = _as_float(section.get("availability"))
+ if availability is not None:
+ rows.append(_row("在线率", f"{availability * 100:.1f}%"))
+
+ if section.get("sessions_total") is not None:
+ rows.append(
+ _row(
+ "会话统计",
+ f"会话 {section.get('sessions_total')} 次 · "
+ f"重连 {section.get('reconnects_total')} 次 · "
+ f"启动尝试 {section.get('connect_attempts_total')} 次",
+ )
+ )
+
+ up_total = _as_float(section.get("tunnel_uptime_seconds"))
+ if up_total is not None:
+ down_total = _as_float(section.get("tunnel_downtime_seconds"), 0.0) or 0.0
+ rows.append(
+ _row(
+ "累计在线 / 离线",
+ f"{_format_duration(up_total)} / {_format_duration(down_total)}",
+ )
+ )
+
+ rows.append(_port_rows(section))
+
+ if section.get("last_disconnect_reason"):
+ detail = str(section["last_disconnect_reason"])
+ at = _as_float(section.get("last_disconnect_at"))
+ if at is not None:
+ detail += (
+ f"({_format_duration(now - at)}前,"
+ f"{time.strftime('%m-%d %H:%M:%S', time.localtime(at))})"
+ )
+ rows.append(_row("上次断线", detail))
+
+ notified = _as_float(section.get("last_notification_at"))
+ if notified is not None:
+ rows.append(_row("上次通知", f"{_format_duration(now - notified)}前"))
+
+ if section.get("health_error"):
+ rows.append(_row("检查错误", section["health_error"]))
+ if section.get("error"):
+ rows.append(_row("循环错误", section["error"]))
+
+ return (
+ f''
+ f"
{_esc(name)} {_health_pill(section)}
"
+ f'
{"".join(rows)}
'
+ f'
最近事件
{_feed(section)}
'
+ f""
+ )
+
+
+def _summary(payload: Mapping[str, Any], profiles: Mapping[str, Any]) -> str:
+ """The header line: overall state, pid, daemon uptime and profile count."""
+ if not payload.get("running"):
+ return _pill("守护进程未运行", "bad") + '可执行 ponte start 启动'
+ unhealthy = [
+ name for name, section in profiles.items() if section.get("healthy") is False
+ ]
+ if unhealthy:
+ overall = _pill(f"{len(unhealthy)}/{len(profiles)} 条隧道异常", "bad")
+ elif profiles and all(
+ section.get("healthy") is True for section in profiles.values()
+ ):
+ overall = _pill("全部健康", "ok")
+ else:
+ overall = _pill("等待首次检查", "unknown")
+ pieces = [overall]
+ if payload.get("pid") is not None:
+ pieces.append(f'pid {_esc(payload["pid"])}')
+ uptime = _as_float(payload.get("uptime_seconds"))
+ if uptime is not None:
+ pieces.append(f'守护进程运行 {_esc(_format_duration(uptime))}')
+ pieces.append(f'{len(profiles)} 条隧道')
+ return "".join(pieces)
+
+
+def dashboard_html(
+ payload: Mapping[str, Any],
+ *,
+ refresh: int = 5,
+ now: float | None = None,
+) -> str:
+ """Render the whole dashboard page from a ``ponte status`` payload."""
+ moment = time.time() if now is None else now
+ profiles = _profiles(payload)
+ if not payload.get("running"):
+ cards: list[str] = [
+ '
守护进程未运行
'
+ '
先执行 ponte start 启动隧道,'
+ "本页面会在下一轮自动刷新。
"
+ ]
+ else:
+ cards = [
+ _profile_card(name, section, now=moment)
+ for name, section in profiles.items()
+ ]
+ if not cards:
+ cards = [
+ '
尚无隧道上报
'
+ '
守护进程已启动,等待第一次健康检查。
'
+ ]
+ return _PAGE.substitute(
+ refresh=max(1, int(refresh)),
+ version=_esc(__version__),
+ title="隧道看板",
+ summary=_summary(payload, profiles),
+ cards="".join(cards),
+ )
+
+
+# ---------------------------------------------------------------------------
+# The HTTP server
+# ---------------------------------------------------------------------------
+
+
+def serve_url(host: str, port: int, path: str = "/") -> str:
+ """The URL to open for a bind *host*/*port* (``""`` means IPv4 loopback)."""
+ if not host:
+ return f"http://127.0.0.1:{port}{path}"
+ if ":" in host and not host.startswith("["):
+ return f"http://[{host}]:{port}{path}"
+ return f"http://{host}:{port}{path}"
+
+
+def _address_family(host: str) -> int:
+ """Pick the socket family for *host* (IPv6 literals contain a colon)."""
+ return socket.AF_INET6 if ":" in host else socket.AF_INET
+
+
+class PonteHTTPServer(ThreadingHTTPServer):
+ """The server ``ponte serve`` runs, carrying its own status provider.
+
+ Subclassing rather than closing over the provider in the handler keeps the
+ request handlers plain methods — and lets a test start the server on port
+ ``0`` and read back the port the OS assigned.
+ """
+
+ daemon_threads = True
+ allow_reuse_address = True
+
+ def __init__(
+ self,
+ address: tuple[str, int],
+ provider: Callable[[], dict[str, Any]],
+ *,
+ token: str = "",
+ refresh: int = 5,
+ ) -> None:
+ # Set before ``super().__init__``: TCPServer creates its socket there,
+ # so an IPv6 loopback (``::1``) has to pick its family first.
+ self.address_family = _address_family(address[0])
+ self.provider = provider
+ self.token = token
+ self.refresh = refresh
+ super().__init__(address, PonteRequestHandler)
+
+
+class PonteRequestHandler(BaseHTTPRequestHandler):
+ """Serves the four read-only endpoints; one instance per connection."""
+
+ server_version = f"ponte/{__version__}"
+ sys_version = ""
+ # HTTP/1.1 keeps the connection alive for a scraper's next request; every
+ # response sends an explicit Content-Length, which is what that requires.
+ protocol_version = "HTTP/1.1"
+
+ @property
+ def _state(self) -> PonteHTTPServer:
+ """The owning server, typed so the handler can read its settings."""
+ return cast(PonteHTTPServer, self.server)
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
+ """Route the stdlib's stderr chatter into the ponte logger."""
+ logger.debug("%s %s", self.address_string(), format % args)
+
+ # -- methods -----------------------------------------------------------
+
+ def do_GET(self) -> None: # noqa: N802 - stdlib naming
+ self._respond(head=False)
+
+ def do_HEAD(self) -> None: # noqa: N802
+ self._respond(head=True)
+
+ def do_POST(self) -> None: # noqa: N802
+ self._method_not_allowed()
+
+ def do_PUT(self) -> None: # noqa: N802
+ self._method_not_allowed()
+
+ def do_PATCH(self) -> None: # noqa: N802
+ self._method_not_allowed()
+
+ def do_DELETE(self) -> None: # noqa: N802
+ self._method_not_allowed()
+
+ def _method_not_allowed(self) -> None:
+ """Everything here is read-only, and says so instead of pretending."""
+ self._send_json(
+ 405,
+ {"error": "read-only endpoint; use GET"},
+ extra={"Allow": "GET, HEAD"},
+ )
+
+ # -- routing -----------------------------------------------------------
+
+ def _respond(self, *, head: bool) -> None:
+ """Route one request: 404 → 401 → the endpoint."""
+ parsed = urlparse(self.path)
+ path = parsed.path.rstrip("/") or "/"
+ if path not in _ROUTES:
+ self._send_json(
+ 404,
+ {"error": "not found", "endpoints": list(_ROUTES)},
+ head=head,
+ )
+ return
+ if not self._authorized(parse_qs(parsed.query)):
+ self._send_json(
+ 401,
+ {"error": "unauthorized: missing or invalid token"},
+ head=head,
+ extra={"WWW-Authenticate": 'Bearer realm="ponte"'},
+ )
+ return
+
+ try:
+ payload = self._state.provider()
+ except Exception as exc: # noqa: BLE001 - answer, never drop the client
+ logger.exception("serve: status provider failed")
+ self._send_json(
+ 500, {"error": f"status unavailable: {type(exc).__name__}"}, head=head
+ )
+ return
+
+ if path in ("/", "/index.html"):
+ self._send_text(
+ 200,
+ dashboard_html(payload, refresh=self._state.refresh),
+ "text/html; charset=utf-8",
+ head=head,
+ )
+ elif path == "/healthz":
+ code, body = health_response(payload)
+ self._send_json(code, body, head=head)
+ elif path == "/metrics":
+ self._send_text(
+ 200,
+ render_metrics(payload),
+ "text/plain; version=0.0.4; charset=utf-8",
+ head=head,
+ )
+ else: # "/status.json"
+ self._send_json(200, dict(payload), head=head)
+
+ def _authorized(self, query: Mapping[str, list[str]]) -> bool:
+ """Check the token in constant time, from the header or ``?token=``.
+
+ The query string exists because Prometheus scrapes a URL and per-target
+ ``Authorization`` headers are awkward to configure; the header is what a
+ hand-written client would use.
+ """
+ expected = self._state.token
+ if not expected:
+ return True
+ supplied = ""
+ header = self.headers.get("Authorization", "")
+ if header.lower().startswith("bearer "):
+ supplied = header[7:].strip()
+ if not supplied:
+ supplied = (query.get("token") or [""])[0]
+ return hmac.compare_digest(
+ supplied.encode("utf-8"), expected.encode("utf-8")
+ )
+
+ # -- responses ---------------------------------------------------------
+
+ def _send(
+ self,
+ code: int,
+ body: bytes,
+ content_type: str,
+ *,
+ head: bool,
+ extra: Mapping[str, str] | None = None,
+ ) -> None:
+ """Send one complete response (never cached)."""
+ self.send_response(code)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ # Status data must never be cached: a browser or proxy replaying a
+ # "healthy" page for a tunnel that has since died is exactly the
+ # failure this endpoint exists to prevent.
+ self.send_header("Cache-Control", "no-store")
+ for key, value in (extra or {}).items():
+ self.send_header(key, value)
+ self.end_headers()
+ if head:
+ return
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
+ self.wfile.write(body)
+
+ def _send_json(
+ self,
+ code: int,
+ payload: Mapping[str, Any],
+ *,
+ head: bool = False,
+ extra: Mapping[str, str] | None = None,
+ ) -> None:
+ """Send a JSON response (compact: this is machine food)."""
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self._send(
+ code,
+ body,
+ "application/json; charset=utf-8",
+ head=head,
+ extra=extra,
+ )
+
+ def _send_text(
+ self,
+ code: int,
+ text: str,
+ content_type: str,
+ *,
+ head: bool,
+ ) -> None:
+ """Send a text response (HTML or metrics)."""
+ self._send(code, text.encode("utf-8"), content_type, head=head)
+
+
+def create_server(
+ provider: Callable[[], dict[str, Any]],
+ *,
+ host: str = "127.0.0.1",
+ port: int = 8787,
+ token: str = "",
+ refresh: int = 5,
+) -> PonteHTTPServer:
+ """Build (but do not start) the dashboard server.
+
+ The caller owns the lifecycle; ``ponte serve`` runs ``serve_forever`` on it,
+ a test calls ``serve_forever`` on a thread and shuts it down again.
+
+ Raises:
+ ConfigValidationError: when *host* is not loopback and *token* is empty.
+ OSError: when the address is unusable or the port is already taken.
+ """
+ ensure_bindable(host, token)
+ return PonteHTTPServer((host, port), provider, token=token, refresh=refresh)
diff --git a/tests/test_config.py b/tests/test_config.py
index a35ada3..528948f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -12,7 +12,9 @@
ConfigParseError,
ConfigValidationError,
TunnelConfig,
+ ensure_bindable,
get_config,
+ is_loopback_host,
load_config,
)
@@ -929,3 +931,132 @@ def test_profile_invalid_identity_file_is_named(tmp_path) -> None:
) + _profile_entry(tmp_path, "db")
with pytest.raises(ConfigValidationError, match="web"):
load_config(_write_toml(tmp_path, body))
+
+
+# ---------------------------------------------------------------------------
+# [serve] —— 本地 HTTP 看板
+# ---------------------------------------------------------------------------
+
+
+def _serve_config_file(tmp_path, section: str) -> str:
+ """一份最小可用配置,后面接上任意的 ``[serve]`` 段文本。"""
+ return _tunnels_config(
+ tmp_path,
+ """
+[[tunnels]]
+remote_port = 23334
+local_host = "localhost"
+local_port = 2222
+
+"""
+ + section,
+ )
+
+
+def test_serve_defaults_to_loopback(tmp_path) -> None:
+ """没有 [serve] 段时默认只监听本机:看板不需要用户做任何决定就应该是安全的。"""
+ serve = load_config(_minimal(tmp_path)).serve
+ assert serve.host == "127.0.0.1"
+ assert serve.port == 8787
+ assert serve.token == ""
+ assert serve.loopback is True
+
+
+def test_serve_section_is_parsed(tmp_path) -> None:
+ path = _serve_config_file(
+ tmp_path,
+ """
+[serve]
+host = "192.168.1.5"
+port = 9100
+token = "s3cret"
+refresh = 15
+""",
+ )
+ serve = load_config(path).serve
+ assert serve.host == "192.168.1.5"
+ assert serve.port == 9100
+ assert serve.token == "s3cret"
+ assert serve.refresh == 15
+ assert serve.loopback is False
+
+
+def test_serve_non_loopback_without_token_is_rejected(tmp_path) -> None:
+ """拒绝而不是警告:看板会列出服务器、用户与端口。"""
+ path = _serve_config_file(
+ tmp_path,
+ """
+[serve]
+host = "0.0.0.0"
+""",
+ )
+ with pytest.raises(ConfigValidationError, match="token"):
+ load_config(path)
+
+
+def test_serve_allows_an_explicit_non_loopback_bind_with_token(tmp_path) -> None:
+ """带上令牌就允许对外,把选择权交给用户而不是替他决定。"""
+ path = _serve_config_file(
+ tmp_path,
+ """
+[serve]
+host = "0.0.0.0"
+token = "s3cret"
+""",
+ )
+ assert load_config(path).serve.host == "0.0.0.0"
+
+
+def test_serve_port_must_be_in_range(tmp_path) -> None:
+ path = _serve_config_file(
+ tmp_path,
+ """
+[serve]
+port = 70000
+""",
+ )
+ with pytest.raises(ConfigValidationError, match="serve.port"):
+ load_config(path)
+
+
+def test_serve_unknown_key_warns(tmp_path) -> None:
+ """拼错的键要报出来,而不是默默用默认值。"""
+ path = _serve_config_file(
+ tmp_path,
+ """
+[serve]
+hsot = "127.0.0.1"
+""",
+ )
+ cfg = load_config(path)
+ assert any("serve.hsot" in warning for warning in cfg.warnings)
+
+
+@pytest.mark.parametrize(
+ ("host", "expected"),
+ [
+ ("127.0.0.1", True),
+ ("127.1.2.3", True),
+ ("localhost", True),
+ ("LOCALHOST", True),
+ ("[::1]", True),
+ ("", False),
+ ("0.0.0.0", False),
+ ("::", False),
+ ("192.168.1.5", False),
+ ("example.com", False),
+ ],
+)
+def test_is_loopback_host(host, expected) -> None:
+ """白名单判定:不认识的写法一律当成“对外暴露”。
+
+ ``""`` 特别重要:``http.server`` 把空地址当成 *所有网卡*,所以它绝不是
+ 回环地址,不能因为“看起来是空的”就放行。
+ """
+ assert is_loopback_host(host) is expected
+
+
+def test_ensure_bindable_treats_an_empty_host_as_exposed() -> None:
+ with pytest.raises(ConfigValidationError, match="token"):
+ ensure_bindable("", "")
+ ensure_bindable("", "s3cret")
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
index fa3ee88..af119ab 100644
--- a/tests/test_daemon.py
+++ b/tests/test_daemon.py
@@ -1072,3 +1072,36 @@ def test_service_tool_calls_go_through_run_tool(monkeypatch, tmp_path) -> None:
assert args == ["systemctl", "--user", "daemon-reload"]
assert kwargs["creationflags"] == creation_flags()
assert kwargs["capture_output"] is True and kwargs["text"] is True
+
+
+def test_status_names_the_target_of_each_profile(tmp_path) -> None:
+ """每条隧道都要能说出自己连的是哪台服务器。
+
+ 目标来自配置而不是状态文件(状态文件里就没有这个信息),所以它不能
+ 因为 daemon 刚重启、还没上报而消失——否则看板上会出现一栏“健康的
+ 未知服务器”,而多隧道时那正是最需要弄清楚的一件事。
+ """
+ cfg = _two_profile_cfg(tmp_path)
+ d = TunnelDaemon(cfg)
+ _live_pid(tmp_path)
+ d._store.begin(["web", "db"], started_at=time.time())
+
+ s = d.status()
+
+ assert [profile.destination for profile in s.profiles] == [
+ profile.destination for profile in cfg.profiles
+ ]
+ # 字面断言(而不是子串包含):既钉死格式,也更严格。
+ assert s.profiles[0].destination == "testuser@web.example.com"
+
+
+def test_status_leaves_the_target_unknown_for_a_dropped_profile(tmp_path) -> None:
+ """配置里已删掉的 profile 还会显示(daemon 仍管着它),但没有目标可报。"""
+ d = TunnelDaemon(_cfg(tmp_path))
+ _live_pid(tmp_path)
+ d._store.begin(["default", "ghost"], started_at=time.time())
+
+ ghost = d.status().get_profile("ghost")
+
+ assert ghost is not None
+ assert ghost.destination is None
diff --git a/tests/test_main.py b/tests/test_main.py
index 22e3461..909e5ce 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -13,6 +13,7 @@
HealthConfig,
Profile,
RetryConfig,
+ ServeConfig,
SSHConfig,
SSHOptions,
Tunnel,
@@ -554,3 +555,197 @@ def _interrupt(_seconds: float) -> None:
result = CliRunner().invoke(app, ["logs", "--follow"])
assert result.exit_code == 0
assert "已停止跟随" in result.output
+
+
+# ---------------------------------------------------------------------------
+# ponte serve(本地 HTTP 看板)
+# ---------------------------------------------------------------------------
+
+
+class _FakeServeDaemon:
+ """``ponte serve`` 只用到 ``config.serve`` 与 ``status()``,假对象给这两个。"""
+
+ def __init__(self, serve: ServeConfig | None = None) -> None:
+ self.config = dataclasses.replace(
+ _cfg(), serve=serve if serve is not None else ServeConfig()
+ )
+
+ def status(self) -> DaemonStatus:
+ return _status(
+ ProfileStatus(
+ name="default",
+ destination="testuser@example.com:22",
+ healthy=True,
+ ),
+ running=True,
+ pid=4242,
+ uptime_seconds=10.0,
+ )
+
+
+class _FakeServer:
+ """记录 CLI 怎么启动/收尾服务器,不碰真 socket。"""
+
+ def __init__(self, **kwargs) -> None:
+ self.kwargs = kwargs
+ self.forever = False
+ self.closed = False
+
+ def serve_forever(self, poll_interval: float = 0.5) -> None:
+ self.forever = True
+ raise KeyboardInterrupt
+
+ def server_close(self) -> None:
+ self.closed = True
+
+
+def _record_server(record: dict):
+ """返回一个可注入的 ``create_server``,把参数与 provider 记进 *record*。"""
+
+ def _create(provider, **kwargs) -> _FakeServer:
+ record["provider"] = provider
+ server = _FakeServer(**kwargs)
+ record["server"] = server
+ return server
+
+ return _create
+
+
+def test_serve_is_registered_in_help(monkeypatch) -> None:
+ monkeypatch.setattr("ponte.main.get_config", lambda: _cfg())
+ result = CliRunner().invoke(app, ["--help"])
+ assert result.exit_code == 0
+ assert "serve" in result.output
+
+
+def test_serve_command_serves_and_stops_cleanly(monkeypatch) -> None:
+ monkeypatch.setattr("ponte.main._daemon", lambda: _FakeServeDaemon(ServeConfig(port=9100)))
+ record: dict = {}
+ monkeypatch.setattr("ponte.main.create_server", _record_server(record))
+
+ result = CliRunner().invoke(app, ["serve"])
+
+ assert result.exit_code == 0
+ assert "http://127.0.0.1:9100/" in result.output
+ assert record["server"].closed is True
+ assert record["server"].kwargs["port"] == 9100
+ # provider 必须现读现取:看板的“新鲜度”全靠它,缓存一次就失去意义。
+ payload = record["provider"]()
+ assert payload["profiles"]["default"]["healthy"] is True
+
+
+def test_serve_refuses_to_expose_without_a_token(monkeypatch) -> None:
+ """绑定非回环地址而没有令牌:直接拒绝,不提供“先跑起来再说”。"""
+ monkeypatch.setattr("ponte.main._daemon", lambda: _FakeServeDaemon())
+ monkeypatch.setattr("ponte.main.create_server", _record_server({}))
+
+ result = CliRunner().invoke(app, ["serve", "--host", "0.0.0.0"])
+
+ assert result.exit_code == 1
+ assert "token" in result.output
+
+
+def test_serve_command_line_overrides_win_and_warn(monkeypatch) -> None:
+ """命令行参数覆盖 [serve],并就把看板摆到局域网的后果给出警告。"""
+ monkeypatch.setattr("ponte.main._daemon", lambda: _FakeServeDaemon(ServeConfig(port=8787)))
+ record: dict = {}
+ monkeypatch.setattr("ponte.main.create_server", _record_server(record))
+
+ result = CliRunner().invoke(
+ app,
+ [
+ "serve",
+ "--host", "0.0.0.0",
+ "--port", "9100",
+ "--token", "s3cret",
+ "--refresh", "30",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert record["server"].kwargs == {
+ "host": "0.0.0.0",
+ "port": 9100,
+ "token": "s3cret",
+ "refresh": 30,
+ }
+ assert "警告" in result.output
+
+
+def test_serve_open_launches_the_browser(monkeypatch) -> None:
+ monkeypatch.setattr("ponte.main._daemon", lambda: _FakeServeDaemon(ServeConfig(port=9100)))
+ monkeypatch.setattr("ponte.main.create_server", _record_server({}))
+ opened: list[str] = []
+ monkeypatch.setattr("ponte.main.webbrowser.open", opened.append)
+
+ result = CliRunner().invoke(app, ["serve", "--open"])
+
+ assert result.exit_code == 0
+ assert opened == ["http://127.0.0.1:9100/"]
+
+
+def test_serve_config_applies_only_given_overrides() -> None:
+ from ponte.main import _serve_config
+
+ base = ServeConfig(host="127.0.0.1", port=9100, token="t", refresh=7)
+ assert _serve_config(base, host=None, port=None, token=None, refresh=None) == base
+ changed = _serve_config(base, host="0.0.0.0", port=1, token="x", refresh=2)
+ assert changed == ServeConfig(host="0.0.0.0", port=1, token="x", refresh=2)
+
+
+def test_config_command_reports_serve_without_leaking_the_token(monkeypatch) -> None:
+ """ponte config 的输出经常被粘进 issue:令牌只报“有没有”。"""
+ cfg = dataclasses.replace(
+ _cfg(),
+ serve=ServeConfig(host="0.0.0.0", port=9100, token="s3cret", refresh=15),
+ )
+ monkeypatch.setattr("ponte.main.get_config", lambda: cfg)
+ result = CliRunner().invoke(app, ["config"])
+ assert result.exit_code == 0
+ assert "9100" in result.output
+ assert "已设置" in result.output
+ assert "s3cret" not in result.output
+
+
+def test_status_shows_the_target_destination(monkeypatch) -> None:
+ """多隧道时,最先要说清的是“这张表是哪个服务器”。"""
+ s = _status(
+ ProfileStatus(
+ name="default", destination="testuser@example.com:22", healthy=True
+ ),
+ running=True,
+ pid=1,
+ uptime_seconds=10.0,
+ )
+
+ class _Daemon:
+ def status(self) -> DaemonStatus:
+ return s
+
+ monkeypatch.setattr("ponte.main._daemon", lambda: _Daemon())
+ result = CliRunner().invoke(app, ["status"])
+ assert result.exit_code == 0
+ assert "testuser@example.com:22" in result.output
+
+
+def test_status_json_includes_destination(monkeypatch) -> None:
+ """--json 里也要有目标:监控脚本靠它区分是哪条隧道。"""
+ import json as _json
+
+ s = _status(
+ ProfileStatus(
+ name="default", destination="testuser@example.com:22", healthy=True
+ ),
+ running=True,
+ pid=7,
+ )
+
+ class _Daemon:
+ def status(self) -> DaemonStatus:
+ return s
+
+ monkeypatch.setattr("ponte.main._daemon", lambda: _Daemon())
+ result = CliRunner().invoke(app, ["status", "--json"])
+ assert result.exit_code == 0
+ payload = _json.loads(result.output)
+ assert payload["profiles"]["default"]["destination"] == "testuser@example.com:22"
diff --git a/tests/test_serve.py b/tests/test_serve.py
new file mode 100644
index 0000000..ddf9cac
--- /dev/null
+++ b/tests/test_serve.py
@@ -0,0 +1,534 @@
+"""Tests for ``ponte serve`` — the HTTP dashboard, health probe and metrics.
+
+The renderers are pure functions over a ``ponte status --json`` payload, so most
+of this file needs no sockets. The end-to-end section starts a *real* server on
+an OS-assigned port and speaks real HTTP to it, because the parts worth being
+sure about — routing, status codes, the token gate, the absence of caching —
+only exist once bytes go over a socket.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import re
+import threading
+import urllib.error
+import urllib.request
+from collections.abc import Iterator
+
+import pytest
+
+from ponte import __version__
+from ponte.config import ConfigValidationError, ensure_bindable
+from ponte.serve import (
+ create_server,
+ dashboard_html,
+ health_response,
+ render_metrics,
+ serve_url,
+)
+
+_NOW = 1_700_000_000.0
+
+
+def _profile(**overrides) -> dict:
+ """One profile section, shaped exactly like ``ponte status --json``."""
+ section = {
+ "destination": "deploy@example.com:22",
+ "healthy": True,
+ "process_alive": True,
+ "health_error": None,
+ "error": None,
+ "remote_ports": {"23334": True},
+ "local_ports": {"1080": False},
+ "connect_attempts_total": 4,
+ "sessions_total": 3,
+ "reconnects_total": 1,
+ "tunnel_uptime_seconds": 3600.0,
+ "tunnel_downtime_seconds": 60.0,
+ "availability": 0.9,
+ "current_session_at": _NOW - 120.0,
+ "last_disconnect_at": _NOW - 600.0,
+ "last_disconnect_reason": "ssh exited with code 255",
+ "last_notification_at": None,
+ "recent_events": [
+ {"at": _NOW - 700.0, "type": "disconnected", "reason": "ssh exited with code 255"},
+ {"at": _NOW - 690.0, "type": "retrying", "attempt": 1, "delay": 5.0},
+ {"at": _NOW - 120.0, "type": "connected"},
+ ],
+ }
+ section.update(overrides)
+ return section
+
+
+def _payload(**overrides) -> dict:
+ """A whole-daemon payload."""
+ base = {
+ "running": True,
+ "pid": 4242,
+ "started_at": _NOW - 3600.0,
+ "uptime_seconds": 3600.0,
+ "healthy": True,
+ "profiles": {"web": _profile()},
+ }
+ base.update(overrides)
+ return base
+
+
+def _parse_families(text: str) -> dict[str, dict]:
+ """Parse exposition text, asserting the format's grouping rules holds.
+
+ Returns ``{name: {"type": str, "samples": [line, ...]}}``. The assertions
+ inside are the real test: a metric's samples must form one uninterrupted
+ group, every family needs exactly one ``# HELP``/``# TYPE``, and a sample
+ may never appear before its own header.
+ """
+ families: dict[str, dict] = {}
+ current: str | None = None
+ for line in text.splitlines():
+ if line.startswith("# HELP "):
+ name, _, help_text = line[len("# HELP ") :].partition(" ")
+ assert name not in families, f"duplicate HELP for {name}"
+ assert help_text.strip(), f"empty HELP for {name}"
+ families[name] = {"type": None, "samples": [], "help": help_text}
+ current = name
+ elif line.startswith("# TYPE "):
+ name, _, kind = line[len("# TYPE ") :].partition(" ")
+ assert families[name]["type"] is None, f"duplicate TYPE for {name}"
+ families[name]["type"] = kind
+ current = name
+ elif line.strip():
+ name = line.split("{")[0].split(" ")[0]
+ assert name == current, f"sample {name} is not grouped under {current}"
+ families[current]["samples"].append(line)
+ for name, family in families.items():
+ assert family["type"] in ("gauge", "counter"), name
+ assert family["samples"], f"{name} has no samples"
+ return families
+
+
+#: ``key="value"`` with backslash escapes honoured, i.e. what a Prometheus
+#: parser sees. Values keep their escapes; :func:`_unescape` undoes them.
+_LABEL_PATTERN = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)="((?:[^"\\]|\\.)*)"')
+
+
+def _labels(line: str) -> dict[str, str]:
+ """Parse the label set of a sample line (``{}`` when it has none)."""
+ return {
+ match.group(1): match.group(2)
+ for match in _LABEL_PATTERN.finditer(line)
+ }
+
+
+def _unescape(value: str) -> str:
+ """Undo Prometheus label escaping (``\\n``, ``\\"``, ``\\\\``)."""
+ out: list[str] = []
+ index = 0
+ while index < len(value):
+ char = value[index]
+ if char == "\\" and index + 1 < len(value):
+ nxt = value[index + 1]
+ out.append({"n": "\n", '"': '"', "\\": "\\"}.get(nxt, nxt))
+ index += 2
+ continue
+ out.append(char)
+ index += 1
+ return "".join(out)
+
+
+def _sample(families: dict[str, dict], name: str, **labels: str) -> str | None:
+ """Return the sample of *name* whose labels include *labels*."""
+ for line in families[name]["samples"]:
+ parsed = _labels(line)
+ if all(parsed.get(key) == value for key, value in labels.items()):
+ return line
+ return None
+
+
+# ---------------------------------------------------------------------------
+# /healthz
+# ---------------------------------------------------------------------------
+
+
+def test_healthz_down_when_daemon_is_not_running() -> None:
+ code, body = health_response({"running": False})
+ assert code == 503
+ assert body["status"] == "down"
+
+
+def test_healthz_ok_when_every_profile_is_healthy() -> None:
+ code, body = health_response(_payload())
+ assert code == 200
+ assert body == {"status": "ok", "profiles": 1}
+
+
+def test_healthz_degraded_names_the_broken_profile() -> None:
+ """A 503 has to say *which* tunnel broke, or the alert is unactionable."""
+ payload = _payload(
+ profiles={
+ "web": _profile(),
+ "db": _profile(healthy=False, health_error="port 23335 is not listening"),
+ }
+ )
+ code, body = health_response(payload)
+ assert code == 503
+ assert body["status"] == "degraded"
+ assert body["unhealthy"] == ["db"]
+ assert body["errors"] == {"db": "port 23335 is not listening"}
+
+
+def test_healthz_starting_is_not_reported_as_down() -> None:
+ """No health check yet must not fire a false alert on every restart.
+
+ A fresh ``ponte start`` waits up to ``check_interval`` for its first
+ answer; calling that "down" would page someone on every restart.
+ """
+ code, body = health_response(_payload(profiles={"web": _profile(healthy=None)}))
+ assert code == 200
+ assert body["status"] == "starting"
+
+
+def test_healthz_ok_when_no_profile_reported_at_all() -> None:
+ code, body = health_response(_payload(profiles={}))
+ assert code == 200
+ assert body["status"] == "starting"
+
+
+def test_healthz_survives_a_malformed_payload() -> None:
+ """The status file is written by whichever daemon version is installed."""
+ code, body = health_response({"running": True, "profiles": "nonsense"})
+ assert code == 200
+ assert body["status"] == "starting"
+
+
+# ---------------------------------------------------------------------------
+# /metrics
+# ---------------------------------------------------------------------------
+
+
+def test_metrics_exposes_the_profile_state() -> None:
+ families = _parse_families(render_metrics(_payload(), now=_NOW))
+
+ assert _sample(families, "ponte_up") == "ponte_up 1"
+ assert _sample(families, "ponte_profiles_configured") == (
+ "ponte_profiles_configured 1"
+ )
+ assert _sample(families, "ponte_profiles_unhealthy") == (
+ "ponte_profiles_unhealthy 0"
+ )
+ assert _sample(families, "ponte_build_info") == (
+ f'ponte_build_info{{version="{__version__}"}} 1'
+ )
+ assert _sample(families, "ponte_profile_healthy", profile="web") == (
+ 'ponte_profile_healthy{profile="web"} 1'
+ )
+ assert _sample(families, "ponte_profile_sessions_total", profile="web") == (
+ 'ponte_profile_sessions_total{profile="web"} 3'
+ )
+ # Identity travels in one info metric, so a legend can show the server
+ # without repeating the string on every sample.
+ assert _sample(
+ families,
+ "ponte_profile_info",
+ profile="web",
+ destination="deploy@example.com:22",
+ ) == (
+ 'ponte_profile_info{profile="web",destination="deploy@example.com:22"} 1'
+ )
+
+
+def test_metrics_session_age_is_computed_live() -> None:
+ families = _parse_families(render_metrics(_payload(), now=_NOW))
+ assert _sample(families, "ponte_profile_session_uptime_seconds", profile="web") == (
+ 'ponte_profile_session_uptime_seconds{profile="web"} 120.000'
+ )
+
+
+def test_metrics_reports_port_listening_state() -> None:
+ """The strongest signal of the lot: is the port *actually* forwarded?"""
+ families = _parse_families(render_metrics(_payload(), now=_NOW))
+ assert _sample(
+ families, "ponte_profile_port_listening", profile="web", kind="remote", port="23334"
+ ) == (
+ 'ponte_profile_port_listening{profile="web",kind="remote",port="23334"} 1'
+ )
+ assert _sample(
+ families, "ponte_profile_port_listening", profile="web", kind="local", port="1080"
+ ) == 'ponte_profile_port_listening{profile="web",kind="local",port="1080"} 0'
+
+
+def test_metrics_omits_unknown_values_instead_of_exporting_nan() -> None:
+ """A gap in a graph says "no data"; a NaN line invites a guess."""
+ payload = _payload(
+ profiles={
+ "web": _profile(
+ healthy=None,
+ process_alive=None,
+ current_session_at=None,
+ availability=None,
+ sessions_total=None,
+ last_disconnect_at=None,
+ last_notification_at=None,
+ remote_ports={},
+ local_ports={},
+ )
+ }
+ )
+ text = render_metrics(payload, now=_NOW)
+ for absent in (
+ "ponte_profile_healthy",
+ "ponte_profile_process_alive",
+ "ponte_profile_session_uptime_seconds",
+ "ponte_profile_availability_ratio",
+ "ponte_profile_sessions_total",
+ "ponte_profile_port_listening",
+ ):
+ assert absent not in text
+ assert "ponte_up 1" in text
+
+
+def test_metrics_when_daemon_is_stopped_still_answers_200_up_zero() -> None:
+ """The scrape must keep working while the thing it measures is down."""
+ families = _parse_families(render_metrics({"running": False}))
+ assert _sample(families, "ponte_up") == "ponte_up 0"
+ assert "ponte_profile_sessions_total" not in families
+
+
+def test_metrics_escapes_label_values() -> None:
+ """A quote or newline in a name must not forge or split a label.
+
+ Profile names come from the config and destinations from ``[ssh]``; a real
+ newline leaking into a label would break the sample across two lines and
+ corrupt the whole scrape, not just that metric.
+ """
+ name = 'we"b\n'
+ destination = 'host"x\\y\nz'
+ families = _parse_families(render_metrics(_payload(profiles={name: _profile(destination=destination)}), now=_NOW))
+
+ healthy = _sample(families, "ponte_profile_healthy")
+ assert healthy is not None
+ assert _unescape(_labels(healthy)["profile"]) == name
+
+ info = _sample(families, "ponte_profile_info")
+ assert info is not None
+ assert _unescape(_labels(info)["destination"]) == destination
+
+
+# ---------------------------------------------------------------------------
+# The dashboard
+# ---------------------------------------------------------------------------
+
+
+def test_dashboard_shows_the_tunnel_state() -> None:
+ page = dashboard_html(_payload(), refresh=7, now=_NOW)
+ assert "web" in page
+ assert "deploy@example.com:22" in page
+ assert "2m 0s" in page # current session duration
+ assert "90.0%" in page # availability
+ assert "监听中" in page and "未监听" in page
+ assert "ssh exited with code 255" in page
+ assert '' in page
+
+
+def test_dashboard_escapes_everything_from_outside() -> None:
+ """Profile names come from config and reasons from SSH's stderr."""
+ payload = _payload(
+ profiles={
+ "": _profile(
+ destination='">',
+ last_disconnect_reason="sshd said no",
+ recent_events=[
+ {"at": _NOW, "type": "disconnected", "reason": "boom"}
+ ],
+ )
+ }
+ )
+ page = dashboard_html(payload, now=_NOW)
+ assert "" not in page
+ assert "<script>alert(1)</script>" in page
+ assert "sshd" not in page
+ assert "boom" not in page
+
+
+def test_dashboard_reports_a_stopped_daemon() -> None:
+ page = dashboard_html({"running": False}, now=_NOW)
+ assert "守护进程未运行" in page
+ assert "ponte start" in page
+
+
+def test_dashboard_waits_quietly_for_the_first_check() -> None:
+ page = dashboard_html(_payload(profiles={"web": _profile(healthy=None)}), now=_NOW)
+ assert "等待首次检查" in page
+
+
+# ---------------------------------------------------------------------------
+# End to end: a real server, real HTTP
+# ---------------------------------------------------------------------------
+
+
+@contextlib.contextmanager
+def _running_server(provider, **kwargs) -> Iterator[str]:
+ """Start a server on an OS-assigned port and yield its base URL."""
+ server = create_server(provider, host="127.0.0.1", port=0, **kwargs)
+ thread = threading.Thread(
+ target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
+ )
+ thread.start()
+ try:
+ yield f"http://127.0.0.1:{server.server_address[1]}"
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+def _get(url: str, *, headers: dict[str, str] | None = None, method: str = "GET"):
+ """Return ``(status, headers, body_text)`` for *url* without raising."""
+ request = urllib.request.Request(url, headers=headers or {}, method=method)
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response:
+ return response.status, response.headers, response.read().decode("utf-8")
+ except urllib.error.HTTPError as error:
+ return error.code, error.headers, error.read().decode("utf-8")
+
+
+def test_end_to_end_endpoints_answer() -> None:
+ with _running_server(lambda: _payload()) as base:
+ status, headers, body = _get(base + "/")
+ assert status == 200
+ assert headers["Content-Type"].startswith("text/html")
+ assert "web" in body
+
+ status, _, body = _get(base + "/healthz")
+ assert status == 200
+ assert json.loads(body)["status"] == "ok"
+
+ status, headers, body = _get(base + "/metrics")
+ assert status == 200
+ assert headers["Content-Type"].startswith("text/plain")
+ assert "ponte_up 1" in body
+
+ status, _, body = _get(base + "/status.json")
+ assert status == 200
+ assert json.loads(body)["profiles"]["web"]["sessions_total"] == 3
+
+
+def test_end_to_end_healthz_tracks_the_tunnel() -> None:
+ """The probe reports the tunnel, not the process: 503 when it breaks."""
+ state = {"payload": _payload()}
+ with _running_server(lambda: state["payload"]) as base:
+ assert _get(base + "/healthz")[0] == 200
+ state["payload"] = _payload(
+ profiles={"web": _profile(healthy=False, health_error="port closed")}
+ )
+ status, _, body = _get(base + "/healthz")
+ assert status == 503
+ assert json.loads(body)["errors"] == {"web": "port closed"}
+
+
+def test_end_to_end_status_is_never_cached() -> None:
+ """A replayed "healthy" page is the exact failure the endpoint prevents."""
+ state = {"payload": _payload()}
+ with _running_server(lambda: state["payload"]) as base:
+ _, headers, _ = _get(base + "/")
+ assert headers["Cache-Control"] == "no-store"
+ state["payload"] = {"running": False}
+ assert "ponte start" in _get(base + "/")[2]
+
+
+def test_end_to_end_unknown_path_and_method() -> None:
+ with _running_server(lambda: _payload()) as base:
+ status, _, body = _get(base + "/nope")
+ assert status == 404
+ assert "/metrics" in json.loads(body)["endpoints"]
+
+ status, headers, body = _get(base + "/", method="POST")
+ assert status == 405
+ assert headers["Allow"] == "GET, HEAD"
+ assert "read-only" in json.loads(body)["error"]
+
+
+def test_end_to_end_head_sends_headers_without_a_body() -> None:
+ with _running_server(lambda: _payload()) as base:
+ status, headers, body = _get(base + "/metrics", method="HEAD")
+ assert status == 200
+ assert int(headers["Content-Length"]) > 0
+ assert body == ""
+
+
+def test_end_to_end_token_gate() -> None:
+ """With a token set, nothing is served without it — header or query."""
+ with _running_server(lambda: _payload(), token="s3cret") as base:
+ for path in _ROUTES_FOR_TOKEN_TEST:
+ status, headers, _ = _get(base + path)
+ assert status == 401, path
+ assert headers["WWW-Authenticate"].startswith("Bearer")
+
+ assert _get(base + "/healthz?token=s3cret")[0] == 200
+ assert _get(base + "/", headers={"Authorization": "Bearer s3cret"})[0] == 200
+ assert _get(base + "/healthz?token=wrong")[0] == 401
+
+
+_ROUTES_FOR_TOKEN_TEST = ("/", "/healthz", "/metrics", "/status.json")
+
+
+def test_end_to_end_provider_failure_answers_500_without_dropping_the_client() -> None:
+ def broken() -> dict:
+ raise RuntimeError("status file is a mess")
+
+ with _running_server(broken) as base:
+ status, _, body = _get(base + "/healthz")
+ assert status == 500
+ assert "status unavailable" in json.loads(body)["error"]
+
+
+# ---------------------------------------------------------------------------
+# Bind safety and URL rendering
+# ---------------------------------------------------------------------------
+
+
+def test_serve_refuses_to_expose_without_a_token() -> None:
+ """Refusing beats warning: the page maps your infrastructure."""
+ with pytest.raises(ConfigValidationError) as caught:
+ create_server(lambda: _payload(), host="0.0.0.0", port=0)
+ assert "token" in str(caught.value)
+
+
+def test_serve_allows_a_non_loopback_bind_when_a_token_is_set() -> None:
+ """The gate is the token, not the address: with one, exposing is allowed.
+
+ Checked through :func:`ensure_bindable` rather than by actually binding a
+ public interface — a test has no business opening a port the whole LAN can
+ reach (and on Windows that alone can raise a firewall prompt).
+ """
+ ensure_bindable("0.0.0.0", "s3cret") # does not raise
+ with pytest.raises(ConfigValidationError):
+ ensure_bindable("0.0.0.0", "")
+
+
+def test_create_server_carries_its_settings() -> None:
+ server = create_server(
+ lambda: _payload(), host="127.0.0.1", port=0, token="s3cret", refresh=9
+ )
+ try:
+ assert server.token == "s3cret"
+ assert server.refresh == 9
+ finally:
+ server.server_close()
+
+
+@pytest.mark.parametrize(
+ ("host", "expected"),
+ [
+ ("", "http://127.0.0.1:8787/"),
+ ("127.0.0.1", "http://127.0.0.1:8787/"),
+ ("0.0.0.0", "http://0.0.0.0:8787/"),
+ ("::1", "http://[::1]:8787/"),
+ ("192.168.1.5", "http://192.168.1.5:8787/metrics"),
+ ],
+)
+def test_serve_url_formats_the_link(host: str, expected: str) -> None:
+ path = "/metrics" if host == "192.168.1.5" else "/"
+ assert serve_url(host, 8787, path) == expected