Files
3x-ui/internal/database/db.go
T
DIMFLIX da01b7637d feat(sub): client-side balancers for the JSON subscription (#6243)
* feat(sub): add SubBalancer model and migration

Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up.

* feat(sub): add SubBalancer service

List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart.

* feat(sub): add SubBalancer API controller and routes

GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it.

* feat(sub): emit client-side balancers in JSON subscription

For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present.

* test(sub): cover SubBalancer service and JSON output

Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup.

* feat(sub): add sub-balancers i18n keys

pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales.

* feat(sub): add SubBalancer schema and API queries

Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key.

* feat(sub): add subscription balancers settings tab

SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled.

* test(sub): add SubBalancer form modal test

Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled).

* feat(sub): register sub-balancers in API docs and OpenAPI

Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it.

* docs: sync openapi.json with frontend

docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API.

* docs: add subscription balancers API reference

Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations.

* feat(sub): accept roundRobin balancer strategy

Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist.

* feat(sub): add roundRobin strategy label

pages.settings.subBalancers.strategyRoundRobin added to all 13 locales.

* feat(sub): expose roundRobin in balancer form

Zod strategy enum, form modal label key, and table strategy colour for roundRobin.

* docs(sub): list roundRobin in strategy description

The create/update strategy param description now mentions roundRobin alongside the other three.

* feat(sub): add subJsonObservatory setting

Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask.

* feat(sub): wire observatory config through sub controller

WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction.

* feat(sub): emit observatory conditionally with configurable probes

burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override.

* feat(sub): add subJsonObservatory to AllSetting model

Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string.

* feat(sub): add balancer observatory config card

New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults.

* fix(sub): hide save/restart header on sub-balancers tab

Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly.

* feat(sub): add observatory config i18n keys

pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales.

* feat(sub): regenerate openapi for subJsonObservatory

openapigen picks up the new AllSetting field; openapi.json synced into docs.

* feat(sub): add observatory tab to sub-balancers

Mirrors the Xray Balancers page: two tabs (Balancers + Observatory).
Wires allSetting/updateSetting into the tab and adds tabBalancers /
tabObservatory labels to all locales. The page Save header is shown
again on this tab so the observatory config can be saved.

* refactor(sub): drop observatory tab from sub-formats

Now that the observatory config lives under sub-balancers, remove the
duplicate tab plus its state and defaults from sub-formats.

* fix(sub): add missing inboundsCount i18n key

The sub-balancers table rendered the raw key path in the Inbounds
column because pages.settings.subBalancers.inboundsCount was not
defined. Added it to all 13 locales.

* test(sub): pin disabled-inbound exclusion from balancer

The balancer builds its members from the subscriber's already-filtered
entry set, so an inbound disabled for that user can never surface as a
member. Adds tests for both shapes (one of several disabled, and the
only selected one disabled).

* fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback

Three coupled defects on the balancer observatory surface, flagged in PR review:

- The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this.

- Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value.

- routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking.

Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it.

* fix(sub-balancer): localize controller toasts and reject malformed ids

Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success.

* fix(sub-balancer): enforce remark length cap server-side

The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap.

* fix(sub-balancer): exclude mtproto from balancer member picker

SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment.

* docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope

- Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only).

- Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX.

- Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror.

* style(model): trim SubBalancer comment to 2-line cap

CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3.

* fix(sub-balancer): parse enabled explicitly and preserve it on partial update

parseSubBalancerForm treated any non-"false" value as true (so "bogus"
silently enabled) and always overwrote Enabled on update, so a PATCH that
omitted the toggle reset a disabled balancer back to enabled. Parse the
field with strconv.ParseBool and return *bool: absent means "no change"
on update and "true" on create; a malformed value is rejected as 400.
Update keeps the stored Enabled when the pointer is nil.

* fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds

DelInbound cascaded hosts but left the deleted inbound id in every
sub_balancers.InboundIds, so the balancer kept emitting a member no
subscriber could resolve — a dangling outbound tag with no proxy behind it.
Strip the id inside the existing delete transaction (same shape as the hosts
cascade, #5648); with the last member gone the balancer stops emitting.

* fix(sub-balancer): return not-found when deleting a missing balancer

Delete returned the gorm result error only, which is nil when no row matched,
so the controller reported success:true for an id that never existed — a stale
UI row looked like a clean delete. Check RowsAffected and return a not-found
error on 0 so the toast reflects reality.

* style(sub): shorten leastPing/leastLoad observatory comments

The observatory-emission guard comment and its test comment ran a few
lines long; trim them to a couple of lines each without dropping the
invariant that leastPing/leastLoad require a burst observatory.

* fix(sub): validate observatory setting instead of silently dropping it

SetObservatoryConfig applied whatever survived json.Unmarshal with no checks,
so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even
unparseable JSON was either silently applied or silently ignored. Validate
each field: parse durations with time.ParseDuration, require http(s) URLs for
destination/connectivity, and log a warning naming the field and the bad value
on every fallback — including the unmarshal error, which was a quiet return.
Bad values now keep the built-in defaults instead of leaking into the emitted
burstObservatory.

* fix(sub): deduplicate burst-observatory defaults across Go and frontend

The burst-observatory ping defaults lived in three places that had drifted:
Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod
PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity
pre-check URL). Align them to one set: https probe destination, sampling 2,
and empty connectivity (skip the direct pre-check). The settings tab now
parses the stored JSON through PingConfigSchema and seeds its default from
DEFAULT_BURST_OBSERVATORY instead of carrying its own literal.

* refactor(sub): extract proxy outbounds once before the balancer loop

buildBalancerConfig unmarshalled every inbound document and re-extracted its
first outbound on each balancer, so with B balancers and N inbound docs the
same document was parsed B*N times. Pull each doc's proxy outbound in a single
pre-pass over the entries and cache it per entry; buildBalancerConfig now
clones the cached map before retagging, so one parse serves every balancer.
Output is byte-for-byte unchanged.

* fix(sub): form balancer member tags from the inbound protocol, not tcp→vless

balancerTransport derived the bal-N tag suffix from the outbound's transport
network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was
mislabelled "vless" in every client config — the tag lied about the proxy
type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless,
bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and
dedup suffix are unchanged. Update the existing tag assertions and add a vmess
case that fails under the old mapping.

* fix(sub-balancer): default strategy to random in the create form

The create-balancer form seeded strategy to 'leastLoad', but the service
validate() defaults an empty strategy to 'random' and the API docs say the
default is 'random' — so a freshly opened form showed leastLoad while saving
without touching the field silently stored random. Align the form default to
'random' so what the admin sees is what gets persisted.

* feat(api-docs): document the SubBalancer response schema

The five sub-balancer endpoints carried no responseSchema, so the API docs
page rendered them without a typed example. Add example: tags to every
SubBalancer field, allow the struct through openapigen, and point the list
(responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate
the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/.

* style(sub-balancer): drop whitespace-only separator lines, add final newline

subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as
separators between statements and had no trailing newline. Replace them with
clean empty blank lines and end each file with a newline.

* fix(i18n): translate sub-balancer toasts and observatory note

The sub-balancer toast messages (list/create/update/delete/invalidId) and
the observatory note were left in English across 11 non-English locales
(ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key
in the subBalancers block was already translated. Translate them to match the
meaning and terminology of the surrounding keys in each file; the JSON
structure and keys are unchanged.

* fix(sub-balancer): hide disabled inbounds from the member picker

The picker offered every protocol-eligible inbound regardless of its enable
flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled
member is therefore dropped from every subscriber's entries, and when it was
the balancer's only member the balancer document silently stops being emitted
— with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll
MembersDisabled already documents that backend behavior.

Filter the way the sibling client picker has since #5645: hide disabled
inbounds, but keep one that is already selected so editing an existing
balancer cannot silently drop a member.

Drop the `?? []` on the useWatch result so the new useMemo dependency stays
referentially stable.

* style(sub): trim the balancerMemberSuffix comment to the 2-line cap

Comment blocks in committed Go are capped at 2 lines; the name already carries
what the function picks, so keep only the why.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-08-23 22:34:20 +02:00

2386 lines
66 KiB
Go

package database
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"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"
"github.com/mattn/go-sqlite3"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var db *gorm.DB
var backupSQLiteTimeout = 2 * time.Minute
const (
DialectSQLite = "sqlite"
DialectPostgres = "postgres"
)
func IsPostgres() bool {
if db == nil {
return config.GetDBKind() == "postgres"
}
return db.Name() == "postgres"
}
func Dialect() string {
if db == nil {
return ""
}
return db.Name()
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
sqliteBackupDirPrefix = ".x-ui-backup-"
)
func allModels() []any {
return []any{
&model.User{},
&model.Inbound{},
&model.OutboundTraffics{},
&model.Setting{},
&model.InboundClientIps{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
&model.Node{},
&model.ApiToken{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.ClientHwid{},
&model.ClientExternalLink{},
&model.ClientGroup{},
&model.InboundFallback{},
&model.Host{},
&model.NodeClientTraffic{},
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
&model.SubBalancer{},
}
}
func migrateClientTrafficLastSubFetchColumn() error {
migrator := db.Migrator()
if !migrator.HasTable(&xray.ClientTraffic{}) || migrator.HasColumn(&xray.ClientTraffic{}, "last_sub_fetch") {
return nil
}
return migrator.AddColumn(&xray.ClientTraffic{}, "LastSubFetch")
}
func initModels() error {
if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
return err
}
models := allModels()
for _, mdl := range models {
if IsPostgres() && postgresModelSettled(mdl) {
continue
}
if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(db, 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 := dropLegacyInboundPortUnique(); err != nil {
return err
}
if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
return err
}
if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
return err
}
if err := migrateApiTokenScopeAndExpiry(); err != nil {
return err
}
if err := dropLegacyForeignKeys(); err != nil {
return err
}
if err := pruneOrphanedClientInbounds(); err != nil {
return err
}
if err := pruneOrphanedHosts(); err != nil {
return err
}
if err := normalizeInboundSubSortIndex(); err != nil {
return err
}
if err := normalizeClientExternalLinkEnable(); err != nil {
return err
}
if err := normalizeClientExternalLinkTimestamps(); err != nil {
return err
}
if err := repairOverflowedTrafficCounters(); err != nil {
return err
}
if err := dedupeInboundSettingsClients(); err != nil {
return err
}
if err := migrateLegacySocksInboundsToMixed(); err != nil {
return err
}
if err := migrateShadowsocksRemovedCiphers(); err != nil {
return err
}
if err := migrateVmessRemovedSecurities(); err != nil {
return err
}
if err := migrateTgIDIndex(); err != nil {
return err
}
if err := migrateClientTrafficResetColumns(); err != nil {
return err
}
if err := migrateSyncOrphanColumns(); err != nil {
return err
}
if err := migrateClientEmailLowerIndex(); 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 postgresModelSettled(mdl any) bool {
migrator := db.Migrator()
if !migrator.HasTable(mdl) {
return false
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(mdl); err != nil || stmt.Schema == nil {
return false
}
for _, dbName := range stmt.Schema.DBNames {
if !migrator.HasColumn(mdl, dbName) {
return false
}
}
for _, idx := range stmt.Schema.ParseIndexes() {
if !migrator.HasIndex(mdl, idx.Name) {
return false
}
}
return true
}
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
}
type sqliteIndexListRow struct {
Name string `gorm:"column:name"`
Unique int `gorm:"column:unique"`
Origin string `gorm:"column:origin"`
}
func sqliteUniquePortIndexes() (autoIndexes, explicitIndexes []string, err error) {
var list []sqliteIndexListRow
if err = db.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
return nil, nil, err
}
for _, idx := range list {
if idx.Unique != 1 {
continue
}
var cols []struct {
Name string `gorm:"column:name"`
}
if err = db.Raw(`PRAGMA index_info("` + idx.Name + `")`).Scan(&cols).Error; err != nil {
return nil, nil, err
}
if len(cols) != 1 || cols[0].Name != "port" {
continue
}
if idx.Origin == "c" {
explicitIndexes = append(explicitIndexes, idx.Name)
} else {
autoIndexes = append(autoIndexes, idx.Name)
}
}
return autoIndexes, explicitIndexes, nil
}
// dropLegacyInboundPortUnique removes the pre-multi-node UNIQUE on inbounds.port,
// which AutoMigrate never drops and which blocks cross-node port reuse on old SQLite DBs.
func dropLegacyInboundPortUnique() error {
if IsPostgres() {
return nil
}
autoIndexes, explicitIndexes, err := sqliteUniquePortIndexes()
if err != nil {
return err
}
for _, name := range explicitIndexes {
if err := db.Exec(`DROP INDEX IF EXISTS "` + name + `"`).Error; err != nil {
return err
}
}
if len(autoIndexes) == 0 {
return nil
}
log.Printf("Rebuilding inbounds table to drop the legacy UNIQUE constraint on port")
return rebuildInboundsWithoutInlineUniquePort()
}
func sqliteTableColumns(tx *gorm.DB, table string) ([]string, error) {
var rows []struct {
Name string `gorm:"column:name"`
}
if err := tx.Raw(`PRAGMA table_info("` + table + `")`).Scan(&rows).Error; err != nil {
return nil, err
}
cols := make([]string, 0, len(rows))
for _, r := range rows {
cols = append(cols, r.Name)
}
return cols, nil
}
func rebuildInboundsWithoutInlineUniquePort() error {
return db.Transaction(func(tx *gorm.DB) error {
var list []sqliteIndexListRow
if err := tx.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
return err
}
for _, idx := range list {
if idx.Origin != "c" {
continue
}
if err := tx.Exec(`DROP INDEX IF EXISTS "` + idx.Name + `"`).Error; err != nil {
return err
}
}
if err := tx.Exec(`ALTER TABLE inbounds RENAME TO inbounds_legacy_rebuild`).Error; err != nil {
return err
}
if err := tx.Migrator().CreateTable(&model.Inbound{}); err != nil {
return err
}
newCols, err := sqliteTableColumns(tx, "inbounds")
if err != nil {
return err
}
oldCols, err := sqliteTableColumns(tx, "inbounds_legacy_rebuild")
if err != nil {
return err
}
oldSet := make(map[string]struct{}, len(oldCols))
for _, c := range oldCols {
oldSet[c] = struct{}{}
}
shared := make([]string, 0, len(newCols))
for _, c := range newCols {
if _, ok := oldSet[c]; ok {
shared = append(shared, `"`+c+`"`)
}
}
colList := strings.Join(shared, ", ")
if err := tx.Exec(`INSERT INTO inbounds (` + colList + `) SELECT ` + colList + ` FROM inbounds_legacy_rebuild`).Error; err != nil {
return err
}
return tx.Exec(`DROP TABLE inbounds_legacy_rebuild`).Error
})
}
// AutoMigrate adds the columns; an older SQLite ALTER TABLE leaves them NULL,
// and a NULL traffic_reset fails every ClientRecord scan, not just the new query.
func migrateClientTrafficResetColumns() error {
if db.Migrator().HasColumn(&model.ClientRecord{}, "traffic_reset") {
if err := db.Exec("UPDATE clients SET traffic_reset = 'never' WHERE traffic_reset IS NULL").Error; err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.ClientRecord{}, "traffic_reset_day") {
if err := db.Exec("UPDATE clients SET traffic_reset_day = 1 WHERE traffic_reset_day IS NULL").Error; err != nil {
return err
}
}
return nil
}
// AutoMigrate adds the column; this only backfills the NULLs an older SQLite
// ALTER TABLE leaves behind, so the reaper's predicate never compares to NULL.
func migrateSyncOrphanColumns() error {
if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") {
return nil
}
return db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error
}
// The client identity checks match emails case-insensitively; without an
// expression index (which no GORM struct tag can declare) they seq-scan.
func migrateClientEmailLowerIndex() error {
if db.Migrator().HasIndex(&model.ClientRecord{}, "idx_clients_email_lower") {
return nil
}
return db.Exec("CREATE INDEX IF NOT EXISTS idx_clients_email_lower ON clients (LOWER(email))").Error
}
func migrateHostVerifyPeerCertByNameColumn() error {
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
return nil
}
if IsPostgres() {
var dataType string
if err := db.Raw(
`SELECT data_type FROM information_schema.columns WHERE table_name = 'hosts' AND column_name = 'verify_peer_cert_by_name'`,
).Scan(&dataType).Error; err != nil {
return err
}
if dataType != "boolean" {
return nil
}
if err := db.Exec(`ALTER TABLE hosts ALTER COLUMN verify_peer_cert_by_name DROP DEFAULT`).Error; err != nil {
return err
}
return db.Exec(`ALTER TABLE hosts ALTER COLUMN verify_peer_cert_by_name TYPE text USING ''`).Error
}
return db.Exec(`UPDATE hosts SET verify_peer_cert_by_name = '' WHERE verify_peer_cert_by_name IS NULL OR typeof(verify_peer_cert_by_name) <> 'text'`).Error
}
func seedHostsFromExternalProxy() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "HostsFromExternalProxy") {
return nil
}
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 _, err := CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "HostsFromExternalProxy"}).Error
})
}
func seedWireguardPeersToClients() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "WireguardPeersToClients") {
return nil
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", string(model.WireGuard)).Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
usedEmails := map[string]struct{}{}
var existingEmails []string
if err := tx.Model(&model.ClientRecord{}).Pluck("email", &existingEmails).Error; err != nil {
return err
}
for _, e := range existingEmails {
usedEmails[e] = struct{}{}
}
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("WireguardPeersToClients: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
peers, ok := settings["peers"].([]any)
if !ok || len(peers) == 0 {
continue
}
var linkCount int64
if err := tx.Model(&model.ClientInbound{}).Where("inbound_id = ?", inbound.Id).Count(&linkCount).Error; err != nil {
return err
}
if linkCount > 0 {
continue
}
clientObjs := make([]any, 0, len(peers))
for i, raw := range peers {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
email := wireguardPeerEmail(inbound.Remark, obj, i, usedEmails)
usedEmails[email] = struct{}{}
obj["email"] = email
if sub, _ := obj["subId"].(string); strings.TrimSpace(sub) == "" {
obj["subId"] = random.NumLower(16)
}
if _, ok := obj["enable"]; !ok {
obj["enable"] = true
}
blob, err := json.Marshal(obj)
if err != nil {
continue
}
var c model.Client
if err := json.Unmarshal(blob, &c); err != nil {
log.Printf("WireguardPeersToClients: skip peer in inbound %d: %v", inbound.Id, err)
continue
}
c.Email = email
incoming := c.ToRecord()
var row model.ClientRecord
err = tx.Where("email = ?", email).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = *incoming
} else if err != nil {
return err
} else {
model.MergeClientRecord(&row, incoming)
if err := tx.Save(&row).Error; err != nil {
return err
}
}
link := model.ClientInbound{ClientId: row.Id, InboundId: inbound.Id}
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
FirstOrCreate(&link).Error; err != nil {
return err
}
clientObjs = append(clientObjs, obj)
}
delete(settings, "peers")
settings["clients"] = clientObjs
newSettings, err := json.Marshal(settings)
if err != nil {
return err
}
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: "WireguardPeersToClients"}).Error
})
}
func wireguardPeerEmail(remark string, peer map[string]any, index int, used map[string]struct{}) string {
base := strings.TrimSpace(remark)
if base == "" {
base = "wg"
}
suffix := strconv.Itoa(index + 1)
if c, ok := peer["comment"].(string); ok && strings.TrimSpace(c) != "" {
suffix = strings.TrimSpace(c)
}
email := strings.ReplaceAll(base+"-"+suffix, " ", "-")
candidate := email
for n := 2; ; n++ {
if _, taken := used[candidate]; !taken {
return candidate
}
candidate = email + "-" + strconv.Itoa(n)
}
}
// seedMtprotoSecretsToClients converts each legacy single-secret mtproto inbound
// into a one-client inbound so MTProto joins the shared multi-client model: the
// inbound-level secret becomes the first client's FakeTLS secret, and a
// ClientRecord + client_inbounds link are created so per-client traffic, limits,
// and share links work exactly like every other protocol. One-time, self-gated
// on the "MtprotoSecretsToClients" seeder row. Mirrors seedWireguardPeersToClients.
func seedMtprotoSecretsToClients() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "MtprotoSecretsToClients") {
return nil
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", string(model.MTProto)).Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
usedEmails := map[string]struct{}{}
var existingEmails []string
if err := tx.Model(&model.ClientRecord{}).Pluck("email", &existingEmails).Error; err != nil {
return err
}
for _, e := range existingEmails {
usedEmails[e] = struct{}{}
}
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("MtprotoSecretsToClients: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
if clients, ok := settings["clients"].([]any); ok && len(clients) > 0 {
continue
}
var linkCount int64
if err := tx.Model(&model.ClientInbound{}).Where("inbound_id = ?", inbound.Id).Count(&linkCount).Error; err != nil {
return err
}
if linkCount > 0 {
continue
}
secret, _ := settings["secret"].(string)
secret = strings.TrimSpace(secret)
if secret == "" {
domain, _ := settings["fakeTlsDomain"].(string)
secret = model.GenerateFakeTLSSecret(strings.TrimSpace(domain))
}
email := mtprotoInboundClientEmail(inbound.Remark, usedEmails)
usedEmails[email] = struct{}{}
obj := map[string]any{
"email": email,
"secret": secret,
"enable": true,
"subId": random.NumLower(16),
}
c := model.Client{Email: email, Secret: secret, Enable: true, SubID: obj["subId"].(string)}
incoming := c.ToRecord()
var row model.ClientRecord
err := tx.Where("email = ?", email).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = *incoming
} else if err != nil {
return err
} else {
model.MergeClientRecord(&row, incoming)
if err := tx.Save(&row).Error; err != nil {
return err
}
}
link := model.ClientInbound{ClientId: row.Id, InboundId: inbound.Id}
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
FirstOrCreate(&link).Error; err != nil {
return err
}
delete(settings, "secret")
settings["clients"] = []any{obj}
newSettings, err := json.Marshal(settings)
if err != nil {
return err
}
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: "MtprotoSecretsToClients"}).Error
})
}
// stripMtprotoInboundSecrets removes the vestigial inbound-level `secret` from
// every mtproto inbound. seedMtprotoSecretsToClients already drops it while
// converting legacy single-secret inbounds, but inbounds that already had clients
// kept the dead field, and the old HealMtprotoSecret regenerated it on every
// save. mtg and every share link read only per-client secrets, so the
// inbound-level value is dead data that once leaked into stale, unusable links.
// One-time, self-gated on the "StripMtprotoInboundSecrets" seeder row.
func stripMtprotoInboundSecrets() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "StripMtprotoInboundSecrets") {
return nil
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", string(model.MTProto)).Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for _, inbound := range inbounds {
stripped, ok := model.StripMtprotoInboundSecret(inbound.Settings)
if !ok {
continue
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", stripped).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "StripMtprotoInboundSecrets"}).Error
})
}
// mtprotoInboundClientEmail derives a stable, unique client email for a migrated
// mtproto inbound from its remark.
func mtprotoInboundClientEmail(remark string, used map[string]struct{}) string {
base := strings.TrimSpace(remark)
if base == "" {
base = "mtproto"
}
email := strings.ReplaceAll(base, " ", "-")
candidate := email
for n := 2; ; n++ {
if _, taken := used[candidate]; !taken {
return candidate
}
candidate = email + "-" + strconv.Itoa(n)
}
}
// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array
// and inserts one Host row per entry on tx, returning the number of rows created.
// It is the shared core of both the one-time seedHostsFromExternalProxy startup
// migration and the inbound-import path: an inbound exported from a build that
// predated the hosts table carries its external proxies inline in
// streamSettings.externalProxy, and the startup migration is gated off after its
// first run, so a freshly imported inbound must be converted here instead. Blank
// or malformed streamSettings, or one without externalProxy entries, is a no-op.
func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) {
if strings.TrimSpace(streamSettings) == "" {
return 0, nil
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return 0, nil
}
eps, ok := stream["externalProxy"].([]any)
if !ok || len(eps) == 0 {
return 0, nil
}
created := 0
for i, raw := range eps {
ep, ok := raw.(map[string]any)
if !ok {
continue
}
if err := tx.Create(externalProxyEntryToHost(inboundId, i, ep)).Error; err != nil {
return created, err
}
created++
}
return created, nil
}
func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host {
security, _ := ep["forceTls"].(string)
switch security {
case "same", "tls", "none":
default:
security = "same"
}
dest, _ := ep["dest"].(string)
port := 0
if p, ok := ep["port"].(float64); ok {
port = int(p)
}
remark, _ := ep["remark"].(string)
if strings.TrimSpace(remark) == "" {
remark = "imported " + strconv.Itoa(index+1)
}
if len(remark) > 256 {
remark = remark[:256]
}
sni, _ := ep["sni"].(string)
fingerprint, _ := ep["fingerprint"].(string)
ech, _ := ep["echConfigList"].(string)
return &model.Host{
GroupId: random.NumLower(16),
InboundId: inboundId,
SortOrder: index,
Remark: remark,
Address: dest,
Port: port,
Security: security,
Sni: sni,
Fingerprint: fingerprint,
Alpn: anyToNonEmptyStrings(ep["alpn"]),
PinnedPeerCertSha256: anyToNonEmptyStrings(ep["pinnedPeerCertSha256"]),
EchConfigList: ech,
}
}
func anyToNonEmptyStrings(v any) []string {
switch t := v.(type) {
case []any:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case []string:
out := make([]string, 0, len(t))
for _, s := range t {
if s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func pruneOrphanedHosts() error {
res := db.Exec("DELETE FROM hosts WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
if res.Error != nil {
log.Printf("Error pruning orphaned hosts rows: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Pruned %d orphaned hosts row(s)", res.RowsAffected)
}
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
}
// migrateLegacySocksInboundsToMixed renames legacy socks inbounds to mixed.
// The protocol enum dropped socks in favor of mixed (identical settings shape,
// same behavior plus HTTP on the shared port), so rows predating the rename
// fail model validation — most visibly when pushed to a node, where one legacy
// inbound stalled the entire node's config and traffic sync (#5685).
func migrateLegacySocksInboundsToMixed() error {
res := db.Exec("UPDATE inbounds SET protocol = 'mixed' WHERE protocol = 'socks'")
if res.Error != nil {
log.Printf("Error migrating legacy socks inbounds to mixed: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Migrated %d legacy socks inbound(s) to mixed", res.RowsAffected)
}
return nil
}
// migrateShadowsocksRemovedCiphers rewrites shadowsocks inbounds still using
// the "none"/"plain" ciphers that xray-core v26.7.11 removed; one such row
// makes the whole generated config unbuildable and keeps xray from starting.
func migrateShadowsocksRemovedCiphers() error {
var inbounds []model.Inbound
if err := db.Where("protocol = ?", model.Shadowsocks).Find(&inbounds).Error; err != nil {
return err
}
migrated := int64(0)
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 {
continue
}
changed := false
if method, _ := settings["method"].(string); method != "" {
if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
settings["method"] = replacement
changed = true
}
}
if clients, ok := settings["clients"].([]any); ok {
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
method, _ := cm["method"].(string)
if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
cm["method"] = replacement
clients[i] = cm
changed = true
}
}
}
if !changed {
continue
}
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("migrateShadowsocksRemovedCiphers: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
migrated++
}
if migrated > 0 {
log.Printf("Rewrote removed shadowsocks cipher on %d inbound(s)", migrated)
}
return nil
}
// migrateVmessRemovedSecurities rewrites the vmess "none"/"zero" security
// values that xray-core v26.7.11 removed to "auto" (what the core now treats
// them as), on both the clients column and each vmess inbound's settings.
func migrateVmessRemovedSecurities() error {
res := db.Exec("UPDATE clients SET security = 'auto' WHERE security IN ('none', 'zero')")
if res.Error != nil {
log.Printf("Error migrating removed vmess security values on clients: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Migrated %d client(s) off removed vmess security values", res.RowsAffected)
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", model.VMESS).Find(&inbounds).Error; err != nil {
return err
}
migrated := int64(0)
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 {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if security, _ := cm["security"].(string); security == "none" || security == "zero" {
cm["security"] = "auto"
clients[i] = cm
changed = true
}
}
if !changed {
continue
}
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("migrateVmessRemovedSecurities: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
migrated++
}
if migrated > 0 {
log.Printf("Rewrote removed vmess security values on %d inbound(s)", migrated)
}
return nil
}
// migrateTgIDIndex creates an index on the clients.tg_id column so that
// lookups by Telegram ID do not require a full table scan. The index tag
// on the struct field already causes AutoMigrate to create it on new
// installations; the explicit migration ensures existing databases get it.
func migrateTgIDIndex() error {
if db.Migrator().HasIndex(&model.ClientRecord{}, "idx_clients_tg_id") {
return nil
}
return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID")
}
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
// minimum (rows written by builds that defaulted the column to 0, or by nodes
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
func normalizeInboundSubSortIndex() error {
res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
if res.Error != nil {
log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected)
}
return nil
}
// normalizeClientExternalLinkEnable keeps external-link rows written before the
// enable column existed enabled; disabled rows from newer builds stay false.
func normalizeClientExternalLinkEnable() error {
res := db.Exec("UPDATE client_external_links SET enable = ? WHERE enable IS NULL", true)
if res.Error != nil {
log.Printf("Error normalizing client external link enable: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Normalized enable on %d client external link(s)", res.RowsAffected)
}
return nil
}
// normalizeClientExternalLinkTimestamps zeroes the NULLs an older build could
// leave behind, so the sub-side expiry predicate never drops a legacy row.
func normalizeClientExternalLinkTimestamps() error {
res := db.Exec("UPDATE client_external_links SET expiry_time = 0 WHERE expiry_time IS NULL")
if res.Error != nil {
log.Printf("Error normalizing client external link expiry_time: %v", res.Error)
return res.Error
}
expiryRows := res.RowsAffected
res = db.Exec("UPDATE client_external_links SET last_fetch_at = 0 WHERE last_fetch_at IS NULL")
if res.Error != nil {
log.Printf("Error normalizing client external link last_fetch_at: %v", res.Error)
return res.Error
}
if expiryRows+res.RowsAffected > 0 {
log.Printf("Normalized timestamps on %d client external link(s)", expiryRows+res.RowsAffected)
}
return nil
}
// repairOverflowedTrafficCounters heals traffic counters that historic
// compounding bugs pushed past int64: on SQLite an overflowing INTEGER is
// silently promoted to REAL, after which the column no longer scans into the
// Go int64 field and every reader of the table fails (#5762). REAL cells are
// cast back to INTEGER (SQLite caps the cast at math.MaxInt64), then values
// are clamped into [0, TrafficMax] on both backends so the next delta cannot
// overflow again.
func repairOverflowedTrafficCounters() error {
targets := []struct {
table string
columns []string
}{
{"client_traffics", []string{"up", "down"}},
{"inbounds", []string{"up", "down"}},
{"outbound_traffics", []string{"up", "down", "total"}},
{"node_client_traffics", []string{"up", "down"}},
}
for _, target := range targets {
for _, col := range target.columns {
statements := []string{
fmt.Sprintf("UPDATE %s SET %s = %d WHERE %s > %d", target.table, col, TrafficMax, col, TrafficMax),
fmt.Sprintf("UPDATE %s SET %s = 0 WHERE %s < 0", target.table, col, col),
}
if !IsPostgres() {
statements = append([]string{
fmt.Sprintf("UPDATE %s SET %s = CAST(%s AS INTEGER) WHERE typeof(%s) = 'real'", target.table, col, col, col),
}, statements...)
}
var repaired int64
for _, statement := range statements {
res := db.Exec(statement)
if res.Error != nil {
log.Printf("Error repairing %s.%s: %v", target.table, col, res.Error)
return res.Error
}
repaired += res.RowsAffected
}
if repaired > 0 {
log.Printf("Repaired %d overflowed %s.%s value(s)", repaired, target.table, col)
}
}
}
return nil
}
// dedupeInboundSettingsClients collapses duplicate same-email entries inside
// every inbound's settings.clients array, keeping the first occurrence.
// Retried or raced multi-node client adds on older builds appended the same
// client several times (#5770), which the client lists then rendered as
// phantom duplicates. Runs on every start (idempotent, writes only changed
// rows) because a restored backup or a not-yet-upgraded node's snapshot can
// reintroduce duplicates.
func dedupeInboundSettingsClients() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
repaired := int64(0)
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 {
continue
}
clients, _ := settings["clients"].([]any)
if len(clients) < 2 {
continue
}
seen := make(map[string]struct{}, len(clients))
kept := make([]any, 0, len(clients))
for _, c := range clients {
if cm, ok := c.(map[string]any); ok {
if email, _ := cm["email"].(string); email != "" {
key := strings.ToLower(email)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
}
}
kept = append(kept, c)
}
if len(kept) == len(clients) {
continue
}
settings["clients"] = kept
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("dedupeInboundSettingsClients: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
repaired++
}
if repaired > 0 {
log.Printf("Removed duplicate client entries from %d inbound(s)", repaired)
}
return nil
}
func isIgnorableDuplicateColumnErr(gdb *gorm.DB, err error, mdl any) bool {
if err == nil {
return false
}
errMsg := strings.ToLower(err.Error())
const sqlitePrefix = "duplicate column name:"
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
col := strings.TrimSpace(after)
col = strings.Trim(col, "`\"[]")
return col != "" && gdb != nil && gdb.Migrator().HasColumn(mdl, col)
}
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
rest := after
if e := strings.Index(rest, "\""); e > 0 {
col := rest[:e]
return col != "" && gdb != nil && gdb.Migrator().HasColumn(mdl, col)
}
}
}
return false
}
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
}
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", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
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, "InboundClientTgIdFix2") {
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
}
}
if !slices.Contains(seedersHistory, "FreedomFinalRulesPrivateEgressBlock") {
if err := hardenFreedomFinalRules(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "InboundRealityFinalmaskTcpStrip") {
if err := stripRealityFinalmaskTcp(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
if err := clearLegacyProxySettings(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
if err := seedNodeInboundsAdopted(); err != nil {
return err
}
}
if err := seedHostsFromExternalProxy(); err != nil {
return err
}
if err := resetIpLimitsWithoutFail2ban(); err != nil {
return err
}
if err := seedWireguardPeersToClients(); err != nil {
return err
}
if err := backfillEmptyHostGroupIds(); err != nil {
return err
}
// Self-gated on the "MtprotoSecretsToClients" row.
if err := seedMtprotoSecretsToClients(); err != nil {
return err
}
// Self-gated on the "StripMtprotoInboundSecrets" row. Must run after the
// seeder above so legacy single-secret inbounds are first converted to a
// client (which preserves the secret) before the inbound-level copy is
// dropped from every mtproto inbound.
if err := stripMtprotoInboundSecrets(); err != nil {
return err
}
// Idempotent, not seeder-gated: bad values can re-enter via a restored
// backup, so re-check on every start.
return normalizeSettingPaths()
}
// seedNodeInboundsAdopted keeps the pre-existing reconcile behavior for nodes
// that were already syncing before the inbounds_adopted_at gate was introduced.
func seedNodeInboundsAdopted() error {
if err := db.Model(&model.Node{}).
Where("inbounds_adopted_at = 0").
Update("inbounds_adopted_at", time.Now().Unix()).Error; err != nil {
return err
}
return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
}
// backfillEmptyHostGroupIds is idempotent and not seeder-gated: builds that
// predate group ids on the inbound-import path (and restored backups) can
// re-introduce hosts rows with an empty group_id, and such rows render as a
// synthetic fallback_<id> group the update/delete API cannot address, so
// re-check on every start.
func backfillEmptyHostGroupIds() error {
var hosts []*model.Host
if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil {
return err
}
if len(hosts) == 0 {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
for _, h := range hosts {
if err := tx.Model(h).Update("group_id", random.NumLower(16)).Error; err != nil {
return err
}
}
return nil
})
}
func resetIpLimitsWithoutFail2ban() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "ResetIpLimitNoFail2ban") {
return nil
}
state, probeErr := fail2banEnforcementState()
if state == fail2banEnforcing {
return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
}
if state == fail2banUnknown {
log.Printf("ResetIpLimitNoFail2ban: fail2ban-client present but not runnable (%v); keeping configured IP limits, will retry next start", probeErr)
return nil
}
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("ResetIpLimitNoFail2ban: 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
}
v, present := obj["limitIp"]
if !present {
continue
}
if n, isNum := v.(float64); isNum && n == 0 {
continue
}
obj["limitIp"] = 0
clients[i] = obj
mutated = true
}
if !mutated {
continue
}
settings["clients"] = clients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("ResetIpLimitNoFail2ban: 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
}
}
if err := tx.Model(&model.ClientRecord{}).Where("limit_ip <> ?", 0).
Update("limit_ip", 0).Error; err != nil {
return err
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
})
}
type fail2banState int
const (
fail2banEnforcing fail2banState = iota
fail2banAbsent
fail2banUnknown
)
// fail2banEnforcementState separates "fail2ban is not installed" from "the probe
// itself failed", so a transient failure never drives an irreversible cleanup.
func fail2banEnforcementState() (fail2banState, error) {
if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
return fail2banAbsent, nil
}
if runtime.GOOS == "windows" {
return fail2banAbsent, nil
}
if _, err := exec.LookPath("fail2ban-client"); err != nil {
return fail2banAbsent, nil
}
if err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run(); err != nil {
return fail2banUnknown, err
}
return fail2banEnforcing, nil
}
func clearLegacyProxySettings() error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("key IN ?", []string{"panelProxy", "tgBotProxy"}).
Delete(&model.Setting{}).Error; err != nil {
return err
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "LegacyProxySettingsCleanup"}).Error
})
}
func normalizeSettingPaths() error {
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
var rows []model.Setting
if err := db.Where("key IN ?", pathKeys).Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
fixed := row.Value
if !strings.HasPrefix(fixed, "/") {
fixed = "/" + fixed
}
if !strings.HasSuffix(fixed, "/") {
fixed += "/"
}
if fixed == row.Value {
continue
}
if err := db.Model(&model.Setting{}).Where("id = ?", row.Id).
Update("value", fixed).Error; 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)
if s, isStr := tgRaw.(string); isStr {
if id, err := strconv.ParseInt(strings.ReplaceAll(strings.TrimSpace(s), " ", ""), 10, 64); err == nil {
obj["tgId"] = id
}
}
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: "InboundClientTgIdFix2"}).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
}
func isUnrestrictedFreedomFinalRules(v any, present bool) bool {
if !present || v == nil {
return true
}
rules, ok := v.([]any)
return ok && len(rules) == 0
}
func hardenFreedomFinalRules() 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: "FreedomFinalRulesPrivateEgressBlock"}).Error
}
if err != nil {
return err
}
updated, changed, rErr := rewriteFreedomFinalRulesPrivateEgress(setting.Value)
if rErr != nil {
log.Printf("FreedomFinalRulesPrivateEgressBlock: skip (invalid xrayTemplateConfig json): %v", rErr)
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesPrivateEgressBlock"}).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: "FreedomFinalRulesPrivateEgressBlock"}).Error
})
}
func rewriteFreedomFinalRulesPrivateEgress(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
}
finalRules, present := settings["finalRules"]
if !isUnrestrictedFreedomFinalRules(finalRules, present) &&
!isAllowOnlyFinalRules(finalRules) &&
!isLegacyPrivateOnlyFinalRules(finalRules) {
continue
}
settings["finalRules"] = []any{
map[string]any{"action": "block", "ip": []any{"geoip:private"}},
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 stripRealityFinalmaskTcp() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for i := range inbounds {
updated, changed := stripRealityFinalmaskTcpFromStream(inbounds[i].StreamSettings)
if !changed {
continue
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbounds[i].Id).
Update("stream_settings", updated).Error; err != nil {
return err
}
log.Printf("InboundRealityFinalmaskTcpStrip: removed finalmask.tcp from REALITY inbound %d (%s)", inbounds[i].Id, inbounds[i].Tag)
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundRealityFinalmaskTcpStrip"}).Error
})
}
func stripRealityFinalmaskTcpFromStream(raw string) (string, bool) {
if strings.TrimSpace(raw) == "" {
return raw, false
}
var stream map[string]any
if err := json.Unmarshal([]byte(raw), &stream); err != nil {
return raw, false
}
if sec, _ := stream["security"].(string); sec != "reality" {
return raw, false
}
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return raw, false
}
if tcp, _ := finalmask["tcp"].([]any); len(tcp) == 0 {
return raw, false
}
delete(finalmask, "tcp")
if len(finalmask) == 0 {
delete(stream, "finalmask")
}
out, err := json.Marshal(stream)
if err != nil {
return raw, false
}
return string(out), true
}
func isAllowOnlyFinalRules(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
}
for k := range rule {
if k != "action" {
return false
}
}
return true
}
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
})
}
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
}
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
}
func isTableEmpty(tableName string) (bool, error) {
var count int64
err := db.Table(tableName).Count(&count).Error
return count == 0, err
}
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 = openPostgresWithRetry(dsn, c)
if err != nil {
return err
}
default:
dir := path.Dir(dbPath)
if err = os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
log.Printf("clean SQLite backup directories: %v", err)
}
sync := sqliteSynchronous()
journal := sqliteJournalMode()
dsn := dbPath + "?_journal_mode=" + journal + "&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c)
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
pragmas := []string{
"PRAGMA journal_mode=" + journal,
"PRAGMA busy_timeout=10000",
"PRAGMA synchronous=" + sync,
fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024),
fmt.Sprintf("PRAGMA mmap_size=%d", int64(envInt("XUI_DB_MMAP_MB", 256))*1024*1024),
"PRAGMA temp_store=MEMORY",
}
for _, p := range pragmas {
if _, err := sqlDB.ExecContext(context.Background(), p); 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 normalizeApiTokenCreatedAtSeconds() error {
return db.Model(&model.ApiToken{}).
Where("created_at >= ?", model.ApiTokenUnixMillisecondsThreshold).
UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
}
func migrateApiTokenScopeAndExpiry() error {
m := db.Migrator()
if !m.HasColumn(&model.ApiToken{}, "Scope") {
if err := m.AddColumn(&model.ApiToken{}, "Scope"); err != nil {
return err
}
}
if !m.HasColumn(&model.ApiToken{}, "ExpiresAt") {
if err := m.AddColumn(&model.ApiToken{}, "ExpiresAt"); err != nil {
return err
}
}
return db.Model(&model.ApiToken{}).Where("scope IS NULL OR TRIM(scope) = ''").
Updates(map[string]any{"scope": model.ApiScopeAdmin, "expires_at": 0}).Error
}
// openPostgresWithRetry retries the initial PostgreSQL connection with
// backoff so a database that starts slower than the panel (or drops out
// briefly) does not immediately kill the process and trip systemd's
// restart loop. Every failed attempt logs the real driver error, which
// used to be buried behind a generic startup failure.
func openPostgresWithRetry(dsn string, c *gorm.Config) (*gorm.DB, error) {
delays := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second, 30 * time.Second}
var lastErr error
for i, delay := range delays {
if delay > 0 {
time.Sleep(delay)
}
conn, err := gorm.Open(postgres.Open(dsn), c)
if err == nil {
if i > 0 {
log.Printf("postgres connection established on attempt %d/%d", i+1, len(delays))
}
return conn, nil
}
lastErr = err
log.Printf("postgres connection attempt %d/%d failed: %v", i+1, len(delays), err)
}
return nil, fmt.Errorf("postgres unreachable after %d attempts: %w", len(delays), lastErr)
}
func sqliteJournalMode() string {
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_JOURNAL_MODE"))) {
case "DELETE":
return "DELETE"
default:
return "WAL"
}
}
func backupSQLiteStepPages() int {
if sqliteJournalMode() == "DELETE" {
return 128
}
return -1
}
func cleanupSQLiteBackupDirs(dir string) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), sqliteBackupDirPrefix) {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func sqliteSynchronous() string {
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
case "OFF":
return "OFF"
case "NORMAL":
return "NORMAL"
case "EXTRA":
return "EXTRA"
default:
return "FULL"
}
}
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
}
func CloseDB() error {
if db != nil {
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
func GetDB() *gorm.DB {
return db
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
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
}
func BackupSQLite(dstPath string) (err error) {
if IsPostgres() {
return errors.New("sqlite backup is unavailable for PostgreSQL")
}
if db == nil {
return errors.New("database is not initialized")
}
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
defer func() {
if err != nil {
_ = os.Remove(dstPath)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
defer cancel()
sourceDB, err := db.DB()
if err != nil {
return err
}
sourceConn, err := sourceDB.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationDB, err := sql.Open("sqlite3", dstPath)
if err != nil {
return err
}
defer destinationDB.Close()
destinationConn, err := destinationDB.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()
return sourceConn.Raw(func(sourceDriver any) error {
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
}
return destinationConn.Raw(func(destinationDriver any) error {
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
}
backup, err := destination.Backup("main", source, "main")
if err != nil {
return err
}
finished := false
defer func() {
if !finished {
_ = backup.Finish()
}
}()
for {
done, err := backup.Step(backupSQLiteStepPages())
if err != nil {
return err
}
if done {
finished = true
return backup.Finish()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil {
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
}