mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-21 03:56:07 +00:00
refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)
* refactor(service): split client.go into focused files
client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):
client.go foundation: ClientService, ClientWithAttachments,
ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
client_locks.go inbound mutation locks, delete tombstones, compactOrphans
client_lookup.go read-only lookups (GetByID, List, EffectiveFlow, ...)
client_link.go inbound association sync (SyncInbound, DetachInbound, ...)
client_crud.go single-client CRUD + validation + protocol defaults
client_inbound_apply.go low-level inbound-settings mutators + by-email setters
client_bulk.go bulk attach/detach/adjust/delete/create + DelDepleted
client_traffic.go traffic-reset paths
client_groups.go client group management
client_paging.go paged listing, filtering, sorting, summary
Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.
* refactor(service): split inbound.go into focused files
inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):
inbound.go core inbound CRUD + InboundService (keeps pkg doc)
inbound_protocol.go protocol / stream capability helpers
inbound_node.go node/runtime/remote coordination + online tracking
inbound_traffic.go traffic accounting, reset, client stats
inbound_client_ips.go per-client IP tracking
inbound_clients.go client lookups within inbounds + copy-clients
inbound_disable.go auto-disable invalid inbounds/clients
inbound_migration.go DB migrations
inbound_sublink.go subscription link providers
inbound_util.go generic slice/string helpers
Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.
* refactor(service): split tgbot.go into focused files
tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):
tgbot.go lifecycle, bot setup, caches, small utils
tgbot_router.go incoming update / command / callback dispatch
tgbot_send.go outbound messaging primitives
tgbot_client.go client views, actions, subscription links
tgbot_inbound.go inbound listing / pickers
tgbot_report.go server usage, exhausted, online, backups, notifications
Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.
* refactor(client): dedupe single-field by-email setters
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.
Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.
SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.
* refactor(service): extract panel/ subpackage
Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):
user.go UserService (auth / 2FA / LDAP)
panel.go PanelService (restart / self-update) + version helpers
panel_other.go non-unix RestartPanel
panel_unix.go unix RestartPanel
api_token.go ApiTokenService
websocket.go WebSocketService
panel_test.go version/shellQuote unit tests
These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract integration/ subpackage
Move the external-provider integration leaves into web/service/integration/
(package integration):
warp.go WarpService (Cloudflare WARP)
nord.go NordService (NordVPN)
custom_geo.go CustomGeoService (custom geo asset management)
*_test.go custom_geo / panel-proxy tests
These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract tgbot/ subpackage
Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.
To support the package boundary without changing behavior:
- core exposes XrayProcess() *xray.Process so tgbot keeps calling the
exact same running-process methods it used via the package-level `p`;
- three core methods tgbot calls are exported: ClientService.checkIs-
EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
GetAllEmails (callers updated in-package);
- tgbot's embedded-field types and the few core type refs (Status,
ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.
Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.
* refactor(service): extract outbound/ subpackage
OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.
The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.
* refactor(util): move wireguard into its own subpackage
util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.
* refactor(sub): drop redundant sub prefix from filenames
Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:
subController.go -> controller.go
subService.go -> service.go
subClashService.go -> clash_service.go
subJsonService.go -> json_service.go
(+ matching _test.go files)
* refactor(controller): rename xui.go -> spa.go
XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".
* refactor: move backend packages under internal/
Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.
Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.
Couplings the Go compiler can't see, updated to the new layout:
- frontend i18n imports of web/translation (react.ts, setup.components.ts)
- vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
- Dockerfile COPY paths for web/dist and web/translation
- locale.go os.DirFS("web") disk fallback -> "internal/web"
- .gitignore and ci.yml go:embed stub for internal/web/dist
- api_docs_test.go repo-root relative walk (one level deeper)
- tools/openapigen filesystem package paths; ApiTokenView repointed to the
web/service/panel subpackage and codegen regenerated (clears a stale
type the ci.yml codegen check was failing on)
Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.
* fix(config): keep test runs from writing logs into the source tree
GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).
Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.
* docs: move subscription-template guide out of root into docs/
sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.
Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.
* fix: update stale web/ path references after the internal/ move
The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.
Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.
Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.
* refactor(web): remove the legacy /xui -> /panel redirect middleware
RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.
* build: fix .dockerignore for internal/ layout and exclude runtime dir
- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
so the stale exclude no longer matched and the locally-built dist could be
sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
binaries, certs — ~150MB) was being shipped into the build context for no
reason. Verified the pattern excludes only the directory and still keeps
x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
This commit is contained in:
@@ -0,0 +1,850 @@
|
||||
// Package database provides database initialization, migration, and management utilities
|
||||
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
const (
|
||||
DialectSQLite = "sqlite"
|
||||
DialectPostgres = "postgres"
|
||||
)
|
||||
|
||||
// IsPostgres reports whether the active connection is a PostgreSQL backend.
|
||||
func IsPostgres() bool {
|
||||
if db == nil {
|
||||
return config.GetDBKind() == "postgres"
|
||||
}
|
||||
return db.Dialector.Name() == "postgres"
|
||||
}
|
||||
|
||||
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
|
||||
func Dialect() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
}
|
||||
return db.Dialector.Name()
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
)
|
||||
|
||||
func initModels() error {
|
||||
models := []any{
|
||||
&model.User{},
|
||||
&model.Inbound{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.Setting{},
|
||||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
for _, mdl := range models {
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Error auto migrating model: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dropLegacyForeignKeys(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pruneOrphanedClientInbounds(); err != nil {
|
||||
return err
|
||||
}
|
||||
if IsPostgres() {
|
||||
if err := resyncPostgresSequences(db, models); err != nil {
|
||||
log.Printf("Error resyncing postgres sequences: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyForeignKeys() error {
|
||||
if !IsPostgres() {
|
||||
return nil
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
|
||||
log.Printf("Error dropping legacy foreign key fk_inbounds_client_stats: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneOrphanedClientInbounds() error {
|
||||
res := db.Exec("DELETE FROM client_inbounds WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
|
||||
if res.Error != nil {
|
||||
log.Printf("Error pruning orphaned client_inbounds rows: %v", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Pruned %d orphaned client_inbounds row(s)", res.RowsAffected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
// SQLite: "duplicate column name: foo"
|
||||
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
|
||||
const sqlitePrefix = "duplicate column name:"
|
||||
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
|
||||
col := strings.TrimSpace(after)
|
||||
col = strings.Trim(col, "`\"[]")
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
|
||||
// Best effort: extract the column name between the first pair of double quotes.
|
||||
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
|
||||
rest := after
|
||||
if e := strings.Index(rest, "\""); e > 0 {
|
||||
col := rest[:e]
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initUser creates a default admin user if the users table is empty.
|
||||
func initUser() error {
|
||||
empty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", 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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
|
||||
func runSeeders(isUsersEmpty bool) error {
|
||||
empty, err := isTableEmpty("history_of_seeders")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
if crypto.IsHashed(user.Password) {
|
||||
continue
|
||||
}
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||
if err := seedApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensHash") {
|
||||
if err := hashExistingApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ClientsTable") {
|
||||
if err := seedClientsFromInboundJSON(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "InboundClientsArrayFix") {
|
||||
if err := normalizeInboundClientsArray(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "InboundClientTgIdFix") {
|
||||
if err := normalizeInboundClientTgId(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "InboundClientSubIdFix") {
|
||||
if err := normalizeInboundClientSubId(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "FreedomFinalRulesReverseFix") {
|
||||
if err := normalizeFreedomFinalRules(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeInboundClientTgId() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("InboundClientTgIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mutated := false
|
||||
for i, raw := range clients {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tgRaw, present := obj["tgId"]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
v, isFloat := tgRaw.(float64)
|
||||
if isFloat && !math.IsNaN(v) && !math.IsInf(v, 0) && v == math.Trunc(v) {
|
||||
continue
|
||||
}
|
||||
obj["tgId"] = int64(0)
|
||||
clients[i] = obj
|
||||
mutated = true
|
||||
}
|
||||
if !mutated {
|
||||
continue
|
||||
}
|
||||
settings["clients"] = clients
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("InboundClientTgIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientTgIdFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeInboundClientSubId() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("InboundClientSubIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mutated := false
|
||||
for i, raw := range clients {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
existing, _ := obj["subId"].(string)
|
||||
if strings.TrimSpace(existing) != "" {
|
||||
continue
|
||||
}
|
||||
obj["subId"] = random.NumLower(16)
|
||||
clients[i] = obj
|
||||
mutated = true
|
||||
}
|
||||
if !mutated {
|
||||
continue
|
||||
}
|
||||
settings["clients"] = clients
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("InboundClientSubIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientSubIdFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeInboundClientsArray() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("InboundClientsArrayFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
raw, exists := settings["clients"]
|
||||
if !exists || raw != nil {
|
||||
continue
|
||||
}
|
||||
settings["clients"] = []any{}
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("InboundClientsArrayFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientsArrayFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeFreedomFinalRules() error {
|
||||
var setting model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesReverseFix"}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, changed, rErr := rewriteFreedomFinalRules(setting.Value)
|
||||
if rErr != nil {
|
||||
log.Printf("FreedomFinalRulesReverseFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesReverseFix"}).Error
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if changed {
|
||||
if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
|
||||
Update("value", updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesReverseFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func rewriteFreedomFinalRules(raw string) (string, bool, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw, false, nil
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return raw, false, err
|
||||
}
|
||||
outbounds, ok := cfg["outbounds"].([]any)
|
||||
if !ok {
|
||||
return raw, false, nil
|
||||
}
|
||||
changed := false
|
||||
for _, ob := range outbounds {
|
||||
obj, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if proto, _ := obj["protocol"].(string); proto != "freedom" {
|
||||
continue
|
||||
}
|
||||
settings, ok := obj["settings"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) {
|
||||
continue
|
||||
}
|
||||
settings["finalRules"] = []any{map[string]any{"action": "allow"}}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return raw, false, nil
|
||||
}
|
||||
out, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return raw, false, err
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
|
||||
func isLegacyPrivateOnlyFinalRules(v any) bool {
|
||||
rules, ok := v.([]any)
|
||||
if !ok || len(rules) != 1 {
|
||||
return false
|
||||
}
|
||||
rule, ok := rules[0].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if action, _ := rule["action"].(string); action != "allow" {
|
||||
return false
|
||||
}
|
||||
ips, ok := rule["ip"].([]any)
|
||||
if !ok || len(ips) != 1 {
|
||||
return false
|
||||
}
|
||||
if s, _ := ips[0].(string); s != "geoip:private" {
|
||||
return false
|
||||
}
|
||||
for k := range rule {
|
||||
if k != "action" && k != "ip" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
|
||||
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
|
||||
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
|
||||
// drop the key so the field falls back to its zero value.
|
||||
func normalizeClientJSONFields(obj map[string]any) {
|
||||
normalizeInt := func(key string) {
|
||||
raw, exists := obj[key]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
|
||||
if trimmed == "" {
|
||||
delete(obj, key)
|
||||
return
|
||||
}
|
||||
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
||||
obj[key] = n
|
||||
} else {
|
||||
delete(obj, key)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
|
||||
normalizeInt(k)
|
||||
}
|
||||
}
|
||||
|
||||
func seedClientsFromInboundJSON() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
byEmail := map[string]*model.ClientRecord{}
|
||||
|
||||
var existing []model.ClientRecord
|
||||
if err := tx.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range existing {
|
||||
byEmail[existing[i].Email] = &existing[i]
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
rawList, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, raw := range rawList {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalizeClientJSONFields(obj)
|
||||
blob, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var c model.Client
|
||||
if err := json.Unmarshal(blob, &c); err != nil {
|
||||
log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
|
||||
inbound.Id, err, string(blob))
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
incoming := c.ToRecord()
|
||||
|
||||
row, dup := byEmail[email]
|
||||
if !dup {
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
byEmail[email] = incoming
|
||||
row = incoming
|
||||
} else {
|
||||
conflicts := model.MergeClientRecord(row, incoming)
|
||||
for _, x := range conflicts {
|
||||
log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
|
||||
email, x.Field, x.Old, x.New, x.Kept)
|
||||
}
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
link := model.ClientInbound{
|
||||
ClientId: row.Id,
|
||||
InboundId: inbound.Id,
|
||||
FlowOverride: c.Flow,
|
||||
}
|
||||
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
|
||||
FirstOrCreate(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// seedApiTokens copies the legacy `apiToken` setting into the new
|
||||
// api_tokens table as a row named "default" so existing central panels
|
||||
// keep working after the upgrade. Idempotent — records itself in
|
||||
// history_of_seeders and only runs when api_tokens is empty.
|
||||
func seedApiTokens() error {
|
||||
empty, err := isTableEmpty("api_tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
var legacy model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
|
||||
if err == nil && legacy.Value != "" {
|
||||
row := &model.ApiToken{
|
||||
Name: "default",
|
||||
Token: legacy.Value,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
log.Printf("Error migrating legacy apiToken: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
||||
}
|
||||
|
||||
// hashExistingApiTokens replaces any plaintext token stored before tokens were
|
||||
// hashed at rest with its SHA-256 digest. Callers keep their plaintext copy
|
||||
// (used on remote nodes), so existing tokens keep authenticating; the panel
|
||||
// just can no longer reveal them. Idempotent — already-hashed rows are skipped.
|
||||
func hashExistingApiTokens() error {
|
||||
var rows []*model.ApiToken
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if crypto.IsSHA256Hex(r.Token) {
|
||||
continue
|
||||
}
|
||||
hashed := crypto.HashTokenSHA256(r.Token)
|
||||
if err := db.Model(model.ApiToken{}).Where("id = ?", r.Id).Update("token", hashed).Error; err != nil {
|
||||
log.Printf("Error hashing api token %d: %v", r.Id, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensHash"}).Error
|
||||
}
|
||||
|
||||
// isTableEmpty returns true if the named table contains zero rows.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Table(tableName).Count(&count).Error
|
||||
return count == 0, err
|
||||
}
|
||||
|
||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
|
||||
func InitDB(dbPath string) error {
|
||||
var gormLogger logger.Interface
|
||||
if config.IsDebug() {
|
||||
gormLogger = logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
gormLogger = logger.Discard
|
||||
}
|
||||
c := &gorm.Config{Logger: gormLogger, DisableForeignKeyConstraintWhenMigrating: true}
|
||||
|
||||
var err error
|
||||
switch config.GetDBKind() {
|
||||
case "postgres":
|
||||
dsn := config.GetDBDSN()
|
||||
if dsn == "" {
|
||||
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
|
||||
}
|
||||
db, err = gorm.Open(postgres.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
dir := path.Dir(dbPath)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var maxOpen, maxIdle int
|
||||
switch config.GetDBKind() {
|
||||
case "postgres":
|
||||
maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 25)
|
||||
maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 25)
|
||||
default:
|
||||
maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 8)
|
||||
maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 4)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(maxOpen)
|
||||
sqlDB.SetMaxIdleConns(maxIdle)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
|
||||
|
||||
if err := initModels(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isUsersEmpty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := initUser(); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSeeders(isUsersEmpty)
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection if it exists.
|
||||
func CloseDB() error {
|
||||
if db != nil {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns the global GORM database instance.
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||
signature := []byte("SQLite format 3\x00")
|
||||
buf := make([]byte, len(signature))
|
||||
_, err := file.ReadAt(buf, 0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return bytes.Equal(buf, signature), nil
|
||||
}
|
||||
|
||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||
// No-op on PostgreSQL (WAL there is managed by the server).
|
||||
func Checkpoint() error {
|
||||
if IsPostgres() {
|
||||
return nil
|
||||
}
|
||||
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
}
|
||||
|
||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
||||
// It does not mutate global state or run migrations.
|
||||
func ValidateSQLiteDB(dbPath string) error {
|
||||
if _, err := os.Stat(dbPath); err != nil { // file must exist
|
||||
return err
|
||||
}
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
var res string
|
||||
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if res != "ok" {
|
||||
return errors.New("sqlite integrity check failed: " + res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import "fmt"
|
||||
|
||||
func JSONClientsFromInbound() string {
|
||||
if IsPostgres() {
|
||||
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
||||
}
|
||||
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
|
||||
}
|
||||
|
||||
func JSONFieldText(expr, key string) string {
|
||||
if IsPostgres() {
|
||||
return fmt.Sprintf("(%s ->> '%s')", expr, key)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
|
||||
}
|
||||
|
||||
func GreatestExpr(a, b string) string {
|
||||
if IsPostgres() {
|
||||
return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b)
|
||||
}
|
||||
return fmt.Sprintf("MAX(%s, %s)", a, b)
|
||||
}
|
||||
|
||||
func ClientTrafficEnableMergeExpr() string {
|
||||
if IsPostgres() {
|
||||
return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
|
||||
}
|
||||
return "CASE WHEN ? THEN enable ELSE 0 END"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DumpSQLite writes a portable SQL text dump of the SQLite database at srcPath
|
||||
// to outPath. The output mirrors the `sqlite3 .dump` format (schema + data +
|
||||
// indexes wrapped in a transaction), so it can be rebuilt with RestoreSQLite or
|
||||
// loaded by the sqlite3 CLI. The source database is opened read-only in effect
|
||||
// and left untouched.
|
||||
func DumpSQLite(srcPath, outPath string) error {
|
||||
data, err := DumpSQLiteToBytes(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outPath, data, 0o644)
|
||||
}
|
||||
|
||||
// DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite
|
||||
// but returns it in memory, which the panel uses to stream a migration download.
|
||||
func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return nil, fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("PRAGMA foreign_keys=OFF;\n")
|
||||
b.WriteString("BEGIN TRANSACTION;\n")
|
||||
|
||||
// Tables in creation order, each followed by its data.
|
||||
type object struct{ name, ddl string }
|
||||
var tables []object
|
||||
rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var o object
|
||||
if err := rows.Scan(&o.name, &o.ddl); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, t := range tables {
|
||||
b.WriteString(t.ddl)
|
||||
b.WriteString(";\n")
|
||||
if err := dumpTableData(sqlDB, t.name, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// AUTOINCREMENT bookkeeping, restored verbatim like the sqlite3 CLI does.
|
||||
if sqliteTableExists(sqlDB, "sqlite_sequence") {
|
||||
b.WriteString("DELETE FROM sqlite_sequence;\n")
|
||||
if err := dumpTableData(sqlDB, "sqlite_sequence", &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes, triggers and views after the data is in place.
|
||||
rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows2.Next() {
|
||||
var ddl string
|
||||
if err := rows2.Scan(&ddl); err != nil {
|
||||
rows2.Close()
|
||||
return nil, err
|
||||
}
|
||||
b.WriteString(ddl)
|
||||
b.WriteString(";\n")
|
||||
}
|
||||
if err := rows2.Err(); err != nil {
|
||||
rows2.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
b.WriteString("COMMIT;\n")
|
||||
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
// RestoreSQLite rebuilds a SQLite database at dstPath from a SQL text dump
|
||||
// produced by DumpSQLite (or `sqlite3 .dump`). dstPath must not already exist so
|
||||
// an existing database is never clobbered silently.
|
||||
func RestoreSQLite(dumpPath, dstPath string) error {
|
||||
script, err := os.ReadFile(dumpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dstPath); err == nil {
|
||||
return fmt.Errorf("destination already exists: %s", dstPath)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// mattn/go-sqlite3 executes every statement in a multi-statement string.
|
||||
if _, err := sqlDB.Exec(string(script)); err != nil {
|
||||
sqlDB.Close()
|
||||
os.Remove(dstPath)
|
||||
return fmt.Errorf("restore failed: %w", err)
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// dumpTableData appends one INSERT statement per row of table to b.
|
||||
func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
|
||||
rows, err := db.Query(`SELECT * FROM "` + table + `"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := len(cols)
|
||||
prefix := `INSERT INTO "` + table + `" VALUES(`
|
||||
|
||||
for rows.Next() {
|
||||
vals := make([]any, n)
|
||||
ptrs := make([]any, n)
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return err
|
||||
}
|
||||
b.WriteString(prefix)
|
||||
for i, v := range vals {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(sqliteLiteral(v))
|
||||
}
|
||||
b.WriteString(");\n")
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// sqliteLiteral renders a scanned column value as a SQLite SQL literal.
|
||||
func sqliteLiteral(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return "NULL"
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||
case bool:
|
||||
if x {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case string:
|
||||
return quoteSQLiteText(x)
|
||||
case []byte:
|
||||
if utf8.Valid(x) {
|
||||
return quoteSQLiteText(string(x))
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("X'")
|
||||
for _, c := range x {
|
||||
fmt.Fprintf(&sb, "%02x", c)
|
||||
}
|
||||
sb.WriteByte('\'')
|
||||
return sb.String()
|
||||
default:
|
||||
return quoteSQLiteText(fmt.Sprintf("%v", x))
|
||||
}
|
||||
}
|
||||
|
||||
func quoteSQLiteText(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
func sqliteTableExists(db *sql.DB, name string) bool {
|
||||
var found string
|
||||
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestCopyAllModelsIntoSQLite exercises the same AutoMigrate + copyTable
|
||||
// machinery that ExportPostgresToSQLite relies on, but with a SQLite source so
|
||||
// it needs no external database. The Postgres source path uses identical gorm
|
||||
// reads (see MigrateData), so this validates the destination-side copy.
|
||||
func TestCopyAllModelsIntoSQLite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcPath := filepath.Join(dir, "src.db")
|
||||
dstPath := filepath.Join(dir, "dst.db")
|
||||
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer closeGorm(src)
|
||||
for _, m := range migrationModels() {
|
||||
if err := src.AutoMigrate(m); err != nil {
|
||||
t.Fatalf("automigrate src %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed a few rows across parent/child tables and a composite-PK table.
|
||||
if err := src.Create(&model.User{Username: "admin", Password: "x"}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := src.Create(&model.Inbound{UserId: 1, Remark: "in", Port: 443, Protocol: "vless", Tag: "inbound-443"}).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
if err := src.Create(&xray.ClientTraffic{InboundId: 1, Email: "a@b.c", Enable: true, Up: 10, Down: 20}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open dst: %v", err)
|
||||
}
|
||||
defer closeGorm(dst)
|
||||
if err := copyAllModels(src, dst); err != nil {
|
||||
t.Fatalf("copyAllModels: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
model any
|
||||
want int64
|
||||
}{
|
||||
{&model.User{}, 1},
|
||||
{&model.Inbound{}, 1},
|
||||
{&xray.ClientTraffic{}, 1},
|
||||
} {
|
||||
var got int64
|
||||
if err := dst.Model(tc.model).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count %T: %v", tc.model, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%T: got %d rows, want %d", tc.model, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Spot-check a copied value survived the round-trip.
|
||||
var ct xray.ClientTraffic
|
||||
if err := dst.Where("email = ?", "a@b.c").First(&ct).Error; err != nil {
|
||||
t.Fatalf("read back traffic: %v", err)
|
||||
}
|
||||
if ct.Up != 10 || ct.Down != 20 || !ct.Enable {
|
||||
t.Errorf("traffic mismatch: %+v", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDumpAndRestoreSQLiteRoundTrip dumps a seeded SQLite db to .dump text and
|
||||
// rebuilds it, asserting the row survives.
|
||||
func TestDumpAndRestoreSQLiteRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcPath := filepath.Join(dir, "src.db")
|
||||
dumpPath := filepath.Join(dir, "out.dump")
|
||||
dstPath := filepath.Join(dir, "rebuilt.db")
|
||||
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
if err := src.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if err := src.Create(&model.Setting{Key: "secret", Value: "o'brien \"quote\""}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if sqlDB, _ := src.DB(); sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
if err := DumpSQLite(srcPath, dumpPath); err != nil {
|
||||
t.Fatalf("DumpSQLite: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(dumpPath); err != nil || fi.Size() == 0 {
|
||||
t.Fatalf("dump missing/empty: %v", err)
|
||||
}
|
||||
if err := RestoreSQLite(dumpPath, dstPath); err != nil {
|
||||
t.Fatalf("RestoreSQLite: %v", err)
|
||||
}
|
||||
|
||||
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open dst: %v", err)
|
||||
}
|
||||
defer closeGorm(dst)
|
||||
var s model.Setting
|
||||
if err := dst.Where("key = ?", "secret").First(&s).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if s.Value != "o'brien \"quote\"" {
|
||||
t.Errorf("value mismatch after round-trip: %q", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// closeGorm closes the underlying *sql.DB so Windows can delete the temp file.
|
||||
func closeGorm(db *gorm.DB) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
if s, err := db.DB(); err == nil {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// migrationModels is the FK-aware order in which tables are created and copied
|
||||
// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
|
||||
// related tests.
|
||||
//
|
||||
// Important: When adding a new top-level model (like OutboundSubscription),
|
||||
// you must add it here **in addition to** the list in internal/database/db.go:initModels().
|
||||
// This list is used for:
|
||||
// - Creating the destination schema during cross-DB migration
|
||||
// - Truncating tables
|
||||
// - Copying data row-by-row
|
||||
// - Resyncing Postgres sequences after bulk insert
|
||||
//
|
||||
// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
|
||||
// so they do not need manual updates.
|
||||
func migrationModels() []any {
|
||||
return []any{
|
||||
&model.User{},
|
||||
&model.Setting{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
&model.Inbound{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.InboundClientIps{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateData copies every row from the configured SQLite file at srcPath into
|
||||
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
||||
// (re)created with AutoMigrate before the copy. Source data is left untouched.
|
||||
func MigrateData(srcPath, dstDSN string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||
}
|
||||
if dstDSN == "" {
|
||||
return errors.New("destination DSN is required")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
|
||||
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite source: %w", err)
|
||||
}
|
||||
srcSQL, err := src.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcSQL.Close()
|
||||
|
||||
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open postgres destination: %w", err)
|
||||
}
|
||||
dstSQL, err := dst.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstSQL.Close()
|
||||
dstSQL.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
log.Println("Creating destination schema...")
|
||||
for _, m := range migrationModels() {
|
||||
if err := dst.AutoMigrate(m); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
|
||||
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
|
||||
// client_traffics rows whose inbound was deleted. Drop it here too so copying
|
||||
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
|
||||
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
|
||||
return fmt.Errorf("drop legacy foreign key: %w", err)
|
||||
}
|
||||
|
||||
// Empty the destination tables so the migration is idempotent: a fresh
|
||||
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
|
||||
// panel start, and a partially-failed earlier run leaves rows behind. Either
|
||||
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
|
||||
// our tables (only) before copying.
|
||||
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
|
||||
return fmt.Errorf("clear destination tables: %w", err)
|
||||
}
|
||||
|
||||
totalRows := 0
|
||||
for _, m := range migrationModels() {
|
||||
n, err := copyTable(src, dst, m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
}
|
||||
totalRows += n
|
||||
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||
}
|
||||
|
||||
if err := resetPostgresSequences(dst); err != nil {
|
||||
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
|
||||
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportPostgresToSQLite copies every row from the PostgreSQL database described
|
||||
// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
|
||||
// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
|
||||
// dstPath is created/overwritten; the PostgreSQL source is left untouched.
|
||||
func ExportPostgresToSQLite(srcDSN, dstPath string) error {
|
||||
if srcDSN == "" {
|
||||
return errors.New("source DSN is required")
|
||||
}
|
||||
if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Start from an empty file so AutoMigrate creates the canonical schema.
|
||||
if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open postgres source: %w", err)
|
||||
}
|
||||
srcSQL, err := src.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcSQL.Close()
|
||||
|
||||
// No WAL: keep all data in the main file so it is complete once closed.
|
||||
dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite destination: %w", err)
|
||||
}
|
||||
dstSQL, err := dst.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstSQL.Close()
|
||||
|
||||
return copyAllModels(src, dst)
|
||||
}
|
||||
|
||||
// copyAllModels (re)creates the schema on dst and copies every migrated table
|
||||
// from src to dst in FK-safe order. src/dst may be any gorm backend.
|
||||
func copyAllModels(src, dst *gorm.DB) error {
|
||||
for _, m := range migrationModels() {
|
||||
if err := dst.AutoMigrate(m); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if _, err := copyTable(src, dst, m); err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||
const batchSize = 500
|
||||
|
||||
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
||||
|
||||
stmt := &gorm.Statement{DB: src}
|
||||
if err := stmt.Parse(mdl); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
|
||||
table := stmt.Schema.Table
|
||||
columns := stmt.Schema.DBNames
|
||||
|
||||
ctx := context.Background()
|
||||
total := 0
|
||||
for offset := 0; ; offset += batchSize {
|
||||
batchPtr := reflect.New(sliceType)
|
||||
q := src.Model(mdl).Limit(batchSize).Offset(offset)
|
||||
if order != "" {
|
||||
q = q.Order(order)
|
||||
}
|
||||
if err := q.Find(batchPtr.Interface()).Error; err != nil {
|
||||
return total, err
|
||||
}
|
||||
slice := batchPtr.Elem()
|
||||
n := slice.Len()
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
rows := make([]map[string]any, n)
|
||||
for i := 0; i < n; i++ {
|
||||
rv := reflect.Indirect(slice.Index(i))
|
||||
row := make(map[string]any, len(columns))
|
||||
for _, name := range columns {
|
||||
value, _ := stmt.Schema.FieldsByDBName[name].ValueOf(ctx, rv)
|
||||
row[name] = value
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
|
||||
if err := dst.Table(table).CreateInBatches(rows, 200).Error; err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += n
|
||||
if n < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// truncatePostgresTables empties every migrated table on dst in a single
|
||||
// statement, resetting identity sequences. CASCADE covers the inbound/client
|
||||
// foreign keys regardless of insertion order. Only the panel's own tables are
|
||||
// touched, never the rest of the schema.
|
||||
func truncatePostgresTables(dst *gorm.DB, models []any) error {
|
||||
tables := make([]string, 0, len(models))
|
||||
for _, m := range models {
|
||||
stmt := &gorm.Statement{DB: dst}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
return err
|
||||
}
|
||||
tables = append(tables, `"`+stmt.Schema.Table+`"`)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Println("Clearing destination tables...")
|
||||
return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
|
||||
}
|
||||
|
||||
// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
|
||||
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||
func resetPostgresSequences(dst *gorm.DB) error {
|
||||
return resyncPostgresSequences(dst, migrationModels())
|
||||
}
|
||||
|
||||
// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next
|
||||
// auto-increment INSERT won't collide with an existing row. Table names are
|
||||
// resolved from the models themselves (not hardcoded), so they always match the
|
||||
// migrated tables. The statement is a no-op for tables without an id sequence
|
||||
// (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
|
||||
// run both after migration and on every Postgres startup.
|
||||
func resyncPostgresSequences(db *gorm.DB, models []any) error {
|
||||
for _, m := range models {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
continue
|
||||
}
|
||||
t := stmt.Table
|
||||
// t comes from the trusted model set parsed by GORM, not user input, so
|
||||
// interpolating it as an identifier is safe. We ignore errors per-table.
|
||||
_ = db.Exec(
|
||||
`SELECT setval(pg_get_serial_sequence(?, 'id'), COALESCE((SELECT MAX(id) FROM "`+t+`"), 1), true)
|
||||
WHERE pg_get_serial_sequence(?, 'id') IS NOT NULL`,
|
||||
t, t,
|
||||
).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigrateData_CompositeKeyTableLargerThanBatch(t *testing.T) {
|
||||
dsn := os.Getenv("XUI_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
|
||||
}
|
||||
|
||||
// Seed a SQLite source with the full schema and >500 client_inbounds rows.
|
||||
srcPath := t.TempDir() + "/x-ui.db"
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if err := src.AutoMigrate(m); err != nil {
|
||||
t.Fatalf("automigrate %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
const n = 600 // > batchSize (500) so the between-batches path is exercised
|
||||
links := make([]model.ClientInbound, 0, n)
|
||||
for i := 1; i <= n; i++ {
|
||||
links = append(links, model.ClientInbound{ClientId: i, InboundId: 1})
|
||||
}
|
||||
if err := src.CreateInBatches(links, 200).Error; err != nil {
|
||||
t.Fatalf("seed client_inbounds: %v", err)
|
||||
}
|
||||
if sqlDB, err := src.DB(); err == nil {
|
||||
sqlDB.Close() // flush before MigrateData reopens the file
|
||||
}
|
||||
|
||||
// Make the test re-runnable: drop any tables from a previous run.
|
||||
dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
|
||||
t.Fatalf("drop tables: %v", err)
|
||||
}
|
||||
|
||||
if err := MigrateData(srcPath, dsn); err != nil {
|
||||
t.Fatalf("MigrateData: %v", err) // fails here before the fix
|
||||
}
|
||||
|
||||
var got int64
|
||||
if err := dst.Model(&model.ClientInbound{}).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if got != n {
|
||||
t.Fatalf("client_inbounds rows = %d, want %d", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateData_PreservesFalseDefaultedColumns(t *testing.T) {
|
||||
dsn := os.Getenv("XUI_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
|
||||
}
|
||||
|
||||
srcPath := t.TempDir() + "/x-ui.db"
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if err := src.AutoMigrate(m); err != nil {
|
||||
t.Fatalf("automigrate %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := src.Create([]*model.ClientRecord{
|
||||
{Email: "on@example.com"},
|
||||
{Email: "off@example.com"},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed clients: %v", err)
|
||||
}
|
||||
if err := src.Model(&model.ClientRecord{}).Where("email = ?", "off@example.com").
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable client: %v", err)
|
||||
}
|
||||
if err := src.Create(&model.Node{Name: "n-off", Address: "1.2.3.4", Port: 1, ApiToken: "tok"}).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
if err := src.Model(&model.Node{}).Where("name = ?", "n-off").
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable node: %v", err)
|
||||
}
|
||||
if sqlDB, err := src.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
|
||||
t.Fatalf("drop tables: %v", err)
|
||||
}
|
||||
|
||||
if err := MigrateData(srcPath, dsn); err != nil {
|
||||
t.Fatalf("MigrateData: %v", err)
|
||||
}
|
||||
|
||||
var off model.ClientRecord
|
||||
if err := dst.Where("email = ?", "off@example.com").First(&off).Error; err != nil {
|
||||
t.Fatalf("load disabled client: %v", err)
|
||||
}
|
||||
if off.Enable {
|
||||
t.Fatalf("disabled client re-enabled after migration (enable=%v)", off.Enable)
|
||||
}
|
||||
|
||||
var on model.ClientRecord
|
||||
if err := dst.Where("email = ?", "on@example.com").First(&on).Error; err != nil {
|
||||
t.Fatalf("load enabled client: %v", err)
|
||||
}
|
||||
if !on.Enable {
|
||||
t.Fatalf("enabled client wrongly disabled after migration")
|
||||
}
|
||||
|
||||
var node model.Node
|
||||
if err := dst.Where("name = ?", "n-off").First(&node).Error; err != nil {
|
||||
t.Fatalf("load node: %v", err)
|
||||
}
|
||||
if node.Enable {
|
||||
t.Fatalf("disabled node re-enabled after migration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
// Package model defines the database models and data structures used by the 3x-ui panel.
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// Protocol represents the protocol type for Xray inbounds.
|
||||
type Protocol string
|
||||
|
||||
// Protocol constants for different Xray inbound protocols.
|
||||
// Hysteria v2 is not a distinct protocol — it is plain "hysteria"
|
||||
// with streamSettings.version = 2. The share-link URI scheme
|
||||
// "hysteria2://" is independent of this and is still emitted by the
|
||||
// link generator when the stream version is 2.
|
||||
const (
|
||||
VMESS Protocol = "vmess"
|
||||
VLESS Protocol = "vless"
|
||||
Tunnel Protocol = "tunnel"
|
||||
HTTP Protocol = "http"
|
||||
Trojan Protocol = "trojan"
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
Hysteria Protocol = "hysteria"
|
||||
MTProto Protocol = "mtproto"
|
||||
)
|
||||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
type Inbound struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` // Unique identifier
|
||||
UserId int `json:"-"` // Associated user ID
|
||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
|
||||
// Xray configuration fields
|
||||
Listen string `json:"listen" form:"listen"`
|
||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
|
||||
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
|
||||
Settings string `json:"settings" form:"settings"`
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
|
||||
// OriginNodeGuid is the panelGuid of the node that physically hosts this
|
||||
// inbound, propagated up across hops (#4983). Empty for an inbound that
|
||||
// lives on this panel's own xray; set to the originating node's GUID when
|
||||
// the inbound was synced from a node (kept as-is across further hops). Lets
|
||||
// the master attribute a deeply nested inbound to the real node instead of
|
||||
// the intermediate one it was fetched through.
|
||||
OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
|
||||
|
||||
// FallbackParent is populated by the API layer when this inbound is
|
||||
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
||||
// The frontend uses it to rewrite client-share links so they advertise
|
||||
// the master's externally reachable endpoint instead of the child's
|
||||
// loopback listen. Not persisted.
|
||||
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
// FallbackParentInfo carries everything the frontend needs to rewrite a
|
||||
// child inbound's client link: where to connect (the master's address
|
||||
// and port) and which path matched on the master's fallbacks array.
|
||||
// The frontend already has the master inbound in its dbInbounds list,
|
||||
// so we only ship identifiers + the match path here.
|
||||
type FallbackParentInfo struct {
|
||||
MasterId int `json:"masterId"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
type OutboundTraffics struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Up int64 `json:"up" form:"up" gorm:"default:0"`
|
||||
Down int64 `json:"down" form:"down" gorm:"default:0"`
|
||||
Total int64 `json:"total" form:"total" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// InboundClientIps stores IP addresses associated with inbound clients for access control.
|
||||
type InboundClientIps struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
||||
Ips string `json:"ips" form:"ips"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
|
||||
// JSON-text string. Empty or unparseable storage renders as null so API
|
||||
// consumers don't have to special-case the legacy double-encoded shape.
|
||||
func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
|
||||
type alias InboundClientIps
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}{
|
||||
alias: alias(ic),
|
||||
Ips: jsonStringFieldToRaw(ic.Ips),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
|
||||
// JSON-encoded string (legacy shape), normalising back to the JSON-text the
|
||||
// column stores.
|
||||
func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
|
||||
type alias InboundClientIps
|
||||
aux := struct {
|
||||
*alias
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}{
|
||||
alias: (*alias)(ic),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
ic.Ips = jsonStringFieldFromRaw(aux.Ips)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
||||
type HistoryOfSeeders struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SeederName string `json:"seederName"`
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Token string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
|
||||
// objects rather than escaped strings, so API consumers don't need to JSON.parse
|
||||
// a string inside a string. Empty fields render as null; fields whose stored
|
||||
// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
|
||||
func (i Inbound) MarshalJSON() ([]byte, error) {
|
||||
type alias Inbound
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||
Sniffing json.RawMessage `json:"sniffing"`
|
||||
}{
|
||||
alias: alias(i),
|
||||
Settings: jsonStringFieldToRaw(i.Settings),
|
||||
StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
|
||||
Sniffing: jsonStringFieldToRaw(i.Sniffing),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
|
||||
// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
|
||||
// string (the legacy shape). Either form is normalised back to the JSON-text
|
||||
// string the DB column stores.
|
||||
func (i *Inbound) UnmarshalJSON(data []byte) error {
|
||||
type alias Inbound
|
||||
aux := struct {
|
||||
*alias
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||
Sniffing json.RawMessage `json:"sniffing"`
|
||||
}{
|
||||
alias: (*alias)(i),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
i.Settings = jsonStringFieldFromRaw(aux.Settings)
|
||||
i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
|
||||
i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonStringFieldToRaw(s string) json.RawMessage {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return json.RawMessage(trimmed)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonStringFieldFromRaw(r json.RawMessage) string {
|
||||
trimmed := bytes.TrimSpace(r)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return ""
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return string(trimmed)
|
||||
}
|
||||
|
||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
protocol := string(i.Protocol)
|
||||
settings := i.Settings
|
||||
switch i.Protocol {
|
||||
case Shadowsocks:
|
||||
if healed, ok := HealShadowsocksClientMethods(settings); ok {
|
||||
settings = healed
|
||||
}
|
||||
case VMESS:
|
||||
if stripped, ok := StripVmessClientSecurity(settings); ok {
|
||||
settings = stripped
|
||||
}
|
||||
case VLESS:
|
||||
if stripped, ok := StripVlessInboundEncryption(settings); ok {
|
||||
settings = stripped
|
||||
}
|
||||
}
|
||||
return &xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(listen),
|
||||
Port: i.Port,
|
||||
Protocol: protocol,
|
||||
Settings: json_util.RawMessage(settings),
|
||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||
Tag: i.Tag,
|
||||
Sniffing: json_util.RawMessage(i.Sniffing),
|
||||
}
|
||||
}
|
||||
|
||||
func StripVmessClientSecurity(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
clients, ok := parsed["clients"].([]any)
|
||||
if !ok {
|
||||
return settings, false
|
||||
}
|
||||
changed := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, has := cm["security"]; has {
|
||||
delete(cm, "security")
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return settings, false
|
||||
}
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
func StripVlessInboundEncryption(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
if _, has := parsed["encryption"]; !has {
|
||||
return settings, false
|
||||
}
|
||||
delete(parsed, "encryption")
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// HealShadowsocksClientMethods normalises the per-client `method` field
|
||||
// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
|
||||
// - Legacy ciphers (aes-*, chacha20-*): every client must carry a
|
||||
// per-user `method` matching the inbound's top-level method, otherwise
|
||||
// xray fails with "unsupported cipher method:".
|
||||
// - Shadowsocks 2022 (2022-blake3-*): xray's multi-user code rejects the
|
||||
// inbound with "users must have empty method" when a client carries
|
||||
// one — strip stale entries left over from a switch off a legacy
|
||||
// cipher.
|
||||
//
|
||||
// Returns the rewritten settings string and true when anything changed.
|
||||
func HealShadowsocksClientMethods(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
method, _ := parsed["method"].(string)
|
||||
clients, ok := parsed["clients"].([]any)
|
||||
if !ok {
|
||||
return settings, false
|
||||
}
|
||||
is2022 := strings.HasPrefix(method, "2022-blake3-")
|
||||
changed := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if is2022 {
|
||||
if _, hasKey := cm["method"]; hasKey {
|
||||
delete(cm, "method")
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if method == "" {
|
||||
continue
|
||||
}
|
||||
existing, _ := cm["method"].(string)
|
||||
if existing == method {
|
||||
continue
|
||||
}
|
||||
cm["method"] = method
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return settings, false
|
||||
}
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// GenerateFakeTLSSecret builds an MTProto FakeTLS secret for the given domain:
|
||||
// the "ee" FakeTLS marker, 16 random bytes, then the domain encoded as hex.
|
||||
// This single value is what mtg's config and the client tg:// link both use.
|
||||
func GenerateFakeTLSSecret(domain string) string {
|
||||
return "ee" + mtprotoRandomMiddle() + hex.EncodeToString([]byte(domain))
|
||||
}
|
||||
|
||||
func mtprotoRandomMiddle() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(fmt.Errorf("mtproto: crypto/rand read failed: %w", err))
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// mtprotoSecretMiddle returns the 16-byte random middle of an existing secret
|
||||
// when it is well-formed, otherwise a freshly generated one. Reusing the middle
|
||||
// keeps the secret stable when only the FakeTLS domain changes.
|
||||
func mtprotoSecretMiddle(secret string) string {
|
||||
s := secret
|
||||
if strings.HasPrefix(s, "ee") || strings.HasPrefix(s, "dd") {
|
||||
s = s[2:]
|
||||
}
|
||||
if len(s) >= 32 {
|
||||
mid := s[:32]
|
||||
if _, err := hex.DecodeString(mid); err == nil {
|
||||
return mid
|
||||
}
|
||||
}
|
||||
return mtprotoRandomMiddle()
|
||||
}
|
||||
|
||||
// HealMtprotoSecret normalises an mtproto inbound's settings JSON before the
|
||||
// value leaves for the mtg sidecar or a share link: it rebuilds `secret` so it
|
||||
// is always a valid FakeTLS secret whose trailing domain matches
|
||||
// `fakeTlsDomain`, generating the random middle when one is missing and
|
||||
// rewriting the domain suffix when the domain changed. Returns the rewritten
|
||||
// settings and true when anything changed.
|
||||
func HealMtprotoSecret(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
domain, _ := parsed["fakeTlsDomain"].(string)
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" {
|
||||
return settings, false
|
||||
}
|
||||
secret, _ := parsed["secret"].(string)
|
||||
expected := "ee" + mtprotoSecretMiddle(secret) + hex.EncodeToString([]byte(domain))
|
||||
if secret == expected {
|
||||
return settings, false
|
||||
}
|
||||
parsed["secret"] = expected
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// Setting stores key-value configuration settings for the 3x-ui panel.
|
||||
type Setting struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Key string `json:"key" form:"key"`
|
||||
Value string `json:"value" form:"value"`
|
||||
}
|
||||
|
||||
// Node represents a remote 3x-ui panel registered with the central panel.
|
||||
// The central panel polls each node's existing /panel/api/server/status
|
||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
|
||||
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
|
||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
|
||||
BasePath string `json:"basePath" form:"basePath" example:"/"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
||||
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
||||
|
||||
// Guid is the remote panel's stable self-identifier (its panelGuid),
|
||||
// learned from each heartbeat. It is the globally stable node identity used
|
||||
// to attribute online clients/inbounds to the physical node across a chain
|
||||
// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
|
||||
// Observed-state only — never user-edited.
|
||||
Guid string `json:"guid" gorm:"column:guid;index"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
// truthful without us having to read LastHeartbeat separately.
|
||||
Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
|
||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never
|
||||
LatencyMs int `json:"latencyMs" example:"42"`
|
||||
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||
PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
|
||||
CpuPct float64 `json:"cpuPct" example:"23.5"`
|
||||
MemPct float64 `json:"memPct" example:"45.1"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||
LastError string `json:"lastError"`
|
||||
|
||||
// XrayState and XrayError are captured from the remote node's /panel/api/server/status
|
||||
// during heartbeats. They let the central panel distinguish "panel API reachable"
|
||||
// (status=online) from "Xray core itself has failed on the node" for monitoring.
|
||||
XrayState string `json:"xrayState" gorm:"column:xray_state"`
|
||||
XrayError string `json:"xrayError" gorm:"column:xray_error"`
|
||||
|
||||
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||
|
||||
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
|
||||
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
|
||||
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
|
||||
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
|
||||
|
||||
// ParentGuid + Transitive are set only when a node is surfaced as part of a
|
||||
// node tree (#4983): direct nodes carry the master panel's own GUID, a
|
||||
// transitive sub-node carries its parent node's GUID. Transitive nodes are
|
||||
// read-only projections (Id == 0, not persisted) — never edited or deployed.
|
||||
ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
|
||||
Transitive bool `json:"transitive,omitempty" gorm:"-"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
|
||||
}
|
||||
|
||||
// NodeSummary is the read-only identity of a node as published one hop up: the
|
||||
// view a panel exposes about the nodes it directly manages, so a master can
|
||||
// surface transitive sub-nodes in a chained topology (#4983). Counts are
|
||||
// computed by the consuming master from its own per-GUID data, never trusted
|
||||
// from the child, so this carries identity/health only.
|
||||
type NodeSummary struct {
|
||||
Guid string `json:"guid"`
|
||||
ParentGuid string `json:"parentGuid"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Scheme string `json:"scheme"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
LastHeartbeat int64 `json:"lastHeartbeat"`
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
PanelVersion string `json:"panelVersion"`
|
||||
XrayVersion string `json:"xrayVersion"`
|
||||
// XrayState/XrayError forwarded so masters can surface xray failure on transitive sub-nodes too.
|
||||
XrayState string `json:"xrayState"`
|
||||
XrayError string `json:"xrayError,omitempty"`
|
||||
}
|
||||
|
||||
type CustomGeoResource struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||
Url string `json:"url" gorm:"not null"`
|
||||
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
type ClientRecord struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:''"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientRecord) TableName() string { return "clients" }
|
||||
|
||||
type ClientGroup struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientGroup) TableName() string { return "client_groups" }
|
||||
|
||||
// MarshalJSON emits the reverse column as a nested JSON object rather than an
|
||||
// escaped JSON-text string, matching the same convention Inbound uses for its
|
||||
// JSON-text columns. Empty storage renders as null.
|
||||
func (r ClientRecord) MarshalJSON() ([]byte, error) {
|
||||
type alias ClientRecord
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Reverse json.RawMessage `json:"reverse"`
|
||||
}{
|
||||
alias: alias(r),
|
||||
Reverse: jsonStringFieldToRaw(r.Reverse),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
|
||||
// JSON-encoded string (legacy shape).
|
||||
func (r *ClientRecord) UnmarshalJSON(data []byte) error {
|
||||
type alias ClientRecord
|
||||
aux := struct {
|
||||
*alias
|
||||
Reverse json.RawMessage `json:"reverse"`
|
||||
}{
|
||||
alias: (*alias)(r),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ClientInbound struct {
|
||||
ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"`
|
||||
InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
|
||||
FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||
|
||||
type InboundFallback struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
|
||||
ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
|
||||
Name string `json:"name"`
|
||||
Alpn string `json:"alpn"`
|
||||
Path string `json:"path"`
|
||||
Dest string `json:"dest"`
|
||||
Xver int `json:"xver"`
|
||||
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
|
||||
}
|
||||
|
||||
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
|
||||
|
||||
func (c *Client) ToRecord() *ClientRecord {
|
||||
rec := &ClientRecord{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
UUID: c.ID,
|
||||
Password: c.Password,
|
||||
Auth: c.Auth,
|
||||
Flow: c.Flow,
|
||||
Security: c.Security,
|
||||
LimitIP: c.LimitIP,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
Enable: c.Enable,
|
||||
TgID: c.TgID,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
Reset: c.Reset,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
if c.Reverse != nil {
|
||||
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||
rec.Reverse = string(b)
|
||||
}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *ClientRecord) ToClient() *Client {
|
||||
c := &Client{
|
||||
ID: r.UUID,
|
||||
Email: r.Email,
|
||||
SubID: r.SubID,
|
||||
Password: r.Password,
|
||||
Auth: r.Auth,
|
||||
Flow: r.Flow,
|
||||
Security: r.Security,
|
||||
LimitIP: r.LimitIP,
|
||||
TotalGB: r.TotalGB,
|
||||
ExpiryTime: r.ExpiryTime,
|
||||
Enable: r.Enable,
|
||||
TgID: r.TgID,
|
||||
Group: r.Group,
|
||||
Comment: r.Comment,
|
||||
Reset: r.Reset,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
if r.Reverse != "" {
|
||||
var rev ClientReverse
|
||||
if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
|
||||
c.Reverse = &rev
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type ClientMergeConflict struct {
|
||||
Field string
|
||||
Old any
|
||||
New any
|
||||
Kept any
|
||||
}
|
||||
|
||||
type OutboundSubscription struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Url string `json:"url" form:"url"`
|
||||
Enabled bool `json:"enabled" form:"enabled" gorm:"default:true"`
|
||||
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
|
||||
TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
|
||||
UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
|
||||
Priority int `json:"priority" form:"priority" gorm:"default:0"` // order among subscriptions in the merged outbounds (lower = earlier)
|
||||
Prepend bool `json:"prepend" form:"prepend" gorm:"default:false"` // place this subscription's outbounds before the manual template outbounds
|
||||
LastUpdated int64 `json:"lastUpdated" form:"lastUpdated"`
|
||||
LastError string `json:"lastError" form:"lastError"`
|
||||
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
|
||||
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
OutboundCount int `json:"outboundCount" gorm:"-"`
|
||||
}
|
||||
|
||||
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
|
||||
var conflicts []ClientMergeConflict
|
||||
keep := func(field string, oldV, newV, kept any) {
|
||||
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
|
||||
}
|
||||
const redacted = "<redacted>"
|
||||
keepSecret := func(field string) {
|
||||
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
|
||||
}
|
||||
|
||||
incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
|
||||
(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
|
||||
|
||||
if existing.UUID != incoming.UUID && incoming.UUID != "" {
|
||||
if incomingNewer || existing.UUID == "" {
|
||||
existing.UUID = incoming.UUID
|
||||
}
|
||||
keepSecret("uuid")
|
||||
}
|
||||
if existing.Password != incoming.Password && incoming.Password != "" {
|
||||
if incomingNewer || existing.Password == "" {
|
||||
existing.Password = incoming.Password
|
||||
keepSecret("password")
|
||||
}
|
||||
}
|
||||
if existing.Auth != incoming.Auth && incoming.Auth != "" {
|
||||
if incomingNewer || existing.Auth == "" {
|
||||
existing.Auth = incoming.Auth
|
||||
keepSecret("auth")
|
||||
}
|
||||
}
|
||||
if existing.Flow != incoming.Flow && incoming.Flow != "" {
|
||||
if incomingNewer || existing.Flow == "" {
|
||||
keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
|
||||
existing.Flow = incoming.Flow
|
||||
}
|
||||
}
|
||||
if existing.Security != incoming.Security && incoming.Security != "" {
|
||||
if incomingNewer || existing.Security == "" {
|
||||
keep("security", existing.Security, incoming.Security, incoming.Security)
|
||||
existing.Security = incoming.Security
|
||||
}
|
||||
}
|
||||
if existing.SubID != incoming.SubID && incoming.SubID != "" {
|
||||
if incomingNewer || existing.SubID == "" {
|
||||
existing.SubID = incoming.SubID
|
||||
keepSecret("subId")
|
||||
}
|
||||
}
|
||||
if existing.TotalGB != incoming.TotalGB {
|
||||
picked := existing.TotalGB
|
||||
if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
|
||||
picked = incoming.TotalGB
|
||||
}
|
||||
if picked != existing.TotalGB {
|
||||
keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
|
||||
existing.TotalGB = picked
|
||||
}
|
||||
}
|
||||
if existing.ExpiryTime != incoming.ExpiryTime {
|
||||
picked := existing.ExpiryTime
|
||||
if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
|
||||
picked = incoming.ExpiryTime
|
||||
}
|
||||
if picked != existing.ExpiryTime {
|
||||
keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
|
||||
existing.ExpiryTime = picked
|
||||
}
|
||||
}
|
||||
if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
|
||||
picked := existing.LimitIP
|
||||
if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
|
||||
picked = incoming.LimitIP
|
||||
}
|
||||
if picked != existing.LimitIP {
|
||||
keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
|
||||
existing.LimitIP = picked
|
||||
}
|
||||
}
|
||||
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
|
||||
if incomingNewer || existing.TgID == 0 {
|
||||
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
|
||||
existing.TgID = incoming.TgID
|
||||
}
|
||||
}
|
||||
if existing.Reset != incoming.Reset && incoming.Reset != 0 {
|
||||
if incomingNewer || existing.Reset == 0 {
|
||||
keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
|
||||
existing.Reset = incoming.Reset
|
||||
}
|
||||
}
|
||||
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
|
||||
if incomingNewer || existing.Reverse == "" {
|
||||
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
|
||||
existing.Reverse = incoming.Reverse
|
||||
}
|
||||
}
|
||||
if existing.Comment != incoming.Comment && incoming.Comment != "" {
|
||||
if incomingNewer || existing.Comment == "" {
|
||||
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
||||
existing.Comment = incoming.Comment
|
||||
}
|
||||
}
|
||||
if existing.Group != incoming.Group && incoming.Group != "" {
|
||||
if incomingNewer || existing.Group == "" {
|
||||
keep("group", existing.Group, incoming.Group, incoming.Group)
|
||||
existing.Group = incoming.Group
|
||||
}
|
||||
}
|
||||
if existing.Enable != incoming.Enable {
|
||||
if incoming.Enable {
|
||||
if !existing.Enable {
|
||||
keep("enable", existing.Enable, incoming.Enable, true)
|
||||
existing.Enable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
|
||||
existing.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
if incoming.UpdatedAt > existing.UpdatedAt {
|
||||
existing.UpdatedAt = incoming.UpdatedAt
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateFakeTLSSecret(t *testing.T) {
|
||||
domain := "www.cloudflare.com"
|
||||
s := GenerateFakeTLSSecret(domain)
|
||||
if !strings.HasPrefix(s, "ee") {
|
||||
t.Fatalf("secret must start with ee, got %q", s)
|
||||
}
|
||||
wantSuffix := hex.EncodeToString([]byte(domain))
|
||||
if !strings.HasSuffix(s, wantSuffix) {
|
||||
t.Fatalf("secret must end with hex(domain) %q, got %q", wantSuffix, s)
|
||||
}
|
||||
if len(s) != 2+32+len(wantSuffix) {
|
||||
t.Fatalf("unexpected secret length %d", len(s))
|
||||
}
|
||||
if _, err := hex.DecodeString(s[2:34]); err != nil {
|
||||
t.Fatalf("middle is not valid hex: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealMtprotoSecret(t *testing.T) {
|
||||
domain := "example.com"
|
||||
suffix := hex.EncodeToString([]byte(domain))
|
||||
|
||||
in := `{"fakeTlsDomain":"example.com","secret":""}`
|
||||
out, changed := HealMtprotoSecret(in)
|
||||
if !changed {
|
||||
t.Fatal("expected heal to populate an empty secret")
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("healed settings not valid json: %v", err)
|
||||
}
|
||||
got, _ := parsed["secret"].(string)
|
||||
if !strings.HasPrefix(got, "ee") || !strings.HasSuffix(got, suffix) {
|
||||
t.Fatalf("healed secret malformed: %q", got)
|
||||
}
|
||||
|
||||
if _, changed2 := HealMtprotoSecret(out); changed2 {
|
||||
t.Fatal("expected no change for an already-valid secret")
|
||||
}
|
||||
|
||||
mid := got[2:34]
|
||||
newDomain := "telegram.org"
|
||||
in3 := `{"fakeTlsDomain":"telegram.org","secret":"` + got + `"}`
|
||||
out3, changed3 := HealMtprotoSecret(in3)
|
||||
if !changed3 {
|
||||
t.Fatal("expected heal to rewrite the domain suffix")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out3), &parsed); err != nil {
|
||||
t.Fatalf("healed settings not valid json: %v", err)
|
||||
}
|
||||
got3, _ := parsed["secret"].(string)
|
||||
if got3[2:34] != mid {
|
||||
t.Fatalf("random middle should be preserved on domain change: %q vs %q", got3[2:34], mid)
|
||||
}
|
||||
if !strings.HasSuffix(got3, hex.EncodeToString([]byte(newDomain))) {
|
||||
t.Fatalf("suffix not updated for new domain: %q", got3)
|
||||
}
|
||||
|
||||
if _, changed4 := HealMtprotoSecret(`{"secret":"ee"}`); changed4 {
|
||||
t.Fatal("expected no change when fakeTlsDomain is missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
|
||||
in := Inbound{
|
||||
Id: 7,
|
||||
Protocol: VLESS,
|
||||
Port: 443,
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Sniffing: `{"enabled":true}`,
|
||||
}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||
if _, ok := parsed[field].(map[string]any); !ok {
|
||||
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(out), `"settings":"`) {
|
||||
t.Errorf("settings should not be emitted as a JSON string: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
|
||||
in := Inbound{Id: 1, Protocol: VLESS}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||
if parsed[field] != nil {
|
||||
t.Errorf("expected %s to be null, got %v", field, parsed[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "nested objects (modern)",
|
||||
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
|
||||
},
|
||||
{
|
||||
name: "JSON-encoded strings (legacy)",
|
||||
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var in Inbound
|
||||
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(in.Settings, `"decryption":"none"`) {
|
||||
t.Errorf("Settings not normalised: %q", in.Settings)
|
||||
}
|
||||
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
|
||||
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
|
||||
}
|
||||
if !strings.Contains(in.Sniffing, `"enabled":true`) {
|
||||
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
|
||||
in := Inbound{Id: 1, Settings: "not json at all"}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), `"settings":"not json at all"`) {
|
||||
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
|
||||
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
|
||||
out, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
obj, ok := parsed["reverse"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
|
||||
}
|
||||
if obj["tag"] != "vless-in" {
|
||||
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
|
||||
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
|
||||
out, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
if parsed["reverse"] != nil {
|
||||
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
|
||||
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var rec ClientRecord
|
||||
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
|
||||
t.Errorf("Reverse not normalised: %q", rec.Reverse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
|
||||
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
|
||||
out, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := parsed["ips"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
|
||||
}
|
||||
if len(arr) != 1 {
|
||||
t.Errorf("expected 1 entry, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
|
||||
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var row InboundClientIps
|
||||
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
|
||||
t.Errorf("Ips not normalised: %q", row.Ips)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type NodeClientTraffic struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
NodeId int `json:"nodeId" gorm:"uniqueIndex:idx_node_email,priority:1;not null"`
|
||||
Email string `json:"email" gorm:"uniqueIndex:idx_node_email,priority:2;not null"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
Reference in New Issue
Block a user