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 }