mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 21:22:07 +03:00
0054e671f8
* feat(tuic): implement native in-process Go TUIC v5 server - Implement native TUIC v5 protocol server on pure Go using quic-go - Bridge decrypted TCP/UDP traffic into Xray-core via loopback SOCKS5 inbound - Support full Xray routing rules (geosite/geoip) and cascading outbounds - Implement atomic per-client traffic accounting with TotalGB and ExpiryTime - Add automatic legacy cleanup for older Rust tuic-server binaries, configs, and orphaned processes - Eliminate external Rust tuic-server downloads from install/CI scripts * fix(tuic): address traffic accounting, client reload, and socket lifecycle issues * fix(service): update checkTuicSocksReverseConflict to use bindAddr for listenOverlaps * fix(tuic): resolve traffic double-accounting, UDP fragmentation, and socket lifecycle issues * feat(tuic): complete native Go integration and address audit findings - Integrate an isolated QUIC fork pinned to a specific commit - Preserve original QUIC dependencies for Xray, Hysteria and Gin - Apply BBR, CUBIC and Reno to server connections and exported client profiles - Bridge Xray BBR with correct monotonic time and congestion type conversions - Handle congestion sender recreation after PMTU changes - Update congestion control for new connections without restarting the listener - Preserve existing connections and their selected congestion controller - Apply per-inbound log levels through the shared panel logger - Add lifecycle, authentication and TCP/UDP relay events without exposing secrets - Rate-limit repeated authentication and relay warnings - Support native and QUIC UDP relay modes on the same listener - Recover UDP associations after relay worker failures - Fix TCP relay cancellation, idle shutdown and half-close handling - Close active sessions when client credentials are revoked or disabled - Track traffic by immutable client statistics IDs across email and UUID changes - Prevent ambiguous accounting and duplicate UUIDs within TUIC inbounds - Persist pending traffic in a durable shutdown journal - Replay journal batches transactionally without duplicate accounting - Report server shutdown failures through the shared logger - Preserve legacy flat and nested TUIC settings compatibility - Normalize congestion controller values consistently across backend and frontend - Preserve controller, UDP mode and SNI in client links and subscriptions - Separate client profile options from server settings in the TUIC form - Keep certificate path autofill explicit when changing client SNI - Align UDP packet size validation with protocol limits - Simplify and localize TUIC field hints and certificate autofill messages - Add controller, TCP/UDP, logging and live settings update tests - Add accounting identity, journal replay and shutdown regression tests - Add relay recovery, session revocation and legacy frontend form tests * fix(service): alias the TUIC duplicate-UUID subquery for PostgreSQL < 16 syncInboundClients runs a COUNT(*) FROM (subquery) for every client sync, whatever the protocol. PostgreSQL before 16 rejects a FROM subquery with no alias, so on the distro PostgreSQL install.sh provisions (14 on Ubuntu 22.04, 15 on Debian 12) every client add or edit failed with SQLSTATE 42601. Reproduced against postgres:15 with the new env-gated test. * fix(database): create tuic_traffic_receipts through the model migration AddTuicTrafficBatch issued CREATE TABLE IF NOT EXISTS at runtime, a schema change outside db.go. The table was invisible to allModels and migrationModels, so x-ui migrate-db dropped the receipts and a retained journal could be counted twice after a SQLite to PostgreSQL move. It is now a GORM model in both lists, and the insert uses OnConflict DoNothing. * chore(tuic): skip the ICMP-dependent relay test on Windows, drop dead collectors Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails a read there and TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure was red on every Windows run. Server.CollectTotalTraffic and Manager.CollectTraffic had no caller. * refactor(tuic): serve TUIC on apernet/quic-go instead of a personal fork The native server depended on github.com/poise52/quic-go, a personal fork of apernet/quic-go patched only to pick the congestion controller before the handshake. That put a second QUIC/TLS stack in the binary that no upstream security fix reaches. apernet/quic-go is already in the graph through xray-core and exposes SetCongestionControl, so BBR is now installed on each accepted connection with Xray's own congestion.UseBBR; the cross-module BBR adapter is gone. apernet ships New Reno as its only built-in sender, so a cubic setting is served as new_reno server-side (clients still get cubic in their profile). The test inspectors now read the sender under congestionMutex, which the post-handshake install writes under. Linux loopback, single stream through Xray: 2428 -> 3383 Mbit/s (bbr). --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
3252 lines
92 KiB
Go
3252 lines
92 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/maskcompat"
|
|
"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.NodePendingReset{},
|
|
&model.OutboundSubscription{},
|
|
&model.SubBalancer{},
|
|
&model.TuicTrafficReceipt{},
|
|
}
|
|
}
|
|
|
|
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 migrateOutboundSubscriptionUserAgentColumn() error {
|
|
migrator := db.Migrator()
|
|
if !migrator.HasTable(&model.OutboundSubscription{}) || migrator.HasColumn(&model.OutboundSubscription{}, "user_agent") {
|
|
return nil
|
|
}
|
|
return migrator.AddColumn(&model.OutboundSubscription{}, "UserAgent")
|
|
}
|
|
|
|
func migrateInboundExcludeFromSubColumn() error {
|
|
migrator := db.Migrator()
|
|
if !migrator.HasTable(&model.Inbound{}) || migrator.HasColumn(&model.Inbound{}, "exclude_from_sub") {
|
|
return nil
|
|
}
|
|
return migrator.AddColumn(&model.Inbound{}, "ExcludeFromSub")
|
|
}
|
|
|
|
func initModels() error {
|
|
if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
|
|
return err
|
|
}
|
|
if err := migrateOutboundSubscriptionUserAgentColumn(); err != nil {
|
|
return err
|
|
}
|
|
if err := migrateInboundExcludeFromSubColumn(); 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 := migrateClientResetWeekdayColumns(); 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
|
|
}
|
|
|
|
// Existing clients keep weekly renewal disabled, including nullable columns
|
|
// left by an earlier ALTER TABLE; configured nonzero weekdays are preserved.
|
|
func migrateClientResetWeekdayColumns() error {
|
|
for _, table := range []string{"clients", "client_traffics"} {
|
|
if err := db.Table(table).Where("reset_weekday IS NULL").UpdateColumn("reset_weekday", 0).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 seedMtprotoCustomShareAddrToHosts() error {
|
|
const seederName = "MtprotoCustomShareAddrToHosts"
|
|
var count int64
|
|
if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", seederName).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
var inbounds []model.Inbound
|
|
if err := tx.Where("protocol = ? AND TRIM(COALESCE(share_addr_strategy, '')) = ?", string(model.MTProto), "custom").Find(&inbounds).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, inbound := range inbounds {
|
|
if err := CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, inbound.ShareAddr); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{
|
|
"share_addr_strategy": "listen",
|
|
"share_addr": "",
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Create(&model.HistoryOfSeeders{SeederName: seederName}).Error
|
|
})
|
|
}
|
|
|
|
func CreateHostFromMtprotoCustomShareAddr(tx *gorm.DB, inboundId int, rawAddress string) error {
|
|
address := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(rawAddress), "]"), "[")
|
|
if address == "" {
|
|
return nil
|
|
}
|
|
var sameAddress []model.Host
|
|
if err := tx.Where("inbound_id = ? AND address = ? AND is_disabled = ?", inboundId, address, false).
|
|
Find(&sameAddress).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, host := range sameAddress {
|
|
if !slices.Contains(host.ExcludeFromSubTypes, "raw") {
|
|
return nil
|
|
}
|
|
}
|
|
return tx.Create(&model.Host{
|
|
GroupId: random.NumLower(16), InboundId: inboundId,
|
|
Remark: address, Address: address, Security: "same",
|
|
}).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 legacy zero defaults to 1.
|
|
// Explicit negatives are left alone so primary inbounds can sort first.
|
|
func normalizeInboundSubSortIndex() error {
|
|
res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index = 0")
|
|
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 seedRandomSubscriptionPaths() error {
|
|
settings := []model.Setting{
|
|
{Key: "subPath", Value: "/" + random.NumLower(16) + "/"},
|
|
{Key: "subJsonPath", Value: "/" + random.NumLower(16) + "/"},
|
|
{Key: "subClashPath", Value: "/" + random.NumLower(16) + "/"},
|
|
}
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
for i := range settings {
|
|
if err := tx.Where("key = ?", settings[i].Key).FirstOrCreate(&settings[i]).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskObjectsFix", "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, "UppercaseFreedomFinalRulesFix") {
|
|
if err := fixUppercaseFreedomFinalRules(); 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, "OutboundRemovedKeysFix") {
|
|
if err := migrateOutboundRemovedKeys(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !slices.Contains(seedersHistory, "FreedomDomainStrategyFix") {
|
|
if err := migrateFreedomDomainStrategy(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !slices.Contains(seedersHistory, "DNSOutboundLegacyKeysFix") {
|
|
if err := migrateDNSOutboundLegacyKeys(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !slices.Contains(seedersHistory, "DNSOutboundQTypeZeroFix") {
|
|
if err := migrateDNSOutboundQTypeZero(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !slices.Contains(seedersHistory, "WireguardDomainStrategyFix") {
|
|
if err := migrateWireguardDomainStrategy(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !slices.Contains(seedersHistory, "XdnsFinalmaskObjectsFix") {
|
|
if err := migrateXdnsFinalmaskObjects(); 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 := seedMtprotoCustomShareAddrToHosts(); 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 migrateOutboundRemovedKeys() 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: "OutboundRemovedKeysFix"}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, changed, rErr := rewriteRemovedOutboundKeys(setting.Value)
|
|
if rErr != nil {
|
|
log.Printf("OutboundRemovedKeysFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
|
return db.Create(&model.HistoryOfSeeders{SeederName: "OutboundRemovedKeysFix"}).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: "OutboundRemovedKeysFix"}).Error
|
|
})
|
|
}
|
|
|
|
// rewriteRemovedOutboundKeys moves outbound proxySettings.tag to sockopt.dialerProxy
|
|
// and drops freedom sockopt.addressPortStrategy: xray-core v26.9.8 refuses both.
|
|
func rewriteRemovedOutboundKeys(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 proxySettings, present := obj["proxySettings"]; present {
|
|
ps, _ := proxySettings.(map[string]any)
|
|
if tag, _ := ps["tag"].(string); tag != "" {
|
|
sockopt := outboundSockopt(obj, true)
|
|
if current, _ := sockopt["dialerProxy"].(string); current == "" {
|
|
sockopt["dialerProxy"] = tag
|
|
}
|
|
}
|
|
delete(obj, "proxySettings")
|
|
changed = true
|
|
}
|
|
if proto, _ := obj["protocol"].(string); strings.EqualFold(proto, "freedom") {
|
|
if sockopt := outboundSockopt(obj, false); sockopt != nil {
|
|
if _, present := sockopt["addressPortStrategy"]; present {
|
|
delete(sockopt, "addressPortStrategy")
|
|
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 outboundSockopt(obj map[string]any, create bool) map[string]any {
|
|
stream, _ := obj["streamSettings"].(map[string]any)
|
|
if stream == nil {
|
|
if !create {
|
|
return nil
|
|
}
|
|
stream = map[string]any{}
|
|
obj["streamSettings"] = stream
|
|
}
|
|
sockopt, _ := stream["sockopt"].(map[string]any)
|
|
if sockopt == nil {
|
|
if !create {
|
|
return nil
|
|
}
|
|
sockopt = map[string]any{}
|
|
stream["sockopt"] = sockopt
|
|
}
|
|
return sockopt
|
|
}
|
|
|
|
func migrateFreedomDomainStrategy() 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: "FreedomDomainStrategyFix"}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, changed, rErr := rewriteFreedomDomainStrategy(setting.Value)
|
|
if rErr != nil {
|
|
log.Printf("FreedomDomainStrategyFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
|
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).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: "FreedomDomainStrategyFix"}).Error
|
|
})
|
|
}
|
|
|
|
// rewriteFreedomDomainStrategy moves a freedom outbound's legacy strategy keys
|
|
// into sockopt.domainStrategy, the placement the core's deprecation warning names.
|
|
func rewriteFreedomDomainStrategy(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); !strings.EqualFold(proto, "freedom") {
|
|
continue
|
|
}
|
|
settings, hasSettings := obj["settings"].(map[string]any)
|
|
_, hasRoot := obj["targetStrategy"]
|
|
_, hasSettingsTarget := settings["targetStrategy"]
|
|
_, hasSettingsDomain := settings["domainStrategy"]
|
|
if !hasRoot && !hasSettingsTarget && !hasSettingsDomain {
|
|
continue
|
|
}
|
|
strategy := freedomMigratedStrategy(obj, settings)
|
|
delete(obj, "targetStrategy")
|
|
if hasSettings {
|
|
delete(settings, "targetStrategy")
|
|
delete(settings, "domainStrategy")
|
|
}
|
|
if strategy != "" {
|
|
outboundSockopt(obj, true)["domainStrategy"] = strategy
|
|
}
|
|
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
|
|
}
|
|
|
|
// freedomMigratedStrategy clones the core's own resolution order for a freedom
|
|
// outbound (infra/conf/freedom.go), returning "" when none of them holds one.
|
|
func freedomMigratedStrategy(obj, settings map[string]any) string {
|
|
if s, ok := freedomStrategyValue(obj["targetStrategy"]); ok && !strings.EqualFold(s, "asis") {
|
|
return s
|
|
}
|
|
legacy := settings["targetStrategy"]
|
|
if s, ok := legacy.(string); !ok || s == "" {
|
|
legacy = settings["domainStrategy"]
|
|
}
|
|
if s, ok := freedomStrategyValue(legacy); ok && !strings.EqualFold(s, "asis") {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// freedomStrategyValue reports a strategy the core accepts -- anything else is a
|
|
// hard load error in freedom and sockopt alike, so it cannot be migrated.
|
|
func freedomStrategyValue(value any) (string, bool) {
|
|
s, ok := value.(string)
|
|
if !ok || s == "" {
|
|
return "", false
|
|
}
|
|
if !freedomDomainStrategies[strings.ToLower(s)] {
|
|
return "", false
|
|
}
|
|
return s, true
|
|
}
|
|
|
|
var freedomDomainStrategies = map[string]bool{
|
|
"asis": true, "useip": true, "useipv4": true, "useipv6": true,
|
|
"useipv4v6": true, "useipv6v4": true, "forceip": true, "forceipv4": true,
|
|
"forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
|
|
}
|
|
|
|
func migrateWireguardDomainStrategy() 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: "WireguardDomainStrategyFix"}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, changed, rErr := rewriteWireguardDomainStrategy(setting.Value)
|
|
if rErr != nil {
|
|
log.Printf("WireguardDomainStrategyFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
|
return db.Create(&model.HistoryOfSeeders{SeederName: "WireguardDomainStrategyFix"}).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: "WireguardDomainStrategyFix"}).Error
|
|
})
|
|
}
|
|
|
|
// rewriteWireguardDomainStrategy splits the settings.domainStrategy xray-core 26.9.30 (#6771)
|
|
// ignores: sockopt.domainStrategy now picks the endpoint's family, targetStrategy the targets'.
|
|
func rewriteWireguardDomainStrategy(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); !strings.EqualFold(proto, "wireguard") {
|
|
continue
|
|
}
|
|
settings, _ := obj["settings"].(map[string]any)
|
|
legacy, hasLegacy := settings["domainStrategy"]
|
|
remoteDNS, _ := settings["remoteDNS"].([]any)
|
|
localDNS := len(remoteDNS) == 1 && remoteDNS[0] == "local"
|
|
if !hasLegacy && !localDNS {
|
|
continue
|
|
}
|
|
delete(settings, "domainStrategy")
|
|
strategy, _ := legacy.(string)
|
|
if !wireguardFamilyStrategies[strings.ToLower(strategy)] {
|
|
strategy = ""
|
|
}
|
|
if strategy != "" {
|
|
if sockopt := outboundSockopt(obj, true); !strategyIsSet(sockopt["domainStrategy"]) {
|
|
sockopt["domainStrategy"] = strategy
|
|
}
|
|
}
|
|
if localDNS {
|
|
delete(settings, "remoteDNS")
|
|
if strategy == "" {
|
|
strategy = "ForceIP"
|
|
}
|
|
}
|
|
if strategy != "" && !strategyIsSet(obj["targetStrategy"]) {
|
|
obj["targetStrategy"] = strategy
|
|
}
|
|
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
|
|
}
|
|
|
|
// wireguardFamilyStrategies are the old wireguard values that pinned an address family;
|
|
// plain ForceIP pinned none, and any other value already failed the old core's load.
|
|
var wireguardFamilyStrategies = map[string]bool{
|
|
"forceipv4": true, "forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
|
|
}
|
|
|
|
func strategyIsSet(value any) bool {
|
|
s, _ := value.(string)
|
|
return s != "" && !strings.EqualFold(s, "asis")
|
|
}
|
|
|
|
// migrateXdnsFinalmaskObjects upgrades every stored xdns mask to the object shape
|
|
// xray-core 26.9.30 requires, wherever the panel keeps a finalmask.
|
|
func migrateXdnsFinalmaskObjects() error {
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
var inbounds []model.Inbound
|
|
if err := tx.Select("id", "stream_settings").Find(&inbounds).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, inbound := range inbounds {
|
|
if updated, changed := upgradeLegacyXdnsJSON(inbound.StreamSettings, streamFinalmask, false); changed {
|
|
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
|
Update("stream_settings", updated).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
var hosts []model.Host
|
|
if err := tx.Select("id", "final_mask").Find(&hosts).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, host := range hosts {
|
|
if updated, changed := upgradeLegacyXdnsJSON(host.FinalMask, wholeFinalmask, false); changed {
|
|
if err := tx.Model(&model.Host{}).Where("id = ?", host.Id).
|
|
Update("final_mask", updated).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
var subs []model.OutboundSubscription
|
|
if err := tx.Select("id", "last_fetched_outbounds").Find(&subs).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, sub := range subs {
|
|
if updated, changed := upgradeLegacyXdnsJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false); changed {
|
|
if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id).
|
|
Update("last_fetched_outbounds", updated).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, stored := range []struct {
|
|
key string
|
|
locate func(any) []any
|
|
indent bool
|
|
}{
|
|
{"xrayTemplateConfig", templateFinalmasks, true},
|
|
{"subJsonFinalMask", wholeFinalmask, false},
|
|
} {
|
|
var setting model.Setting
|
|
err := tx.Where("key = ?", stored.key).First(&setting).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if updated, changed := upgradeLegacyXdnsJSON(setting.Value, stored.locate, stored.indent); changed {
|
|
if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key).
|
|
Update("value", updated).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return tx.Create(&model.HistoryOfSeeders{SeederName: "XdnsFinalmaskObjectsFix"}).Error
|
|
})
|
|
}
|
|
|
|
// upgradeLegacyXdnsJSON rewrites the finalmasks locate finds in one stored JSON document,
|
|
// leaving the document byte-for-byte alone when nothing in it is legacy.
|
|
func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (string, bool) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return raw, false
|
|
}
|
|
var doc any
|
|
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
|
return raw, false
|
|
}
|
|
changed := false
|
|
for _, mask := range locate(doc) {
|
|
if maskcompat.UpgradeLegacyXdns(mask) {
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return raw, false
|
|
}
|
|
var out []byte
|
|
var err error
|
|
if indent {
|
|
out, err = json.MarshalIndent(doc, "", " ")
|
|
} else {
|
|
out, err = json.Marshal(doc)
|
|
}
|
|
if err != nil {
|
|
return raw, false
|
|
}
|
|
return string(out), true
|
|
}
|
|
|
|
func wholeFinalmask(doc any) []any { return []any{doc} }
|
|
|
|
func streamFinalmask(doc any) []any {
|
|
stream, _ := doc.(map[string]any)
|
|
return []any{stream["finalmask"]}
|
|
}
|
|
|
|
func outboundListFinalmasks(doc any) []any {
|
|
list, _ := doc.([]any)
|
|
finalmasks := make([]any, 0, len(list))
|
|
for _, entry := range list {
|
|
obj, _ := entry.(map[string]any)
|
|
finalmasks = append(finalmasks, streamFinalmask(obj["streamSettings"])...)
|
|
}
|
|
return finalmasks
|
|
}
|
|
|
|
func templateFinalmasks(doc any) []any {
|
|
cfg, _ := doc.(map[string]any)
|
|
return append(outboundListFinalmasks(cfg["inbounds"]), outboundListFinalmasks(cfg["outbounds"])...)
|
|
}
|
|
|
|
// migrateDNSOutboundLegacyKeys rewrites stored dns outbounds once, because the
|
|
// core logs nonIPQuery/blockTypes as deprecated on every config load.
|
|
func migrateDNSOutboundLegacyKeys() 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: "DNSOutboundLegacyKeysFix"}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, changed, rErr := rewriteDNSOutboundLegacyKeys(setting.Value)
|
|
if rErr != nil {
|
|
log.Printf("DNSOutboundLegacyKeysFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
|
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundLegacyKeysFix"}).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: "DNSOutboundLegacyKeysFix"}).Error
|
|
})
|
|
}
|
|
|
|
// rewriteDNSOutboundLegacyKeys turns a dns outbound's legacy nonIPQuery and
|
|
// blockTypes into rules, in the order the core's legacy builder used.
|
|
func rewriteDNSOutboundLegacyKeys(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); !strings.EqualFold(proto, "dns") {
|
|
continue
|
|
}
|
|
settings, _ := obj["settings"].(map[string]any)
|
|
if settings == nil {
|
|
continue
|
|
}
|
|
nonIPQuery, hasMode := settings["nonIPQuery"]
|
|
blockTypes, hasTypes := settings["blockTypes"]
|
|
// JSON null is absent to the core, which decides on nil pointers.
|
|
hasMode = hasMode && nonIPQuery != nil
|
|
hasTypes = hasTypes && blockTypes != nil
|
|
if !hasMode && !hasTypes {
|
|
continue
|
|
}
|
|
// The core refuses legacy keys next to real rules, so existing rules win.
|
|
if rules, hasRules := settings["rules"]; !hasRules || rules == nil {
|
|
settings["rules"] = legacyDNSOutboundRules(dnsNonIPQueryMode(nonIPQuery), legacyDNSBlockTypes(blockTypes))
|
|
}
|
|
delete(settings, "nonIPQuery")
|
|
delete(settings, "blockTypes")
|
|
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
|
|
}
|
|
|
|
// dnsNonIPQueryMode reports the mode the core resolved: everything but drop and
|
|
// skip meant reject, and any other value never loaded in the first place.
|
|
func dnsNonIPQueryMode(value any) string {
|
|
mode, _ := value.(string)
|
|
mode = strings.ToLower(strings.TrimSpace(mode))
|
|
if mode == "drop" || mode == "skip" {
|
|
return mode
|
|
}
|
|
return "reject"
|
|
}
|
|
|
|
// legacyDNSBlockTypes accepts every shape the old card could save: a list, a
|
|
// bare number, or a comma-separated string, minus the qTypes the core rejects.
|
|
func legacyDNSBlockTypes(value any) []int {
|
|
items, ok := value.([]any)
|
|
if !ok && value != nil {
|
|
items = []any{value}
|
|
}
|
|
var out []int
|
|
for _, item := range items {
|
|
for _, part := range strings.Split(fmt.Sprint(item), ",") {
|
|
qType, err := strconv.Atoi(strings.TrimSpace(part))
|
|
if err != nil || qType < 0 || qType > 65535 {
|
|
continue
|
|
}
|
|
out = append(out, qType)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// legacyDNSOutboundRules mirrors the core's own legacy dns policy: the blocked
|
|
// qTypes, then the hijack, then the mode's answer for everything else.
|
|
func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
|
|
rules := make([]any, 0, 3)
|
|
if len(blockTypes) > 0 {
|
|
rule := map[string]any{"action": "drop", "qType": dnsQTypeValue(blockTypes)}
|
|
if mode == "reject" {
|
|
rule["action"] = "return"
|
|
rule["rCode"] = 5
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
rules = append(rules, map[string]any{"action": "hijack", "qType": "1,28"})
|
|
fallback := map[string]any{"action": "direct"}
|
|
switch mode {
|
|
case "reject":
|
|
fallback["action"] = "return"
|
|
fallback["rCode"] = 5
|
|
case "drop":
|
|
fallback["action"] = "drop"
|
|
}
|
|
return append(rules, fallback)
|
|
}
|
|
|
|
// dnsQTypeValue keeps a lone qType a number the way the core marshals one, except
|
|
// 0: the core drops a numeric 0, and a rule with no qTypes matches every query.
|
|
func dnsQTypeValue(blockTypes []int) any {
|
|
if len(blockTypes) == 1 && blockTypes[0] != 0 {
|
|
return blockTypes[0]
|
|
}
|
|
parts := make([]string, 0, len(blockTypes))
|
|
for _, qType := range blockTypes {
|
|
parts = append(parts, strconv.Itoa(qType))
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// migrateDNSOutboundQTypeZero repairs the numeric qType 0 that 3.8.0's legacy-keys
|
|
// seeder stored, which that seeder's own history row keeps it from revisiting.
|
|
func migrateDNSOutboundQTypeZero() 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: "DNSOutboundQTypeZeroFix"}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, changed, rErr := RewriteDNSOutboundQTypeZero(setting.Value)
|
|
if rErr != nil {
|
|
log.Printf("DNSOutboundQTypeZeroFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
|
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).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: "DNSOutboundQTypeZeroFix"}).Error
|
|
})
|
|
}
|
|
|
|
// RewriteDNSOutboundQTypeZero spells a dns rule's numeric qType 0 as "0", the one
|
|
// form the core reads as query type 0 rather than as every query.
|
|
func RewriteDNSOutboundQTypeZero(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, _ := cfg["outbounds"].([]any)
|
|
changed := false
|
|
for _, ob := range outbounds {
|
|
obj, _ := ob.(map[string]any)
|
|
if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
|
|
continue
|
|
}
|
|
settings, _ := obj["settings"].(map[string]any)
|
|
rules, _ := settings["rules"].([]any)
|
|
for _, r := range rules {
|
|
rule, _ := r.(map[string]any)
|
|
if qType, ok := rule["qType"].(float64); ok && qType == 0 {
|
|
rule["qType"] = "0"
|
|
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 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); !strings.EqualFold(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); !strings.EqualFold(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 fixUppercaseFreedomFinalRules() 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: "UppercaseFreedomFinalRulesFix"}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, changed, rErr := rewriteUppercaseFreedomFinalRules(setting.Value)
|
|
if rErr != nil {
|
|
log.Printf("UppercaseFreedomFinalRulesFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
|
return db.Create(&model.HistoryOfSeeders{SeederName: "UppercaseFreedomFinalRulesFix"}).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: "UppercaseFreedomFinalRulesFix"}).Error
|
|
})
|
|
}
|
|
|
|
// Re-runs both finalRules rewrites, because the rows of the two seeders above it
|
|
// already exist on any panel that walked past a differently spelled outbound.
|
|
func rewriteUppercaseFreedomFinalRules(raw string) (string, bool, error) {
|
|
if !hasNonLowercaseFreedomOutbound(raw) {
|
|
return raw, false, nil
|
|
}
|
|
reversed, reversedChanged, err := rewriteFreedomFinalRules(raw)
|
|
if err != nil {
|
|
return raw, false, err
|
|
}
|
|
hardened, hardenedChanged, err := rewriteFreedomFinalRulesPrivateEgress(reversed)
|
|
if err != nil {
|
|
return raw, false, err
|
|
}
|
|
if !reversedChanged && !hardenedChanged {
|
|
return raw, false, nil
|
|
}
|
|
return hardened, true, nil
|
|
}
|
|
|
|
func hasNonLowercaseFreedomOutbound(raw string) bool {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return false
|
|
}
|
|
var cfg map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
return false
|
|
}
|
|
outbounds, ok := cfg["outbounds"].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, ob := range outbounds {
|
|
obj, ok := ob.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if proto, _ := obj["protocol"].(string); strings.EqualFold(proto, "freedom") && proto != "freedom" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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 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
|
|
}
|
|
model.NormalizeLegacyClientFields(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}
|
|
|
|
// Reopening replaces the process pool; the replaced one would keep its file open.
|
|
if err := CloseDB(); err != nil {
|
|
log.Printf("close the replaced database pool: %v", err)
|
|
}
|
|
|
|
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, 0o700); 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
|
|
}
|
|
if err := restrictSQLiteFilePerms(dbPath); err != nil {
|
|
log.Printf("restrict SQLite file permissions: %v", 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 isUsersEmpty {
|
|
if err := seedRandomSubscriptionPaths(); 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)
|
|
}
|
|
|
|
// The store holds client secrets, so it and its WAL/SHM side files stay
|
|
// owner-only. Best effort: a store the panel cannot chmod still opens.
|
|
func restrictSQLiteFilePerms(dbPath string) error {
|
|
for _, name := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
|
|
if err := os.Chmod(name, 0o600); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|