From 5129ad5439b22945bfa93cf5e6ac3fbb8a5f4770 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 22 Oct 2026 20:59:11 -0700 Subject: [PATCH] perf(actions): cache successful runner token verification (#39934) Every Actions runner RPC (`FetchTask` polling, `UpdateTask`, `UpdateLog`, ...) goes through the `withRunner` interceptor, which verifies the runner token with PBKDF2 (`auth_model.HashToken`, ~2 ms). In a 60s CPU profile of a production instance with many runners, this accounted for ~49% of all CPU samples. - Cache successful verifications in a dedicated LRU of up to 10000 runners, keyed by the stored salt and hash and holding only a SHA-256 digest of the token. A hit costs ~100 ns. - The runner is still loaded from the DB on every RPC and failures are never cached, so deleted runners and wrong tokens never pass. - The shared `auth_model.TokenCache` is not used because its default 20 entries miss on every poll once more runners poll in turn. --------- 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 0621984aae1..fe32012fb4f 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/v2" + 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 + // withErrorMessages sends plain errors to runners as CodeUnknown with their message, like connect v1 did. func withErrorMessages(next connect.ServerFunc) connect.ServerFunc { return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { @@ -57,7 +63,7 @@ func withRunner(next connect.ServerFunc) connect.ServerFunc { } return status.Error(codes.Internal, err.Error()) } - if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { + if !verifyRunnerToken(runner, token) { return errUnregisteredRunner } @@ -88,6 +94,18 @@ func withRunner(next connect.ServerFunc) connect.ServerFunc { } } +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(spec connect.Spec) string { splits := strings.Split(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()) +}