feat: server transfer rotation

This commit is contained in:
naiba
2026-05-25 10:17:34 +00:00
parent 37b6db806f
commit 6b88cdb012
43 changed files with 7072 additions and 134 deletions
+170 -12
View File
@@ -3,6 +3,7 @@ package rpc
import (
"context"
"fmt"
"log"
"strings"
petname "github.com/dustinkirkland/golang-petname"
@@ -21,6 +22,18 @@ type authHandler struct {
}
func (a *authHandler) Check(ctx context.Context) (uint64, error) {
return a.check(ctx)
}
func (a *authHandler) CheckRequestTask(ctx context.Context) (uint64, error) {
return a.check(ctx)
}
// 所有 auth caller 走完全相同的 ServerTransfer dual-secret 容忍策略。
// revertDelivery 不在 auth 阶段消费 —— 真正派发 rollback ApplyConfig 的
// pushRevertIfOnline 才有资格清理它,否则 auth 提前清就会让 OnAgentReconnect
// 找不到 recovery 记录,agent 10s timer 一到就锁死在被拒绝的新 secret 上。
func (a *authHandler) check(ctx context.Context) (uint64, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return 0, status.Errorf(codes.Unauthenticated, "获取 metaData 失败")
@@ -37,6 +50,107 @@ func (a *authHandler) Check(ctx context.Context) (uint64, error) {
ip, _ := ctx.Value(model.CtxKeyRealIP{}).(string)
var clientUUID string
if value, ok := md["client_uuid"]; ok {
clientUUID = value[0]
}
if _, err := uuid.ParseUUID(clientUUID); err != nil {
// Keep this counter on the same trigger surface as the
// unknown-secret path below: an attacker who pairs a bad secret
// with a malformed/missing UUID otherwise bypasses
// WAFBlockReasonTypeAgentAuthFail entirely and gets unbounded
// retries (TestAuthBadSecret*InvalidUUIDStillIncrementsAgentAuthFailWAF).
model.BlockIP(singleton.DB, ip, model.WAFBlockReasonTypeAgentAuthFail, model.BlockIDgRPC)
return 0, status.Error(codes.Unauthenticated, "客户端 UUID 不合法")
}
// Per-transfer handshake secret path: ApplyConfig delivers a random
// per-transfer token instead of the destination user's global AgentSecret
// (see PushIfOnline). When the agent reconnects under that token the auth
// layer recognises it here, scoped to the matching server UUID, and
// promotes the transfer to Verified. The user-global secret lookup below
// continues to handle every non-transfer agent, plus the still-tolerated
// previous-owner secret during the Pending window. Checked before the
// global lookup so the handshake-secret token can never collide with
// some other user's accidental match.
if singleton.ServerTransferShared != nil {
if t, ok := singleton.ServerTransferShared.LookupByHandshakeSecret(clientSecret); ok {
cid, found := singleton.ServerShared.UUIDToID(clientUUID)
if !found || cid != t.ServerID {
return 0, status.Error(codes.Unauthenticated, "transfer handshake secret bound to a different server")
}
// Auth via per-transfer HandshakeSecret succeeds only when
// MarkVerified actually performs the Pending → Verified
// transition. A lost CAS (concurrent Cancel/Fail/Timeout)
// means the credential is stale; the verifiedHandshakes
// fallthrough below will still admit it if it had been
// promoted by a successful previous reconnect, otherwise it
// is rejected.
verified, _, err := singleton.ServerTransferShared.MarkVerified(t.ServerID, t.ID)
if err != nil {
log.Printf("NEZHA>> ServerTransfer MarkVerified(cid=%d) via handshake secret failed: %v", t.ServerID, err)
return 0, status.Error(codes.Unauthenticated, "transfer handshake verification failed")
}
if verified {
model.UnblockIP(singleton.DB, ip, model.BlockIDgRPC)
return t.ServerID, nil
}
}
// Bounded terminal-recovery window: a transfer was Cancel/Fail/
// Timeout-ed and the agent may still be presenting either of its
// per-transfer secrets. Single lookup + kind switch:
//
// forward — agent committed t.HandshakeSecret to disk before
// the dashboard observed MarkVerified. Admit so
// RequestTask → OnAgentReconnect can deliver the
// rollback ApplyConfig. DO NOT call MarkVerified
// (transfer is terminal) and DO NOT promote into
// verifiedHandshakes (the agent's stable post-rollback
// credential will be the revert secret, not this one).
//
// revert — agent has applied the rollback and presented
// t.RevertHandshakeSecret. Promote via
// MarkRevertDelivered so the credential survives
// past the recovery window (~24h sweep).
//
// SECURITY: terminalSecretRecovery is only populated by
// revertTransition. A stolen per-transfer secret on a transfer
// whose terminal status was forged in the DB never reaches this
// table — TestAuthHandshakeSecretRejectedAfterTransferTerminated
// pins that path closed.
if t, kind, ok := singleton.ServerTransferShared.LookupByTerminalSecretRecovery(clientSecret); ok {
cid, found := singleton.ServerShared.UUIDToID(clientUUID)
if !found || cid != t.ServerID {
return 0, status.Error(codes.Unauthenticated, "transfer terminal-recovery secret bound to a different server")
}
model.UnblockIP(singleton.DB, ip, model.BlockIDgRPC)
if kind == singleton.TerminalRecoveryRevert {
if err := singleton.ServerTransferShared.MarkRevertDelivered(t.ServerID, t.ID); err != nil {
log.Printf("NEZHA>> ServerTransfer MarkRevertDelivered(server=%d transfer=%d) failed: %v", t.ServerID, t.ID, err)
}
}
return t.ServerID, nil
}
// Post-MarkVerified path: the agent's persisted client_secret is
// the per-transfer HandshakeSecret (PushIfOnline never delivers a
// user-global secret), and no follow-up ApplyConfig swaps it back
// out. So every reconnect after the first one — stream drop, agent
// restart, etc. — must still match this credential, bound strictly
// to (serverID, UUID). The match is constrained to a single server
// because the handshake secret was generated per-transfer; it does
// not unlock any other agent. A new transfer for the same server
// invalidates the entry inside Register, closing this acceptance
// window before the next HandshakeSecret takes over.
if cid, ok := singleton.ServerTransferShared.LookupServerByVerifiedHandshakeSecret(clientSecret); ok {
if uuidCID, found := singleton.ServerShared.UUIDToID(clientUUID); found && uuidCID == cid {
model.UnblockIP(singleton.DB, ip, model.BlockIDgRPC)
return cid, nil
}
return 0, status.Error(codes.Unauthenticated, "transfer verified handshake secret bound to a different server")
}
}
singleton.UserLock.RLock()
userId, ok := singleton.AgentSecretToUserId[clientSecret]
if !ok {
@@ -48,15 +162,6 @@ func (a *authHandler) Check(ctx context.Context) (uint64, error) {
model.UnblockIP(singleton.DB, ip, model.BlockIDgRPC)
var clientUUID string
if value, ok := md["client_uuid"]; ok {
clientUUID = value[0]
}
if _, err := uuid.ParseUUID(clientUUID); err != nil {
return 0, status.Error(codes.Unauthenticated, "客户端 UUID 不合法")
}
clientID, hasID, err := authorizeAgentForUUID(userId, clientUUID)
if err != nil {
return 0, status.Error(codes.Unauthenticated, err.Error())
@@ -90,6 +195,16 @@ func (a *authHandler) Check(ctx context.Context) (uint64, error) {
// an agent persistently fails with "client UUID does not belong to the
// agent secret owner", it pins down which user's secret has been reused
// against a server they don't own.
//
// Server transfer interaction: while a ServerTransfer is Pending for this
// server, the agent is still authenticating with the previous owner's
// AgentSecret (the new secret has not yet propagated). To keep that agent
// online during the rollover, accept userId==FromUserID for the duration of
// the pending window. The dual-secret tolerance is narrowly scoped to the
// affected server only — every other agent of either user is unaffected.
// Once the agent reconnects under the new owner's secret (userId==ToUserID
// matching server.UserID), MarkVerified promotes the transfer and closes
// the tolerance window.
func authorizeAgentForUUID(userId uint64, clientUUID string) (clientID uint64, hasID bool, err error) {
cid, found := singleton.ServerShared.UUIDToID(clientUUID)
if !found {
@@ -106,8 +221,51 @@ func authorizeAgentForUUID(userId uint64, clientUUID string) (clientID uint64, h
// agent secrets, so keep it compatible by allowing any existing UUID.
return cid, true, nil
}
if server.UserID != userId {
return 0, false, fmt.Errorf("client UUID does not belong to the agent secret owner")
if server.GetUserID() == userId {
// SECURITY: while a transfer is Pending, Server.UserID has already
// been flipped to ToUserID by Register, so userId==Server.UserID
// here also matches the destination user's user-global AgentSecret.
// PushIfOnline only delivers the per-transfer HandshakeSecret on
// the wire; the destination user's global AgentSecret is never
// pushed to the agent, so a reconnect under that secret is not
// proof of agent rotation. Admitting it would let the destination
// user — who can see Server.UUID — authenticate as the agent
// during the Pending window. Reject the user-global secret until
// the transfer settles; the HandshakeSecret path in check() is
// the only valid promotion route.
if singleton.ServerTransferShared != nil {
if _, ok := singleton.ServerTransferShared.LookupPending(cid); ok {
return 0, false, fmt.Errorf("destination user's global AgentSecret cannot authenticate during a pending transfer; agent must rotate to per-transfer HandshakeSecret")
}
}
return cid, true, nil
}
return cid, true, nil
// server.UserID != userId — normally an impersonation attempt. Allow it
// only when a ServerTransfer for this server is Pending AND the secret in
// hand is the previous owner's (FromUserID), OR when a recently terminated
// transfer left a revert-delivery for FromUserID and the agent is still
// presenting its pre-transfer global secret.
//
// SECURITY: we deliberately do NOT accept the destination user's global
// AgentSecret on the LookupRevertDelivery path. PushIfOnline only ever
// delivers per-transfer HandshakeSecret / RevertHandshakeSecret to the
// agent — the ToUserID global secret never travels over the wire — so a
// reconnect under that credential is not proof of agent rotation; it can
// only come from the destination user themselves, who can see Server.UUID
// once Register flips Server.UserID. Admitting it would let that user
// impersonate the agent during the rollback window, trigger
// pushRevertIfOnline to leak RevertHandshakeSecret, and then be promoted
// into verifiedHandshakes via MarkRevertDelivered. The legitimate recovery
// paths are: FromUserID global secret (handled below), forward
// HandshakeSecret and RevertHandshakeSecret (handled by the
// terminalSecretRecovery / verifiedHandshakes lookups in check()).
if singleton.ServerTransferShared != nil {
if t, ok := singleton.ServerTransferShared.LookupRevertDelivery(cid); ok && t.FromUserID == userId {
return cid, true, nil
}
if t, ok := singleton.ServerTransferShared.LookupPending(cid); ok && t.FromUserID == userId {
return cid, true, nil
}
}
return 0, false, fmt.Errorf("client UUID does not belong to the agent secret owner")
}