fix: disconnect 吊销每设备凭证 + 客户端断开时真正调用(F4,#28)
审查 F4 两层问题:①DisconnectNode 只撤账户级 ent.DpUUID,而 connect 下发的是
每设备 devDp——吊销从未对准目标;②客户端从未调用过 disconnect 端点(只有
fetchConfig),端点是死代码,凭证一律活到 TTL(付费 24h)。
- server:disconnect 收 optional body {device_id},吊销该设备 dp_uuid(优先)+
账户级遗留兜底;旧客户端无 body 走兜底,行为不回归。nil hub 守卫(测试友好,
与 ListNodes 一致)。
- client:ConnectApi.disconnect(best-effort,5s 超时吞错);_disconnect 加
revokeCredential 参数,仅在「不会紧接重连同节点」的路径置 true(用户主动断开/
额度耗尽/登出)——看门狗断开→重连若也吊销,revoke 可能晚于新 connect 推送、
误杀新会话。
- test:httpapi disconnect 三态(带 device_id 双吊销/无 body 仅兜底/设备已移除
不炸);client 功能套件 182 过(golden 为已知 macOS 本地漂移,不相关)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -332,7 +332,17 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ─── POST /v1/nodes/{id}/disconnect ──────────────────────────────────────────
|
||||
|
||||
// disconnectRequest is the optional JSON body: device_id 指认要吊销凭证的设备。
|
||||
// 旧客户端不带 body(或不带 device_id)→ 仅吊销遗留账户级凭证(历史行为)。
|
||||
type disconnectRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
|
||||
// DisconnectNode handles POST /v1/nodes/{id}/disconnect.
|
||||
//
|
||||
// F4:connect 下发的是每设备凭证(EnsureDeviceDpUUID),吊销也必须对准它——
|
||||
// 此前这里只撤账户级 ent.DpUUID,设备凭证一直活到 TTL(付费 24h),「断开」在
|
||||
// 服务端形同空转。现按 device_id 吊销该设备的 dp_uuid,账户级作为遗留兜底仍撤。
|
||||
func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := auth.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -346,6 +356,10 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Optional body(旧客户端无 body → device_id 为空,走兜底路径)。
|
||||
var req disconnectRequest
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&req)
|
||||
|
||||
// Load dp_uuid.
|
||||
ent, err := a.store.EntitlementForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
@@ -367,14 +381,33 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Push revoke command.
|
||||
_ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{
|
||||
Type: agentv1.CommandTypeRevoke,
|
||||
Revoke: &agentv1.RevokePayload{DpUUID: ent.DpUUID},
|
||||
})
|
||||
// 收集待吊销凭证:每设备(connect 真正下发的)+ 账户级(遗留兜底)。
|
||||
dpUUIDs := make([]string, 0, 2)
|
||||
if devID := strings.TrimSpace(req.DeviceID); devID != "" {
|
||||
devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, devID)
|
||||
if derr != nil && !errors.Is(derr, nodes.ErrDeviceNotFound) {
|
||||
slog.Warn("disconnect: device dp_uuid lookup failed", "user", uid, "device", devID, "err", derr)
|
||||
}
|
||||
if devDp != "" {
|
||||
dpUUIDs = append(dpUUIDs, devDp)
|
||||
}
|
||||
}
|
||||
if ent.DpUUID != "" {
|
||||
dpUUIDs = append(dpUUIDs, ent.DpUUID)
|
||||
}
|
||||
|
||||
// Delete persisted credential.
|
||||
_ = a.store.DeleteCredential(r.Context(), node.ID, ent.DpUUID)
|
||||
for _, dp := range dpUUIDs {
|
||||
// Push revoke command(best-effort:agent 离线时命令进 Redis 队列,重连即达;
|
||||
// 删除持久化凭证后 resync 也不会再下发)。nil hub = 测试环境,跳过推送。
|
||||
if a.hub != nil {
|
||||
_ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{
|
||||
Type: agentv1.CommandTypeRevoke,
|
||||
Revoke: &agentv1.RevokePayload{DpUUID: dp},
|
||||
})
|
||||
}
|
||||
// Delete persisted credential.
|
||||
_ = a.store.DeleteCredential(r.Context(), node.ID, dp)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user