diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0acf3..dd44625 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: @@ -17,9 +62,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `pythonw.exe` sat next to `sys.executable`, the Scheduled Task quietly pointed at `python.exe` and a console window appeared at every logon. Installation now refuses with an actionable message instead of installing a task that pops up. +- **A flapping tunnel was reported as 100% available.** `status --json` rounded + `availability` to one decimal *as a 0..1 ratio*, so 97.9% became `1.0` — and + that field is what the dashboard, `/status.json` and the Prometheus + `ponte_profile_availability_ratio` gauge all read, while `ponte status` printed + the truthful number from the unrounded value. The ratio is now rounded to + three decimals, which keeps the tenth of a percent those surfaces display. ### 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 @@ -43,10 +109,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + dashboard labels every row with it. +- **The dashboard is one row per tunnel, not one card per tunnel.** Three + tunnels already needed a scroll, and the first requirement of a status page is + "see everything at once". Each row now carries the name and verdict, the + destination, the jump chain, the forwarded ports as chips, and one line of + session/availability/disconnect facts; the statistics table and the event feed + moved into the row's own `
` disclosure, so nothing was dropped — it is + only deferred. Port chips carry a glyph and a side label (`远程`/`本地`) rather + than relying on colour, and a row that is *unknown* shows `未观测` for the group + the probe never reached instead of a red `未监听`. The pill, the row's colour + bar and the header count all read one verdict helper, so they cannot disagree + about which tunnels are in trouble. The header also became a count of what is + actually wrong (a `0` tile is not rendered at all), and the page stopped being + dark-only: it now ships the light palette it had always advertised with + ``, draws availability as a + small bar next to its digits, and uses tabular numerals so a number changing + under a refresh does not reflow the row it is in. (While building it: the + disclosure caret was written as a CSS `\25be` escape inside an ordinary Python + string, where Python reads `\25` as an *octal* escape — the caret rendered as a + control character.) +- **The dashboard refreshes in place instead of reloading itself under you.** + The auto-refresh was a ``, so every tick threw away + the page you were reading: an expanded row snapped shut, the scroll position + reset, and clicking a row could be undone before you finished reading it (on a + slow link it was worse than manual refreshing). The page is still rendered + complete on the server and still runs no script by default — the meta refresh + now lives inside `