fix(nodes): 节点健康/connect 绑定 agent 在线状态,不再伪装连上
本次 google 打不开暴露:agent 离线 6 天,节点却一直显示健康、还让客户端 "连上"(凭证推送失败被 _ = 吞掉),实际节点不认 UUID。三处收口: - Hub.IsOnline(nodeUUID):基于本地 agent 流注册的存活信号(单实例权威; 多实例需 Redis presence,另议)。 - ListNodes:effectiveNodeStatus 把 DB='up' 但 agent 离线的节点降级为 "down",不再永远显示健康(status 列只反映供给/调度,不反映 agent 死活)。 - ConnectNode:agent 离线即 503 ErrNodeUnavailable(凭证持久化留待 resync), 在线时 push 失败也 503——不再吞 pushErr、不再下发节点无法兑现的配置。 测试:TestHub_IsOnline(注册/结束/未注册)、TestEffectiveNodeStatus (up+离线→down 等)。新增 apierr.ErrNodeUnavailable(503)。 掉线告警 + 静默吞错全量审计另起 TODO。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -46,12 +46,24 @@ type nodeResponse struct {
|
||||
NameZH string `json:"name_zh"`
|
||||
NameEN string `json:"name_en"`
|
||||
Tier string `json:"tier"` // "free" | "pro"
|
||||
Status string `json:"status"` // always "up" in this endpoint
|
||||
Status string `json:"status"` // "up" only when the agent is also online; "down" otherwise
|
||||
// host/port 暴露节点入口,供客户端实测真实延迟(per-client ping 服务端无法代知)。
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// effectiveNodeStatus downgrades a DB-'up' node to "down" when its agent is
|
||||
// offline. The DB status column reflects provisioning/scheduler state, NOT agent
|
||||
// liveness — without this, a node whose agent has been offline for days still
|
||||
// shows healthy (the gap that hid a 6-day agent outage and let clients "connect"
|
||||
// to a node that couldn't serve them).
|
||||
func effectiveNodeStatus(dbStatus string, agentOnline bool) string {
|
||||
if dbStatus == "up" && !agentOnline {
|
||||
return "down"
|
||||
}
|
||||
return dbStatus
|
||||
}
|
||||
|
||||
// ListNodes handles GET /v1/nodes.
|
||||
func (a *NodeAPI) ListNodes(w http.ResponseWriter, r *http.Request) {
|
||||
nodeRows, err := a.store.ListUp(r.Context())
|
||||
@@ -73,7 +85,7 @@ func (a *NodeAPI) ListNodes(w http.ResponseWriter, r *http.Request) {
|
||||
NameZH: n.NameZH,
|
||||
NameEN: n.NameEN,
|
||||
Tier: n.Tier,
|
||||
Status: n.Status,
|
||||
Status: effectiveNodeStatus(n.Status, a.hub == nil || a.hub.IsOnline(n.UUID)),
|
||||
Host: host,
|
||||
Port: port,
|
||||
})
|
||||
@@ -189,21 +201,34 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
ExpiresAtUnix: expiresAt.Unix(),
|
||||
}
|
||||
|
||||
// 5. Push to the node agent via Hub (real gRPC channel).
|
||||
pushErr := a.hub.Push(r.Context(), node.UUID, &agentv1.Command{
|
||||
Type: agentv1.CommandTypeUpsert,
|
||||
Upsert: &agentv1.UpsertPayload{Credential: cred},
|
||||
})
|
||||
if pushErr != nil {
|
||||
// Non-fatal: command is queued in Redis and will be replayed on reconnect.
|
||||
// Log but don't abort — return the config so the client can attempt the tunnel.
|
||||
_ = pushErr
|
||||
// 5. Refuse early if the node's agent is offline. A credential we can't push
|
||||
// now leaves the client "connected" to a node that rejects its UUID — the
|
||||
// exact failure that hid a 6-day agent outage. Persist it so the agent
|
||||
// applies it on resync, then signal unavailable instead of faking success.
|
||||
if !a.hub.IsOnline(node.UUID) {
|
||||
if persistErr := a.store.PersistCredential(r.Context(), node.ID, cred, expiresAt); persistErr != nil {
|
||||
slog.Error("connect: persist credential (agent offline) failed", "node", nodeUUID, "user", uid, "err", persistErr)
|
||||
}
|
||||
slog.Warn("connect: node agent offline, refusing connect", "node", nodeUUID, "user", uid)
|
||||
apierr.WriteJSON(w, http.StatusServiceUnavailable, apierr.ErrNodeUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Persist credential for agent resync.
|
||||
// 6. Push to the node agent via Hub. The agent is online, so a push failure is
|
||||
// a real error — surface it, don't pretend the tunnel will work.
|
||||
if pushErr := a.hub.Push(r.Context(), node.UUID, &agentv1.Command{
|
||||
Type: agentv1.CommandTypeUpsert,
|
||||
Upsert: &agentv1.UpsertPayload{Credential: cred},
|
||||
}); pushErr != nil {
|
||||
slog.Error("connect: push credential to agent failed", "node", nodeUUID, "user", uid, "err", pushErr)
|
||||
apierr.WriteJSON(w, http.StatusServiceUnavailable, apierr.ErrNodeUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Persist credential for agent resync (best-effort; the push above already
|
||||
// delivered it to the live agent).
|
||||
if persistErr := a.store.PersistCredential(r.Context(), node.ID, cred, expiresAt); persistErr != nil {
|
||||
// Non-fatal: tunnel still works via Hub push; resync will miss it on agent restart.
|
||||
_ = persistErr
|
||||
slog.Error("connect: persist credential failed", "node", nodeUUID, "user", uid, "err", persistErr)
|
||||
}
|
||||
|
||||
// 7. Render and return the full sing-box CLIENT config JSON.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestEffectiveNodeStatus guards the "don't show a node healthy when its agent is
|
||||
// offline" fix: a DB-'up' node with an offline agent must report "down".
|
||||
func TestEffectiveNodeStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
db string
|
||||
online bool
|
||||
want string
|
||||
}{
|
||||
{"up + agent online", "up", true, "up"},
|
||||
{"up + agent offline → down", "up", false, "down"}, // the core fix
|
||||
{"draining untouched when offline", "draining", false, "draining"},
|
||||
{"down stays down", "down", true, "down"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := effectiveNodeStatus(c.db, c.online); got != c.want {
|
||||
t.Errorf("effectiveNodeStatus(%q, %v) = %q, want %q", c.db, c.online, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user