mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 06:45:01 +03:00
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:
@@ -69,8 +69,8 @@ responses for limited tokens, or node sync payloads.
|
|||||||
- Frontend: `cd /src/frontend && npx vitest run --project unit --project components`.
|
- Frontend: `cd /src/frontend && npx vitest run --project unit --project components`.
|
||||||
The Storybook browser project needs Chromium and is not provisioned.
|
The Storybook browser project needs Chromium and is not provisioned.
|
||||||
- Running a live panel: `XUI_DB_FOLDER=/tmp/xui XUI_LOG_FOLDER=/tmp/xui
|
- Running a live panel: `XUI_DB_FOLDER=/tmp/xui XUI_LOG_FOLDER=/tmp/xui
|
||||||
XUI_BIN_FOLDER=/src/build/bin x-ui` (creates `admin`/`admin` on first start;
|
XUI_BIN_FOLDER=/src/build/bin x-ui` (logs a generated username and password on
|
||||||
`x-ui setting -h` changes port, base path and credentials).
|
first start; `x-ui setting -h` changes port, base path and credentials).
|
||||||
- Baseline logs from the image build: `/src/.oss-scanner-go-test.log` and
|
- Baseline logs from the image build: `/src/.oss-scanner-go-test.log` and
|
||||||
`/src/.oss-scanner-vitest.log`. A built image does not prove the tests passed.
|
`/src/.oss-scanner-vitest.log`. A built image does not prove the tests passed.
|
||||||
- PostgreSQL is not installed: tests gated on `XUI_TEST_PG_DSN` skip, so a
|
- PostgreSQL is not installed: tests gated on `XUI_TEST_PG_DSN` skip, so a
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on
|
|||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
|
Open [http://localhost:2053](http://localhost:2053) and log in with the username and password the first start prints to the terminal (`No panel user existed, so one was generated…`). Lost them? Set new ones with `go run . setting -username <user> -password <pass>`.
|
||||||
|
|
||||||
### Inside VS Code
|
### Inside VS Code
|
||||||
|
|
||||||
|
|||||||
+13
-25
@@ -57,11 +57,7 @@ func Dialect() string {
|
|||||||
return db.Name()
|
return db.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const sqliteBackupDirPrefix = ".x-ui-backup-"
|
||||||
defaultUsername = "admin"
|
|
||||||
defaultPassword = "admin"
|
|
||||||
sqliteBackupDirPrefix = ".x-ui-backup-"
|
|
||||||
)
|
|
||||||
|
|
||||||
func allModels() []any {
|
func allModels() []any {
|
||||||
return []any{
|
return []any{
|
||||||
@@ -1316,26 +1312,22 @@ func isIgnorableDuplicateColumnErr(gdb *gorm.DB, err error, mdl any) bool {
|
|||||||
return false
|
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")
|
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 {
|
if err != nil {
|
||||||
log.Printf("Error checking if users table is empty: %v", err)
|
return "", "", err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if empty {
|
if err := db.Create(&model.User{Username: username, Password: hashedPassword}).Error; err != nil {
|
||||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
return "", "", err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return nil
|
return username, password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedRandomSubscriptionPaths() error {
|
func seedRandomSubscriptionPaths() error {
|
||||||
@@ -3145,10 +3137,6 @@ func InitDB(dbPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := initUser(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return runSeeders(isUsersEmpty)
|
return runSeeders(isUsersEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInitDB_GeneratesPerPanelSubscriptionPaths(t *testing.T) {
|
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) {
|
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
|
||||||
dbDir := t.TempDir()
|
dbDir := t.TempDir()
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func InitDB(t testing.TB, path string) {
|
|||||||
t.Fatalf("InitDB: %v", err)
|
t.Fatalf("InitDB: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
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) {
|
func migratedTemplate() ([]byte, error) {
|
||||||
@@ -56,6 +60,10 @@ func migratedTemplate() ([]byte, error) {
|
|||||||
migrated.err = err
|
migrated.err = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, _, err := database.EnsureFirstUser(); err != nil {
|
||||||
|
migrated.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
// Closing the last connection checkpoints the WAL into the main file.
|
// Closing the last connection checkpoints the WAL into the main file.
|
||||||
if err := database.CloseDB(); err != nil {
|
if err := database.CloseDB(); err != nil {
|
||||||
migrated.err = err
|
migrated.err = err
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestInitDBGivesEachTestItsOwnDatabase(t *testing.T) {
|
|||||||
t.Fatalf("count users: %v", err)
|
t.Fatalf("count users: %v", err)
|
||||||
}
|
}
|
||||||
if admins != 1 {
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
|
|||||||
return user, nil
|
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) {
|
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/tunnelmonitor"
|
"github.com/mhsanaei/3x-ui/v3/internal/tunnelmonitor"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web"
|
"github.com/mhsanaei/3x-ui/v3/internal/web"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||||
@@ -112,6 +111,15 @@ func runWebServer() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
|
// Created here, not in InitDB: a CLI call's output is often piped away, and
|
||||||
|
// this log is the one place an operator reads the first login from.
|
||||||
|
username, password, err := database.EnsureFirstUser()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating the first panel user: %v", err)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
log.Printf("No panel user existed, so one was generated. Username: %s Password: %s (shown once; change it in Panel Settings)", username, password)
|
||||||
|
}
|
||||||
|
|
||||||
server := web.NewServer()
|
server := web.NewServer()
|
||||||
global.SetWebServer(server)
|
global.SetWebServer(server)
|
||||||
@@ -260,15 +268,11 @@ func showSetting(show bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userService := panel.UserService{}
|
userService := panel.UserService{}
|
||||||
userModel, err := userService.GetFirstUser()
|
hasDefaultCredential, err := userService.HasDefaultCredential()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("get current user info failed, error info:", err)
|
fmt.Println("get current user info failed, error info:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if userModel.Username == "" || userModel.Password == "" {
|
|
||||||
fmt.Println("current username or password is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("current panel settings as follows:")
|
fmt.Println("current panel settings as follows:")
|
||||||
if certFile == "" || keyFile == "" {
|
if certFile == "" || keyFile == "" {
|
||||||
fmt.Println("Warning: Panel is not secure with SSL")
|
fmt.Println("Warning: Panel is not secure with SSL")
|
||||||
@@ -276,10 +280,6 @@ func showSetting(show bool) {
|
|||||||
fmt.Println("Panel is secure with SSL")
|
fmt.Println("Panel is secure with SSL")
|
||||||
}
|
}
|
||||||
|
|
||||||
hasDefaultCredential := func() bool {
|
|
||||||
return userModel.Username == "admin" && crypto.CheckPasswordHash(userModel.Password, "admin")
|
|
||||||
}()
|
|
||||||
|
|
||||||
fmt.Println("hasDefaultCredential:", hasDefaultCredential)
|
fmt.Println("hasDefaultCredential:", hasDefaultCredential)
|
||||||
fmt.Println("port:", port)
|
fmt.Println("port:", port)
|
||||||
fmt.Println("webBasePath:", webBasePath)
|
fmt.Println("webBasePath:", webBasePath)
|
||||||
|
|||||||
Reference in New Issue
Block a user