mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 12:17:14 +00:00
d2ac3b4d7a
* fix(cli): let -getApiToken name the token it regenerates The flag's help text said "Display current API token". It cannot display anything -- tokens are stored as SHA-256 hashes, and the command's own first two output lines say so. What it does is destroy and reissue a credential: GetApiToken calls RecreateByName on the hardcoded name "cli-fallback". The help therefore invited an operator to run a command they believed was read-only, and it revoked a token someone else was holding. Because that name is a single global slot, two callers silently invalidate each other, and the loser is left with a token that answers HTTP 404 with an empty body -- indistinguishable from a wrong base path, so the failure does not even say what happened. install.sh is one of those callers, at lines 1231 and 1325, so the collision already exists inside this repository. Add -tokenName, defaulting to cli-fallback so install.sh and every existing invocation behave exactly as before. -getApiToken stays a boolean on purpose: install.sh calls it as `x-ui setting -getApiToken true`, and a string flag would swallow that trailing argument and mint a token named "true". The name now reaches both branches of GetApiToken. On a database with no tokens the command used to create one called "install", which the CLI could then never rotate -- defeating the stated purpose of the cli-fallback constant, that -getApiToken cannot accumulate admin-equivalent credentials it never revokes. Both branches use the resolved name, so repeated calls rotate a single slot instead of leaving a permanent token behind. Also cap the name at 64 characters in RecreateByName. Create already enforces that limit on the same column; RecreateByName did not, and it now receives operator input. Assisted-by: Claude Code:claude-opus-5 (mostly) * fix(cli): keep the installer's token out of the rotated slot Folding both branches of GetApiToken onto one name made the bug worse in the exact case this change is about. install.sh records the token it gets on a fresh panel; with both branches on cli-fallback, the next bare -getApiToken rotated that very row and silently invalidated the credential written into the install-result file. Restore the split default -- "install" when the database has no tokens, cli-fallback when it does -- so nothing about an unnamed call changes. An explicit -tokenName still applies to both branches, which is what keeps the flag coherent: -tokenName ci-bot now yields ci-bot on a fresh panel too, rather than "install". Pin it with a test that reads the install row's id and hash before and after a rotation, since a name-only assertion would pass against a deleted-and- recreated row. * fix(cli): stop the `-getApiToken true` form from swallowing -tokenName Three corrections from review. Go's flag package stops parsing at the first non-flag argument, so the trailing `true` in install.sh's invocation does not merely get ignored -- it terminates parsing. An operator copying that documented shape and writing `x-ui setting -getApiToken true -tokenName ci-bot` left tokenName empty, so the command rotated cli-fallback: the shared-slot collision this change exists to remove, reachable through the one form the repository itself demonstrates. Verified against the built binary, which printed `The API token "cli-fallback" has been regenerated`. Drop the stray `true` from both install.sh call sites so the documented form no longer teaches the trap, and warn whenever `setting` is given positional arguments, naming what was ignored. A warning rather than an error, because an older install.sh in the wild still passes `true` and must keep working. Cover both branches in the help strings. They described only the rotation path, so on a fresh panel -h announced cli-fallback while the command actually mints `install`, and nothing is regenerated or invalidated there at all -- misleading help being the defect this change set out to remove. Assert the concrete error in the name-length test. It checked only that some error came back, which RecreateByName's empty-name guard and its transaction errors would satisfy just as well.
264 lines
7.6 KiB
Go
264 lines
7.6 KiB
Go
package panel
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
|
)
|
|
|
|
type ApiTokenService struct{}
|
|
|
|
const apiTokenLength = 48
|
|
|
|
type ApiTokenView struct {
|
|
Id int `json:"id" example:"2"`
|
|
Name string `json:"name" example:"central-panel-a"`
|
|
Token string `json:"token,omitempty" example:"new-token-string"`
|
|
Enabled bool `json:"enabled" example:"true"`
|
|
CreatedAt int64 `json:"createdAt" example:"1736000000"`
|
|
Scope string `json:"scope" example:"admin"`
|
|
ExpiresAt int64 `json:"expiresAt" example:"0"`
|
|
}
|
|
|
|
func apiTokenCreatedAtSeconds(createdAt int64) int64 {
|
|
if createdAt >= model.ApiTokenUnixMillisecondsThreshold {
|
|
return createdAt / 1000
|
|
}
|
|
return createdAt
|
|
}
|
|
|
|
// toView builds the metadata view returned by List. It never carries the
|
|
// token value: only a SHA-256 hash is stored, and the plaintext is shown
|
|
// exactly once at creation time.
|
|
func toView(t *model.ApiToken) *ApiTokenView {
|
|
return &ApiTokenView{
|
|
Id: t.Id,
|
|
Name: t.Name,
|
|
Enabled: t.Enabled,
|
|
CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
|
|
Scope: t.Scope,
|
|
ExpiresAt: t.ExpiresAt,
|
|
}
|
|
}
|
|
|
|
// NormalizeScope validates a requested scope, defaulting empty to admin so
|
|
// callers that omit it keep the legacy full-access behavior.
|
|
func NormalizeScope(scope string) (string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(scope)) {
|
|
case "", model.ApiScopeAdmin:
|
|
return model.ApiScopeAdmin, nil
|
|
case model.ApiScopeMonitor:
|
|
return model.ApiScopeMonitor, nil
|
|
case model.ApiScopeNodeSync:
|
|
return model.ApiScopeNodeSync, nil
|
|
default:
|
|
return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
|
|
}
|
|
}
|
|
|
|
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
|
|
db := database.GetDB()
|
|
var rows []*model.ApiToken
|
|
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]*ApiTokenView, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, toView(r))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, common.NewError("token name is required")
|
|
}
|
|
if len(name) > 64 {
|
|
return nil, common.NewError("token name must be 64 characters or fewer")
|
|
}
|
|
normScope, err := NormalizeScope(scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
|
|
return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
|
|
}
|
|
db := database.GetDB()
|
|
var count int64
|
|
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, common.NewError("a token with that name already exists")
|
|
}
|
|
plaintext := random.Seq(apiTokenLength)
|
|
row := &model.ApiToken{
|
|
Name: name,
|
|
Token: crypto.HashTokenSHA256(plaintext),
|
|
Enabled: true,
|
|
Scope: normScope,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
if err := db.Create(row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
view := toView(row)
|
|
view.Token = plaintext
|
|
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")
|
|
}
|
|
// Same column, same limit as Create: the CLI now feeds this operator input.
|
|
if len(name) > 64 {
|
|
return nil, common.NewError("token name must be 64 characters or fewer")
|
|
}
|
|
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")
|
|
}
|
|
db := database.GetDB()
|
|
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
|
|
}
|
|
|
|
func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
scope, err := requireExpectedScope(expectedScope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("token not found with expected scope")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
db := database.GetDB()
|
|
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("token not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
scope, err := requireExpectedScope(expectedScope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("token not found with expected scope")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nowMilli() int64 { return time.Now().UnixMilli() }
|
|
|
|
// DisableExpectedScope fails closed unless the stored scope matches the caller,
|
|
// preventing rotation from revoking a newly minted token after a wrong ID.
|
|
func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
return s.SetEnabledExpectedScope(id, expectedScope, false)
|
|
}
|
|
|
|
func requireExpectedScope(expectedScope string) (string, error) {
|
|
if strings.TrimSpace(expectedScope) == "" {
|
|
return "", common.NewError("expected scope is required")
|
|
}
|
|
scope, err := NormalizeScope(expectedScope)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return scope, nil
|
|
}
|
|
|
|
// MatchToken returns the enabled, non-expired api_token row whose stored
|
|
// SHA-256 hash matches the presented bearer value, or (nil,false). The loop
|
|
// scans every enabled row with constant-time compares, then applies expiry and
|
|
// scope checks to avoid treating corrupt values as admin.
|
|
func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
|
|
if presented == "" {
|
|
return nil, false
|
|
}
|
|
db := database.GetDB()
|
|
var rows []*model.ApiToken
|
|
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
|
return nil, false
|
|
}
|
|
presentedHash := []byte(crypto.HashTokenSHA256(presented))
|
|
var matched *model.ApiToken
|
|
for _, r := range rows {
|
|
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
|
|
matched = r
|
|
}
|
|
}
|
|
if matched == nil {
|
|
return nil, false
|
|
}
|
|
if !model.IsKnownApiScope(matched.Scope) {
|
|
return nil, false
|
|
}
|
|
if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
|
|
return nil, false
|
|
}
|
|
return matched, true
|
|
}
|
|
|
|
// Match is the legacy boolean form for callers that do not need scope.
|
|
func (s *ApiTokenService) Match(presented string) bool {
|
|
_, ok := s.MatchToken(presented)
|
|
return ok
|
|
}
|