A persistent SSH tunnel daemon — reverse, local & SOCKS · 持久 SSH 隧道守护工具(反向 / 本地 / SOCKS)
Keep
ssh -Nforwardings alive across network drops and reboots — reconnect with exponential backoff + jitter, and register an OS-level auto-start service so it survives crashes.
- 🧭 All three forwarding kinds —
[[tunnels]]rules are tagged bykind:remote(-R, the default),local(-L) anddynamic(-D, a SOCKS5 proxy). Mix them freely — they share one SSH connection. Duplicate listen ports are rejected at config load, beforeExitOnForwardFailurecan turn a typo into an endless reconnect loop. - 🧵 Many tunnels, one daemon —
[[profiles]]gives each SSH endpoint a name, its own key and its own forwarding rules. Every profile is supervised independently (own connection, reconnect budget, health checks and status file section), so one unreachable server no longer drags the others down, andponte status/ponte watchhave a row per tunnel. A pre-profilesconfig is read as a single profile nameddefault. - 🔁 Self-healing — infinite reconnect with exponential backoff + full
jitter (
max_retries=0= retry forever), so a drop never becomes a dead tunnel. A session that stays up ≥stable_afterseconds resets the retry budget, so a long-running tunnel is never abandoned after a few flaky drops. - 🔄 Hot reload —
ponte reloadre-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 -HUPdoes 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/configstill counts — onlyhostis required in[ssh]. Leave outuserand/oridentity_fileand ponte stops forcinguser@and-i, so OpenSSH resolves them itself:Hostaliases,User,IdentityFile, ssh-agent. A machine wheressh myserveralready works needs no duplication, and ponte no longer overrides anIdentityFileyou set there.ponte config --ssh-commandprints 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 tossh -Jverbatim (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/configas well — no second key path to keep in sync.ponte doctorprobes the one hop that is actually reachable from here and says "the bastion is unreachable" instead of leaving you with a login failure, whileponte testvalidates the whole chain. - 🛟 Crash recovery —
installregisters 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
clear diagnostics instead of a black box. A "zombie" SSH process (alive but
ports down) is force-reconnected after 3 consecutive failed checks, and
checks back off exponentially during outages so the server's
MaxStartupsis never hammered. - 🔔 It tells you when it breaks — after
[notify].on_consecutive_failuresfailed attempts in a row, ponte pushes to an ntfy topic and/or a JSON webhook, at most once percooldownfor the same tunnel, and re-arms only after a session that stays up ≥stable_afterseconds.ponte notify-testproves 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. - 📊 A dashboard and a metrics endpoint —
ponte serveputs the same status on HTTP:/is a self-contained dashboard (one file, no CDN, no external assets, complete without JavaScript),/healthzanswers503when a tunnel is actually broken,/metricsspeaks Prometheus and/status.jsonis exactlyponte status --json. Loopback-only by default; exposing it needs an explicit token. - 🖥️ Cross-platform — resolves
sshautomatically, per-platform runtime paths, and portable remote-port probing (socket→ss/lsof/netstat).
pipx install ponte-cli # or: pip install ponte-cli (pipx keeps it isolated)
ponte init # create the config file and print its path
$EDITOR ~/.config/ponte/config.toml # set host/user, point identity_file at your key
ponte test # verify SSH connectivity
ponte start # run the daemon in the background
ponte status # check process + remote ports
ponte install # register auto-start + crash restartNo config file yet? ponte init writes one from the shipped template. The
config lives outside the package, so pip install -U ponte-cli never
touches it. The PyPI distribution is ponte-cli, while the command and the
import package stay ponte; a checkout installs the same way (pipx install .).
| Command | Purpose |
|---|---|
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] [--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 [--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. Run
ponte --install-completion once to add shell completion
(bash / zsh / fish / PowerShell).
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.
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: one row per tunnel — verdict, destination and jump chain, forwarded ports as chips, session age, availability, last disconnect reason; click a row for the full statistics and the event feed. Light and dark, refreshed in place (expanded rows and scroll position survive), and a <noscript> reload if scripting is off |
/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.
"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
异常, the dashboard prints 未观测 for a port group the probe never reached
instead of a red 未监听, 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
only together with a token; ponte refuses the combination of "exposed" and
"no token" instead of warning about it:
[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"Loopback is decided by parsing the address, never by matching its spelling: a
name such as 127.corp.example resolves wherever its owner points it, so it
counts as exposed and needs a token. Shorthand the OS would accept but the
parser does not (127.1, or the absolute form localhost.) also counts as
exposed — the error message says so, and 127.0.0.1 is what the default uses
anyway.
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.
# 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-stringAlert 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.
| Platform | Mechanism | Generated artifact |
|---|---|---|
| Windows | Scheduled Task (boot or logon) | Register-ScheduledTask (pythonw -m ponte.main --config <file> start --foreground) |
| Linux | systemd user unit | ~/.config/systemd/user/ponte.service |
| macOS | launchd LaunchAgent | ~/Library/LaunchAgents/com.modusensus.ponte.plist |
The daemon is always launched with an explicit --config, so a service running
under a different account (e.g. a SYSTEM Scheduled Task) still reads your
config instead of silently falling back to another one.
ponte (local daemon, Python)
main.py ──▶ daemon.py ──▶ retry.py ──▶ core.py ──▶ ssh -N (-R/-L/-D)
(typer (lifecycle (infinite (pure SSH
CLI) orchestration) backoff) subprocess)
│
▼
health.py ── periodic checks: process alive + remote ports
main.py— typer CLI entrydaemon.py— lifecycle orchestration, service install/uninstall, graceful stopretry.py— exponential backoff + jitter reconnect state machinecore.py— SSH argument building, subprocess management, port probinghealth.py— periodic liveness + remote-port checksnotify.py— ntfy / webhook alerts on repeated failuresdoctor.py— one-shot diagnostics used byponte doctorserve.py— read-only HTTP surface (dashboard / health probe / metrics) over the same payloadponte status --jsonemitsconfig.py— TOML load/validate (built-intomllibon 3.11+)
Run ponte init, then edit the file it prints. All paths support ~ and
environment-variable expansion. Resolution order (first existing file wins):
--config PATH$PONTE_CONFIG- user config dir —
%APPDATA%\ponte\config.toml(Windows),~/.config/ponte/config.toml(Linux),~/Library/Application Support/ponte/config.toml(macOS) ponte/config.tomlinside the installed package — legacy, honoured only so pre-0.3 installs keep working; it is overwritten bypip install -U, so migrate withponte init.
Sections:
[ssh]—host/port/user/identity_file/known_hosts_file/jump/options(any extra key there is passed through verbatim as-o key=value). Onlyhostis required: leave outuserand/oridentity_fileand ponte stops forcinguser@and-i, so OpenSSH resolves them from your~/.ssh/config(Hostalias,User,IdentityFile) or ssh-agent.ponte config --ssh-commandshows the resulting command line.[ssh] jump— the bastion in front ofhost, in OpenSSHProxyJumpsyntax:"bastion","ops@bastion","ops@bastion:2222", or a chain such as"ops@hop1, root@hop2". ponte passes it tossh -Jand never talks to the hop itself, so the bastion's identity comes from its ownHostblock in~/.ssh/config— there is deliberately no per-hopidentity_filehere.proxy_jumpis accepted as an alias (set only one). Combiningjumpwith aProxyJump/ProxyCommandin[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 hasname, its own[profiles.ssh]and its own[[profiles.tunnels]]. Mixed with a top-level[ssh]/[[tunnels]]it is rejected rather than guessed at;retry/health/daemon/servicestay global policy for every profile.[[tunnels]]— forwarding rules.kindpicks the flag and what the fields mean:remote(default,-R: the server listens onremote_portand forwards back tolocal_host:local_port),local(-L: this machine listens onlocal_host:local_portand forwards toremote_host:remote_port) anddynamic(-D: a SOCKS5 proxy onlocal_host:local_port). For-L/-Dthe bind address defaults to127.0.0.1, so an omitted field is never a LAN exposure.[daemon]— pid/log paths (default per-platform:%LOCALAPPDATA%\ponte,~/.local/state/ponte,~/Library/Application Support/ponte), log rotation[retry]—max_retries(0 = forever), backoff params,jitter,stable_after[health]— check interval, remote probe toggle/timeout,max_check_interval(backoff ceiling while unhealthy)[notify]—enabled(defaultfalse),on_consecutive_failures,cooldown(seconds between two alerts for the same profile), and the channels:ntfy_topic(plus optionalntfy_server/ntfy_token) and/orwebhook_url, which receives the alert as JSON[service]— service name, autostart, POSIX kill grace[windows]— Windows-only knobs (task_name,ssh_exe,pythonw_exe,run_as).run_asisuser(default: logon-time, runs as you, can read~/.ssh) orsystem(boot-time, survives login/reboot, needs elevation and an identity file SYSTEM can read).pythonw_exepins the windowless interpreter the Scheduled Task runs: ponte refuses to install a task that would fall back topython.exe, because that flashes a console window at every logon.
| Symptom | Where to look |
|---|---|
Permission denied (publickey) |
public key on server ~/.ssh/authorized_keys; on Windows strip inherited ACLs (icacls id_rsa /inheritance:r /grant:r <user>:(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 |
pip install -e ".[dev]"
pytest --cov=ponte --cov-report=term-missing # threshold in pyproject.toml
ruff check . # lint
mypy # type check
python _smoke_test.py # zero-dependency quick checkCI runs lint + types on Linux, and the test suite across
Windows/Linux/macOS × Python 3.11/3.12, reporting coverage to
Codecov. One extra job re-runs the
suite with PONTE_TEST_THREAD_DELAY=0.15: that injects latency into
worker-thread sleeps and waits, so a test that only passes on a fast machine
fails there every time instead of flaking once in a while. Set the same
variable locally to reproduce such a machine. A build job also installs
the built wheel and runs ponte init, so a packaging regression cannot ship
again. See CONTRIBUTING.md.
- Never commit the private key:
.gitignoreexcludesid_rsa/id_rsa.pub; place your own keys on each machine. - Runtime files (
ponte.pid/ponte.status.json/ponte.stop/ponte.log*) are generated and not committed. - Upgrading from ≤ 0.2.x: the config file moved out of the package. Run
ponte init(it migrates the in-package file if one exists), thenponte installagain so the service picks up the new--configargument. - Legacy pre-Python scripts (
tunnel.ps1,setup.ps1,ssh-tunnel.bat,ssh-tunnel.vbs,fix-wsl-tunnel.sh) live inlegacy/and are deprecated — the CLI replaces them.setup.ps1copied your private key into the project directory; do not use it. - Found a security issue? See SECURITY.md for how to report it privately.
让
ssh -N -R在网络抖动与重启后依然存活——断线自动以指数退避 + 抖动重连, 并注册系统级开机自启服务,进程崩溃也能被拉活。
- 🧵 一个守护进程,多条隧道 —
[[profiles]]给每个 SSH 端点一个名字、 一套密钥和一组转发规则;每条 profile 各自重连、各自健康检查、在状态文件里 各占一段,所以一台服务器连不上不会拖垮其它隧道,ponte status/ponte watch也是每条隧道一行。升级前的单隧道配置会被当作名为default的 profile 读取。 - 🧭 三种转发都支持 —
[[tunnels]]规则用kind区分:remote(-R,默认)、local(-L)、dynamic(-D,SOCKS5 代理),可以混用且共用一条 SSH 连接。 重复的监听端口在加载配置时就会被拒绝,而不是让ExitOnForwardFailure把一次手误变成无限重连。 - 🔁 自愈 — 无限重连 + 指数退避 + 全抖动(
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次失败后, 向 ntfy 主题和/或 JSON webhook 推送一条告警;同一隧道每个cooldown秒最多 一条,且只有会话稳定运行 ≥stable_after秒才重新武装。ponte notify-test让你在真出事之前就验证通道可用。默认关闭:不开启就绝不会外发任何数据。 - 🩺
ponte doctor— 一条命令逐项体检:配置、密钥及其权限、SSH 连通性、 监听端口、开机自启状态、通知通道,每行都给出具体修法而不是留个黑箱。 - 📊 看板与指标接口 —
ponte serve把同一份状态摆到 HTTP 上:/是一个 自包含的看板(单文件、不依赖 CDN 与任何外部资源,关掉脚本也能完整渲染),/healthz在隧道真的断时回503,/metrics说 Prometheus 格式,/status.json就是ponte status --json。 默认只监听本机;要对外必须先给令牌。 - 🖥️ 跨平台 — 自动查找
ssh、按平台落盘运行时文件、可移植的远程端口探测 (socket→ss/lsof/netstat)。
pipx install ponte-cli # 或 pip install ponte-cli(pipx 会隔离安装)
ponte init # 生成配置文件并打印路径
$EDITOR ~/.config/ponte/config.toml # 填 host/user,identity_file 指向你的密钥
ponte test # 验证 SSH 连通性
ponte start # 后台启动守护进程
ponte status # 查看进程 + 远程端口
ponte install # 注册开机自启 + 崩溃重启还没有配置文件?ponte init 会从内置模板生成一份。配置存放在包外,
pip install -U ponte-cli 不会覆盖它。PyPI 上的发行名是 ponte-cli,
命令与导入包名仍为 ponte(从源码目录安装同样可用 pipx 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] [--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 [--ssh-command] |
打印生效配置、来源文件与配置告警(--ssh-command 打印实际执行的 ssh 命令) |
全局选项(写在子命令之前):--config/-c PATH 指定配置文件,
--version/-V 打印版本。拼错/未知的配置项会由 ponte config 报出来,
不再被静默忽略。运行一次 ponte --install-completion 即可启用 shell 补全
(bash / zsh / fish / PowerShell)。
ponte watch 给坐在机器前的你看,ponte serve 给其它一切:浏览器、手机
(同机)、Uptime Kuma、Prometheus。
ponte serve # http://127.0.0.1:8787/(默认只监听本机)
ponte serve --open # 顺手在浏览器里打开| 接口 | 回答什么问题 |
|---|---|
/ |
看板:一行一条隧道——状态、目标与跳板机、端口 chips、会话时长、在线率、上次断线原因;点开该行看完整统计与事件流。跟随浅色/深色主题,原地刷新(展开的行与滚动位置都不会丢),关掉脚本则退化为整页刷新 |
/healthz |
隧道正常时 200,任一条断开立即 503 —— 监控就探这个 |
/metrics |
Prometheus 文本格式:会话时长、累计在线/离线、在线率、重连次数、端口监听状态 |
/status.json |
与 ponte status --json 完全一致的载荷 |
为什么 /healthz 与 /metrics 故意不一致。 /healthz 会失败,监控才能
报警;/metrics 永远回 200,把状态当数字报出来——因为采挂掉会盖住
“它为何挂了”,而那正是画图的目的。另外首次健康检查完成前,/healthz
报的是 starting(200),所以重启守护进程不会造成误报。
“未知”不是“挂了”。 远程端口探测本身就是一条独立的 SSH 连接,在共享/NAT
出口上它自己就会失败(我们实测约三分之一的检查如此)。所以 ponte 把“没问成”
和“问了、答案是坏的”分开:/healthz 回 200 unverified(而不是 503),
ponte status 显示黄色的 未知 并附上探测失败原因(而不是 异常),看板对探针
没探到的那一组端口写明「未观测」而不是画成红色的「未监听」,
ponte_profiles_unknown 单独计数。只有确凿的失败——SSH 进程没了,
或探针确实连上并看到端口没在听——才会报警或触发假死强制重连。
安全模型。 看板会列出你的服务器地址、登录用户与转发端口——这是一张
内网拓扑图,不是一行状态。所以 ponte serve 只绑 127.0.0.1。绑到局域网或
公网是可以的,但必须同时给令牌:ponte 对“对外 + 无令牌”的组合是直接
拒绝,而不是警告一句了事。
[serve]
host = "0.0.0.0" # 显式选择对外
port = 8787
token = "一个足够长的随机串" # 非回环地址必需
refresh = 5 # 看板自动刷新秒数
# 也支持 IPv6:host = "::1"回环与否是解析地址得出的,不靠拼写匹配:像 127.corp.example 这样的名字
解析到哪里由它的所有者决定,所以它算“对外”,需要令牌。操作系统接受、但解析器
不认的简写(127.1,或绝对形式 localhost.)同样算“对外”——报错信息里会说明,
而默认值本来就是 127.0.0.1。
客户端用 ?token=...(脚本/采集器方便)或 Authorization: Bearer ...。
四个接口全是只读、每次请求都重新读取守护进程状态,并带
Cache-Control: no-store——所以页面不会拿旧的“健康”去骗一个已经挂了的隧道。
# prometheus.yml
scrape_configs:
- job_name: ponte
static_configs:
- targets: ["127.0.0.1:8787"]
# 带令牌时:metrics_path: /metrics?token=一个足够长的随机串最值得拿来报警的一条是 ponte_profile_port_listening == 0:进程活着、
转发端口却没了,正是那种悄无声息的典型故障。
| 平台 | 机制 | 生成物 |
|---|---|---|
| Windows | 计划任务(开机或登录) | Register-ScheduledTask(pythonw -m ponte.main --config <文件> start --foreground) |
| Linux | systemd user 单元 | ~/.config/systemd/user/ponte.service |
| macOS | launchd LaunchAgent | ~/Library/LaunchAgents/com.modusensus.ponte.plist |
守护进程始终带显式 --config 启动:即使服务以其它身份运行(例如 SYSTEM
计划任务),读到的仍是你这份配置,而不是静默回退到别处。
ponte(本地守护进程,Python)
main.py ──▶ daemon.py ──▶ retry.py ──▶ core.py ──▶ ssh -N(-R/-L/-D)
(typer (生命周期 (无限退避 (纯 SSH
CLI) 编排) 重连) subprocess)
│
▼
health.py ── 周期检查:进程存活 + 远程端口(-R)+ 本地监听(-L/-D)
一个守护进程为每个 [[profiles]] 建一个 ProfileRunner(各自的 SSH 会话、
重连循环、健康检查),进程级的 pid / 状态文件 / 服务注册由守护进程统一持有;
状态文件按 profile 分区,所以一条隧道挂了不会影响其它隧道。
main.py— typer 命令行入口daemon.py— 生命周期编排、服务安装/卸载、优雅停止retry.py— 指数退避 + 抖动重连状态机core.py— SSH 参数构建、子进程管理、端口探测health.py— 周期存活 + 远程端口检查notify.py— 连续失败时的 ntfy / webhook 告警doctor.py—ponte doctor使用的体检项serve.py— 只读 HTTP 接口(看板 / 探活 / 指标),渲染的就是ponte status --json那份载荷config.py— TOML 加载/校验(3.11+ 内置tomllib)
先运行 ponte init,再编辑它打印出的文件。所有路径支持 ~ 与环境变量展开。
查找顺序(先找到的生效):
--config PATH- 环境变量
$PONTE_CONFIG - 用户配置目录 —— Windows
%APPDATA%\ponte\config.toml、 Linux~/.config/ponte/config.toml、 macOS~/Library/Application Support/ponte/config.toml - 包内
ponte/config.toml—— 旧位置,仅为兼容 0.3 之前的安装保留; 它会被pip install -U覆盖,请用ponte init迁移。
各段含义:
[ssh]—host/port/user/identity_file/known_hosts_file/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仍是所有 profile 共用的全局策略。[[tunnels]]— 转发规则。kind决定用哪个转发开关、各字段是什么意思:remote(默认,-R)服务器监听remote_port并转发回local_host:local_port;local(-L)本机监听local_host:local_port并转发到服务器侧的remote_host:remote_port;dynamic(-D)在local_host:local_port上开一个 SOCKS5 代理。-L/-D的绑定地址默认127.0.0.1,省略字段不会意外暴露到局域网。[daemon]— pid/log 路径(平台默认:%LOCALAPPDATA%\ponte、~/.local/state/ponte、~/Library/Application Support/ponte)、日志滚动[retry]—max_retries(0 = 无限)、退避参数、jitter、stable_after[health]— 检查间隔、远程探测开关/超时、max_check_interval(不健康期间的间隔退避上限)[notify]—enabled(默认false)、on_consecutive_failures、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)或system(开机即起、重启也能拉起,但需提权,且identity_file必须是 SYSTEM 能读到的文件)。pythonw_exe指定计划任务使用的无窗口解释器: 若只能回退到python.exe,ponte 会拒绝安装——那会导致每次登录弹出黑色窗口。
| 症状 | 排查方向 |
|---|---|
| 「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 |
pip install -e ".[dev]"
pytest --cov=ponte --cov-report=term-missing # 阈值见 pyproject.toml
ruff check . # 静态检查
mypy # 类型检查
python _smoke_test.py # 零依赖快速自检CI 在 Linux 上跑 lint + 类型检查,在 Windows/Linux/macOS × Python
3.11/3.12 上跑测试,覆盖率上报到
Codecov。另有一个任务会用
PONTE_TEST_THREAD_DELAY=0.15 再跑一遍:它往工作线程的 sleep/wait 里注入
延迟,于是"只有机器够快才通过"的测试会每次都在那里失败,而不是偶发地
红一次。本地设同一个变量即可复现这种机器。build 任务会安装打好的 wheel 并
执行 ponte init,避免打包问题再次溜进发布。详见
CONTRIBUTING.md。
- 私钥绝不计入仓库:
.gitignore已排除id_rsa/id_rsa.pub; 各机器自行放置密钥。 - 运行时文件(
ponte.pid/ponte.status.json/ponte.stop/ponte.log*)为生成物,不入库。 - 从 ≤ 0.2.x 升级:配置文件已迁出包目录。请运行
ponte init(若存在包内旧配置会直接迁移),然后重新ponte install, 让服务带上新的--config参数。 - Python 化之前的遗留脚本(
tunnel.ps1、setup.ps1、ssh-tunnel.bat、ssh-tunnel.vbs、fix-wsl-tunnel.sh)已移到legacy/并标记 为废弃,请改用 CLI。其中setup.ps1会把你的私钥复制进项目目录, 不要再使用。 - 发现安全问题?见 SECURITY.md,请私下报告。