From 34c248bb799c4d570acb7387c7637061098f6017 Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Fri, 14 Aug 2026 23:12:43 +0500 Subject: [PATCH] fix(cli): stop -getApiToken accumulating admin tokens (#6175) * fix(cli): stop -getApiToken accumulating admin tokens `x-ui setting -getApiToken` reads like a getter, but when tokens already exist it minted a brand-new one named `cli-fallback-` on every invocation. The plaintext is printed once and the row stays enabled forever, so an operator who runs the command a few times while debugging silently leaves several admin-equivalent credentials behind that nobody can tell apart or revoke knowingly. Keep the convenience the fallback was added for, but rotate a single `cli-fallback` token instead: RecreateByName drops any existing row with that name before issuing a new one, so at most one CLI-issued token exists at a time and the previous plaintext stops working. * fix(api-token): preserve token on failed replacement --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> --- internal/web/service/panel/api_token.go | 24 ++++++ internal/web/service/panel/api_token_test.go | 77 +++++++++++++++++++- main.go | 13 ++-- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go index 6360be522..aa274fea7 100644 --- a/internal/web/service/panel/api_token.go +++ b/internal/web/service/panel/api_token.go @@ -5,6 +5,8 @@ import ( "errors" "strings" + "gorm.io/gorm" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/common" @@ -86,6 +88,28 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) { return view, nil } +// RecreateByName replaces any token with this name, keeping exactly one so a +// repeatedly-run caller cannot accumulate credentials it can never revoke. +func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, common.NewError("token name is required") + } + plaintext := random.Seq(apiTokenLength) + row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true} + if err := database.GetDB().Transaction(func(tx *gorm.DB) error { + if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil { + return err + } + return tx.Create(row).Error + }); err != nil { + return nil, err + } + view := toView(row) + view.Token = plaintext + return view, nil +} + func (s *ApiTokenService) Delete(id int) error { if id <= 0 { return common.NewError("invalid token id") diff --git a/internal/web/service/panel/api_token_test.go b/internal/web/service/panel/api_token_test.go index 04f37fcb6..1b4298cd3 100644 --- a/internal/web/service/panel/api_token_test.go +++ b/internal/web/service/panel/api_token_test.go @@ -1,6 +1,17 @@ package panel -import "testing" +import ( + "errors" + "testing" + + "gorm.io/gorm" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +var errInjectedTokenCreate = errors.New("injected token create failure") func TestApiTokenCreatedAtSeconds(t *testing.T) { tests := []struct { @@ -21,3 +32,67 @@ func TestApiTokenCreatedAtSeconds(t *testing.T) { }) } } + +func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + if err := database.InitDB(config.GetDBPath()); err != nil { + t.Fatalf("init db: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + svc := ApiTokenService{} + first, err := svc.RecreateByName("cli-fallback") + if err != nil { + t.Fatalf("first recreate: %v", err) + } + db := database.GetDB() + const callback = "test:fail-token-replacement" + if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) { + if token, ok := tx.Statement.Dest.(*model.ApiToken); ok && token.Name == "cli-fallback" { + tx.AddError(errInjectedTokenCreate) + } + }); err != nil { + t.Fatalf("register callback: %v", err) + } + t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) }) + + if _, err := svc.RecreateByName("cli-fallback"); !errors.Is(err, errInjectedTokenCreate) { + t.Fatalf("recreate error = %v, want %v", err, errInjectedTokenCreate) + } + var row model.ApiToken + if err := db.Where("name = ?", "cli-fallback").First(&row).Error; err != nil { + t.Fatalf("load preserved token: %v", err) + } + if !svc.Match(first.Token) { + t.Fatal("original token was revoked after replacement failure") + } +} + +func TestRecreateByNameKeepsOneToken(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + if err := database.InitDB(config.GetDBPath()); err != nil { + t.Fatalf("init db: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + svc := ApiTokenService{} + first, err := svc.RecreateByName("cli-fallback") + if err != nil { + t.Fatalf("first recreate: %v", err) + } + second, err := svc.RecreateByName("cli-fallback") + if err != nil { + t.Fatalf("second recreate: %v", err) + } + if first.Token == second.Token { + t.Fatal("second call returned the same plaintext, want a rotated token") + } + + var count int64 + if err := database.GetDB().Model(model.ApiToken{}).Where("name = ?", "cli-fallback").Count(&count).Error; err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Fatalf("token rows = %d, want 1", count) + } +} diff --git a/main.go b/main.go index eaeaad87d..ac1ebdd02 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,6 @@ import ( "os" "os/signal" "syscall" - "time" _ "unsafe" "github.com/mhsanaei/3x-ui/v3/internal/config" @@ -32,6 +31,10 @@ import ( "github.com/op/go-logging" ) +// cliFallbackTokenName is the single token the CLI regenerates, so `-getApiToken` +// cannot accumulate admin-equivalent credentials that are never revoked. +const cliFallbackTokenName = "cli-fallback" + // runWebServer initializes and starts the web server for the 3x-ui panel. func runWebServer() { log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion()) @@ -455,14 +458,14 @@ func GetApiToken(getApiToken bool) { fmt.Printf("There are %d API token(s) configured. Existing tokens cannot be retrieved in plaintext because only hashes are stored.\n", len(tokens)) fmt.Println("If you have lost your token, you can manage and generate new tokens through the Panel UI (Settings -> API Tokens).") - // Create a new fallback token so the CLI is still useful without the UI - fallbackName := fmt.Sprintf("cli-fallback-%d", time.Now().Unix()) - created, err := apiTokenService.Create(fallbackName) + // Rotate one reusable fallback so repeated calls cannot pile up + // indefinitely many admin-equivalent tokens that never expire. + created, err := apiTokenService.RecreateByName(cliFallbackTokenName) if err != nil { fmt.Println("Failed to create a fallback API token:", err) return } - fmt.Println("\nA new fallback token has been generated for your convenience:") + fmt.Println("\nThe CLI fallback token has been regenerated (any previous one is now invalid):") fmt.Println("apiToken:", created.Token) return }