mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 20:37:18 +00:00
da01b7637d
* 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>
2386 lines
66 KiB
Go
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
|
|
}
|