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>
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// migrationModels is the FK-aware order in which tables are created and copied
|
|
// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
|
|
// related tests.
|
|
//
|
|
// Important: When adding a new top-level model (like OutboundSubscription),
|
|
// you must add it here **in addition to** allModels() in internal/database/db.go;
|
|
// TestMigrationModelsMatchPanelModels fails when the two lists drift apart.
|
|
// This list is used for:
|
|
// - Creating the destination schema during cross-DB migration
|
|
// - Truncating tables
|
|
// - Copying data row-by-row
|
|
// - Resyncing Postgres sequences after bulk insert
|
|
//
|
|
// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
|
|
// so they do not need manual updates.
|
|
func migrationModels() []any {
|
|
return []any{
|
|
&model.User{},
|
|
&model.Setting{},
|
|
&model.HistoryOfSeeders{},
|
|
&model.Node{},
|
|
&model.ApiToken{},
|
|
&model.Inbound{},
|
|
&xray.ClientTraffic{},
|
|
&model.OutboundTraffics{},
|
|
&model.InboundClientIps{},
|
|
&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{},
|
|
}
|
|
}
|
|
|
|
// MigrateData copies every row from the configured SQLite file at srcPath into
|
|
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
|
// (re)created with AutoMigrate; truncate and copy then run in one transaction,
|
|
// so a failed migration leaves the destination data unchanged. Source data is
|
|
// left untouched.
|
|
func MigrateData(srcPath, dstDSN string) error {
|
|
if _, err := os.Stat(srcPath); err != nil {
|
|
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
|
}
|
|
if dstDSN == "" {
|
|
return errors.New("destination DSN is required")
|
|
}
|
|
|
|
if err := os.MkdirAll(path.Dir(srcPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
|
|
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
|
if err != nil {
|
|
return fmt.Errorf("open sqlite source: %w", err)
|
|
}
|
|
srcSQL, err := src.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcSQL.Close()
|
|
|
|
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
|
|
if err != nil {
|
|
return fmt.Errorf("open postgres destination: %w", err)
|
|
}
|
|
dstSQL, err := dst.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstSQL.Close()
|
|
dstSQL.SetConnMaxLifetime(time.Hour)
|
|
|
|
log.Println("Creating destination schema...")
|
|
for _, m := range migrationModels() {
|
|
if err := dst.AutoMigrate(m); err != nil {
|
|
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
|
}
|
|
}
|
|
|
|
totalRows := 0
|
|
txErr := dst.Transaction(func(tx *gorm.DB) error {
|
|
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
|
|
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
|
|
// client_traffics rows whose inbound was deleted. Drop it here too so copying
|
|
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
|
|
if err := tx.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
|
|
return fmt.Errorf("drop legacy foreign key: %w", err)
|
|
}
|
|
|
|
// Empty the destination tables before copying: a fresh PostgreSQL DB
|
|
// already holds an auto-seeded admin (id=1) from any prior panel start,
|
|
// so a plain INSERT with explicit ids would collide on users_pkey. Only
|
|
// the panel's own tables are cleared, and a failure anywhere in this
|
|
// transaction rolls the clear back with everything else.
|
|
if err := truncatePostgresTables(tx, migrationModels()); err != nil {
|
|
return fmt.Errorf("clear destination tables: %w", err)
|
|
}
|
|
|
|
for _, m := range migrationModels() {
|
|
n, err := copyTable(src, tx, m)
|
|
if err != nil {
|
|
return fmt.Errorf("copy %T: %w", m, err)
|
|
}
|
|
totalRows += n
|
|
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
// setval is never rolled back by PostgreSQL, so sequences are resynced only
|
|
// after the transaction has committed.
|
|
if err := resetPostgresSequences(dst); err != nil {
|
|
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
|
}
|
|
|
|
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
|
|
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
|
|
return nil
|
|
}
|
|
|
|
// ExportPostgresToSQLite copies every row from the PostgreSQL database described
|
|
// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
|
|
// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
|
|
// dstPath is created/overwritten; the PostgreSQL source is left untouched.
|
|
func ExportPostgresToSQLite(srcDSN, dstPath string) error {
|
|
if srcDSN == "" {
|
|
return errors.New("source DSN is required")
|
|
}
|
|
if err := os.MkdirAll(path.Dir(dstPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Start from an empty file so AutoMigrate creates the canonical schema.
|
|
if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
|
if err != nil {
|
|
return fmt.Errorf("open postgres source: %w", err)
|
|
}
|
|
srcSQL, err := src.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcSQL.Close()
|
|
|
|
// No WAL: keep all data in the main file so it is complete once closed.
|
|
dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
|
|
if err != nil {
|
|
return fmt.Errorf("open sqlite destination: %w", err)
|
|
}
|
|
dstSQL, err := dst.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstSQL.Close()
|
|
|
|
return copyAllModels(src, dst)
|
|
}
|
|
|
|
// copyAllModels (re)creates the schema on dst and copies every migrated table
|
|
// from src to dst in FK-safe order. src/dst may be any gorm backend.
|
|
func copyAllModels(src, dst *gorm.DB) error {
|
|
for _, m := range migrationModels() {
|
|
if err := dst.AutoMigrate(m); err != nil {
|
|
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
|
}
|
|
}
|
|
for _, m := range migrationModels() {
|
|
if _, err := copyTable(src, dst, m); err != nil {
|
|
return fmt.Errorf("copy %T: %w", m, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
|
const batchSize = 500
|
|
|
|
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
|
|
|
stmt := &gorm.Statement{DB: src}
|
|
if err := stmt.Parse(mdl); err != nil {
|
|
return 0, err
|
|
}
|
|
order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
|
|
table := stmt.Schema.Table
|
|
columns := stmt.Schema.DBNames
|
|
|
|
ctx := context.Background()
|
|
total := 0
|
|
for offset := 0; ; offset += batchSize {
|
|
batchPtr := reflect.New(sliceType)
|
|
q := src.Model(mdl).Limit(batchSize).Offset(offset)
|
|
if order != "" {
|
|
q = q.Order(order)
|
|
}
|
|
if err := q.Find(batchPtr.Interface()).Error; err != nil {
|
|
return total, err
|
|
}
|
|
slice := batchPtr.Elem()
|
|
n := slice.Len()
|
|
if n == 0 {
|
|
break
|
|
}
|
|
|
|
rows := make([]map[string]any, n)
|
|
for i := range n {
|
|
rv := reflect.Indirect(slice.Index(i))
|
|
row := make(map[string]any, len(columns))
|
|
for _, name := range columns {
|
|
value, _ := stmt.Schema.FieldsByDBName[name].ValueOf(ctx, rv)
|
|
row[name] = value
|
|
}
|
|
rows[i] = row
|
|
}
|
|
|
|
if err := dst.Table(table).CreateInBatches(rows, 200).Error; err != nil {
|
|
return total, err
|
|
}
|
|
total += n
|
|
if n < batchSize {
|
|
break
|
|
}
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// truncatePostgresTables empties every migrated table on dst in a single
|
|
// statement, resetting identity sequences. CASCADE covers the inbound/client
|
|
// foreign keys regardless of insertion order. Only the panel's own tables are
|
|
// touched, never the rest of the schema.
|
|
func truncatePostgresTables(dst *gorm.DB, models []any) error {
|
|
tables := make([]string, 0, len(models))
|
|
for _, m := range models {
|
|
stmt := &gorm.Statement{DB: dst}
|
|
if err := stmt.Parse(m); err != nil {
|
|
return err
|
|
}
|
|
tables = append(tables, `"`+stmt.Schema.Table+`"`)
|
|
}
|
|
if len(tables) == 0 {
|
|
return nil
|
|
}
|
|
log.Println("Clearing destination tables...")
|
|
return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
|
|
}
|
|
|
|
// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
|
|
// otherwise the next INSERT-without-id would clash with copied rows.
|
|
func resetPostgresSequences(dst *gorm.DB) error {
|
|
return resyncPostgresSequences(dst, migrationModels())
|
|
}
|
|
|
|
// resyncPostgresSequences sets each model's id sequence to MAX(id); idempotent. Id-less
|
|
// composite-PK tables are skipped — Postgres rejects MAX(id) at parse time and logs it (#5665).
|
|
func resyncPostgresSequences(db *gorm.DB, models []any) error {
|
|
for _, m := range models {
|
|
t, ok := tableWithIdColumn(db, m)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// t comes from the trusted model set parsed by GORM, not user input, so
|
|
// interpolating it as an identifier is safe. We ignore errors per-table.
|
|
_ = db.Exec(
|
|
`SELECT setval(pg_get_serial_sequence(?, 'id'), COALESCE((SELECT MAX(id) FROM "`+t+`"), 1), true)
|
|
WHERE pg_get_serial_sequence(?, 'id') IS NOT NULL`,
|
|
t, t,
|
|
).Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tableWithIdColumn resolves a model's table name and reports whether its GORM
|
|
// schema maps an "id" database column.
|
|
func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
|
|
stmt := &gorm.Statement{DB: db}
|
|
if err := stmt.Parse(m); err != nil {
|
|
return "", false
|
|
}
|
|
if stmt.Schema == nil || stmt.Schema.LookUpField("id") == nil {
|
|
return "", false
|
|
}
|
|
return stmt.Table, true
|
|
}
|
|
|
|
// PrepareSQLiteForMigration rejects SQLite files that are not a panel database
|
|
// before the caller causes any downtime, then AutoMigrates the panel schema
|
|
// onto the file so backups from older versions gain the newer tables and
|
|
// columns the row copy reads. Data-level upgrades are not needed here: they
|
|
// run dialect-agnostically on the destination via InitDB after the import.
|
|
func PrepareSQLiteForMigration(dbPath string) error {
|
|
gdb, err := gorm.Open(sqlite.Open(dbPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sqlDB, err := gdb.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer sqlDB.Close()
|
|
|
|
for _, table := range []string{"users", "settings", "inbounds"} {
|
|
if !sqliteTableExists(sqlDB, table) {
|
|
return fmt.Errorf("not a 3x-ui panel database: required table %q is missing", table)
|
|
}
|
|
}
|
|
for _, m := range migrationModels() {
|
|
if err := gdb.AutoMigrate(m); err != nil && !isIgnorableDuplicateColumnErr(gdb, err, m) {
|
|
return fmt.Errorf("upgrade panel schema for %T: %w", m, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|