Forráskód Böngészése

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-<unix>` 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 <[email protected]>
n0ctal 17 órája
szülő
commit
34c248bb79

+ 24 - 0
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")

+ 76 - 1
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)
+	}
+}

+ 8 - 5
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
 	}