diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index b96992fcd..d683202df 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -69,8 +69,8 @@ responses for limited tokens, or node sync payloads. - Frontend: `cd /src/frontend && npx vitest run --project unit --project components`. The Storybook browser project needs Chromium and is not provisioned. - 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; - `x-ui setting -h` changes port, base path and credentials). + XUI_BIN_FOLDER=/src/build/bin x-ui` (logs a generated username and password on + 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 `/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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79991fa73..3c86c5a73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,7 @@ Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on 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 -password `. ### Inside VS Code diff --git a/internal/database/db.go b/internal/database/db.go index bd202f508..1d5f3e815 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -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) } diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index a7b740362..c65e74bf5 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -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) diff --git a/internal/database/dbtest/dbtest.go b/internal/database/dbtest/dbtest.go index 71e93e4a9..ef5401f9f 100644 --- a/internal/database/dbtest/dbtest.go +++ b/internal/database/dbtest/dbtest.go @@ -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 diff --git a/internal/database/dbtest/dbtest_test.go b/internal/database/dbtest/dbtest_test.go index 3f33ee722..3436e203f 100644 --- a/internal/database/dbtest/dbtest_test.go +++ b/internal/database/dbtest/dbtest_test.go @@ -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) } }) } diff --git a/internal/web/service/panel/user.go b/internal/web/service/panel/user.go index 3c69aa512..dd6970d86 100644 --- a/internal/web/service/panel/user.go +++ b/internal/web/service/panel/user.go @@ -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() diff --git a/internal/web/service/panel/user_test.go b/internal/web/service/panel/user_test.go new file mode 100644 index 000000000..ba79578d4 --- /dev/null +++ b/internal/web/service/panel/user_test.go @@ -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) + } + }) + } +} diff --git a/main.go b/main.go index ce88580eb..559b78696 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/sub" "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/web" "github.com/mhsanaei/3x-ui/v3/internal/web/global" @@ -112,6 +111,15 @@ func runWebServer() { if err != nil { 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() global.SetWebServer(server) @@ -260,15 +268,11 @@ func showSetting(show bool) { } userService := panel.UserService{} - userModel, err := userService.GetFirstUser() + hasDefaultCredential, err := userService.HasDefaultCredential() if err != nil { 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:") if certFile == "" || keyFile == "" { fmt.Println("Warning: Panel is not secure with SSL") @@ -276,10 +280,6 @@ func showSetting(show bool) { 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("port:", port) fmt.Println("webBasePath:", webBasePath)