可观测性

Server 在管理监听器暴露 GET /metrics、/health/live 和 /health/ready。/metrics 默认不要求任何凭据, 生产环境必须只允许 Prometheus 所在内网访问,公网入口通过 Nginx 仅暴露健康检查;两种受控方式(来源网段 放行与 server.metrics.token)见下文“/metrics 的访问控制”。

WebSSH/SFTP

WebSSH 会话暴露以下指标。指标只包含状态、方向和稳定错误类别,不包含用户名、目标地址、终端内容、SFTP 路径或文件内容:

  • tunnelmesh_webssh_sessions_active
  • tunnelmesh_webssh_tickets_created_total
  • tunnelmesh_webssh_ticket_reuse_total{reason}
  • tunnelmesh_webssh_streams_errors_total{error_class}
  • tunnelmesh_webssh_stream_duration_seconds
  • tunnelmesh_webssh_bytes_total{direction}

reason 只使用 expired、reused、invalid 等稳定枚举;error_class 使用统一错误归一化结果。后台清理器每 60 秒收敛过期 pending ticket 和 active session,进程退出或 Runtime Close 时会停止清理器。

身份认证(SSO / MFA / 受信任设备)

管理台登录、两步验证、OIDC 单点登录和受信任设备暴露以下 6 个指标族。注册位置 internal/observability/metrics.go,标签契约由 internal/observability/metrics_auth_test.go 守护:

指标 类型 标签
tunnelmesh_auth_login_total Counter method、result
tunnelmesh_auth_mfa_verify_total Counter method、result
tunnelmesh_auth_oidc_step_total Counter step、result
tunnelmesh_auth_trusted_devices Gauge 无
tunnelmesh_auth_pending_challenges Gauge 无
tunnelmesh_auth_login_blocked_buckets Gauge 无

标签取值是一个封闭枚举,任何不在清单内的输入都归一化成 unknown,因此新增入口在指标里是 可见的,而不会静默并入已有取值:

标签 允许取值 含义
login_total.method password、oidc 登录入口类型
login_total.result success、failure、mfa_required、throttled 登录结果;mfa_required 表示密码正确但还欠一个二次验证
mfa_verify_total.method totp、recovery 用哪种第二因子通过校验
mfa_verify_total.result success、invalid、expired、attempts_exceeded invalid 刻意同时覆盖“码不对”和“challenge 不可用”,否则指标会暴露哪些账号有活跃 challenge
oidc_step_total.step discovery、jwks、token、id_token、provision 依赖方流程阶段
oidc_step_total.result ok、error 阶段结果

oidc_step_total 按阶段拆分是为了让“IdP 半坏”可诊断:discovery 成功而 token 失败指向 client 凭据配错,token 成功而 id_token 失败指向签名算法、iss/aud/nonce 校验或 JWKS 轮换。

三个 Gauge 由身份维护清理器在每轮清扫后重新采样,不是请求路径写入:

  • tunnelmesh_auth_trusted_devices:当前所有账号的有效受信任设备总数。突降意味着一次清扫或批量撤销; 持续爬升逼近 auth_settings.max_trusted_devices × 账号数 说明上限配置过小。
  • tunnelmesh_auth_pending_challenges:当前未消费的 auth_challenges 行数(含 login_mfa、 oidc_state、login_ticket)。在没有对应登录速率的情况下增长,说明登录页被弃单或被攻击。
  • tunnelmesh_auth_login_blocked_buckets:当前处于封禁状态的 username+IP 桶数量。这是撞库最早期的信号。

清理器(internal/server/auth_maintenance_sweeper.go)每 1 分钟跑一次,启动时先立即跑一次: 删除已过期的 challenge(按各自 expires_at)、过期或已撤销且超过 7 天保留期的受信任设备、 以及闲置超过同一保留期的登录限流桶,然后重采样三个 Gauge。保留期存在的意义是审计可追溯——审计行 引用了 device id 或 challenge id,立即删除会在证据链里留下悬空标识符。每条语句都是带时间戳边界的 幂等 DELETE,所以集群里每个节点各跑一份清理器是安全的,重复的一轮只会删掉 0 行并花费一次索引扫描; 这比引入分布式锁更可靠。身份认证清理器的间隔与 7 天保留期仍是编译期常量,不可配置;审计日志的保留期是 另一个开关,见“标签和留存”。

指标里不含用户名、客户端 IP、provider id、challenge id、device token 或 TOTP 码。/metrics 无需 管理会话即可读取,把这些放进标签既会造成攻击者可控的基数,也会泄露账号名。

建议告警阈值(起点值,按实际登录量校准后再收紧):

告警 表达式 阈值与判断依据
登录失败率异常 sum(rate(tunnelmesh_auth_login_total{result="failure"}[5m])) / sum(rate(tunnelmesh_auth_login_total[5m])) 持续 10 分钟 > 0.3 且分母 > 0.1/s;低流量时用绝对量而不是比率,避免单次失败把比率打到 1
撞库 / 暴力破解 tunnelmesh_auth_login_blocked_buckets > 20 持续 5 分钟,或 15 分钟内增幅 > 50。桶数独大通常意味着分布式撞库
限流命中飙升 sum(rate(tunnelmesh_auth_login_total{result="throttled"}[5m])) 持续 5 分钟 > 0.05/s;同时检查是否把正常用户挡在门外
恢复码被当作主因子 sum(rate(tunnelmesh_auth_mfa_verify_total{method="recovery",result="success"}[15m])) 15 分钟内 > 5 次。恢复码是验证器丢失时的兜底,常态使用意味着批量丢设备或有人在批量接管
二次验证尝试耗尽 sum(rate(tunnelmesh_auth_mfa_verify_total{result="attempts_exceeded"}[10m])) 10 分钟内 > 10;配合 invalid 一起看,单独升高更像是时钟漂移而非攻击
OIDC 依赖方故障 sum(rate(tunnelmesh_auth_oidc_step_total{result="error"}[5m])) by (step) 任一 step 持续 5 分钟 > 0.02/s。按 step 分组是定位关键:discovery/jwks 指向网络或 issuer,token 指向 client 凭据,id_token 指向签名与 claim,provision 指向 role mapping 或账号冲突
challenge 堆积 tunnelmesh_auth_pending_challenges > 500 且 tunnelmesh_auth_login_total 速率正常。清理器每分钟收敛,持续增长说明登录页被脚本刷
受信任设备异常波动 abs(delta(tunnelmesh_auth_trusted_devices[10m])) 10 分钟内下降 > 50%,通常是一次批量撤销或 device_trust_enabled 被关掉

上述告警中不得放置 Token、用户名、IP 或 provider 明文;需要定位具体账号时改用审计日志 (auth.login.*、auth.mfa.*、auth.device.*、auth.oidc.provider.*、auth.policy.update), 而不是给指标加标签。

客户端连接租约回收

client_connection_leases 只有两条删除路径:客户端正常关闭 WebSocket 时按 connection_id + connection_epoch 释放,以及后台清扫。进程被杀、宿主掉电、网络硬中断都走不到前者,因此在引入清扫器之前,每一个意外退出的客户端都留下一行永不过期也永不删除的租约,而抽屉把它们全部当成「当前连接」显示。

回收器(internal/server/client_connection_sweeper.go)每 1 分钟跑一次,启动时先立即跑一次,删除 expires_at 早于「当前时间减 24 小时」的行,每轮最多 1000 行。间隔、24 小时保留期和 1000 行批量都是编译期常量,不可配置。选择 select-ids-then-delete-by-PK 而不是 DELETE ... LIMIT,因为后者是 MySQL 专有语法,而两步式在 SQLite/MySQL 上都一样可中断、可续跑。每条语句都以时间戳为边界且幂等,所以集群里每个节点各跑一份是安全的,重复的一轮只会删 0 行并花费一次 idx_client_connection_leases_expires 索引扫描;这比引入分布式锁更可靠,代价只是每节点一次轻量扫描。

  • tunnelmesh_client_connection_lease_sweep_total{result,error_class}:每轮清扫一次计数,result 取 ok/error,error_class 复用全局归一化的错误类别。result="error" 持续非零意味着回收没在跑,死行会重新堆积。

保留 24 小时而不是立刻删除,是为了让「昨天那个客户端为什么反复掉线」这类问题还能在详情抽屉里查到历史行;读取侧因此也默认只看未过期租约,state=all 是显式可选且有上限的视图(见客户端运行观测)。

日志只输出 cutoff、reaped、elapsed_ms 与错误,不含 connection ID、client instance ID 或 Token。清扫是后台聚合动作,因此不进审计日志:审计记录的是「谁做了什么」,而这里没有操作者。

已交付两条告警(deploy/prometheus/alert-rules.yaml):

  • TunnelMeshLeaseSweepErrors:sum(increase(...{result="error"}[15m])) > 3,for: 15m。间隔是 1 分钟,所以 15 分钟里多于 3 轮失败才算异常,单轮失败通常是数据库短暂不可达。
  • TunnelMeshLeaseSweepStalled:absent(...) or sum(increase(...[10m])) == 0,for: 5m。正常同期应有约 10 轮,0 轮说明没有任何节点在回收。跨节点求和是刻意的:删除以时间戳为边界且幂等,任一节点跑成功一轮就够,所以这条规则表达的是「整个集群都没在回收」,单节点部署里等价于逐节点检测。

两条规则只有在清扫循环「失败后继续跑」的前提下才有意义:循环若在首轮失败时退出,只会留下一个 error 样本,increase 永远达不到阈值,指标形同虚设。

后台清扫任务

Server 进程内有五个周期清理任务:客户端元数据(client_metadata)、WebSSH 会话(webssh_session)、 客户端连接租约(client_connection_lease)、审计保留(audit_retention)、身份维护(auth_maintenance)。 它们共用同一个循环 internal/server/sweep_loop.go,策略只有一份:

  • 启动时先立即跑一轮,之后按各自固定间隔补跑;
  • 一轮失败只意味着这一轮没做成:错误按 <前缀>_sweep_failed 记入日志,下一个 tick 重试;
  • 只有上下文取消才会结束循环,且取消造成的失败不记错误日志(关停不该以误报开场);
  • 仓储未接线属于配置错误,在进循环之前就返回,不会变成一个每 tick 刷一条日志的僵尸任务。

日志事件共五个:client_metadata_sweep_failed、webssh_session_sweep_failed、 client_connection_lease_sweep_failed、audit_retention_sweep_failed、auth_maintenance_sweep_failed。 它们是「一轮清理失败」的唯一出口,出现即说明任务还在重试,而不是已经放弃;反复出现才需要人介入, 判据是数据库可达性与连接池。这些日志只含错误文本与计数,不含行标识符、DSN 或 Token。

这条策略是被一个静默故障换来的:三个清扫器原先把第一轮数据库错误当作致命错误直接返回,运行它的 goroutine 随即结束,而错误只在 ServerRuntime.Close() 时才被读出来——一个长期运行的 Server 永远不会 调用它。后果是清理能力永久消失且没有任何信号,租约表重新无界增长。进程存活因此不是后台任务的 健康依据,必须看指标或日志事件。审计保留与身份维护原先就是「记录并继续」,现在五个任务实现同一份策略。

五个任务都不需要分布式锁:每条语句都以时间戳为边界且幂等,集群里每节点各跑一份是安全的,重复的一轮 只删 0 行。这与「用一把租约锁保护一个清理任务」相比,少了一个可能自身故障的新组件。

Prometheus 抓取与规则

只有 Server 暴露 /metrics;Agent 与 Client 没有本地 metrics 端点,它们的运行观测通过协议上报 给 Server 后由 Server 输出。因此不要为 Agent/Client 配置单独的 scrape job。

起点配置见 deploy/prometheus/prometheus.yml.example, 其中包含单节点与集群两种形态。集群模式下必须为每个节点单独列出 target,并逐节点标注 node_id,取值与 server.node.id / TUNNELMESH_NODE_ID 完全一致,否则 Cluster Row 与按节点 聚合的查询无法对齐。cluster 与 node_id 都不是应用输出的标签,只能由 Prometheus 侧注入。

不要在 target labels 里覆盖应用自身的标签名。 默认 honor_labels: false,同名 target label 会把指标原标签改写成 exported_*,直接让 recording-rules.yaml 与 alert-rules.yaml 中依赖 这些标签的表达式失效。应用已占用的标签名:

component, mode, result, error_class, stage, direction, protocol,
probe_kind, operation, cache, scope, decision, reason, route,
method, step, agent_id, instance_id, server_node_id

/metrics 的访问控制

/metrics 只应对 Prometheus 所在内网开放;公网入口通过 Nginx 只暴露 /health/live 与 /health/ready。两种受控方式二选一,也可以叠加:

  1. 来源网段放行(首选):docs/deployment/nginx.md 的 location = /metrics 用 allow/deny 只放行 Prometheus 所在网段。此时 Server 自身不需要再配凭据。
  2. Bearer token:设置 server.metrics.token(等价环境变量 TUNNELMESH_SERVER_METRICS_TOKEN, 仅允许环境变量注入、至少 16 字符)。配置后,缺少或不匹配 Authorization: Bearer <token> 的抓取 返回 401 并带 WWW-Authenticate: Bearer realm="metrics";留空(默认)时行为与旧版本完全一致, 升级不会让既有抓取失败。该值不会出现在后台页面、列表响应或日志里,配置导出中也是脱敏的。

无论选哪种,/health/live 与 /health/ready 都不受影响,LB 与容器健康检查继续免凭据可用。Prometheus 侧用 authorization.credentials_file 读取 token,不要把 token 写进 prometheus.yml 正文——该文件 常常被提交进配置仓库。

管理入口的拒绝与 shedding

server.http.max_connections 是连接数准入信号量,超限的新连接会被立即关闭而不是排队(排队连接仍占着 文件描述符,且客户端只会更久地等待)。被拒的连接计入 tunnelmesh_connections_total{component="server",mode="http",result="rejected",error_class="capacity"}, 用它区分“容量不够”和“下游故障”:

increase(tunnelmesh_connections_total{component="server",mode="http",result="rejected"}[5m])

该 result 不在 recording rules 的 failed|error 错误率表达式里,这是刻意的:准入丢弃是保护动作, 不该把整体错误率抬高成告警。需要长期观察时按上面的表达式单独建告警。tunnelmesh_connections_total 的 mode="http" 只覆盖管理监听器,Agent 与 Client 的 WebSocket 仍分别使用 mode="agent" 与 mode="client"。

error_class="capacity" 是所有准入拒绝的统一类别(internal/observability/events.go NormalizeErrorClass 匹配 capacity 与 queue full):单 Agent 连接超限、单 Agent 活跃流超限、 Agent 本地 dial 队列满都归到它,过去它们落在 internal,等于把「运维该抬上限」和「有 bug」混在 一个标签里。同一个类别也用于 tunnelmesh_agent_connection_errors_total。注意 rejected 与 capacity 是两回事:前者是结果、后者是原因,HTTP 准入丢弃同时带这两个标签。

校验:

promtool check config deploy/prometheus/prometheus.yml.example
promtool check rules deploy/prometheus/recording-rules.yaml deploy/prometheus/alert-rules.yaml

Grafana

导入 统一 Dashboard。这是项目唯一的 Dashboard 文件,内部按 Overview、Agent、Network、Cluster、Security、HTTP Proxy Entry 六个 Row 组织全部图表。Prometheus datasource 使用 ${DS_PROMETHEUS}。

Dashboard 顶部有四个变量,全部面板的查询都按它们过滤:

变量 取值来源 说明
cluster label_values(tunnelmesh_ready, cluster) 由 Prometheus target label 注入
node_id label_values(tunnelmesh_ready, node_id) 由 Prometheus target label 注入,取值等于 server.node.id
component label_values(tunnelmesh_connections_active, component) 应用输出标签
agent_id label_values(tunnelmesh_agent_connections, agent_id) 应用输出标签

四个变量都开启 All 且 allValue 为 .*,因此即使抓取配置没有注入 cluster/node_id,选择 All 时面板仍然有数据;但一旦选择具体取值,未注入的标签会筛出空结果。Readiness、Ready nodes 和 Active agent connections 按 node_id 分组,逐节点展示。变量与面板筛选标签的一致性由 deploy/grafana/dashboard_schema_test.go 守护。

延迟和稳定性相关的视图集中在同一 Dashboard:

  • Agent Row:连接池扩缩容决策、Agent 连接选择、活跃连接和错误。
  • Network Row:stream 打开结果、阶段耗时、队列等待、窗口等待、backpressure、探针延迟。
  • Cluster Row:授权缓存命中率、远端校验缓存、授权 revision 和 revision 轮询结果。
  • HTTP Proxy Entry Row:代理请求速率与结果、活跃隧道数(按 route)、隧道时长 p95、代理入口上下行字节、认证失败与 ACL 拒绝、拒绝明细表。

HTTP Proxy Entry Row 依赖的 5 个指标(注册位置 internal/observability/metrics.go,标签契约由 internal/observability/metrics_proxy_entry_test.go 守护):

指标 类型 标签
tunnelmesh_proxy_entry_requests_total Counter route、mode、result、error_class
tunnelmesh_proxy_entry_tunnels_active Gauge route
tunnelmesh_proxy_entry_tunnel_duration_seconds Histogram route、result
tunnelmesh_proxy_entry_auth_failures_total Counter route、reason
tunnelmesh_proxy_entry_acl_denied_total Counter route

server.proxy_entry.enabled=false(默认)时这 5 个指标根本不会被写入,该行所有面板为空,属正常 现象,不是抓取故障;对应的两条告警也刻意不带 absent() 前缀,见下方“告警责任”。

容器部署时挂载 deploy/grafana,并同时加载 recording 与 alert 规则:

volumes:
  - ./deploy/grafana/dashboards:/var/lib/grafana/dashboards/tunnelmesh:ro
  - ./deploy/grafana/provisioning:/etc/grafana/provisioning:ro
  - ./deploy/prometheus:/etc/prometheus/tunnelmesh:ro

provisioning/datasources.yml 中的 Prometheus 地址默认是 http://prometheus:9090,与实际部署 不一致时必须修改;手工导入 Dashboard 时选择 Prometheus 数据源即可绑定 ${DS_PROMETHEUS}。

产物一致性校验(Dashboard schema、安装模板与脚本):

go test ./deploy/... -count=1

deploy/ 只放产物,目录内容与发布包布局见 deploy/README.md。

标签和留存

只允许使用 component、mode、protocol、stage、result、error_class、probe_kind、route、reason、method、step 等低基数标签;身份认证指标的 method 与 step 都是封闭枚举,越界输入归一化为 unknown,基数不会随请求内容增长。Token、连接 ID、stream ID、目标地址、客户端 IP、Proxy-Authorization 和 metadata 不得进入 Prometheus label。默认保留周期由 Prometheus 决定,建议生产环境 15–30 天,长期趋势使用 recording rules 或远端时序存储。

审计日志的留存由 server.audit.retention_days 控制,默认 0 表示永久保留、Server 不删除任何审计行; 配置为正数后,Server 每小时一轮、每轮最多 1000 行地删除早于保留期的事件,日志只输出删除条数与耗时。 默认值保证升级不会销毁历史,删除永远是显式运维决策。

新增的 route 与 reason 的基数边界:

  • route 取值是 tp-<name>.<domain_suffix> 完整域名,数量等于管理后台创建的代理路由数。路由由 人工创建,不是请求维度,因此基数受管理员行为约束;仍然禁止把目标主机名或客户端 IP 拼进这个标签。
  • reason 取值直接复用 internal/proxyentry/errors.go 的稳定错误码(完整清单见 HTTP 代理入口),认证失败路径上只可能是 proxy_auth_required(缺失或 格式非法的 Proxy-Authorization)、proxy_auth_failed(用户名或密码不匹配、凭据被停用/软删)、 proxy_auth_backoff(退避中,不做密码比对)、credential_secret_unavailable(secret store 不可用) 四个之一,不随请求内容变化。

管理面 trace 与审计关联

Server 给每个 /api/v1 请求一个 W3C trace:请求带了合法的 Traceparent 就沿用,没有就现生成一个, 并把同一个值回显在响应头上。处理该请求时写下的审计事件会在 details 里带上 traceId,因此一条审计行、 一行结构化日志和浏览器里的一次报错可以用同一个 ID 串起来,不需要额外的关联表。覆盖面:管理 API 的 全部写入点(代理节点、策略、路由、令牌、凭据、远程服务器、Server 节点、客户端连接、Agent 连接、WebSSH) 与身份认证链路(登录成功/失败/限流、MFA、受信任设备、账号创建与停用、OIDC 即时建号),只要上下文里有 trace 就会被盖章。

边界与代价:

  • 后台任务、启动期迁移和保留期清扫器没有请求上下文,它们的审计不含 traceId——这不是丢数据, 没有“那一次请求”可关联。审计的聚合器(代理入口拒绝聚合、VPN 拒绝聚合)同样不盖章:一行代表窗口内 多次事件,写任何一个 trace 都是错的。
  • 两个低频配置写入点暂未盖章:全局认证策略(auth.policy.updated)与 SSO provider 配置变更,它们的 details 构造器不持有 context。为塞一个 trace id 去改这两条链的函数签名属于越界,留待与各自的 功能改动一起做。
  • Traceparent 是公开格式,不含身份信息;响应头回显不新增敏感面,也不改变鉴权。
  • 排障顺序与故障排查一致:先拿控制台报错响应头上的 ID,再按 details.traceId 反查审计,或反过来从审计行跳到同一请求的日志。

告警责任

平台值班负责 Server readiness、scrape 缺失、存储失败和 relay 失败;网络值班负责 heartbeat miss、探针成功率和 RTT;业务负责人负责 Agent 离线、路由拒绝,以及 tp-* 代理入口的 TunnelMeshProxyEntryAuthFailures 与 TunnelMeshProxyEntryDenials(与路由拒绝同类),以及后台清理的 TunnelMeshLeaseSweepErrors 与 TunnelMeshLeaseSweepStalled(清理停摆不会让进程出错,只能靠指标发现,见后台清扫任务)。值班需要能 区分“ACL 配错”和“密码爆破”,判据是 tunnelmesh_proxy_entry_acl_denied_total 与 tunnelmesh_proxy_entry_auth_failures_total 的相对量级:前者独大通常是来源网段没加进 ACL,后者 独大且集中在单条 route 上是爆破。告警中不得放置 Token 或原始目标地址。

安全值班负责管理台身份链路:tunnelmesh_auth_login_blocked_buckets、tunnelmesh_auth_login_total{result="failure"|"throttled"}、 tunnelmesh_auth_mfa_verify_total{method="recovery"} 和 tunnelmesh_auth_oidc_step_total{result="error"}。判据同样是相对量级: blocked_buckets 与 failure 同时升高是撞库;只有 mfa_verify_total{result="invalid"} 升高而 failure 平稳,更像是验证器时钟漂移 (检查 security.auth.mfa.skew 和服务器 NTP);只有 oidc_step_total 某一个 step 升高是 IdP 侧配置问题,按 单点登录与两步验证 的排障表定位。

探针只保存状态、耗时和 allowlist error class,不保存响应体。探针目标必须经过资源所有者或管理员授权。

探针当前的可用边界、指标查询和端到端 API 状态见 全链路网络探针。

客户端观测用到的 SQL(presence EXISTS、metadata='{}' 时效轴、Summarize 关联聚合、带守卫的 DELETE、64 位 connection_epoch) 已在 CI 的 mysql56 job 上对真实 MySQL 5.6 执行同一套契约测试,mysql56 是 main 的必需检查。本地手工复跑需要设置 TUNNELMESH_TEST_MYSQL_DSN,未设置时只会运行 SQLite 方言,不能据此标记为 MySQL 已验证,见 测试与验证。