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:
@@ -121,5 +121,27 @@ class ConnectApi {
|
||||
return response.body;
|
||||
}
|
||||
|
||||
/// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。
|
||||
///
|
||||
/// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉——
|
||||
/// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。
|
||||
Future<void> disconnect({
|
||||
required String nodeId,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
try {
|
||||
await _client
|
||||
.post(
|
||||
Uri.parse('$baseUrl/v1/nodes/$nodeId/disconnect'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $authToken',
|
||||
},
|
||||
body: jsonEncode({'device_id': deviceId}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
} catch (_) {/* best-effort */}
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
|
||||
@@ -110,7 +110,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
|
||||
if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) {
|
||||
_userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」
|
||||
unawaited(_disconnect());
|
||||
// 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。
|
||||
unawaited(_disconnect(revokeCredential: true));
|
||||
}
|
||||
});
|
||||
// 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest
|
||||
@@ -175,7 +176,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_connect();
|
||||
case VpnPhase.on:
|
||||
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
|
||||
_disconnect();
|
||||
_disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4)
|
||||
case VpnPhase.connecting:
|
||||
break; // 握手进行中,不响应
|
||||
}
|
||||
@@ -277,7 +278,23 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
/// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。
|
||||
/// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)——
|
||||
/// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、
|
||||
/// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。
|
||||
Future<void> _disconnect({bool revokeCredential = false}) async {
|
||||
if (revokeCredential) {
|
||||
final api = _api;
|
||||
final node = _connectedNode;
|
||||
if (api != null && node != null && node.uuid.isNotEmpty) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
|
||||
await api.disconnect(nodeId: node.uuid, deviceId: deviceId);
|
||||
} catch (_) {/* best-effort */}
|
||||
}());
|
||||
}
|
||||
}
|
||||
_stopElapsed();
|
||||
_stopWatchdog();
|
||||
try {
|
||||
@@ -505,7 +522,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_offNotice = _ref.read(appTextProvider).quotaExhaustedNotice;
|
||||
_ref.read(quotaProvider.notifier).markExhausted();
|
||||
logLine('Quota', 'free daily minutes used up → auto disconnect');
|
||||
await _disconnect();
|
||||
await _disconnect(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4)
|
||||
}
|
||||
|
||||
void _stopElapsed() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
"github.com/wangjia/pangolin/server/internal/nodes"
|
||||
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
||||
)
|
||||
|
||||
// fakeDisconnectStore implements the NodeStore methods DisconnectNode touches;
|
||||
// everything else panics via the embedded nil interface (未用到即安全)。
|
||||
type fakeDisconnectStore struct {
|
||||
nodes.NodeStore // embed:未实现的方法调用即 panic,测试只走下面四个
|
||||
|
||||
node *nodes.NodeRow
|
||||
ent *nodes.Entitlement
|
||||
devDp string // EnsureDeviceDpUUID 返回值;"" = 设备不存在
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeDisconnectStore) EntitlementForUser(context.Context, int64) (*nodes.Entitlement, error) {
|
||||
return f.ent, nil
|
||||
}
|
||||
|
||||
func (f *fakeDisconnectStore) NodeByUUID(context.Context, string) (*nodes.NodeRow, error) {
|
||||
return f.node, nil
|
||||
}
|
||||
|
||||
func (f *fakeDisconnectStore) EnsureDeviceDpUUID(context.Context, int64, string) (string, int64, error) {
|
||||
if f.devDp == "" {
|
||||
return "", 0, nodes.ErrDeviceNotFound
|
||||
}
|
||||
return f.devDp, 7, nil
|
||||
}
|
||||
|
||||
func (f *fakeDisconnectStore) DeleteCredential(_ context.Context, _ int64, dpUUID string) error {
|
||||
f.deleted = append(f.deleted, dpUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1.Credential, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int {
|
||||
t.Helper()
|
||||
api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除
|
||||
req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "node-1")
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
ctx = context.WithValue(ctx, codes.CtxKeyUserID, int64(42))
|
||||
rec := httptest.NewRecorder()
|
||||
api.DisconnectNode(rec, req.WithContext(ctx))
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// F4 回归:带 device_id 的 disconnect 必须吊销**每设备** dp_uuid(connect 真正
|
||||
// 下发的那个),账户级作为遗留兜底也一并吊销。
|
||||
func TestDisconnectNode_RevokesDeviceCredential(t *testing.T) {
|
||||
store := &fakeDisconnectStore{
|
||||
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
|
||||
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
|
||||
devDp: "device-dp",
|
||||
}
|
||||
if code := doDisconnect(t, store, `{"device_id":"dev-uuid-1"}`); code != 204 {
|
||||
t.Fatalf("status = %d, want 204", code)
|
||||
}
|
||||
want := map[string]bool{"device-dp": true, "acct-dp": true}
|
||||
if len(store.deleted) != 2 || !want[store.deleted[0]] || !want[store.deleted[1]] {
|
||||
t.Errorf("deleted = %v, want both device-dp and acct-dp", store.deleted)
|
||||
}
|
||||
// 每设备凭证在前(connect 真正下发的),账户级兜底在后。
|
||||
if store.deleted[0] != "device-dp" {
|
||||
t.Errorf("device credential should be revoked first, got %v", store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧客户端无 body → 仅账户级兜底(历史行为不回归)。
|
||||
func TestDisconnectNode_LegacyNoBody(t *testing.T) {
|
||||
store := &fakeDisconnectStore{
|
||||
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
|
||||
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
|
||||
}
|
||||
if code := doDisconnect(t, store, ""); code != 204 {
|
||||
t.Fatalf("status = %d, want 204", code)
|
||||
}
|
||||
if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" {
|
||||
t.Errorf("deleted = %v, want only acct-dp", store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// 设备不存在(已被移除)→ 不炸,仍撤账户级。
|
||||
func TestDisconnectNode_DeviceGone(t *testing.T) {
|
||||
store := &fakeDisconnectStore{
|
||||
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
|
||||
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
|
||||
devDp: "", // ErrDeviceNotFound
|
||||
}
|
||||
if code := doDisconnect(t, store, `{"device_id":"gone"}`); code != 204 {
|
||||
t.Fatalf("status = %d, want 204", code)
|
||||
}
|
||||
if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" {
|
||||
t.Errorf("deleted = %v, want only acct-dp", store.deleted)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user