mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-14 23:31:00 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user