mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
feat(database): add PostgreSQL as an optional backend alongside SQLite
Lets operators with large client counts or multi-node setups pick PostgreSQL at install time without breaking the existing SQLite default. Backend is selected at runtime via XUI_DB_TYPE/XUI_DB_DSN, a small dialect layer keeps the five JSON_EXTRACT/JSON_EACH queries portable, and a new `x-ui migrate-db` subcommand copies SQLite data into PostgreSQL in FK-aware order.
This commit is contained in:
parent
1ef494bcda
commit
e0f41362e2
15 changed files with 435 additions and 119 deletions
|
|
@ -64,6 +64,10 @@ RUN chmod +x \
|
||||||
/usr/bin/x-ui
|
/usr/bin/x-ui
|
||||||
|
|
||||||
ENV XUI_ENABLE_FAIL2BAN="true"
|
ENV XUI_ENABLE_FAIL2BAN="true"
|
||||||
|
# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
|
||||||
|
# Default (unset) is SQLite stored under /etc/x-ui.
|
||||||
|
ENV XUI_DB_TYPE=""
|
||||||
|
ENV XUI_DB_DSN=""
|
||||||
EXPOSE 2053
|
EXPOSE 2053
|
||||||
VOLUME [ "/etc/x-ui" ]
|
VOLUME [ "/etc/x-ui" ]
|
||||||
CMD [ "./x-ui" ]
|
CMD [ "./x-ui" ]
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -30,6 +30,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||||
|
|
||||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||||
|
|
||||||
|
## Database Options
|
||||||
|
|
||||||
|
3X-UI supports two backends, chosen during the install:
|
||||||
|
|
||||||
|
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
|
||||||
|
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
|
||||||
|
|
||||||
|
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
|
||||||
|
|
||||||
|
```
|
||||||
|
XUI_DB_TYPE=postgres
|
||||||
|
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migrating an existing SQLite install to PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||||
|
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
|
||||||
|
systemctl restart x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile postgres up -d
|
||||||
|
```
|
||||||
|
|
||||||
## A Special Thanks to
|
## A Special Thanks to
|
||||||
|
|
||||||
- [alireza0](https://github.com/alireza0/)
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,22 @@ func GetDBPath() string {
|
||||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
|
||||||
|
func GetDBKind() string {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
|
||||||
|
switch v {
|
||||||
|
case "postgres", "postgresql", "pg":
|
||||||
|
return "postgres"
|
||||||
|
default:
|
||||||
|
return "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
|
||||||
|
func GetDBDSN() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||||
|
}
|
||||||
|
|
||||||
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
||||||
func GetLogFolder() string {
|
func GetLogFolder() string {
|
||||||
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
||||||
|
|
|
||||||
121
database/db.go
121
database/db.go
|
|
@ -1,5 +1,5 @@
|
||||||
// Package database provides database initialization, migration, and management utilities
|
// Package database provides database initialization, migration, and management utilities
|
||||||
// for the 3x-ui panel using GORM with SQLite.
|
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
@ -26,6 +27,27 @@ import (
|
||||||
|
|
||||||
var db *gorm.DB
|
var db *gorm.DB
|
||||||
|
|
||||||
|
const (
|
||||||
|
DialectSQLite = "sqlite"
|
||||||
|
DialectPostgres = "postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsPostgres reports whether the active connection is a PostgreSQL backend.
|
||||||
|
func IsPostgres() bool {
|
||||||
|
if db == nil {
|
||||||
|
return config.GetDBKind() == "postgres"
|
||||||
|
}
|
||||||
|
return db.Dialector.Name() == "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
|
||||||
|
func Dialect() string {
|
||||||
|
if db == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return db.Dialector.Name()
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultUsername = "admin"
|
defaultUsername = "admin"
|
||||||
defaultPassword = "admin"
|
defaultPassword = "admin"
|
||||||
|
|
@ -65,20 +87,25 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
errMsg := strings.ToLower(err.Error())
|
errMsg := strings.ToLower(err.Error())
|
||||||
const dupPrefix = "duplicate column name:"
|
// SQLite: "duplicate column name: foo"
|
||||||
if !strings.Contains(errMsg, dupPrefix) {
|
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
|
||||||
return false
|
const sqlitePrefix = "duplicate column name:"
|
||||||
|
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
|
||||||
|
col := strings.TrimSpace(after)
|
||||||
|
col = strings.Trim(col, "`\"[]")
|
||||||
|
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||||
}
|
}
|
||||||
_, after, ok := strings.Cut(errMsg, dupPrefix)
|
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
|
||||||
if !ok {
|
// Best effort: extract the column name between the first pair of double quotes.
|
||||||
return false
|
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
|
||||||
|
rest := after
|
||||||
|
if e := strings.Index(rest, "\""); e > 0 {
|
||||||
|
col := rest[:e]
|
||||||
|
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
col := strings.TrimSpace(after)
|
return false
|
||||||
col = strings.Trim(col, "`\"[]")
|
|
||||||
if col == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return db != nil && db.Migrator().HasColumn(mdl, col)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initUser creates a default admin user if the users table is empty.
|
// initUser creates a default admin user if the users table is empty.
|
||||||
|
|
@ -281,43 +308,56 @@ func isTableEmpty(tableName string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||||
|
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
|
||||||
func InitDB(dbPath string) error {
|
func InitDB(dbPath string) error {
|
||||||
dir := path.Dir(dbPath)
|
|
||||||
err := os.MkdirAll(dir, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var gormLogger logger.Interface
|
var gormLogger logger.Interface
|
||||||
|
|
||||||
if config.IsDebug() {
|
if config.IsDebug() {
|
||||||
gormLogger = logger.Default
|
gormLogger = logger.Default
|
||||||
} else {
|
} else {
|
||||||
gormLogger = logger.Discard
|
gormLogger = logger.Discard
|
||||||
}
|
}
|
||||||
|
c := &gorm.Config{Logger: gormLogger}
|
||||||
|
|
||||||
c := &gorm.Config{
|
var err error
|
||||||
Logger: gormLogger,
|
switch config.GetDBKind() {
|
||||||
}
|
case "postgres":
|
||||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
dsn := config.GetDBDSN()
|
||||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
if dsn == "" {
|
||||||
if err != nil {
|
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
|
||||||
return err
|
}
|
||||||
|
db, err = gorm.Open(postgres.Open(dsn), c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
dir := path.Dir(dbPath)
|
||||||
|
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||||
|
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sqlDB.SetMaxOpenConns(8)
|
sqlDB.SetMaxOpenConns(8)
|
||||||
sqlDB.SetMaxIdleConns(4)
|
sqlDB.SetMaxIdleConns(4)
|
||||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
@ -370,13 +410,12 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||||
|
// No-op on PostgreSQL (WAL there is managed by the server).
|
||||||
func Checkpoint() error {
|
func Checkpoint() error {
|
||||||
// Update WAL
|
if IsPostgres() {
|
||||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
return nil
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||||
|
|
|
||||||
26
database/dialect.go
Normal file
26
database/dialect.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// JSONClientsFromInbound returns the FROM clause that yields one row per element
|
||||||
|
// of inbounds.settings -> clients, with a column named `client.value` whose text
|
||||||
|
// fields can be read with JSONFieldText("client.value", "<key>").
|
||||||
|
func JSONClientsFromInbound() string {
|
||||||
|
if IsPostgres() {
|
||||||
|
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
||||||
|
}
|
||||||
|
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONFieldText returns a SQL expression that extracts the textual value of <key>
|
||||||
|
// from a JSON expression. On both backends the result is the raw (unquoted) string,
|
||||||
|
// so callers do NOT need to trim surrounding quotes.
|
||||||
|
func JSONFieldText(expr, key string) string {
|
||||||
|
if IsPostgres() {
|
||||||
|
return fmt.Sprintf("(%s ->> '%s')", expr, key)
|
||||||
|
}
|
||||||
|
// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
|
||||||
|
// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
|
||||||
|
// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
|
||||||
|
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
|
||||||
|
}
|
||||||
143
database/migrate_data.go
Normal file
143
database/migrate_data.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/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.
|
||||||
|
// Parents come before their children so foreign-key constraints stay satisfied
|
||||||
|
// even when checks are not explicitly disabled.
|
||||||
|
func migrationModels() []any {
|
||||||
|
return []any{
|
||||||
|
&model.User{},
|
||||||
|
&model.Setting{},
|
||||||
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.CustomGeoResource{},
|
||||||
|
&model.Node{},
|
||||||
|
&model.ApiToken{},
|
||||||
|
&model.Inbound{},
|
||||||
|
&xray.ClientTraffic{},
|
||||||
|
&model.OutboundTraffics{},
|
||||||
|
&model.InboundClientIps{},
|
||||||
|
&model.ClientRecord{},
|
||||||
|
&model.ClientInbound{},
|
||||||
|
&model.InboundFallbackChild{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateData copies every row from the configured SQLite file at srcPath into
|
||||||
|
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
||||||
|
// (re)created with AutoMigrate before the copy. Source data is left untouched.
|
||||||
|
func MigrateData(srcPath, dstDSN string) error {
|
||||||
|
if _, err := os.Stat(srcPath); err != nil {
|
||||||
|
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
if dstDSN == "" {
|
||||||
|
return errors.New("destination DSN is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
|
||||||
|
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open sqlite source: %w", err)
|
||||||
|
}
|
||||||
|
srcSQL, err := src.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcSQL.Close()
|
||||||
|
|
||||||
|
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open postgres destination: %w", err)
|
||||||
|
}
|
||||||
|
dstSQL, err := dst.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstSQL.Close()
|
||||||
|
dstSQL.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
|
log.Println("Creating destination schema...")
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
if err := dst.AutoMigrate(m); err != nil {
|
||||||
|
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRows := 0
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
n, err := copyTable(src, dst, m)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy %T: %w", m, err)
|
||||||
|
}
|
||||||
|
totalRows += n
|
||||||
|
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resetPostgresSequences(dst); err != nil {
|
||||||
|
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
|
||||||
|
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTable streams every row of `mdl` from src to dst in batches.
|
||||||
|
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||||
|
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
||||||
|
batchPtr := reflect.New(sliceType)
|
||||||
|
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
|
||||||
|
batch := batchPtr.Elem()
|
||||||
|
if batch.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += batch.Len()
|
||||||
|
return nil
|
||||||
|
}).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPostgresSequences advances each table's id sequence past MAX(id),
|
||||||
|
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||||
|
func resetPostgresSequences(dst *gorm.DB) error {
|
||||||
|
tables := []string{
|
||||||
|
"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
|
||||||
|
"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
|
||||||
|
"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
|
||||||
|
_ = dst.Exec(fmt.Sprintf(
|
||||||
|
`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
|
||||||
|
WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
|
||||||
|
t, t, t,
|
||||||
|
)).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,24 @@ services:
|
||||||
environment:
|
environment:
|
||||||
XRAY_VMESS_AEAD_FORCED: "false"
|
XRAY_VMESS_AEAD_FORCED: "false"
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
|
# To use PostgreSQL instead of the default SQLite, run:
|
||||||
|
# docker compose --profile postgres up -d
|
||||||
|
# and uncomment the two lines below.
|
||||||
|
# XUI_DB_TYPE: "postgres"
|
||||||
|
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
|
||||||
tty: true
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
- "2053:2053"
|
- "2053:2053"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: 3xui_postgres
|
||||||
|
profiles: ["postgres"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: xui
|
||||||
|
POSTGRES_PASSWORD: xui
|
||||||
|
POSTGRES_DB: xui
|
||||||
|
volumes:
|
||||||
|
- $PWD/pgdata/:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
58
frontend/package-lock.json
generated
58
frontend/package-lock.json
generated
|
|
@ -471,61 +471,61 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz",
|
||||||
"integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
|
"integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/devtools-types": "11.4.2",
|
"@intlify/devtools-types": "11.4.4",
|
||||||
"@intlify/message-compiler": "11.4.2",
|
"@intlify/message-compiler": "11.4.4",
|
||||||
"@intlify/shared": "11.4.2"
|
"@intlify/shared": "11.4.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/devtools-types": {
|
"node_modules/@intlify/devtools-types": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
|
||||||
"integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
|
"integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.2",
|
"@intlify/core-base": "11.4.4",
|
||||||
"@intlify/shared": "11.4.2"
|
"@intlify/shared": "11.4.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/message-compiler": {
|
"node_modules/@intlify/message-compiler": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
|
||||||
"integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
|
"integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/shared": "11.4.2",
|
"@intlify/shared": "11.4.4",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/shared": {
|
"node_modules/@intlify/shared": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz",
|
||||||
"integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
|
"integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
|
@ -3080,18 +3080,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz",
|
||||||
"integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
|
"integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.2",
|
"@intlify/core-base": "11.4.4",
|
||||||
"@intlify/devtools-types": "11.4.2",
|
"@intlify/devtools-types": "11.4.4",
|
||||||
"@intlify/shared": "11.4.2",
|
"@intlify/shared": "11.4.4",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
import { DBInbound } from '@/models/dbinbound.js';
|
|
||||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -27,6 +27,7 @@ require (
|
||||||
golang.org/x/text v0.37.0
|
golang.org/x/text v0.37.0
|
||||||
google.golang.org/grpc v1.81.1
|
google.golang.org/grpc v1.81.1
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
@ -53,6 +54,10 @@ require (
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // indirect
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
|
|
||||||
11
go.sum
11
go.sum
|
|
@ -85,6 +85,14 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
|
@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
|
@ -272,6 +281,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
|
|
||||||
24
main.go
24
main.go
|
|
@ -439,6 +439,12 @@ func main() {
|
||||||
|
|
||||||
runCmd := flag.NewFlagSet("run", flag.ExitOnError)
|
runCmd := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
|
||||||
|
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
|
||||||
|
var migrateDsn string
|
||||||
|
var migrateSrc string
|
||||||
|
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
|
||||||
|
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
|
||||||
|
|
||||||
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
||||||
var port int
|
var port int
|
||||||
var username string
|
var username string
|
||||||
|
|
@ -482,6 +488,7 @@ func main() {
|
||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" run run web panel")
|
fmt.Println(" run run web panel")
|
||||||
fmt.Println(" migrate migrate form other/old x-ui")
|
fmt.Println(" migrate migrate form other/old x-ui")
|
||||||
|
fmt.Println(" migrate-db copy data from the SQLite file into a PostgreSQL database")
|
||||||
fmt.Println(" setting set settings")
|
fmt.Println(" setting set settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -501,6 +508,23 @@ func main() {
|
||||||
runWebServer()
|
runWebServer()
|
||||||
case "migrate":
|
case "migrate":
|
||||||
migrateDb()
|
migrateDb()
|
||||||
|
case "migrate-db":
|
||||||
|
if err := migrateDbCmd.Parse(os.Args[2:]); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src := migrateSrc
|
||||||
|
if src == "" {
|
||||||
|
src = config.GetDBPath()
|
||||||
|
}
|
||||||
|
if migrateDsn == "" {
|
||||||
|
fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := database.MigrateData(src, migrateDsn); err != nil {
|
||||||
|
fmt.Println("migration failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
case "setting":
|
case "setting":
|
||||||
err := settingCmd.Parse(os.Args[2:])
|
err := settingCmd.Parse(os.Args[2:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type NodeTrafficSyncJob struct {
|
type NodeTrafficSyncJob struct {
|
||||||
nodeService service.NodeService
|
nodeService service.NodeService
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
running sync.Mutex
|
running sync.Mutex
|
||||||
structural atomicBool
|
structural atomicBool
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomicBool struct {
|
type atomicBool struct {
|
||||||
|
|
|
||||||
|
|
@ -242,12 +242,12 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
|
||||||
func (s *InboundService) getAllEmails() ([]string, error) {
|
func (s *InboundService) getAllEmails() ([]string, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var emails []string
|
var emails []string
|
||||||
err := db.Raw(`
|
query := fmt.Sprintf(
|
||||||
SELECT DISTINCT JSON_EXTRACT(client.value, '$.email')
|
"SELECT DISTINCT %s %s",
|
||||||
FROM inbounds,
|
database.JSONFieldText("client.value", "email"),
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
database.JSONClientsFromInbound(),
|
||||||
`).Scan(&emails).Error
|
)
|
||||||
if err != nil {
|
if err := db.Raw(query).Scan(&emails).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return emails, nil
|
return emails, nil
|
||||||
|
|
@ -261,22 +261,22 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) {
|
||||||
Email string
|
Email string
|
||||||
SubID string
|
SubID string
|
||||||
}
|
}
|
||||||
err := db.Raw(`
|
query := fmt.Sprintf(
|
||||||
SELECT JSON_EXTRACT(client.value, '$.email') AS email,
|
"SELECT %s AS email, %s AS sub_id %s",
|
||||||
JSON_EXTRACT(client.value, '$.subId') AS sub_id
|
database.JSONFieldText("client.value", "email"),
|
||||||
FROM inbounds,
|
database.JSONFieldText("client.value", "subId"),
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
database.JSONClientsFromInbound(),
|
||||||
`).Scan(&rows).Error
|
)
|
||||||
if err != nil {
|
if err := db.Raw(query).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result := make(map[string]string, len(rows))
|
result := make(map[string]string, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
email := strings.ToLower(strings.Trim(r.Email, "\""))
|
email := strings.ToLower(r.Email)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
subID := strings.Trim(r.SubID, "\"")
|
subID := r.SubID
|
||||||
if existing, ok := result[email]; ok {
|
if existing, ok := result[email]; ok {
|
||||||
if existing != subID {
|
if existing != subID {
|
||||||
result[email] = ""
|
result[email] = ""
|
||||||
|
|
@ -296,14 +296,12 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
|
||||||
}
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var count int64
|
var count int64
|
||||||
err := db.Raw(`
|
query := fmt.Sprintf(
|
||||||
SELECT COUNT(*)
|
"SELECT COUNT(*) %s WHERE inbounds.id != ? AND LOWER(%s) = LOWER(?)",
|
||||||
FROM inbounds,
|
database.JSONClientsFromInbound(),
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
database.JSONFieldText("client.value", "email"),
|
||||||
WHERE inbounds.id != ?
|
)
|
||||||
AND LOWER(JSON_EXTRACT(client.value, '$.email')) = LOWER(?)
|
if err := db.Raw(query, exceptInboundId, email).Scan(&count).Error; err != nil {
|
||||||
`, exceptInboundId, email).Scan(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
|
|
@ -2043,14 +2041,12 @@ func (s *InboundService) GetClientReverseTags() (string, error) {
|
||||||
|
|
||||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
db.Exec(`
|
query := fmt.Sprintf(
|
||||||
DELETE FROM client_traffics
|
"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
|
||||||
WHERE email NOT IN (
|
database.JSONFieldText("client.value", "email"),
|
||||||
SELECT JSON_EXTRACT(client.value, '$.email')
|
database.JSONClientsFromInbound(),
|
||||||
FROM inbounds,
|
)
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
db.Exec(query)
|
||||||
)
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddClientStat inserts a per-client accounting row, no-op on email
|
// AddClientStat inserts a per-client accounting row, no-op on email
|
||||||
|
|
@ -2390,12 +2386,14 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||||
emails = append(emails, e)
|
emails = append(emails, e)
|
||||||
}
|
}
|
||||||
var stillReferenced []string
|
var stillReferenced []string
|
||||||
if err = tx.Raw(`
|
emailExpr := database.JSONFieldText("client.value", "email")
|
||||||
SELECT DISTINCT LOWER(JSON_EXTRACT(client.value, '$.email'))
|
stillQuery := fmt.Sprintf(
|
||||||
FROM inbounds,
|
"SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?",
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
emailExpr,
|
||||||
WHERE LOWER(JSON_EXTRACT(client.value, '$.email')) IN ?
|
database.JSONClientsFromInbound(),
|
||||||
`, emails).Scan(&stillReferenced).Error; err != nil {
|
emailExpr,
|
||||||
|
)
|
||||||
|
if err = tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stillSet := make(map[string]struct{}, len(stillReferenced))
|
stillSet := make(map[string]struct{}, len(stillReferenced))
|
||||||
|
|
@ -2756,8 +2754,10 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
|
if !database.IsPostgres() {
|
||||||
logger.Warningf("VACUUM failed: %v", dbErr)
|
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
|
||||||
|
logger.Warningf("VACUUM failed: %v", dbErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
"emptyUsername": "Username is required",
|
"emptyUsername": "Username is required",
|
||||||
"emptyPassword": "Password is required",
|
"emptyPassword": "Password is required",
|
||||||
"wrongUsernameOrPassword": "Invalid username or password or two-factor code.",
|
"wrongUsernameOrPassword": "Invalid username or password or two-factor code.",
|
||||||
"successLogin": " You have successfully logged into your account."
|
"successLogin": "You have successfully logged into your account."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"index": {
|
"index": {
|
||||||
|
|
@ -725,7 +725,7 @@
|
||||||
"generalConfigs": "General",
|
"generalConfigs": "General",
|
||||||
"generalConfigsDesc": "These options will determine general adjustments.",
|
"generalConfigsDesc": "These options will determine general adjustments.",
|
||||||
"logConfigs": "Log",
|
"logConfigs": "Log",
|
||||||
"logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable it wisely only in case of your needs",
|
"logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable them wisely only when needed.",
|
||||||
"blockConfigsDesc": "These options will block traffic based on specific requested protocols and websites.",
|
"blockConfigsDesc": "These options will block traffic based on specific requested protocols and websites.",
|
||||||
"basicRouting": "Basic Routing",
|
"basicRouting": "Basic Routing",
|
||||||
"blockConnectionsConfigsDesc": "These options will block traffic based on the specific requested country.",
|
"blockConnectionsConfigsDesc": "These options will block traffic based on the specific requested country.",
|
||||||
|
|
@ -874,7 +874,7 @@
|
||||||
"expectIPs": "Expect IPs",
|
"expectIPs": "Expect IPs",
|
||||||
"unexpectIPs": "Unexpected IPs",
|
"unexpectIPs": "Unexpected IPs",
|
||||||
"useSystemHosts": "Use System Hosts",
|
"useSystemHosts": "Use System Hosts",
|
||||||
"useSystemHostsDesc": "Use the hosts file from an installed system",
|
"useSystemHostsDesc": "Use the operating system's hosts file",
|
||||||
"serveStale": "Serve Stale",
|
"serveStale": "Serve Stale",
|
||||||
"serveStaleDesc": "Return expired cached results while refreshing in the background",
|
"serveStaleDesc": "Return expired cached results while refreshing in the background",
|
||||||
"serveExpiredTTL": "Serve Expired TTL",
|
"serveExpiredTTL": "Serve Expired TTL",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue