Files
3x-ui/internal/database/db_seed_test.go
T
MHSanaei 9a95153f0e fix(hwid): track devices only for subscriptions a client owns
The subscription routes run the HWID gate before they look up the
subId, and the gate read "no device limit" and "no such subscription"
alike, so a request naming any path segment with an X-HWID header was
answered 404 yet stored that segment, up to about 1 MiB, in
client_hwids for good. Anyone reaching the subscription port could fill
the disk until the panel could no longer save anything.

An unlimited subscription is tracked only while an enabled client owns
its subId, and a one-time seeder drops the rows already stored for
subIds no client owns.
2026-11-01 00:54:59 +01:00

414 lines
13 KiB
Go

package database
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"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) {
pathPattern := regexp.MustCompile(`^/[0-9a-z]{16}/$`)
loadPaths := func(dbPath string) map[string]string {
t.Helper()
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer func() {
if err := CloseDB(); err != nil {
t.Errorf("CloseDB failed: %v", err)
}
}()
keys := []string{"subPath", "subJsonPath", "subClashPath"}
paths := make(map[string]string, len(keys))
for _, key := range keys {
var setting model.Setting
if err := db.Where("key = ?", key).First(&setting).Error; err != nil {
t.Fatalf("read %s: %v", key, err)
}
if !pathPattern.MatchString(setting.Value) {
t.Fatalf("%s = %q, want /<16 lowercase alphanumeric characters>/", key, setting.Value)
}
paths[key] = setting.Value
}
if paths["subPath"] == paths["subJsonPath"] || paths["subPath"] == paths["subClashPath"] || paths["subJsonPath"] == paths["subClashPath"] {
t.Fatalf("subscription paths must be distinct: %v", paths)
}
return paths
}
firstDB := filepath.Join(t.TempDir(), "x-ui.db")
first := loadPaths(firstDB)
reloaded := loadPaths(firstDB)
for key, firstPath := range first {
if firstPath != reloaded[key] {
t.Fatalf("%s changed after restart: %q, then %q", key, firstPath, reloaded[key])
}
}
second := loadPaths(filepath.Join(t.TempDir(), "x-ui.db"))
for key, firstPath := range first {
if firstPath == second[key] {
t.Fatalf("%s reused across panels: %q", key, firstPath)
}
}
}
// 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)
}
}
// Old releases drew the cookie-signing secret from a clock-seeded math/rand, and a
// stored value cannot tell its generator, so an upgrade must replace it exactly once.
func TestSessionSecretRotatesOnceOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
reopen := func() []string {
t.Helper()
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var secrets []string
if err := db.Model(&model.Setting{}).Where("key = ?", "secret").Pluck("value", &secrets).Error; err != nil {
t.Fatalf("read secret: %v", err)
}
return secrets
}
storeSecret := func(value string) {
t.Helper()
if err := db.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil {
t.Fatalf("clear secret: %v", err)
}
if err := db.Create(&model.Setting{Key: "secret", Value: value}).Error; err != nil {
t.Fatalf("store secret: %v", err)
}
}
storeSecret("kept-on-a-fresh-install")
if got := reopen(); len(got) != 1 || got[0] != "kept-on-a-fresh-install" {
t.Fatalf("fresh install secret = %q after restart, want it kept", got)
}
storeSecret("rfBd56ti2SMtYvSgD5xAV0YU99zampta")
if err := db.Where("seeder_name = ?", "SessionSecretRotation").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if got := reopen(); len(got) != 0 {
t.Fatalf("secret = %q after upgrade, want the pre-upgrade secret dropped", got)
}
storeSecret("drawn-after-upgrade")
if got := reopen(); len(got) != 1 || got[0] != "drawn-after-upgrade" {
t.Fatalf("secret = %q, want the rotated secret kept so restarts do not log everyone out", got)
}
}
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
settings, err := json.Marshal(map[string]any{
"clients": []any{
map[string]any{
"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
"email": "alice@example.com",
"enable": true,
"flow": "",
"subId": "alice-sub",
"comment": "from-inbound-json",
},
},
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := model.Inbound{
UserId: 1,
Port: 12345,
Protocol: model.VLESS,
Settings: string(settings),
Tag: "test-inbound",
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
preExisting := &model.ClientRecord{
Email: "alice@example.com",
UUID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
SubID: "alice-sub",
Enable: true,
Comment: "added-via-api",
}
if err := db.Create(preExisting).Error; err != nil {
t.Fatalf("seed client row: %v", err)
}
if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear ClientsTable history: %v", err)
}
if err := seedClientsFromInboundJSON(); err != nil {
t.Fatalf("seedClientsFromInboundJSON should be idempotent against existing rows, got: %v", err)
}
var count int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "alice@example.com").Count(&count).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if count != 1 {
t.Fatalf("alice@example.com should resolve to exactly one row, got %d", count)
}
}
func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
settings, err := json.Marshal(map[string]any{
"clients": []any{
map[string]any{
"id": "00000000-0000-0000-0000-000000000001",
"email": "missing-sub@example.com",
"subId": "",
},
map[string]any{
"id": "00000000-0000-0000-0000-000000000002",
"email": "no-sub-key@example.com",
},
map[string]any{
"id": "00000000-0000-0000-0000-000000000003",
"email": "has-sub@example.com",
"subId": "keep-me-1234",
},
},
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := model.Inbound{
UserId: 1,
Port: 23456,
Protocol: model.VLESS,
Settings: string(settings),
Tag: "subid-fix-inbound",
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := db.Where("seeder_name = ?", "InboundClientSubIdFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := normalizeInboundClientSubId(); err != nil {
t.Fatalf("normalizeInboundClientSubId: %v", err)
}
var reloaded model.Inbound
if err := db.First(&reloaded, inbound.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(reloaded.Settings), &parsed); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
clients, ok := parsed["clients"].([]any)
if !ok || len(clients) != 3 {
t.Fatalf("expected 3 clients, got %v", parsed["clients"])
}
subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`)
for i := range 2 {
obj := clients[i].(map[string]any)
sub, _ := obj["subId"].(string)
if !subIdPattern.MatchString(sub) {
t.Fatalf("client %d: expected 16-char [0-9a-z] subId, got %q", i, sub)
}
}
preserved := clients[2].(map[string]any)["subId"].(string)
if preserved != "keep-me-1234" {
t.Fatalf("expected existing subId preserved, got %q", preserved)
}
var historyCount int64
if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "InboundClientSubIdFix").Count(&historyCount).Error; err != nil {
t.Fatalf("count seeder history: %v", err)
}
if historyCount != 1 {
t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
}
}
func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
seed := []model.Setting{
{Key: "subJsonPath", Value: "YIrCXJOOOL"},
{Key: "subPath", Value: "/sub"},
{Key: "subClashPath", Value: "clash/"},
{Key: "webBasePath", Value: "/panel/"},
}
if err := db.Where("key IN ?", []string{"subPath", "subJsonPath", "subClashPath"}).Delete(&model.Setting{}).Error; err != nil {
t.Fatalf("clear generated subscription paths: %v", err)
}
for i := range seed {
if err := db.Create(&seed[i]).Error; err != nil {
t.Fatalf("seed setting %s: %v", seed[i].Key, err)
}
}
if err := normalizeSettingPaths(); err != nil {
t.Fatalf("normalizeSettingPaths: %v", err)
}
want := map[string]string{
"subJsonPath": "/YIrCXJOOOL/",
"subPath": "/sub/",
"subClashPath": "/clash/",
"webBasePath": "/panel/",
}
for key, expected := range want {
var row model.Setting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
t.Fatalf("read %s: %v", key, err)
}
if row.Value != expected {
t.Errorf("%s = %q, want %q", key, row.Value, expected)
}
}
}
// A padded group name was listed as its own group while every action on it hit the trimmed
// one, so the upgrade folds stored names onto their trimmed form.
func TestClientGroupNamesTrimmedOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
for email, group := range map[string]string{"a@x": "vip ", "b@x": "\tvip", "c@x": "vip"} {
if err := db.Create(&model.ClientRecord{Email: email, Group: group, Enable: true}).Error; err != nil {
t.Fatalf("seed %s: %v", email, err)
}
}
if err := db.Where("seeder_name = ?", "ClientGroupNameTrim").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var groups []string
if err := db.Model(&model.ClientRecord{}).Distinct().Pluck("group_name", &groups).Error; err != nil {
t.Fatalf("read groups: %v", err)
}
if len(groups) != 1 || groups[0] != "vip" {
t.Fatalf("groups after upgrade = %q, want [vip]", groups)
}
}
// Requests naming a subId no client owns stored HWID rows that nothing ever reads; the
// upgrade drops them and keeps the rows of real subscriptions.
func TestOwnerlessClientHwidsPurgedOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.ClientRecord{Email: "a@x", SubID: "owned", Enable: true}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
for i, sub := range []string{"owned", "junk-1", "junk-2"} {
if err := db.Create(&model.ClientHwid{SubID: sub, HwidHash: fmt.Sprintf("h%d", i), FirstSeen: 1, LastSeen: 1}).Error; err != nil {
t.Fatalf("seed hwid %s: %v", sub, err)
}
}
if err := db.Where("seeder_name = ?", "OwnerlessClientHwidPurge").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var subs []string
if err := db.Model(&model.ClientHwid{}).Pluck("sub_id", &subs).Error; err != nil {
t.Fatalf("read hwids: %v", err)
}
if len(subs) != 1 || subs[0] != "owned" {
t.Fatalf("hwid rows after upgrade = %q, want [owned]", subs)
}
}