From b140311d54fa36ee19716cde64c69ecbcb8a2a3d Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 23 Oct 2026 02:36:43 -0700 Subject: [PATCH] perf(actions): cache successful runner token verification (#39934) (#39959) Backport of https://github.com/go-gitea/gitea/pull/39934 to release/v28. Runner token verification (PBKDF2) dominated CPU on instances with many runners. Successful verifications are now cached in a dedicated LRU. Conflict resolved by keeping v28's connect v1 interceptor and adding only the cache. _Backport prepared with Codet._ Co-authored-by: silverwind --- routers/api/actions/runner/interceptor.go | 20 +++++++++++++- .../api/actions/runner/interceptor_test.go | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 routers/api/actions/runner/interceptor_test.go diff --git a/routers/api/actions/runner/interceptor.go b/routers/api/actions/runner/interceptor.go index 8b79962c11d..5b649d01c5d 100644 --- a/routers/api/actions/runner/interceptor.go +++ b/routers/api/actions/runner/interceptor.go @@ -5,6 +5,7 @@ package runner import ( "context" + "crypto/sha256" "errors" "strings" "time" @@ -17,6 +18,7 @@ import ( "gitea.dev/modules/util" "connectrpc.com/connect" + lru "github.com/hashicorp/golang-lru/v2" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -26,6 +28,10 @@ const ( tokenHeaderKey = protocol.TokenHeader ) +type runnerTokenHash struct{ salt, hash string } + +var verifiedRunnerTokens, _ = lru.New[runnerTokenHash, [sha256.Size]byte](10000) // not auth_model.TokenCache, whose small default size thrashes once more runners poll in turn + var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { methodName := getMethodName(request) @@ -42,7 +48,7 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar } return nil, status.Error(codes.Internal, err.Error()) } - if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { + if !verifyRunnerToken(runner, token) { return nil, status.Error(codes.Unauthenticated, "unregistered runner") } @@ -73,6 +79,18 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar } })) +func verifyRunnerToken(runner *actions_model.ActionRunner, token string) bool { + key, digest := runnerTokenHash{runner.TokenSalt, runner.TokenHash}, sha256.Sum256([]byte(token)) + if cached, ok := verifiedRunnerTokens.Get(key); ok && util.CryptoConstTimeEqual(cached[:], digest[:]) { + return true + } + if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { + return false + } + verifiedRunnerTokens.Add(key, digest) + return true +} + func getMethodName(req connect.AnyRequest) string { splits := strings.Split(req.Spec().Procedure, "/") if len(splits) > 0 { diff --git a/routers/api/actions/runner/interceptor_test.go b/routers/api/actions/runner/interceptor_test.go new file mode 100644 index 00000000000..8c0af2b8602 --- /dev/null +++ b/routers/api/actions/runner/interceptor_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "crypto/sha256" + "testing" + + actions_model "gitea.dev/models/actions" + auth_model "gitea.dev/models/auth" + + "github.com/stretchr/testify/assert" +) + +func TestVerifyRunnerToken(t *testing.T) { + verifiedRunnerTokens.Purge() + runner := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("token", "salt")} + assert.True(t, verifyRunnerToken(runner, "token")) + assert.True(t, verifyRunnerToken(runner, "token")) + assert.False(t, verifyRunnerToken(runner, "wrong")) + + other := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("other", "salt")} + assert.False(t, verifyRunnerToken(other, "token")) + assert.Equal(t, [][sha256.Size]byte{sha256.Sum256([]byte("token"))}, verifiedRunnerTokens.Values()) +}