fix(database): create the first panel user with random credentials

The first user is now created when the server starts, with a random
username and password printed once to its log (docker logs, the journal),
instead of a fixed pair seeded by InitDB. CLI calls that initialize the
database are often piped away, as the Docker entrypoint's port lookup is, so
only the server's own log reliably reaches the operator.

`x-ui setting -show` keeps reporting hasDefaultCredential: true until a user
exists, so install.sh still sets its own credentials on a fresh install.
Existing databases are not changed. Verified on a built binary: fresh
setting -show, install.sh-style credential set, first start, login and
restart. The PostgreSQL lane was not run locally; the change adds no query.
This commit is contained in:
MHSanaei
2026-10-24 01:26:05 +02:00
parent 0faad4f4f3
commit 1df8ab29f8
9 changed files with 148 additions and 39 deletions
+13 -25
View File
@@ -57,11 +57,7 @@ func Dialect() string {
return db.Name()
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
sqliteBackupDirPrefix = ".x-ui-backup-"
)
const sqliteBackupDirPrefix = ".x-ui-backup-"
func allModels() []any {
return []any{
@@ -1316,26 +1312,22 @@ func isIgnorableDuplicateColumnErr(gdb *gorm.DB, err error, mdl any) bool {
return false
}
func initUser() error {
// EnsureFirstUser creates the panel's first user with random credentials and returns
// them, or returns "" when one exists; only the starting server calls it, so it can print them.
func EnsureFirstUser() (username, password string, err error) {
empty, err := isTableEmpty("users")
if err != nil || !empty {
return "", "", err
}
username, password = random.Seq(10), random.Seq(16)
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
if err != nil {
log.Printf("Error checking if users table is empty: %v", err)
return err
return "", "", err
}
if empty {
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
if err != nil {
log.Printf("Error hashing default password: %v", err)
return err
}
user := &model.User{
Username: defaultUsername,
Password: hashedPassword,
}
return db.Create(user).Error
if err := db.Create(&model.User{Username: username, Password: hashedPassword}).Error; err != nil {
return "", "", err
}
return nil
return username, password, nil
}
func seedRandomSubscriptionPaths() error {
@@ -3145,10 +3137,6 @@ func InitDB(dbPath string) error {
return err
}
}
if err := initUser(); err != nil {
return err
}
return runSeeders(isUsersEmpty)
}
+50
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
)
func TestInitDB_GeneratesPerPanelSubscriptionPaths(t *testing.T) {
@@ -57,6 +58,55 @@ func TestInitDB_GeneratesPerPanelSubscriptionPaths(t *testing.T) {
}
}
// Every launcher but install.sh serves the panel on the database as InitDB left
// it, so a credential seeded here is one anyone on the network can log in with.
func TestInitDBLeavesNoGuessableCredential(t *testing.T) {
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
var users []model.User
if err := db.Find(&users).Error; err != nil {
t.Fatalf("read users: %v", err)
}
for _, u := range users {
if crypto.CheckPasswordHash(u.Password, "admin") {
t.Fatalf("fresh database holds user %q with the password admin", u.Username)
}
}
}
// The server prints what EnsureFirstUser returns as the only copy of the first
// login, so it must be the stored credential, and a restart must not mint another.
func TestEnsureFirstUserCreatesOneWorkingCredential(t *testing.T) {
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
username, password, err := EnsureFirstUser()
if err != nil {
t.Fatalf("EnsureFirstUser: %v", err)
}
var stored model.User
if err := db.First(&stored).Error; err != nil {
t.Fatalf("read user: %v", err)
}
if stored.Username != username || password == "" || !crypto.CheckPasswordHash(stored.Password, password) {
t.Fatalf("returned %q/%q, stored user %q does not log in with it", username, password, stored.Username)
}
againUser, againPass, err := EnsureFirstUser()
if err != nil || againUser != "" || againPass != "" {
t.Fatalf("second EnsureFirstUser = %q, %q, %v; want nothing created", againUser, againPass, err)
}
var count int64
if err := db.Model(&model.User{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("users = %d (%v), want 1", count, err)
}
}
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
+8
View File
@@ -41,6 +41,10 @@ func InitDB(t testing.TB, path string) {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
// A serving panel always has its first user; a copied template already does.
if _, _, err := database.EnsureFirstUser(); err != nil {
t.Fatalf("EnsureFirstUser: %v", err)
}
}
func migratedTemplate() ([]byte, error) {
@@ -56,6 +60,10 @@ func migratedTemplate() ([]byte, error) {
migrated.err = err
return
}
if _, _, err := database.EnsureFirstUser(); err != nil {
migrated.err = err
return
}
// Closing the last connection checkpoints the WAL into the main file.
if err := database.CloseDB(); err != nil {
migrated.err = err
+1 -1
View File
@@ -30,7 +30,7 @@ func TestInitDBGivesEachTestItsOwnDatabase(t *testing.T) {
t.Fatalf("count users: %v", err)
}
if admins != 1 {
t.Fatalf("users = %d, want the 1 seeded admin a fresh install has", admins)
t.Fatalf("users = %d, want the 1 admin a started panel has", admins)
}
})
}
+13
View File
@@ -36,6 +36,19 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
return user, nil
}
// HasDefaultCredential reports that nobody has chosen the login yet: a database
// holds no user before the first start, and an older one may still hold admin/admin.
func (s *UserService) HasDefaultCredential() (bool, error) {
user, err := s.GetFirstUser()
if database.IsNotFound(err) {
return true, nil
}
if err != nil {
return false, err
}
return user.Username == "admin" && crypto.CheckPasswordHash(user.Password, "admin"), nil
}
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
db := database.GetDB()
+50
View File
@@ -0,0 +1,50 @@
package panel
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
)
// install.sh replaces the credentials only while this reports true, and before
// the first start a database holds no user at all.
func TestHasDefaultCredential(t *testing.T) {
hash := func(t *testing.T, password string) string {
t.Helper()
h, err := crypto.HashPasswordAsBcrypt(password)
if err != nil {
t.Fatalf("hash: %v", err)
}
return h
}
cases := []struct {
name string
user *model.User
want bool
}{
{name: "no user before the first start", user: nil, want: true},
{name: "legacy admin/admin", user: &model.User{Username: "admin", Password: hash(t, "admin")}, want: true},
{name: "chosen credentials", user: &model.User{Username: "admin", Password: hash(t, "s3cret-Pass")}, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
if err := database.GetDB().Where("1 = 1").Delete(&model.User{}).Error; err != nil {
t.Fatalf("clear users: %v", err)
}
if tc.user != nil {
if err := database.GetDB().Create(tc.user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
}
got, err := (&UserService{}).HasDefaultCredential()
if err != nil || got != tc.want {
t.Fatalf("HasDefaultCredential() = %v, %v; want %v", got, err, tc.want)
}
})
}
}