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 <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-14 23:12:43 +05:00
committed by GitHub
parent 17fea2f656
commit 34c248bb79
3 changed files with 108 additions and 6 deletions
+76 -1
View File
@@ -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)
}
}