From 6c9ef87fbe7b5b0da7f6a390fd3fec6690038545 Mon Sep 17 00:00:00 2001 From: The_Just Date: Tue, 7 Apr 2026 19:18:32 +0300 Subject: [PATCH 1/3] add PostgreSQL backend and portable backup system. - Add SQLite/PostgreSQL switching via panel UI and env variables - Introduce portable .xui-backup format for cross-backend backups - Add connection pooling and PrepareStmt cache for PostgreSQL - Fix raw SQL double-quote bug breaking queries on PostgreSQL - Fix GORM record-not-found log spam on every Xray config poll - Add database section to Settings with full EN/RU i18n --- Dockerfile | 2 + README.md | 70 +++++ README.ru_RU.md | 70 +++++ config/database.go | 251 +++++++++++++++++ database/backup.go | 396 +++++++++++++++++++++++++++ database/db.go | 384 +++++++++++++++++++------- database/manager.go | 118 ++++++++ docker-compose.yml | 25 ++ go.mod | 5 + go.sum | 11 + install.sh | 117 ++++++++ main.go | 218 ++++++++++++++- postgres-manager.sh | 271 ++++++++++++++++++ sub/subService.go | 66 ++++- update.sh | 3 + web/assets/js/model/setting.js | 33 ++- web/controller/server.go | 39 ++- web/controller/setting.go | 105 +++++++ web/entity/database.go | 99 +++++++ web/html/index.html | 47 ++-- web/html/settings.html | 85 +++++- web/html/settings/panel/general.html | 131 ++++++++- web/service/database.go | 252 +++++++++++++++++ web/service/inbound.go | 254 ++++++++++------- web/service/server.go | 2 +- web/service/setting.go | 18 +- web/service/tgbot.go | 32 ++- web/translation/translate.ar_EG.toml | 44 +++ web/translation/translate.en_US.toml | 44 +++ web/translation/translate.es_ES.toml | 44 +++ web/translation/translate.fa_IR.toml | 44 +++ web/translation/translate.id_ID.toml | 44 +++ web/translation/translate.ja_JP.toml | 44 +++ web/translation/translate.pt_BR.toml | 44 +++ web/translation/translate.ru_RU.toml | 44 +++ web/translation/translate.tr_TR.toml | 44 +++ web/translation/translate.uk_UA.toml | 44 +++ web/translation/translate.vi_VN.toml | 44 +++ web/translation/translate.zh_CN.toml | 44 +++ web/translation/translate.zh_TW.toml | 44 +++ x-ui.sh | 135 ++++++++- 41 files changed, 3530 insertions(+), 281 deletions(-) create mode 100644 config/database.go create mode 100644 database/backup.go create mode 100644 database/manager.go create mode 100644 postgres-manager.sh create mode 100644 web/entity/database.go create mode 100644 web/service/database.go diff --git a/Dockerfile b/Dockerfile index dabaf7f1..85ea3fd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,7 @@ RUN apk add --no-cache --update \ COPY --from=builder /app/build/ /app/ COPY --from=builder /app/DockerEntrypoint.sh /app/ +COPY --from=builder /app/postgres-manager.sh /app/ COPY --from=builder /app/x-ui.sh /usr/bin/x-ui @@ -47,6 +48,7 @@ RUN rm -f /etc/fail2ban/jail.d/alpine-ssh.conf \ RUN chmod +x \ /app/DockerEntrypoint.sh \ + /app/postgres-manager.sh \ /app/x-ui \ /usr/bin/x-ui diff --git a/README.md b/README.md index f00a2fb0..e08478a7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,76 @@ 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). +## Database Backends + +3X-UI supports both `SQLite` and `PostgreSQL` as interchangeable backends. All application logic is written against [GORM](https://gorm.io/) — Go's database-agnostic ORM — so queries work identically on either engine. You can switch backends at any time through the panel UI without data loss. + +### Choosing a Backend + +| | SQLite | PostgreSQL | +|---|---|---| +| Setup | Zero config, file-based | Requires a running PG server | +| Best for | Single-node, low traffic | Multi-node, high concurrency | +| Backups | Portable + native file export | Portable export | + +### Switching Backends (Panel UI) + +1. Open **Settings → General → Database**. +2. Select a backend (`SQLite` or `PostgreSQL`) and fill in connection details. +3. Click **Test Connection** to verify. +4. Click **Switch Database** — the panel will: + - Save a portable backup of current data automatically. + - Migrate all data to the new backend. + - Restart itself. + +> The target database must be empty before switching. Use **Test Connection** before switching to catch misconfigurations early. + +### Local PostgreSQL (panel-managed) + +When selecting **Local (panel-managed)** mode, the panel installs and manages PostgreSQL automatically (Linux, root only): + +```bash +# The panel uses postgres-manager.sh internally. +# No manual PostgreSQL setup required. +``` + +### External PostgreSQL + +Point the panel at any existing PostgreSQL 13+ server: + +1. Create a dedicated database and user. +2. Enter the connection details in Settings → Database. +3. Use **Test Connection**, then **Switch Database**. + +### Environment Variable Override + +For Docker and infrastructure-as-code deployments, set these environment variables to control the backend without touching the UI: + +```bash +XUI_DB_DRIVER=postgres # or: sqlite +XUI_DB_HOST=127.0.0.1 +XUI_DB_PORT=5432 +XUI_DB_NAME=x-ui +XUI_DB_USER=x-ui +XUI_DB_PASSWORD=change-me +XUI_DB_SSLMODE=disable # or: require, verify-ca, verify-full +XUI_DB_MODE=external # or: local +XUI_DB_PATH=/etc/x-ui/db/x-ui.db # SQLite only +``` + +When any `XUI_DB_*` variable is set, the Database section in the panel UI becomes read-only. + +### Backup & Restore + +| Format | Works with | When to use | +|---|---|---| +| **Portable** (`.xui-backup`) | SQLite + PostgreSQL | Switching backends, Telegram bot backups, long-term storage | +| **Native SQLite** (`.db`) | SQLite only | Quick raw file backup while on SQLite | + +- The Telegram bot sends a **portable backup** automatically — this works regardless of which backend is active. +- Portable backups can be imported back on either SQLite or PostgreSQL. +- Legacy `.db` files from older 3x-ui versions can be imported even while PostgreSQL is active. + ## A Special Thanks to - [alireza0](https://github.com/alireza0/) diff --git a/README.ru_RU.md b/README.ru_RU.md index 6623a801..f3266627 100644 --- a/README.ru_RU.md +++ b/README.ru_RU.md @@ -30,6 +30,76 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install. Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki). +## Базы данных + +3X-UI поддерживает `SQLite` и `PostgreSQL` как взаимозаменяемые бэкенды. Вся логика приложения написана через [GORM](https://gorm.io/) — ORM для Go с поддержкой множества СУБД, — поэтому запросы работают одинаково на любом движке. Переключиться между бэкендами можно в любой момент через UI панели без потери данных. + +### Выбор бэкенда + +| | SQLite | PostgreSQL | +|---|---|---| +| Настройка | Ноль конфигурации, файловая БД | Требует работающий PG-сервер | +| Подходит для | Один узел, малый трафик | Несколько узлов, высокая нагрузка | +| Резервные копии | Портативный + нативный файл | Только портативный | + +### Переключение бэкенда через UI + +1. Откройте **Настройки → Панель → База данных**. +2. Выберите бэкенд (`SQLite` или `PostgreSQL`) и заполните параметры подключения. +3. Нажмите **Проверить подключение**. +4. Нажмите **Переключить базу данных** — панель автоматически: + - Сохранит портативную резервную копию текущих данных. + - Мигрирует все данные в новый бэкенд. + - Перезапустится. + +> Целевая база данных должна быть пустой перед переключением. Всегда используйте **Проверить подключение** перед переключением. + +### Локальный PostgreSQL (управляется панелью) + +Режим **Локальный (управляется панелью)** — панель устанавливает и настраивает PostgreSQL автоматически (только Linux, root): + +```bash +# Панель использует postgres-manager.sh внутри себя. +# Ручная настройка PostgreSQL не требуется. +``` + +### Внешний PostgreSQL + +Подключение к существующему серверу PostgreSQL 13+: + +1. Создайте отдельную БД и пользователя. +2. Введите параметры подключения в Настройки → База данных. +3. Нажмите **Проверить подключение**, затем **Переключить базу данных**. + +### Переопределение через переменные окружения + +Для Docker и IaC-деплоев можно управлять бэкендом через переменные окружения: + +```bash +XUI_DB_DRIVER=postgres # или: sqlite +XUI_DB_HOST=127.0.0.1 +XUI_DB_PORT=5432 +XUI_DB_NAME=x-ui +XUI_DB_USER=x-ui +XUI_DB_PASSWORD=change-me +XUI_DB_SSLMODE=disable # или: require, verify-ca, verify-full +XUI_DB_MODE=external # или: local +XUI_DB_PATH=/etc/x-ui/db/x-ui.db # только для SQLite +``` + +Если задана любая переменная `XUI_DB_*`, раздел «База данных» в UI становится read-only. + +### Резервное копирование и восстановление + +| Формат | Совместим с | Когда использовать | +|---|---|---| +| **Портативный** (`.xui-backup`) | SQLite + PostgreSQL | Переключение бэкендов, резервные копии через Telegram-бот, долгосрочное хранение | +| **Нативный SQLite** (`.db`) | Только SQLite | Быстрый файловый бэкап при активном SQLite | + +- Telegram-бот отправляет **портативную резервную копию** автоматически — это работает независимо от активного бэкенда. +- Портативные копии можно импортировать на SQLite и на PostgreSQL. +- Устаревшие `.db`-файлы от старых версий 3x-ui можно импортировать даже при активном PostgreSQL. + ## Особая благодарность - [alireza0](https://github.com/alireza0/) diff --git a/config/database.go b/config/database.go new file mode 100644 index 00000000..95fb967e --- /dev/null +++ b/config/database.go @@ -0,0 +1,251 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + DatabaseDriverSQLite = "sqlite" + DatabaseDriverPostgres = "postgres" + + DatabaseConfigSourceDefault = "default" + DatabaseConfigSourceFile = "file" + DatabaseConfigSourceEnv = "env" + + DatabaseModeLocal = "local" + DatabaseModeExternal = "external" +) + +type SQLiteDatabaseConfig struct { + Path string `json:"path"` +} + +type PostgresDatabaseConfig struct { + Mode string `json:"mode"` + Host string `json:"host"` + Port int `json:"port"` + DBName string `json:"dbName"` + User string `json:"user"` + Password string `json:"password,omitempty"` + SSLMode string `json:"sslMode"` + ManagedLocally bool `json:"managedLocally"` +} + +type DatabaseConfig struct { + Driver string `json:"driver"` + ConfigSource string `json:"configSource,omitempty"` + SQLite SQLiteDatabaseConfig `json:"sqlite"` + Postgres PostgresDatabaseConfig `json:"postgres"` +} + +func DefaultDatabaseConfig() *DatabaseConfig { + name := GetName() + if name == "" { + name = "x-ui" + } + return (&DatabaseConfig{ + Driver: DatabaseDriverSQLite, + SQLite: SQLiteDatabaseConfig{ + Path: GetDBPath(), + }, + Postgres: PostgresDatabaseConfig{ + Mode: DatabaseModeExternal, + Host: "127.0.0.1", + Port: 5432, + DBName: name, + User: name, + SSLMode: "disable", + ManagedLocally: false, + }, + }).Normalize() +} + +func (c *DatabaseConfig) Clone() *DatabaseConfig { + if c == nil { + return nil + } + cloned := *c + return &cloned +} + +func (c *DatabaseConfig) Normalize() *DatabaseConfig { + if c == nil { + return DefaultDatabaseConfig() + } + + if c.Driver == "" { + c.Driver = DatabaseDriverSQLite + } + c.Driver = strings.ToLower(strings.TrimSpace(c.Driver)) + if c.Driver != DatabaseDriverSQLite && c.Driver != DatabaseDriverPostgres { + c.Driver = DatabaseDriverSQLite + } + + if c.SQLite.Path == "" { + c.SQLite.Path = GetDBPath() + } + + c.Postgres.Mode = strings.ToLower(strings.TrimSpace(c.Postgres.Mode)) + if c.Postgres.Mode != DatabaseModeLocal && c.Postgres.Mode != DatabaseModeExternal { + if c.Postgres.ManagedLocally { + c.Postgres.Mode = DatabaseModeLocal + } else { + c.Postgres.Mode = DatabaseModeExternal + } + } + if c.Postgres.Host == "" { + c.Postgres.Host = "127.0.0.1" + } + if c.Postgres.Port <= 0 { + c.Postgres.Port = 5432 + } + if c.Postgres.DBName == "" { + c.Postgres.DBName = GetName() + } + if c.Postgres.User == "" { + c.Postgres.User = GetName() + } + if c.Postgres.SSLMode == "" { + c.Postgres.SSLMode = "disable" + } + if c.Postgres.Mode == DatabaseModeLocal { + c.Postgres.ManagedLocally = true + } + + return c +} + +func (c *DatabaseConfig) UsesSQLite() bool { + return c != nil && c.Normalize().Driver == DatabaseDriverSQLite +} + +func (c *DatabaseConfig) UsesPostgres() bool { + return c != nil && c.Normalize().Driver == DatabaseDriverPostgres +} + +func HasDatabaseEnvOverride() bool { + driver := strings.TrimSpace(os.Getenv("XUI_DB_DRIVER")) + if driver != "" { + return true + } + return false +} + +func loadDatabaseConfigFromEnv() (*DatabaseConfig, error) { + driver := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_DRIVER"))) + if driver == "" { + return nil, errors.New("database env override is not configured") + } + + cfg := DefaultDatabaseConfig() + cfg.ConfigSource = DatabaseConfigSourceEnv + cfg.Driver = driver + + if path := strings.TrimSpace(os.Getenv("XUI_DB_PATH")); path != "" { + cfg.SQLite.Path = path + } + + if mode := strings.TrimSpace(os.Getenv("XUI_DB_MODE")); mode != "" { + cfg.Postgres.Mode = mode + } + if host := strings.TrimSpace(os.Getenv("XUI_DB_HOST")); host != "" { + cfg.Postgres.Host = host + } + if portStr := strings.TrimSpace(os.Getenv("XUI_DB_PORT")); portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, err + } + cfg.Postgres.Port = port + } + if dbName := strings.TrimSpace(os.Getenv("XUI_DB_NAME")); dbName != "" { + cfg.Postgres.DBName = dbName + } + if user := strings.TrimSpace(os.Getenv("XUI_DB_USER")); user != "" { + cfg.Postgres.User = user + } + if password := os.Getenv("XUI_DB_PASSWORD"); password != "" { + cfg.Postgres.Password = password + } + if sslMode := strings.TrimSpace(os.Getenv("XUI_DB_SSLMODE")); sslMode != "" { + cfg.Postgres.SSLMode = sslMode + } + if managedLocally := strings.TrimSpace(os.Getenv("XUI_DB_MANAGED_LOCALLY")); managedLocally != "" { + value, err := strconv.ParseBool(managedLocally) + if err != nil { + return nil, err + } + cfg.Postgres.ManagedLocally = value + } + + return cfg.Normalize(), nil +} + +func GetDBConfigPath() string { + return filepath.Join(GetDBFolderPath(), "database.json") +} + +func GetBackupFolderPath() string { + return filepath.Join(GetDBFolderPath(), "backups") +} + +func GetPostgresManagerPath() string { + return filepath.Join(getBaseDir(), "postgres-manager.sh") +} + +func LoadDatabaseConfig() (*DatabaseConfig, error) { + if HasDatabaseEnvOverride() { + return loadDatabaseConfigFromEnv() + } + + configPath := GetDBConfigPath() + contents, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + cfg := DefaultDatabaseConfig() + cfg.ConfigSource = DatabaseConfigSourceDefault + return cfg, nil + } + return nil, err + } + + cfg := DefaultDatabaseConfig() + if err := json.Unmarshal(contents, cfg); err != nil { + return nil, err + } + cfg.ConfigSource = DatabaseConfigSourceFile + return cfg.Normalize(), nil +} + +func SaveDatabaseConfig(cfg *DatabaseConfig) error { + if HasDatabaseEnvOverride() { + return errors.New("database configuration is managed by environment variables") + } + if cfg == nil { + return errors.New("database configuration is nil") + } + + normalized := cfg.Clone().Normalize() + normalized.ConfigSource = DatabaseConfigSourceFile + + configPath := GetDBConfigPath() + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + return err + } + + tempPath := configPath + ".tmp" + if err := os.WriteFile(tempPath, data, 0o600); err != nil { + return err + } + return os.Rename(tempPath, configPath) +} diff --git a/database/backup.go b/database/backup.go new file mode 100644 index 00000000..0341f54d --- /dev/null +++ b/database/backup.go @@ -0,0 +1,396 @@ +package database + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/xray" + + "gorm.io/gorm" +) + +const PortableBackupFormatVersion = 1 + +type BackupManifest struct { + FormatVersion int `json:"formatVersion"` + CreatedAt string `json:"createdAt"` + SourceDriver string `json:"sourceDriver"` + AppVersion string `json:"appVersion"` + IncludesConfig bool `json:"includesConfig"` +} + +type BackupSnapshot struct { + Manifest BackupManifest `json:"manifest"` + Users []model.User `json:"users"` + Inbounds []model.Inbound `json:"inbounds"` + ClientTraffics []xray.ClientTraffic `json:"clientTraffics"` + OutboundTraffics []model.OutboundTraffics `json:"outboundTraffics"` + Settings []model.Setting `json:"settings"` + InboundClientIps []model.InboundClientIps `json:"inboundClientIps"` + HistoryOfSeeders []model.HistoryOfSeeders `json:"historyOfSeeders"` +} + +func newBackupSnapshot(sourceDriver string) *BackupSnapshot { + return &BackupSnapshot{ + Manifest: BackupManifest{ + FormatVersion: PortableBackupFormatVersion, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + SourceDriver: sourceDriver, + AppVersion: config.GetVersion(), + IncludesConfig: false, + }, + } +} + +func loadSnapshotRows(conn *gorm.DB, modelRef any, dest any) error { + if !conn.Migrator().HasTable(modelRef) { + return nil + } + return conn.Model(modelRef).Order("id ASC").Find(dest).Error +} + +// ExportSnapshot extracts a logical snapshot from an arbitrary database connection. +func ExportSnapshot(conn *gorm.DB, sourceDriver string) (*BackupSnapshot, error) { + snapshot := newBackupSnapshot(sourceDriver) + + if err := loadSnapshotRows(conn, &model.User{}, &snapshot.Users); err != nil { + return nil, err + } + if err := loadSnapshotRows(conn, &model.Inbound{}, &snapshot.Inbounds); err != nil { + return nil, err + } + for i := range snapshot.Inbounds { + snapshot.Inbounds[i].ClientStats = nil + } + if err := loadSnapshotRows(conn, &xray.ClientTraffic{}, &snapshot.ClientTraffics); err != nil { + return nil, err + } + if err := loadSnapshotRows(conn, &model.OutboundTraffics{}, &snapshot.OutboundTraffics); err != nil { + return nil, err + } + if err := loadSnapshotRows(conn, &model.Setting{}, &snapshot.Settings); err != nil { + return nil, err + } + if err := loadSnapshotRows(conn, &model.InboundClientIps{}, &snapshot.InboundClientIps); err != nil { + return nil, err + } + if err := loadSnapshotRows(conn, &model.HistoryOfSeeders{}, &snapshot.HistoryOfSeeders); err != nil { + return nil, err + } + + return snapshot, nil +} + +// ExportCurrentSnapshot extracts a logical snapshot from the active database. +func ExportCurrentSnapshot() (*BackupSnapshot, error) { + if db == nil { + return nil, errors.New("database is not initialized") + } + return ExportSnapshot(db, GetDriver()) +} + +// LoadSnapshotFromSQLiteFile extracts a logical snapshot from a legacy SQLite database file. +func LoadSnapshotFromSQLiteFile(path string) (*BackupSnapshot, error) { + if err := ValidateSQLiteDB(path); err != nil { + return nil, err + } + cfg := config.DefaultDatabaseConfig() + cfg.Driver = config.DatabaseDriverSQLite + cfg.SQLite.Path = path + conn, err := OpenDatabase(cfg) + if err != nil { + return nil, err + } + defer CloseConnection(conn) + if err := MigrateModels(conn); err != nil { + return nil, err + } + return ExportSnapshot(conn, config.DatabaseDriverSQLite) +} + +// EncodePortableBackup serializes a logical snapshot into the portable .xui-backup format. +func EncodePortableBackup(snapshot *BackupSnapshot) ([]byte, error) { + if snapshot == nil { + return nil, errors.New("backup snapshot is nil") + } + + manifestBytes, err := json.MarshalIndent(snapshot.Manifest, "", " ") + if err != nil { + return nil, err + } + + payload := struct { + Users []model.User `json:"users"` + Inbounds []model.Inbound `json:"inbounds"` + ClientTraffics []xray.ClientTraffic `json:"clientTraffics"` + OutboundTraffics []model.OutboundTraffics `json:"outboundTraffics"` + Settings []model.Setting `json:"settings"` + InboundClientIps []model.InboundClientIps `json:"inboundClientIps"` + HistoryOfSeeders []model.HistoryOfSeeders `json:"historyOfSeeders"` + }{ + Users: snapshot.Users, + Inbounds: snapshot.Inbounds, + ClientTraffics: snapshot.ClientTraffics, + OutboundTraffics: snapshot.OutboundTraffics, + Settings: snapshot.Settings, + InboundClientIps: snapshot.InboundClientIps, + HistoryOfSeeders: snapshot.HistoryOfSeeders, + } + + dataBytes, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return nil, err + } + + buffer := &bytes.Buffer{} + archive := zip.NewWriter(buffer) + + manifestWriter, err := archive.Create("manifest.json") + if err != nil { + return nil, err + } + if _, err := manifestWriter.Write(manifestBytes); err != nil { + return nil, err + } + + dataWriter, err := archive.Create("data.json") + if err != nil { + return nil, err + } + if _, err := dataWriter.Write(dataBytes); err != nil { + return nil, err + } + + if err := archive.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +// EncodeCurrentPortableBackup serializes the active database into the portable backup format. +func EncodeCurrentPortableBackup() ([]byte, error) { + snapshot, err := ExportCurrentSnapshot() + if err != nil { + return nil, err + } + return EncodePortableBackup(snapshot) +} + +// DecodePortableBackup parses a .xui-backup archive back into a logical snapshot. +func DecodePortableBackup(data []byte) (*BackupSnapshot, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + + files := make(map[string]*zip.File, len(reader.File)) + for _, file := range reader.File { + files[file.Name] = file + } + + manifestFile, ok := files["manifest.json"] + if !ok { + return nil, errors.New("portable backup is missing manifest.json") + } + dataFile, ok := files["data.json"] + if !ok { + return nil, errors.New("portable backup is missing data.json") + } + + readZipFile := func(file *zip.File) ([]byte, error) { + rc, err := file.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + + manifestBytes, err := readZipFile(manifestFile) + if err != nil { + return nil, err + } + dataBytes, err := readZipFile(dataFile) + if err != nil { + return nil, err + } + + snapshot := &BackupSnapshot{} + if err := json.Unmarshal(manifestBytes, &snapshot.Manifest); err != nil { + return nil, err + } + if snapshot.Manifest.FormatVersion != PortableBackupFormatVersion { + return nil, fmt.Errorf("unsupported backup format version: %d", snapshot.Manifest.FormatVersion) + } + + payload := struct { + Users []model.User `json:"users"` + Inbounds []model.Inbound `json:"inbounds"` + ClientTraffics []xray.ClientTraffic `json:"clientTraffics"` + OutboundTraffics []model.OutboundTraffics `json:"outboundTraffics"` + Settings []model.Setting `json:"settings"` + InboundClientIps []model.InboundClientIps `json:"inboundClientIps"` + HistoryOfSeeders []model.HistoryOfSeeders `json:"historyOfSeeders"` + }{} + + if err := json.Unmarshal(dataBytes, &payload); err != nil { + return nil, err + } + + snapshot.Users = payload.Users + snapshot.Inbounds = payload.Inbounds + snapshot.ClientTraffics = payload.ClientTraffics + snapshot.OutboundTraffics = payload.OutboundTraffics + snapshot.Settings = payload.Settings + snapshot.InboundClientIps = payload.InboundClientIps + snapshot.HistoryOfSeeders = payload.HistoryOfSeeders + return snapshot, nil +} + +func clearApplicationTables(tx *gorm.DB) error { + deleteAll := func(modelRef any) error { + return tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(modelRef).Error + } + + if err := deleteAll(&xray.ClientTraffic{}); err != nil { + return err + } + if err := deleteAll(&model.OutboundTraffics{}); err != nil { + return err + } + if err := deleteAll(&model.InboundClientIps{}); err != nil { + return err + } + if err := deleteAll(&model.HistoryOfSeeders{}); err != nil { + return err + } + if err := deleteAll(&model.Setting{}); err != nil { + return err + } + if err := deleteAll(&model.Inbound{}); err != nil { + return err + } + if err := deleteAll(&model.User{}); err != nil { + return err + } + return nil +} + +func resetPostgresSequence(tx *gorm.DB, tableName string) error { + var seq string + if err := tx.Raw("SELECT pg_get_serial_sequence(?, ?)", tableName, "id").Scan(&seq).Error; err != nil { + return err + } + if seq == "" { + return nil + } + + var maxID int64 + if err := tx.Raw(fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", tableName)).Scan(&maxID).Error; err != nil { + return err + } + + if maxID > 0 { + return tx.Exec("SELECT setval(CAST(? AS regclass), ?, true)", seq, maxID).Error + } + return tx.Exec("SELECT setval(CAST(? AS regclass), ?, false)", seq, 1).Error +} + +func resetSequences(tx *gorm.DB) error { + if tx.Dialector.Name() != "postgres" { + return nil + } + tables := []string{ + "users", + "inbounds", + "client_traffics", + "outbound_traffics", + "settings", + "inbound_client_ips", + "history_of_seeders", + } + for _, tableName := range tables { + if err := resetPostgresSequence(tx, tableName); err != nil { + return err + } + } + return nil +} + +// ApplySnapshot fully replaces application data in the target database using a logical snapshot. +func ApplySnapshot(conn *gorm.DB, snapshot *BackupSnapshot) error { + if conn == nil { + return errors.New("target database is nil") + } + if snapshot == nil { + return errors.New("backup snapshot is nil") + } + if err := MigrateModels(conn); err != nil { + return err + } + + return conn.Transaction(func(tx *gorm.DB) error { + if err := clearApplicationTables(tx); err != nil { + return err + } + + for i := range snapshot.Inbounds { + snapshot.Inbounds[i].ClientStats = nil + } + + if len(snapshot.Users) > 0 { + if err := tx.Create(&snapshot.Users).Error; err != nil { + return err + } + } + if len(snapshot.Inbounds) > 0 { + if err := tx.Create(&snapshot.Inbounds).Error; err != nil { + return err + } + } + if len(snapshot.ClientTraffics) > 0 { + if err := tx.Create(&snapshot.ClientTraffics).Error; err != nil { + return err + } + } + if len(snapshot.OutboundTraffics) > 0 { + if err := tx.Create(&snapshot.OutboundTraffics).Error; err != nil { + return err + } + } + if len(snapshot.Settings) > 0 { + if err := tx.Create(&snapshot.Settings).Error; err != nil { + return err + } + } + if len(snapshot.InboundClientIps) > 0 { + if err := tx.Create(&snapshot.InboundClientIps).Error; err != nil { + return err + } + } + if len(snapshot.HistoryOfSeeders) > 0 { + if err := tx.Create(&snapshot.HistoryOfSeeders).Error; err != nil { + return err + } + } + + return resetSequences(tx) + }) +} + +// SavePortableBackup writes a portable backup archive to disk. +func SavePortableBackup(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/database/db.go b/database/db.go index 6b579dd9..bb4f16d7 100644 --- a/database/db.go +++ b/database/db.go @@ -1,5 +1,5 @@ // 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 import ( @@ -8,28 +8,119 @@ import ( "io" "io/fs" "log" + "net" + "net/url" "os" - "path" + "path/filepath" "slices" + "strconv" + "time" "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/util/crypto" "github.com/mhsanaei/3x-ui/v2/xray" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" - "gorm.io/gorm/logger" + gormlogger "gorm.io/gorm/logger" ) -var db *gorm.DB +var ( + db *gorm.DB + dbConfig *config.DatabaseConfig +) const ( defaultUsername = "admin" defaultPassword = "admin" ) -func initModels() error { +func gormConfig() *gorm.Config { + var loggerImpl gormlogger.Interface + if config.IsDebug() { + loggerImpl = gormlogger.Default + } else { + loggerImpl = gormlogger.Discard + } + return &gorm.Config{Logger: loggerImpl} +} + +func openSQLiteDatabase(path string) (*gorm.DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, fs.ModePerm); err != nil { + return nil, err + } + return gorm.Open(sqlite.Open(path), gormConfig()) +} + +func buildPostgresDSN(cfg *config.DatabaseConfig) string { + user := url.User(cfg.Postgres.User) + if cfg.Postgres.Password != "" { + user = url.UserPassword(cfg.Postgres.User, cfg.Postgres.Password) + } + authority := net.JoinHostPort(cfg.Postgres.Host, strconv.Itoa(cfg.Postgres.Port)) + u := &url.URL{ + Scheme: "postgres", + User: user, + Host: authority, + Path: cfg.Postgres.DBName, + } + query := u.Query() + query.Set("sslmode", cfg.Postgres.SSLMode) + u.RawQuery = query.Encode() + return u.String() +} + +func openPostgresDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) { + gormCfg := gormConfig() + gormCfg.PrepareStmt = true + conn, err := gorm.Open(postgres.Open(buildPostgresDSN(cfg)), gormCfg) + if err != nil { + return nil, err + } + sqlDB, err := conn.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxIdleConns(5) + sqlDB.SetMaxOpenConns(25) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + sqlDB.SetConnMaxIdleTime(10 * time.Minute) + return conn, nil +} + +// OpenDatabase opens a database connection from the provided runtime configuration. +func OpenDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) { + if cfg == nil { + cfg = config.DefaultDatabaseConfig() + } + cfg = cfg.Clone().Normalize() + + switch cfg.Driver { + case config.DatabaseDriverSQLite: + return openSQLiteDatabase(cfg.SQLite.Path) + case config.DatabaseDriverPostgres: + return openPostgresDatabase(cfg) + default: + return nil, errors.New("unsupported database driver: " + cfg.Driver) + } +} + +// CloseConnection closes a standalone gorm connection. +func CloseConnection(conn *gorm.DB) error { + if conn == nil { + return nil + } + sqlDB, err := conn.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func initModels(conn *gorm.DB) error { models := []any{ &model.User{}, &model.Inbound{}, @@ -39,8 +130,8 @@ func initModels() error { &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, } - for _, model := range models { - if err := db.AutoMigrate(model); err != nil { + for _, item := range models { + if err := conn.AutoMigrate(item); err != nil { log.Printf("Error auto migrating model: %v", err) return err } @@ -48,33 +139,40 @@ func initModels() error { return nil } -// initUser creates a default admin user if the users table is empty. -func initUser() error { - empty, err := isTableEmpty("users") +func isTableEmpty(conn *gorm.DB, tableName string) (bool, error) { + if !conn.Migrator().HasTable(tableName) { + return true, nil + } + var count int64 + err := conn.Table(tableName).Count(&count).Error + return count == 0, err +} + +func initUser(conn *gorm.DB) error { + empty, err := isTableEmpty(conn, "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 + if !empty { + return nil } - return nil + + 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 conn.Create(user).Error } -// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running. -func runSeeders(isUsersEmpty bool) error { - empty, err := isTableEmpty("history_of_seeders") +func runSeeders(conn *gorm.DB, isUsersEmpty bool) error { + empty, err := isTableEmpty(conn, "history_of_seeders") if err != nil { log.Printf("Error checking if users table is empty: %v", err) return err @@ -84,97 +182,184 @@ func runSeeders(isUsersEmpty bool) error { hashSeeder := &model.HistoryOfSeeders{ SeederName: "UserPasswordHash", } - return db.Create(hashSeeder).Error - } else { - var seedersHistory []string - db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory) + return conn.Create(hashSeeder).Error + } - if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { - var users []model.User - db.Find(&users) + var seedersHistory []string + if err := conn.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { + return err + } - for _, user := range users { - hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) - if err != nil { - log.Printf("Error hashing password for user '%s': %v", user.Username, err) - return err - } - db.Model(&user).Update("password", hashedPassword) - } + if slices.Contains(seedersHistory, "UserPasswordHash") || isUsersEmpty { + return nil + } - hashSeeder := &model.HistoryOfSeeders{ - SeederName: "UserPasswordHash", - } - return db.Create(hashSeeder).Error + var users []model.User + if err := conn.Find(&users).Error; err != nil { + return err + } + + for _, user := range users { + hashedPassword, hashErr := crypto.HashPasswordAsBcrypt(user.Password) + if hashErr != nil { + log.Printf("Error hashing password for user '%s': %v", user.Username, hashErr) + return hashErr + } + if err := conn.Model(&user).Update("password", hashedPassword).Error; err != nil { + return err } } - return nil + hashSeeder := &model.HistoryOfSeeders{ + SeederName: "UserPasswordHash", + } + return conn.Create(hashSeeder).Error } -// isTableEmpty returns true if the named table contains zero rows. -func isTableEmpty(tableName string) (bool, error) { - var count int64 - err := db.Table(tableName).Count(&count).Error - return count == 0, err +// MigrateModels migrates the database schema for all panel models. +func MigrateModels(conn *gorm.DB) error { + return initModels(conn) +} + +// PrepareDatabase migrates the schema and optionally seeds the database. +func PrepareDatabase(conn *gorm.DB, seed bool) error { + if err := initModels(conn); err != nil { + return err + } + if !seed { + return nil + } + + isUsersEmpty, err := isTableEmpty(conn, "users") + if err != nil { + return err + } + + if err := initUser(conn); err != nil { + return err + } + return runSeeders(conn, isUsersEmpty) +} + +// TestConnection verifies that the provided database configuration is reachable. +func TestConnection(cfg *config.DatabaseConfig) error { + conn, err := OpenDatabase(cfg) + if err != nil { + return err + } + defer CloseConnection(conn) + + sqlDB, err := conn.DB() + if err != nil { + return err + } + return sqlDB.Ping() } // InitDB sets up the database connection, migrates models, and runs seeders. -func InitDB(dbPath string) error { - dir := path.Dir(dbPath) - err := os.MkdirAll(dir, fs.ModePerm) +func InitDB() error { + cfg, err := config.LoadDatabaseConfig() if err != nil { return err } - - var gormLogger logger.Interface - - if config.IsDebug() { - gormLogger = logger.Default - } else { - gormLogger = logger.Discard - } - - c := &gorm.Config{ - Logger: gormLogger, - } - db, err = gorm.Open(sqlite.Open(dbPath), c) - if err != nil { - return err - } - - if err := initModels(); err != nil { - return err - } - - isUsersEmpty, err := isTableEmpty("users") - if err != nil { - return err - } - - if err := initUser(); err != nil { - return err - } - return runSeeders(isUsersEmpty) + return InitDBWithConfig(cfg) } -// CloseDB closes the database connection if it exists. -func CloseDB() error { - if db != nil { - sqlDB, err := db.DB() - if err != nil { - return err - } - return sqlDB.Close() +// InitDBWithConfig sets up the database using an explicit runtime config. +func InitDBWithConfig(cfg *config.DatabaseConfig) error { + conn, err := OpenDatabase(cfg) + if err != nil { + return err } + if err := PrepareDatabase(conn, true); err != nil { + _ = CloseConnection(conn) + return err + } + + if err := CloseDB(); err != nil { + _ = CloseConnection(conn) + return err + } + + db = conn + dbConfig = cfg.Clone().Normalize() return nil } +// CloseDB closes the global database connection if it exists. +func CloseDB() error { + if db == nil { + dbConfig = nil + return nil + } + err := CloseConnection(db) + db = nil + dbConfig = nil + return err +} + // GetDB returns the global GORM database instance. func GetDB() *gorm.DB { return db } +// GetDBConfig returns a copy of the active database runtime configuration. +func GetDBConfig() *config.DatabaseConfig { + if dbConfig == nil { + return nil + } + return dbConfig.Clone() +} + +// GetDriver returns the active GORM dialector name. +func GetDriver() string { + if db != nil && db.Dialector != nil { + return db.Dialector.Name() + } + if dbConfig != nil { + switch dbConfig.Driver { + case config.DatabaseDriverPostgres: + return "postgres" + case config.DatabaseDriverSQLite: + return "sqlite" + } + } + return "" +} + +// IsSQLite reports whether the active database uses SQLite. +func IsSQLite() bool { + return GetDriver() == "sqlite" +} + +// IsPostgres reports whether the active database uses PostgreSQL. +func IsPostgres() bool { + return GetDriver() == "postgres" +} + +// IsDatabaseEmpty reports whether the provided database contains any application rows. +func IsDatabaseEmpty(conn *gorm.DB) (bool, error) { + tables := []string{ + "users", + "inbounds", + "outbound_traffics", + "settings", + "inbound_client_ips", + "client_traffics", + "history_of_seeders", + } + for _, table := range tables { + empty, err := isTableEmpty(conn, table) + if err != nil { + return false, err + } + if !empty { + return false, nil + } + } + return true, nil +} + // IsNotFound checks if the given error is a GORM record not found error. func IsNotFound(err error) bool { return err == gorm.ErrRecordNotFound @@ -193,22 +378,20 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) { // Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency. func Checkpoint() error { - // Update WAL - err := db.Exec("PRAGMA wal_checkpoint;").Error - if err != nil { - return err + if !IsSQLite() || db == nil { + return nil } - return nil + return db.Exec("PRAGMA wal_checkpoint;").Error } // ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection // and runs a PRAGMA integrity_check to ensure the file is structurally sound. // It does not mutate global state or run migrations. func ValidateSQLiteDB(dbPath string) error { - if _, err := os.Stat(dbPath); err != nil { // file must exist + if _, err := os.Stat(dbPath); err != nil { return err } - gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard}) + gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.Discard}) if err != nil { return err } @@ -217,6 +400,7 @@ func ValidateSQLiteDB(dbPath string) error { return err } defer sqlDB.Close() + var res string if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil { return err diff --git a/database/manager.go b/database/manager.go new file mode 100644 index 00000000..f73fa5b5 --- /dev/null +++ b/database/manager.go @@ -0,0 +1,118 @@ +package database + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" +) + +func configsEqual(a, b *config.DatabaseConfig) bool { + if a == nil || b == nil { + return false + } + a = a.Clone().Normalize() + b = b.Clone().Normalize() + if a.Driver != b.Driver { + return false + } + if a.Driver == config.DatabaseDriverSQLite { + return a.SQLite.Path == b.SQLite.Path + } + return a.Postgres.Mode == b.Postgres.Mode && + a.Postgres.Host == b.Postgres.Host && + a.Postgres.Port == b.Postgres.Port && + a.Postgres.DBName == b.Postgres.DBName && + a.Postgres.User == b.Postgres.User && + a.Postgres.Password == b.Postgres.Password && + a.Postgres.SSLMode == b.Postgres.SSLMode && + a.Postgres.ManagedLocally == b.Postgres.ManagedLocally +} + +func loadSnapshotFromConfig(cfg *config.DatabaseConfig) (*BackupSnapshot, error) { + cfg = cfg.Clone().Normalize() + if cfg.UsesSQLite() { + if _, err := os.Stat(cfg.SQLite.Path); err != nil { + if os.IsNotExist(err) { + return newBackupSnapshot(cfg.Driver), nil + } + return nil, err + } + } + + conn, err := OpenDatabase(cfg) + if err != nil { + return nil, err + } + defer CloseConnection(conn) + if err := MigrateModels(conn); err != nil { + return nil, err + } + return ExportSnapshot(conn, cfg.Driver) +} + +func saveSwitchBackup(snapshot *BackupSnapshot, prefix string) error { + if snapshot == nil { + return nil + } + data, err := EncodePortableBackup(snapshot) + if err != nil { + return err + } + name := fmt.Sprintf("%s-%s.xui-backup", prefix, time.Now().UTC().Format("20060102-150405")) + return SavePortableBackup(filepath.Join(config.GetBackupFolderPath(), name), data) +} + +// SwitchDatabase migrates panel data into a new backend and writes the new runtime configuration. +func SwitchDatabase(target *config.DatabaseConfig) error { + if target == nil { + return errors.New("target database configuration is nil") + } + target = target.Clone().Normalize() + + currentCfg, err := config.LoadDatabaseConfig() + if err != nil { + return err + } + if configsEqual(currentCfg, target) { + return config.SaveDatabaseConfig(target) + } + + sourceSnapshot, err := loadSnapshotFromConfig(currentCfg) + if err != nil { + return err + } + if err := saveSwitchBackup(sourceSnapshot, "switch"); err != nil { + return err + } + + if err := TestConnection(target); err != nil { + return err + } + + targetConn, err := OpenDatabase(target) + if err != nil { + return err + } + defer CloseConnection(targetConn) + + if err := MigrateModels(targetConn); err != nil { + return err + } + isEmpty, err := IsDatabaseEmpty(targetConn) + if err != nil { + return err + } + if !isEmpty { + return errors.New("target database must be empty before switching") + } + + if err := ApplySnapshot(targetConn, sourceSnapshot); err != nil { + return err + } + + return config.SaveDatabaseConfig(target) +} diff --git a/docker-compose.yml b/docker-compose.yml index 198df198..6d5a46ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,31 @@ services: environment: XRAY_VMESS_AEAD_FORCED: "false" XUI_ENABLE_FAIL2BAN: "true" + # XUI_DB_DRIVER: "postgres" + # XUI_DB_MODE: "external" + # XUI_DB_HOST: "127.0.0.1" + # XUI_DB_PORT: "5432" + # XUI_DB_NAME: "xui" + # XUI_DB_USER: "xui" + # XUI_DB_PASSWORD: "change-me" + # XUI_DB_SSLMODE: "disable" tty: true network_mode: host restart: unless-stopped + depends_on: + - postgres + postgres: + image: postgres:17-alpine + container_name: 3xui_postgres + environment: + POSTGRES_DB: xui + POSTGRES_USER: xui + POSTGRES_PASSWORD: change-me + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + +volumes: + postgres_data: diff --git a/go.mod b/go.mod index a51dc36b..d0b29fcf 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 google.golang.org/grpc v1.80.0 + gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -53,6 +54,10 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.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.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 4946712b..3733dbdc 100644 --- a/go.sum +++ b/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/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/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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +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/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= 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.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.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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -271,6 +280,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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= diff --git a/install.sh b/install.sh index af6b8a51..f0030db0 100644 --- a/install.sh +++ b/install.sh @@ -639,7 +639,121 @@ prompt_and_setup_ssl() { esac } +configure_database_backend() { + local db_config_path="/etc/x-ui/database.json" + if [[ -f "${db_config_path}" ]] || compgen -G "/etc/x-ui/*.db" >/dev/null; then + echo -e "${green}Existing database configuration detected. Keeping current backend.${plain}" + return 0 + fi + + local db_choice="" + echo "" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${green} Database Backend Setup ${plain}" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${green}1.${plain} SQLite" + echo -e "${green}2.${plain} PostgreSQL" + read -rp "Choose database backend (default 1): " db_choice + db_choice="${db_choice// /}" + + if [[ "${db_choice}" != "2" ]]; then + echo -e "${green}Using SQLite as the panel database backend.${plain}" + return 0 + fi + + local pg_mode="" + local pg_host="127.0.0.1" + local pg_port="5432" + local pg_db="xui" + local pg_user="xui" + local pg_password="" + local pg_sslmode="disable" + + echo -e "${green}1.${plain} Local PostgreSQL" + echo -e "${green}2.${plain} External PostgreSQL" + read -rp "Choose PostgreSQL mode (default 1): " pg_mode + pg_mode="${pg_mode// /}" + + if [[ "${pg_mode}" != "2" ]]; then + if [[ ! -x "${xui_folder}/postgres-manager.sh" ]]; then + echo -e "${red}postgres-manager.sh is missing from the installation package.${plain}" + exit 1 + fi + + local pg_status + pg_status=$(bash "${xui_folder}/postgres-manager.sh" status 2>/dev/null || true) + if [[ "${pg_status}" != *"installed=true"* ]]; then + local install_pg="" + read -rp "PostgreSQL is not installed. Install automatically now? [Y/n]: " install_pg + if [[ -z "${install_pg}" || "${install_pg}" == "y" || "${install_pg}" == "Y" ]]; then + bash "${xui_folder}/postgres-manager.sh" install + else + echo -e "${red}PostgreSQL installation was declined. Aborting PostgreSQL setup.${plain}" + exit 1 + fi + fi + + read -rp "PostgreSQL host [127.0.0.1]: " pg_host + pg_host="${pg_host:-127.0.0.1}" + read -rp "PostgreSQL port [5432]: " pg_port + pg_port="${pg_port:-5432}" + read -rp "PostgreSQL database name [xui]: " pg_db + pg_db="${pg_db:-xui}" + read -rp "PostgreSQL username [xui]: " pg_user + pg_user="${pg_user:-xui}" + read -rp "PostgreSQL password [random]: " pg_password + [[ -z "${pg_password}" ]] && pg_password=$(gen_random_string 18) + + ${xui_folder}/x-ui database switch \ + -driver postgres \ + -postgres-mode local \ + -postgres-host "${pg_host}" \ + -postgres-port "${pg_port}" \ + -postgres-db "${pg_db}" \ + -postgres-user "${pg_user}" \ + -postgres-password "${pg_password}" \ + -postgres-local true + else + read -rp "External PostgreSQL host: " pg_host + if [[ -z "${pg_host}" ]]; then + echo -e "${red}PostgreSQL host is required.${plain}" + exit 1 + fi + read -rp "External PostgreSQL port [5432]: " pg_port + pg_port="${pg_port:-5432}" + read -rp "Database name [xui]: " pg_db + pg_db="${pg_db:-xui}" + read -rp "Username [xui]: " pg_user + pg_user="${pg_user:-xui}" + read -rp "Password: " pg_password + if [[ -z "${pg_password}" ]]; then + echo -e "${red}PostgreSQL password is required for external connections.${plain}" + exit 1 + fi + read -rp "SSL mode [disable]: " pg_sslmode + pg_sslmode="${pg_sslmode:-disable}" + + ${xui_folder}/x-ui database switch \ + -driver postgres \ + -postgres-mode external \ + -postgres-host "${pg_host}" \ + -postgres-port "${pg_port}" \ + -postgres-db "${pg_db}" \ + -postgres-user "${pg_user}" \ + -postgres-password "${pg_password}" \ + -postgres-sslmode "${pg_sslmode}" \ + -postgres-local false + fi + + if [[ $? -ne 0 ]]; then + echo -e "${red}Failed to configure PostgreSQL backend.${plain}" + exit 1 + fi + echo -e "${green}Database backend configured successfully.${plain}" +} + config_after_install() { + configure_database_backend local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') @@ -822,6 +936,9 @@ install_x-ui() { cd x-ui chmod +x x-ui chmod +x x-ui.sh + if [[ -f postgres-manager.sh ]]; then + chmod +x postgres-manager.sh + fi # Check the system's architecture and rename the file accordingly if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then diff --git a/main.go b/main.go index f8d3357b..bd88ddaa 100644 --- a/main.go +++ b/main.go @@ -3,11 +3,13 @@ package main import ( + "encoding/json" "flag" "fmt" "log" "os" "os/signal" + "path/filepath" "syscall" _ "unsafe" @@ -18,6 +20,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/sys" "github.com/mhsanaei/3x-ui/v2/web" + "github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/web/global" "github.com/mhsanaei/3x-ui/v2/web/service" @@ -46,7 +49,7 @@ func runWebServer() { godotenv.Load() - err := database.InitDB(config.GetDBPath()) + err := database.InitDB() if err != nil { log.Fatalf("Error initializing database: %v", err) } @@ -131,7 +134,7 @@ func runWebServer() { // resetSetting resets all panel settings to their default values. func resetSetting() { - err := database.InitDB(config.GetDBPath()) + err := database.InitDB() if err != nil { fmt.Println("Failed to initialize database:", err) return @@ -154,11 +157,23 @@ func showSetting(show bool) { if err != nil { fmt.Println("get current port failed, error info:", err) } + subPort, err := settingService.GetSubPort() + if err != nil { + fmt.Println("get current sub port failed, error info:", err) + } webBasePath, err := settingService.GetBasePath() if err != nil { fmt.Println("get webBasePath failed, error info:", err) } + listen, err := settingService.GetListen() + if err != nil { + fmt.Println("get current listen failed, error info:", err) + } + subListen, err := settingService.GetSubListen() + if err != nil { + fmt.Println("get current sub listen failed, error info:", err) + } certFile, err := settingService.GetCertFile() if err != nil { @@ -192,6 +207,9 @@ func showSetting(show bool) { fmt.Println("hasDefaultCredential:", hasDefaultCredential) fmt.Println("port:", port) + fmt.Println("listen:", listen) + fmt.Println("subPort:", subPort) + fmt.Println("subListen:", subListen) fmt.Println("webBasePath:", webBasePath) } } @@ -218,7 +236,7 @@ func updateTgbotEnableSts(status bool) { // updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule. func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) { - err := database.InitDB(config.GetDBPath()) + err := database.InitDB() if err != nil { fmt.Println("Error initializing database:", err) return @@ -254,9 +272,9 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri } } -// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication. -func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) { - err := database.InitDB(config.GetDBPath()) +// updateSetting updates various panel settings including ports, credentials, base path, listen IPs, and two-factor authentication. +func updateSetting(port int, subPort int, username string, password string, webBasePath string, listenIP string, subListenIP string, resetTwoFactor bool) { + err := database.InitDB() if err != nil { fmt.Println("Database initialization failed:", err) return @@ -308,14 +326,32 @@ func updateSetting(port int, username string, password string, webBasePath strin if err != nil { fmt.Println("Failed to set listen IP:", err) } else { - fmt.Printf("listen %v set successfully", listenIP) + fmt.Printf("listen %v set successfully\n", listenIP) + } + } + + if subPort > 0 { + err := settingService.SetSubPort(subPort) + if err != nil { + fmt.Println("Failed to set sub port:", err) + } else { + fmt.Printf("Sub port set successfully: %v\n", subPort) + } + } + + if subListenIP != "" { + err := settingService.SetSubListen(subListenIP) + if err != nil { + fmt.Println("Failed to set sub listen IP:", err) + } else { + fmt.Printf("sub listen %v set successfully\n", subListenIP) } } } // updateCert updates the SSL certificate files for the panel. func updateCert(publicKey string, privateKey string) { - err := database.InitDB(config.GetDBPath()) + err := database.InitDB() if err != nil { fmt.Println(err) return @@ -392,7 +428,7 @@ func GetListenIP(getListen bool) { func migrateDb() { inboundService := service.InboundService{} - err := database.InitDB(config.GetDBPath()) + err := database.InitDB() if err != nil { log.Fatal(err) } @@ -401,6 +437,161 @@ func migrateDb() { fmt.Println("Migration done!") } +func defaultDatabaseSetting() *entity.DatabaseSetting { + databaseService := &service.DatabaseService{} + setting, err := databaseService.GetSetting() + if err == nil && setting != nil { + return setting + } + return entity.DatabaseSettingFromConfig(config.DefaultDatabaseConfig()) +} + +func addDatabaseFlags(fs *flag.FlagSet, setting *entity.DatabaseSetting) { + fs.StringVar(&setting.Driver, "driver", setting.Driver, "Database driver: sqlite or postgres") + fs.StringVar(&setting.SQLitePath, "sqlite-path", setting.SQLitePath, "SQLite database path") + fs.StringVar(&setting.PostgresMode, "postgres-mode", setting.PostgresMode, "PostgreSQL mode: local or external") + fs.StringVar(&setting.PostgresHost, "postgres-host", setting.PostgresHost, "PostgreSQL host") + fs.IntVar(&setting.PostgresPort, "postgres-port", setting.PostgresPort, "PostgreSQL port") + fs.StringVar(&setting.PostgresDBName, "postgres-db", setting.PostgresDBName, "PostgreSQL database name") + fs.StringVar(&setting.PostgresUser, "postgres-user", setting.PostgresUser, "PostgreSQL user") + fs.StringVar(&setting.PostgresPassword, "postgres-password", "", "PostgreSQL password") + fs.StringVar(&setting.PostgresSSLMode, "postgres-sslmode", setting.PostgresSSLMode, "PostgreSQL sslmode") + fs.BoolVar(&setting.ManagedLocally, "postgres-local", setting.ManagedLocally, "Treat PostgreSQL as locally managed") +} + +func writeExportFile(outputPath string, defaultName string, data []byte) (string, error) { + targetPath := outputPath + if targetPath == "" { + targetPath = defaultName + } else if info, err := os.Stat(targetPath); err == nil && info.IsDir() { + targetPath = filepath.Join(targetPath, defaultName) + } + + if err := os.WriteFile(targetPath, data, 0o600); err != nil { + return "", err + } + return targetPath, nil +} + +func handleDatabaseCommand(args []string) { + if len(args) == 0 { + fmt.Println("Usage:") + fmt.Println(" x-ui database show") + fmt.Println(" x-ui database test [database flags]") + fmt.Println(" x-ui database switch [database flags]") + fmt.Println(" x-ui database export -type portable|sqlite [-out path]") + fmt.Println(" x-ui database import -file backup.xui-backup") + fmt.Println(" x-ui database install-postgres") + return + } + + databaseService := service.DatabaseService{} + + switch args[0] { + case "show": + setting, err := databaseService.GetSetting() + if err != nil { + fmt.Println("Failed to load database settings:", err) + return + } + contents, err := json.MarshalIndent(setting, "", " ") + if err != nil { + fmt.Println("Failed to serialize database settings:", err) + return + } + fmt.Println(string(contents)) + case "test": + setting := defaultDatabaseSetting() + testCmd := flag.NewFlagSet("database test", flag.ExitOnError) + addDatabaseFlags(testCmd, setting) + _ = testCmd.Parse(args[1:]) + if err := databaseService.TestSetting(setting); err != nil { + fmt.Println("Database connection test failed:", err) + return + } + fmt.Println("Database connection test succeeded.") + case "switch": + setting := defaultDatabaseSetting() + switchCmd := flag.NewFlagSet("database switch", flag.ExitOnError) + addDatabaseFlags(switchCmd, setting) + _ = switchCmd.Parse(args[1:]) + if err := databaseService.SwitchDatabase(setting); err != nil { + fmt.Println("Database switch failed:", err) + return + } + fmt.Println("Database configuration updated. Restart the panel service to apply changes.") + case "export": + exportCmd := flag.NewFlagSet("database export", flag.ExitOnError) + exportType := exportCmd.String("type", "portable", "Export type: portable or sqlite") + outputPath := exportCmd.String("out", "", "Output path or directory") + _ = exportCmd.Parse(args[1:]) + + if err := database.InitDB(); err != nil { + fmt.Println("Failed to initialize database:", err) + return + } + + var ( + data []byte + filename string + err error + ) + switch *exportType { + case "sqlite": + data, filename, err = databaseService.ExportNativeSQLite() + default: + data, filename, err = databaseService.ExportPortableBackup() + } + if err != nil { + fmt.Println("Export failed:", err) + return + } + + targetPath, err := writeExportFile(*outputPath, filename, data) + if err != nil { + fmt.Println("Failed to write backup:", err) + return + } + fmt.Println("Backup exported to:", targetPath) + case "import": + importCmd := flag.NewFlagSet("database import", flag.ExitOnError) + backupFile := importCmd.String("file", "", "Path to .xui-backup or legacy SQLite .db file") + _ = importCmd.Parse(args[1:]) + if *backupFile == "" { + fmt.Println("Import requires -file") + return + } + + if err := database.InitDB(); err != nil { + fmt.Println("Failed to initialize database:", err) + return + } + + file, err := os.Open(*backupFile) + if err != nil { + fmt.Println("Failed to open backup file:", err) + return + } + defer file.Close() + + backupType, err := databaseService.ImportBackup(file) + if err != nil { + fmt.Println("Import failed:", err) + return + } + fmt.Println("Import completed using", backupType, "backup. Restart the panel service to apply changes.") + case "install-postgres": + output, err := databaseService.InstallLocalPostgres() + if err != nil { + fmt.Println("PostgreSQL installation failed:", err) + return + } + fmt.Print(output) + default: + fmt.Println("Unknown database subcommand:", args[0]) + } +} + // main is the entry point of the 3x-ui application. // It parses command-line arguments to run the web server, migrate database, or update settings. func main() { @@ -416,10 +607,12 @@ func main() { settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) var port int + var subPort int var username string var password string var webBasePath string var listenIP string + var subListenIP string var getListen bool var webCertFile string var webKeyFile string @@ -434,10 +627,12 @@ func main() { settingCmd.BoolVar(&reset, "reset", false, "Reset all settings") settingCmd.BoolVar(&show, "show", false, "Display current settings") settingCmd.IntVar(&port, "port", 0, "Set panel port number") + settingCmd.IntVar(&subPort, "subPort", 0, "Set subscription port number") settingCmd.StringVar(&username, "username", "", "Set login username") settingCmd.StringVar(&password, "password", "", "Set login password") settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel") settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP") + settingCmd.StringVar(&subListenIP, "subListenIP", "", "set subscription listenIP IP") settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings") settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP") settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings") @@ -456,6 +651,7 @@ func main() { fmt.Println(" run run web panel") fmt.Println(" migrate migrate form other/old x-ui") fmt.Println(" setting set settings") + fmt.Println(" database manage database backend") } flag.Parse() @@ -483,7 +679,7 @@ func main() { if reset { resetSetting() } else { - updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) + updateSetting(port, subPort, username, password, webBasePath, listenIP, subListenIP, resetTwoFactor) } if show { showSetting(show) @@ -511,6 +707,8 @@ func main() { } else { updateCert(webCertFile, webKeyFile) } + case "database": + handleDatabaseCommand(os.Args[2:]) default: fmt.Println("Invalid subcommands") fmt.Println() diff --git a/postgres-manager.sh b/postgres-manager.sh new file mode 100644 index 00000000..113ab5b3 --- /dev/null +++ b/postgres-manager.sh @@ -0,0 +1,271 @@ +#!/bin/bash + +set -euo pipefail + +release="" +version_id="" + +load_os() { + if [[ -f /etc/os-release ]]; then + source /etc/os-release + release="${ID:-}" + version_id="${VERSION_ID:-}" + return + fi + if [[ -f /usr/lib/os-release ]]; then + source /usr/lib/os-release + release="${ID:-}" + version_id="${VERSION_ID:-}" + return + fi + release="unknown" + version_id="" +} + +load_os + +service_name() { + echo "postgresql" +} + +data_dir() { + case "${release}" in + arch|manjaro|parch) + echo "/var/lib/postgres/data" + ;; + alpine) + echo "/var/lib/postgresql/data" + ;; + centos|fedora|rhel|rocky|almalinux|ol|amzn|virtuozzo) + echo "/var/lib/pgsql/data" + ;; + *) + echo "/var/lib/postgresql/data" + ;; + esac +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + echo "This command requires root privileges" >&2 + exit 1 + fi +} + +is_installed() { + command -v psql >/dev/null 2>&1 +} + +is_running() { + local service + service="$(service_name)" + if command -v systemctl >/dev/null 2>&1; then + systemctl is-active --quiet "${service}" && return 0 || return 1 + fi + if command -v rc-service >/dev/null 2>&1; then + rc-service "${service}" status >/dev/null 2>&1 && return 0 || return 1 + fi + return 1 +} + +start_service() { + local service + service="$(service_name)" + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable "${service}" >/dev/null 2>&1 || true + systemctl start "${service}" + return + fi + if command -v rc-service >/dev/null 2>&1; then + rc-update add "${service}" >/dev/null 2>&1 || true + rc-service "${service}" start + return + fi + echo "No supported service manager found" >&2 + exit 1 +} + +run_as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + return + fi + local quoted="" + for arg in "$@"; do + quoted+=" $(printf "%q" "$arg")" + done + su - postgres -c "${quoted# }" +} + +escape_sql_ident() { + local value="$1" + value="${value//\"/\"\"}" + printf '%s' "${value}" +} + +escape_sql_literal() { + local value="$1" + value="${value//\'/\'\'}" + printf '%s' "${value}" +} + +install_packages() { + require_root + case "${release}" in + ubuntu|debian|armbian) + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql postgresql-client + ;; + fedora|rhel|rocky|almalinux|ol|amzn|virtuozzo) + dnf -y install postgresql-server postgresql + ;; + centos) + if [[ "${version_id}" =~ ^7 ]]; then + yum -y install postgresql-server postgresql + else + dnf -y install postgresql-server postgresql + fi + ;; + arch|manjaro|parch) + pacman -Syu --noconfirm postgresql + ;; + alpine) + apk add --no-cache postgresql postgresql-client + ;; + *) + echo "Unsupported OS for automatic PostgreSQL installation: ${release}" >&2 + exit 1 + ;; + esac +} + +init_local() { + require_root + local pg_data + pg_data="$(data_dir)" + + if ! is_installed; then + install_packages + fi + + case "${release}" in + fedora|rhel|rocky|almalinux|ol|amzn|virtuozzo|centos) + if [[ ! -f "${pg_data}/PG_VERSION" ]]; then + if command -v postgresql-setup >/dev/null 2>&1; then + postgresql-setup --initdb + elif [[ -x /usr/bin/postgresql-setup ]]; then + /usr/bin/postgresql-setup --initdb + fi + fi + ;; + arch|manjaro|parch|alpine) + mkdir -p "${pg_data}" + chown -R postgres:postgres "$(dirname "${pg_data}")" "${pg_data}" >/dev/null 2>&1 || true + if [[ ! -f "${pg_data}/PG_VERSION" ]]; then + run_as_postgres initdb -D "${pg_data}" + fi + ;; + *) + ;; + esac + + start_service +} + +create_db_user() { + require_root + local db_user="" + local db_password="" + local db_name="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --user) + db_user="$2" + shift 2 + ;; + --password) + db_password="$2" + shift 2 + ;; + --db) + db_name="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac + done + + if [[ -z "${db_user}" || -z "${db_name}" ]]; then + echo "Both --user and --db are required" >&2 + exit 1 + fi + + init_local + + local safe_user safe_password safe_db + safe_user="$(escape_sql_ident "${db_user}")" + safe_password="$(escape_sql_literal "${db_password}")" + safe_db="$(escape_sql_ident "${db_name}")" + + if [[ -z "$(run_as_postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$(escape_sql_literal "${db_user}")'")" ]]; then + if [[ -n "${db_password}" ]]; then + run_as_postgres psql -v ON_ERROR_STOP=1 -c "CREATE ROLE \"${safe_user}\" LOGIN PASSWORD '${safe_password}';" + else + run_as_postgres psql -v ON_ERROR_STOP=1 -c "CREATE ROLE \"${safe_user}\" LOGIN;" + fi + elif [[ -n "${db_password}" ]]; then + run_as_postgres psql -v ON_ERROR_STOP=1 -c "ALTER ROLE \"${safe_user}\" WITH PASSWORD '${safe_password}';" + fi + + if [[ -z "$(run_as_postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$(escape_sql_literal "${db_name}")'")" ]]; then + run_as_postgres psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"${safe_db}\" OWNER \"${safe_user}\";" + fi +} + +show_status() { + if is_installed; then + echo "installed=true" + else + echo "installed=false" + fi + if is_running; then + echo "running=true" + else + echo "running=false" + fi + echo "service=$(service_name)" + echo "data_dir=$(data_dir)" +} + +command="${1:-status}" +if [[ $# -gt 0 ]]; then + shift +fi + +case "${command}" in + status) + show_status + ;; + install) + install_packages + init_local + show_status + ;; + init-local) + init_local + show_status + ;; + create-db-user) + create_db_user "$@" + show_status + ;; + *) + echo "Unsupported command: ${command}" >&2 + exit 1 + ;; +esac diff --git a/sub/subService.go b/sub/subService.go index 818f193b..319d9f4c 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -114,18 +114,29 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { db := database.GetDB() - var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( - SELECT DISTINCT inbounds.id - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - WHERE - protocol in ('vmess','vless','trojan','shadowsocks') - AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ? - )`, subId, true).Find(&inbounds).Error + var candidates []*model.Inbound + err := db.Model(model.Inbound{}). + Preload("ClientStats"). + Where("protocol IN ?", []string{"vmess", "vless", "trojan", "shadowsocks"}). + Where("enable = ?", true). + Find(&candidates).Error if err != nil { return nil, err } + + inbounds := make([]*model.Inbound, 0) + for _, inbound := range candidates { + clients, clientsErr := s.inboundService.GetClients(inbound) + if clientsErr != nil { + return nil, clientsErr + } + for _, client := range clients { + if client.SubID == subId { + inbounds = append(inbounds, inbound) + break + } + } + } return inbounds, nil } @@ -140,15 +151,42 @@ func (s *SubService) getClientTraffics(traffics []xray.ClientTraffic, email stri func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) { db := database.GetDB() - var inbound *model.Inbound - err := db.Model(model.Inbound{}). - Where("JSON_TYPE(settings, '$.fallbacks') = 'array'"). - Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", dest). - Find(&inbound).Error + var inbounds []model.Inbound + err := db.Model(model.Inbound{}).Find(&inbounds).Error if err != nil { return "", 0, "", err } + var inbound *model.Inbound + for i := range inbounds { + settings := map[string]any{} + if json.Unmarshal([]byte(inbounds[i].Settings), &settings) != nil { + continue + } + fallbacks, ok := settings["fallbacks"].([]any) + if !ok { + continue + } + found := false + for _, fallback := range fallbacks { + fallbackMap, ok := fallback.(map[string]any) + if !ok { + continue + } + if fallbackDest, ok := fallbackMap["dest"].(string); ok && fallbackDest == dest { + found = true + break + } + } + if found { + inbound = &inbounds[i] + break + } + } + if inbound == nil { + return "", 0, "", fmt.Errorf("fallback master not found") + } + var stream map[string]any json.Unmarshal([]byte(streamSettings), &stream) var masterStream map[string]any diff --git a/update.sh b/update.sh index b9cb3ddc..a9c10dc2 100755 --- a/update.sh +++ b/update.sh @@ -822,6 +822,9 @@ update_x-ui() { rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1 cd x-ui >/dev/null 2>&1 chmod +x x-ui >/dev/null 2>&1 + if [[ -f postgres-manager.sh ]]; then + chmod +x postgres-manager.sh >/dev/null 2>&1 + fi # Check the system's architecture and rename the file accordingly if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index af80a63e..c55d1c7d 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -86,4 +86,35 @@ class AllSetting { equals(other) { return ObjectUtil.equals(this, other); } -} \ No newline at end of file +} + +class DatabaseSetting { + + constructor(data) { + this.driver = "sqlite"; + this.configSource = "default"; + this.readOnly = false; + this.sqlitePath = ""; + this.postgresMode = "external"; + this.postgresHost = "127.0.0.1"; + this.postgresPort = 5432; + this.postgresDBName = ""; + this.postgresUser = ""; + this.postgresPassword = ""; + this.postgresPasswordSet = false; + this.postgresSSLMode = "disable"; + this.managedLocally = false; + this.localInstalled = false; + this.canInstallLocally = false; + this.nativeSQLiteExportAvailable = true; + + if (data == null) { + return; + } + ObjectUtil.cloneProps(this, data); + } + + equals(other) { + return ObjectUtil.equals(this, other); + } +} diff --git a/web/controller/server.go b/web/controller/server.go index d32209e1..ac9a7cf2 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -20,8 +20,9 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`) type ServerController struct { BaseController - serverService service.ServerService - settingService service.SettingService + serverService service.ServerService + settingService service.SettingService + databaseService service.DatabaseService lastStatus *service.Status @@ -253,14 +254,27 @@ func (a *ServerController) getConfigJson(c *gin.Context) { // getDb downloads the database file. func (a *ServerController) getDb(c *gin.Context) { - db, err := a.serverService.GetDb() + exportType := c.Query("type") + if exportType == "" { + exportType = "portable" + } + + var ( + data []byte + filename string + err error + ) + switch exportType { + case "sqlite": + data, filename, err = a.databaseService.ExportNativeSQLite() + default: + data, filename, err = a.databaseService.ExportPortableBackup() + } if err != nil { jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err) return } - filename := "x-ui.db" - if !isValidFilename(filename) { c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename")) return @@ -271,7 +285,7 @@ func (a *ServerController) getDb(c *gin.Context) { c.Header("Content-Disposition", "attachment; filename="+filename) // Write the file contents to the response - c.Writer.Write(db) + c.Writer.Write(data) } func isValidFilename(filename string) bool { @@ -284,15 +298,14 @@ func (a *ServerController) importDB(c *gin.Context) { // Get the file from the request body file, _, err := c.Request.FormFile("db") if err != nil { - jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err) - return + file, _, err = c.Request.FormFile("backup") + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err) + return + } } defer file.Close() - // Always restart Xray before return - defer a.serverService.RestartXrayService() - // lastGetStatusTime removed; no longer needed - // Import it - err = a.serverService.ImportDB(file) + _, err = a.databaseService.ImportBackup(file) if err != nil { jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err) return diff --git a/web/controller/setting.go b/web/controller/setting.go index fc5486bc..9645bbaf 100644 --- a/web/controller/setting.go +++ b/web/controller/setting.go @@ -2,6 +2,7 @@ package controller import ( "errors" + "net/http" "time" "github.com/mhsanaei/3x-ui/v2/util/crypto" @@ -25,6 +26,7 @@ type SettingController struct { settingService service.SettingService userService service.UserService panelService service.PanelService + databaseService service.DatabaseService } // NewSettingController creates a new SettingController and initializes its routes. @@ -44,6 +46,15 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/updateUser", a.updateUser) g.POST("/restartPanel", a.restartPanel) g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) + + database := g.Group("/database") + database.POST("/get", a.getDatabaseSetting) + database.POST("/test", a.testDatabaseSetting) + database.POST("/install-postgres", a.installLocalPostgres) + database.POST("/switch", a.switchDatabase) + database.POST("/export", a.exportDatabase) + database.GET("/export", a.exportDatabase) + database.POST("/import", a.importDatabase) } // getAllSetting retrieves all current settings. @@ -119,3 +130,97 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) { } jsonObj(c, defaultJsonConfig, nil) } + +func (a *SettingController) getDatabaseSetting(c *gin.Context) { + setting, err := a.databaseService.GetSetting() + if err != nil { + jsonMsg(c, "Failed to load database settings", err) + return + } + jsonObj(c, setting, nil) +} + +func (a *SettingController) testDatabaseSetting(c *gin.Context) { + setting := &entity.DatabaseSetting{} + if err := c.ShouldBind(setting); err != nil { + jsonMsg(c, "Failed to parse database settings", err) + return + } + if err := a.databaseService.TestSetting(setting); err != nil { + jsonMsg(c, "Connection test failed", err) + return + } + jsonMsg(c, "Database connection test succeeded", nil) +} + +func (a *SettingController) installLocalPostgres(c *gin.Context) { + output, err := a.databaseService.InstallLocalPostgres() + if err != nil { + jsonMsg(c, "Failed to install PostgreSQL", err) + return + } + jsonObj(c, output, nil) +} + +func (a *SettingController) switchDatabase(c *gin.Context) { + setting := &entity.DatabaseSetting{} + if err := c.ShouldBind(setting); err != nil { + jsonMsg(c, "Failed to parse database settings", err) + return + } + err := a.databaseService.SwitchDatabase(setting) + if err == nil { + err = a.panelService.RestartPanel(3 * time.Second) + } + jsonMsg(c, "Database switch scheduled", err) +} + +func (a *SettingController) exportDatabase(c *gin.Context) { + exportType := c.Query("type") + if exportType == "" { + exportType = c.PostForm("type") + } + if exportType == "" { + exportType = "portable" + } + + var ( + data []byte + filename string + err error + ) + + switch exportType { + case "sqlite": + data, filename, err = a.databaseService.ExportNativeSQLite() + default: + data, filename, err = a.databaseService.ExportPortableBackup() + } + if err != nil { + jsonMsg(c, "Failed to export database", err) + return + } + + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, "application/octet-stream", data) +} + +func (a *SettingController) importDatabase(c *gin.Context) { + file, _, err := c.Request.FormFile("backup") + if err != nil { + jsonMsg(c, "Failed to read uploaded backup", err) + return + } + defer file.Close() + + backupType, err := a.databaseService.ImportBackup(file) + if err == nil { + err = a.panelService.RestartPanel(3 * time.Second) + } + if err != nil { + jsonMsg(c, "Failed to import database backup", err) + return + } + jsonObj(c, backupType, nil) +} diff --git a/web/entity/database.go b/web/entity/database.go new file mode 100644 index 00000000..d891217c --- /dev/null +++ b/web/entity/database.go @@ -0,0 +1,99 @@ +package entity + +import ( + "strings" + + "github.com/mhsanaei/3x-ui/v2/config" +) + +type DatabaseSetting struct { + Driver string `json:"driver" form:"driver"` + ConfigSource string `json:"configSource" form:"configSource"` + ReadOnly bool `json:"readOnly" form:"readOnly"` + SQLitePath string `json:"sqlitePath" form:"sqlitePath"` + PostgresMode string `json:"postgresMode" form:"postgresMode"` + PostgresHost string `json:"postgresHost" form:"postgresHost"` + PostgresPort int `json:"postgresPort" form:"postgresPort"` + PostgresDBName string `json:"postgresDBName" form:"postgresDBName"` + PostgresUser string `json:"postgresUser" form:"postgresUser"` + PostgresPassword string `json:"postgresPassword" form:"postgresPassword"` + PostgresPasswordSet bool `json:"postgresPasswordSet" form:"postgresPasswordSet"` + PostgresSSLMode string `json:"postgresSSLMode" form:"postgresSSLMode"` + ManagedLocally bool `json:"managedLocally" form:"managedLocally"` + LocalInstalled bool `json:"localInstalled" form:"localInstalled"` + CanInstallLocally bool `json:"canInstallLocally" form:"canInstallLocally"` + NativeSQLiteExportAvailable bool `json:"nativeSQLiteExportAvailable" form:"nativeSQLiteExportAvailable"` +} + +func DatabaseSettingFromConfig(cfg *config.DatabaseConfig) *DatabaseSetting { + if cfg == nil { + cfg = config.DefaultDatabaseConfig() + } + cfg = cfg.Clone().Normalize() + return &DatabaseSetting{ + Driver: cfg.Driver, + ConfigSource: cfg.ConfigSource, + SQLitePath: cfg.SQLite.Path, + PostgresMode: cfg.Postgres.Mode, + PostgresHost: cfg.Postgres.Host, + PostgresPort: cfg.Postgres.Port, + PostgresDBName: cfg.Postgres.DBName, + PostgresUser: cfg.Postgres.User, + PostgresPasswordSet: cfg.Postgres.Password != "", + PostgresSSLMode: cfg.Postgres.SSLMode, + ManagedLocally: cfg.Postgres.ManagedLocally, + NativeSQLiteExportAvailable: cfg.Driver == config.DatabaseDriverSQLite, + } +} + +func (s *DatabaseSetting) Normalize() *DatabaseSetting { + if s == nil { + s = &DatabaseSetting{} + } + s.Driver = strings.ToLower(strings.TrimSpace(s.Driver)) + if s.Driver == "" { + s.Driver = config.DatabaseDriverSQLite + } + s.PostgresMode = strings.ToLower(strings.TrimSpace(s.PostgresMode)) + if s.PostgresMode == "" { + s.PostgresMode = config.DatabaseModeExternal + } + if s.PostgresHost == "" { + s.PostgresHost = "127.0.0.1" + } + if s.PostgresPort <= 0 { + s.PostgresPort = 5432 + } + if s.PostgresSSLMode == "" { + s.PostgresSSLMode = "disable" + } + if s.PostgresMode == config.DatabaseModeLocal { + s.ManagedLocally = true + } + if s.SQLitePath == "" { + s.SQLitePath = config.GetDBPath() + } + return s +} + +func (s *DatabaseSetting) ToConfig(existing *config.DatabaseConfig) *config.DatabaseConfig { + current := config.DefaultDatabaseConfig() + if existing != nil { + current = existing.Clone().Normalize() + } + s = s.Normalize() + + current.Driver = s.Driver + current.SQLite.Path = s.SQLitePath + current.Postgres.Mode = s.PostgresMode + current.Postgres.Host = s.PostgresHost + current.Postgres.Port = s.PostgresPort + current.Postgres.DBName = s.PostgresDBName + current.Postgres.User = s.PostgresUser + if s.PostgresPassword != "" { + current.Postgres.Password = s.PostgresPassword + } + current.Postgres.SSLMode = s.PostgresSSLMode + current.Postgres.ManagedLocally = s.ManagedLocally + return current.Normalize() +} diff --git a/web/html/index.html b/web/html/index.html index bbbbb708..9d5e3421 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -410,15 +410,22 @@ - - + + - + - - + + + + + + + + + @@ -864,6 +871,7 @@ }; const backupModal = { visible: false, + nativeSQLiteExportAvailable: false, show() { this.visible = true; }, @@ -1064,24 +1072,28 @@ } txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json'); }, - openBackup() { + async openBackup() { + const dbMsg = await HttpUtil.post('/panel/setting/database/get'); + if (dbMsg.success) { + backupModal.nativeSQLiteExportAvailable = !!dbMsg.obj.nativeSQLiteExportAvailable; + } backupModal.show(); }, - exportDatabase() { - window.location = basePath + 'panel/api/server/getDb'; + exportDatabase(type = 'portable') { + window.location = `${basePath}panel/setting/database/export?type=${type}`; }, importDatabase() { const fileInput = document.createElement('input'); fileInput.type = 'file'; - fileInput.accept = '.db'; + fileInput.accept = '.xui-backup,.db,.zip'; fileInput.addEventListener('change', async (event) => { - const dbFile = event.target.files[0]; - if (dbFile) { + const backupFile = event.target.files[0]; + if (backupFile) { const formData = new FormData(); - formData.append('db', dbFile); + formData.append('backup', backupFile); backupModal.hide(); this.loading(true); - const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, { + const uploadMsg = await HttpUtil.post('/panel/setting/database/import', formData, { headers: { 'Content-Type': 'multipart/form-data', } @@ -1091,13 +1103,8 @@ return; } this.loading(true); - const restartMsg = await HttpUtil.post("/panel/setting/restartPanel"); - this.loading(false); - if (restartMsg.success) { - this.loading(true); - await PromiseUtil.sleep(5000); - location.reload(); - } + await PromiseUtil.sleep(5000); + location.reload(); } }); fileInput.click(); diff --git a/web/html/settings.html b/web/html/settings.html index 21294da7..9ae17b74 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -119,6 +119,8 @@ }, oldAllSetting: new AllSetting(), allSetting: new AllSetting(), + oldDatabaseSetting: new DatabaseSetting(), + databaseSetting: new DatabaseSetting(), saveBtnDisable: true, entryHost: null, entryPort: null, @@ -276,6 +278,14 @@ this.saveBtnDisable = true; } }, + async getDatabaseSetting() { + const msg = await HttpUtil.post("/panel/setting/database/get"); + if (!msg.success) { + return; + } + this.oldDatabaseSetting = new DatabaseSetting(msg.obj); + this.databaseSetting = new DatabaseSetting(msg.obj); + }, async loadInboundTags() { const msg = await HttpUtil.get("/panel/api/inbounds/list"); if (msg && msg.success && Array.isArray(msg.obj)) { @@ -295,6 +305,78 @@ await this.getAllSetting(); } }, + async testDatabaseSetting() { + this.loading(true); + await HttpUtil.post("/panel/setting/database/test", this.databaseSetting); + this.loading(false); + }, + async installLocalPostgres() { + this.loading(true); + const msg = await HttpUtil.post("/panel/setting/database/install-postgres"); + this.loading(false); + if (msg.success) { + await this.getDatabaseSetting(); + } + }, + async switchDatabase() { + await new Promise(resolve => { + this.$confirm({ + title: '{{ i18n "pages.settings.database.switchDatabaseTitle" }}', + content: '{{ i18n "pages.settings.database.switchDatabaseConfirm" }}', + class: themeSwitcher.currentTheme, + okText: '{{ i18n "sure" }}', + cancelText: '{{ i18n "cancel" }}', + onOk: () => resolve(), + }); + }); + + this.loading(true); + const msg = await HttpUtil.post("/panel/setting/database/switch", this.databaseSetting); + this.loading(false); + if (!msg.success) { + return; + } + + await PromiseUtil.sleep(5000); + window.location.reload(); + }, + downloadDatabaseExport(type) { + window.location = `${basePath}panel/setting/database/export?type=${type}`; + }, + exportPortableBackup() { + this.downloadDatabaseExport('portable'); + }, + exportNativeSQLite() { + this.downloadDatabaseExport('sqlite'); + }, + importDatabaseBackup() { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.xui-backup,.db,.zip'; + fileInput.addEventListener('change', async (event) => { + const backupFile = event.target.files[0]; + if (!backupFile) { + return; + } + + const formData = new FormData(); + formData.append('backup', backupFile); + this.loading(true); + const msg = await HttpUtil.post('/panel/setting/database/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + } + }); + this.loading(false); + if (!msg.success) { + return; + } + + await PromiseUtil.sleep(5000); + window.location.reload(); + }); + fileInput.click(); + }, async updateUser() { const sendUpdateUserRequest = async () => { this.loading(true); @@ -627,6 +709,7 @@ this.entryProtocol = window.location.protocol; this.entryIsIP = this._isIp(this.entryHost); await this.getAllSetting(); + await this.getDatabaseSetting(); await this.loadInboundTags(); while (true) { await PromiseUtil.sleep(1000); @@ -635,4 +718,4 @@ } }); -{{ template "page/body_end" .}} \ No newline at end of file +{{ template "page/body_end" .}} diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html index 6969a1b4..5b1b4b53 100644 --- a/web/html/settings/panel/general.html +++ b/web/html/settings/panel/general.html @@ -108,7 +108,130 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -125,7 +248,7 @@ - + @@ -146,7 +269,7 @@ - +