This commit is contained in:
Evgeniy 2026-04-13 06:39:03 +00:00 committed by GitHub
commit 9be83ebfcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3523 additions and 281 deletions

View file

@ -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

View file

@ -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/)

View file

@ -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/)

251
config/database.go Normal file
View file

@ -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)
}

396
database/backup.go Normal file
View file

@ -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)
}

View file

@ -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

111
database/manager.go Normal file
View file

@ -0,0 +1,111 @@
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
}
if err := ApplySnapshot(targetConn, sourceSnapshot); err != nil {
return err
}
return config.SaveDatabaseConfig(target)
}

View file

@ -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:

5
go.mod
View file

@ -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

11
go.sum
View file

@ -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=

View file

@ -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

218
main.go
View file

@ -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()

271
postgres-manager.sh Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -86,4 +86,35 @@ class AllSetting {
equals(other) {
return ObjectUtil.equals(this, other);
}
}
}
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);
}
}

View file

@ -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

View file

@ -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)
}

99
web/entity/database.go Normal file
View file

@ -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()
}

View file

@ -410,15 +410,22 @@
<a-list class="ant-backup-list w-100" bordered>
<a-list-item class="ant-backup-list-item">
<a-list-item-meta>
<template #title>{{ i18n "pages.index.exportDatabase" }}</template>
<template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template>
<template #title>Export Portable Backup</template>
<template #description>Portable backups work for both SQLite and PostgreSQL.</template>
</a-list-item-meta>
<a-button @click="exportDatabase()" type="primary" icon="download" />
<a-button @click="exportDatabase('portable')" type="primary" icon="download" />
</a-list-item>
<a-list-item class="ant-backup-list-item">
<a-list-item-meta>
<template #title>{{ i18n "pages.index.importDatabase" }}</template>
<template #description>{{ i18n "pages.index.importDatabaseDesc" }}</template>
<template #title>Export Native SQLite</template>
<template #description>Downloads the raw SQLite database file when SQLite is the active backend.</template>
</a-list-item-meta>
<a-button @click="exportDatabase('sqlite')" type="primary" icon="download" :disabled="!backupModal.nativeSQLiteExportAvailable" />
</a-list-item>
<a-list-item class="ant-backup-list-item">
<a-list-item-meta>
<template #title>Import Backup</template>
<template #description>Accepts portable `.xui-backup` files and legacy SQLite `.db` backups.</template>
</a-list-item-meta>
<a-button @click="importDatabase()" type="primary" icon="upload" />
</a-list-item>
@ -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();

View file

@ -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 @@
}
});
</script>
{{ template "page/body_end" .}}
{{ template "page/body_end" .}}

View file

@ -108,7 +108,130 @@
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="4" header='{{ i18n "pages.settings.externalTraffic" }}'>
<a-collapse-panel key="4" header='{{ i18n "pages.settings.database.sectionTitle" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.backend" }}</template>
<template #description>{{ i18n "pages.settings.database.backendDesc" }}</template>
<template #control>
<a-radio-group v-model="databaseSetting.driver" :disabled="databaseSetting.readOnly" button-style="solid">
<a-radio-button value="sqlite">SQLite</a-radio-button>
<a-radio-button value="postgres">PostgreSQL</a-radio-button>
</a-radio-group>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="databaseSetting.readOnly || databaseSetting.configSource === 'default'">
<template #title>{{ i18n "pages.settings.database.configSource" }}</template>
<template #description v-if="databaseSetting.readOnly">{{ i18n "pages.settings.database.configSourceEnvDesc" }}</template>
<template #description v-else>{{ i18n "pages.settings.database.configSourceDefaultDesc" }}</template>
<template #control>
<a-tag :color="databaseSetting.readOnly ? 'red' : 'orange'">[[ databaseSetting.readOnly ? 'env' : 'default' ]]</a-tag>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="databaseSetting.driver === 'sqlite'">
<template #title>{{ i18n "pages.settings.database.sqlitePath" }}</template>
<template #control>
<a-input type="text" v-model="databaseSetting.sqlitePath" :disabled="databaseSetting.readOnly"></a-input>
</template>
</a-setting-list-item>
<template v-if="databaseSetting.driver === 'postgres'">
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.postgresMode" }}</template>
<template #description>
<span v-if="databaseSetting.postgresMode === 'local'">{{ i18n "pages.settings.database.postgresModeLocalDesc" }}</span>
<span v-else>{{ i18n "pages.settings.database.postgresModeExternalDesc" }}</span>
</template>
<template #control>
<a-radio-group v-model="databaseSetting.postgresMode" :disabled="databaseSetting.readOnly" button-style="solid">
<a-radio-button value="local">{{ i18n "pages.settings.database.postgresModeLocal" }}</a-radio-button>
<a-radio-button value="external">{{ i18n "pages.settings.database.postgresModeExternal" }}</a-radio-button>
</a-radio-group>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'local'">
<template #title>{{ i18n "pages.settings.database.postgresInstallation" }}</template>
<template #description>
<span v-if="databaseSetting.localInstalled"><a-tag color="green"></a-tag> {{ i18n "pages.settings.database.postgresInstallReady" }}</span>
<span v-else-if="!databaseSetting.canInstallLocally"><a-tag color="orange">!</a-tag> {{ i18n "pages.settings.database.postgresInstallNeedRoot" }}</span>
<span v-else><a-tag color="orange">!</a-tag> {{ i18n "pages.settings.database.postgresInstallHint" }}</span>
</template>
<template #control>
<a-button @click="installLocalPostgres" :disabled="databaseSetting.readOnly || !databaseSetting.canInstallLocally || databaseSetting.localInstalled">
<span v-if="databaseSetting.localInstalled">{{ i18n "pages.settings.database.postgresAlreadyInstalled" }}</span>
<span v-else>{{ i18n "pages.settings.database.postgresInstallBtn" }}</span>
</a-button>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
<template #title>{{ i18n "pages.settings.database.host" }}</template>
<template #control>
<a-input type="text" v-model="databaseSetting.postgresHost" :disabled="databaseSetting.readOnly"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
<template #title>{{ i18n "pages.settings.database.port" }}</template>
<template #control>
<a-input-number :min="1" :max="65535" v-model="databaseSetting.postgresPort" :style="{ width: '100%' }" :disabled="databaseSetting.readOnly"></a-input-number>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.dbName" }}</template>
<template #control>
<a-input type="text" v-model="databaseSetting.postgresDBName" :disabled="databaseSetting.readOnly"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.user" }}</template>
<template #control>
<a-input type="text" v-model="databaseSetting.postgresUser" :disabled="databaseSetting.readOnly"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.password" }}</template>
<template #description v-if="databaseSetting.postgresPasswordSet">{{ i18n "pages.settings.database.passwordHint" }}</template>
<template #control>
<a-input type="password" v-model="databaseSetting.postgresPassword" :disabled="databaseSetting.readOnly"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
<template #title>{{ i18n "pages.settings.database.sslMode" }}</template>
<template #control>
<a-select v-model="databaseSetting.postgresSSLMode" :disabled="databaseSetting.readOnly"
:dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option value="disable">disable</a-select-option>
<a-select-option value="require">require</a-select-option>
<a-select-option value="verify-ca">verify-ca</a-select-option>
<a-select-option value="verify-full">verify-full</a-select-option>
</a-select>
</template>
</a-setting-list-item>
</template>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.actions" }}</template>
<template #description>{{ i18n "pages.settings.database.actionsDesc" }}</template>
<template #control>
<a-space wrap>
<a-button @click="testDatabaseSetting" :disabled="databaseSetting.readOnly">{{ i18n "pages.settings.database.testConnection" }}</a-button>
<a-button type="primary" @click="switchDatabase" :disabled="databaseSetting.readOnly">{{ i18n "pages.settings.database.switchDatabase" }}</a-button>
</a-space>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.database.backupRestore" }}</template>
<template #description>
<b>{{ i18n "pages.settings.database.exportPortableLabel" }}</b> — {{ i18n "pages.settings.database.exportPortableDesc" }}<br>
<b>{{ i18n "pages.settings.database.exportNativeSQLiteLabel" }}</b> — {{ i18n "pages.settings.database.exportNativeSQLiteDesc" }}<br>
<b>{{ i18n "pages.settings.database.importLabel" }}</b> — {{ i18n "pages.settings.database.importDesc" }}
</template>
<template #control>
<a-space wrap>
<a-button @click="exportPortableBackup">{{ i18n "pages.settings.database.exportPortableBtn" }}</a-button>
<a-button @click="exportNativeSQLite" :disabled="!databaseSetting.nativeSQLiteExportAvailable">{{ i18n "pages.settings.database.exportNativeSQLiteBtn" }}</a-button>
<a-button @click="importDatabaseBackup">{{ i18n "pages.settings.database.importBtn" }}</a-button>
</a-space>
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="5" header='{{ i18n "pages.settings.externalTraffic" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.externalTrafficInformEnable"}}</template>
<template #description>{{ i18n "pages.settings.externalTrafficInformEnableDesc"}}</template>
@ -125,7 +248,7 @@
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="5" header='{{ i18n "pages.settings.dateAndTime" }}'>
<a-collapse-panel key="6" header='{{ i18n "pages.settings.dateAndTime" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.timeZone"}}</template>
<template #description>{{ i18n "pages.settings.timeZoneDesc"}}</template>
@ -146,7 +269,7 @@
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="6" header='LDAP'>
<a-collapse-panel key="7" header='LDAP'>
<a-setting-list-item paddings="small">
<template #title>Enable LDAP sync</template>
<template #control>
@ -277,4 +400,4 @@
</a-setting-list-item>
</a-collapse-panel>
</a-collapse>
{{end}}
{{end}}

252
web/service/database.go Normal file
View file

@ -0,0 +1,252 @@
package service
import (
"bytes"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/entity"
)
type DatabaseService struct{}
func (s *DatabaseService) currentConfig() (*config.DatabaseConfig, error) {
current, err := config.LoadDatabaseConfig()
if err != nil {
return nil, err
}
return current.Normalize(), nil
}
func (s *DatabaseService) mergeSettingWithCurrent(setting *entity.DatabaseSetting) (*config.DatabaseConfig, error) {
current, err := s.currentConfig()
if err != nil {
return nil, err
}
target := setting.ToConfig(current)
if target.UsesPostgres() && target.Postgres.Password == "" && current.UsesPostgres() {
sameEndpoint := target.Postgres.Host == current.Postgres.Host &&
target.Postgres.Port == current.Postgres.Port &&
target.Postgres.DBName == current.Postgres.DBName &&
target.Postgres.User == current.Postgres.User
if sameEndpoint {
target.Postgres.Password = current.Postgres.Password
}
}
return target.Normalize(), nil
}
func (s *DatabaseService) canInstallLocally() bool {
if runtime.GOOS == "windows" {
return false
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return false
}
if strings.TrimSpace(os.Getenv("container")) != "" {
return false
}
output, err := exec.Command("id", "-u").Output()
return err == nil && strings.TrimSpace(string(output)) == "0"
}
func (s *DatabaseService) postgresManagerExists() bool {
path := config.GetPostgresManagerPath()
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func (s *DatabaseService) runPostgresManager(args ...string) (string, error) {
if !s.postgresManagerExists() {
return "", errors.New("postgres-manager.sh not found")
}
cmd := exec.Command(config.GetPostgresManagerPath(), args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output)))
}
return string(output), nil
}
func (s *DatabaseService) postgresStatus() (bool, bool) {
if s.postgresManagerExists() {
output, err := s.runPostgresManager("status")
if err == nil {
installed := strings.Contains(output, "installed=true")
running := strings.Contains(output, "running=true")
return installed, running
}
}
_, err := exec.LookPath("psql")
return err == nil, false
}
func (s *DatabaseService) GetSetting() (*entity.DatabaseSetting, error) {
current, err := s.currentConfig()
if err != nil {
return nil, err
}
setting := entity.DatabaseSettingFromConfig(current)
setting.ReadOnly = current.ConfigSource == config.DatabaseConfigSourceEnv
setting.CanInstallLocally = s.canInstallLocally()
setting.LocalInstalled, _ = s.postgresStatus()
return setting, nil
}
func (s *DatabaseService) TestSetting(setting *entity.DatabaseSetting) error {
target, err := s.mergeSettingWithCurrent(setting)
if err != nil {
return err
}
return database.TestConnection(target)
}
func (s *DatabaseService) InstallLocalPostgres() (string, error) {
if !s.canInstallLocally() {
return "", errors.New("local PostgreSQL installation requires root privileges")
}
return s.runPostgresManager("init-local")
}
func (s *DatabaseService) prepareLocalPostgres(target *config.DatabaseConfig) error {
if !target.UsesPostgres() || !target.Postgres.ManagedLocally {
return nil
}
if !s.canInstallLocally() {
return errors.New("local PostgreSQL management requires root privileges")
}
if _, err := s.runPostgresManager("init-local"); err != nil {
return err
}
args := []string{
"create-db-user",
"--user", target.Postgres.User,
"--db", target.Postgres.DBName,
}
if target.Postgres.Password != "" {
args = append(args, "--password", target.Postgres.Password)
}
_, err := s.runPostgresManager(args...)
return err
}
func (s *DatabaseService) SwitchDatabase(setting *entity.DatabaseSetting) error {
target, err := s.mergeSettingWithCurrent(setting)
if err != nil {
return err
}
if err := s.prepareLocalPostgres(target); err != nil {
return err
}
return database.SwitchDatabase(target)
}
func (s *DatabaseService) backupFilename(prefix string) string {
return fmt.Sprintf("%s-%s.xui-backup", prefix, time.Now().UTC().Format("20060102-150405"))
}
func (s *DatabaseService) saveCurrentRestorePoint(prefix string) (string, error) {
data, err := database.EncodeCurrentPortableBackup()
if err != nil {
return "", err
}
path := filepath.Join(config.GetBackupFolderPath(), s.backupFilename(prefix))
return path, database.SavePortableBackup(path, data)
}
func (s *DatabaseService) ExportPortableBackup() ([]byte, string, error) {
data, err := database.EncodeCurrentPortableBackup()
if err != nil {
return nil, "", err
}
return data, s.backupFilename("portable"), nil
}
func (s *DatabaseService) ExportNativeSQLite() ([]byte, string, error) {
currentCfg, err := s.currentConfig()
if err != nil {
return nil, "", err
}
if !currentCfg.UsesSQLite() {
return nil, "", errors.New("native SQLite export is only available when SQLite is the active backend")
}
if err := database.Checkpoint(); err != nil {
return nil, "", err
}
contents, err := os.ReadFile(currentCfg.SQLite.Path)
if err != nil {
return nil, "", err
}
return contents, "x-ui.db", nil
}
func (s *DatabaseService) decodeImport(raw []byte) (*database.BackupSnapshot, string, error) {
snapshot, err := database.DecodePortableBackup(raw)
if err == nil {
return snapshot, "portable", nil
}
reader := bytes.NewReader(raw)
isSQLite, sqliteErr := database.IsSQLiteDB(reader)
if sqliteErr == nil && isSQLite {
tempFile, err := os.CreateTemp("", "xui-legacy-*.db")
if err != nil {
return nil, "", err
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)
defer tempFile.Close()
if _, err := tempFile.Write(raw); err != nil {
return nil, "", err
}
if err := tempFile.Close(); err != nil {
return nil, "", err
}
snapshot, err := database.LoadSnapshotFromSQLiteFile(tempPath)
if err != nil {
return nil, "", err
}
return snapshot, "sqlite-legacy", nil
}
return nil, "", errors.New("unsupported backup format")
}
func (s *DatabaseService) ImportBackup(file multipart.File) (string, error) {
raw, err := io.ReadAll(file)
if err != nil {
return "", err
}
snapshot, backupType, err := s.decodeImport(raw)
if err != nil {
return "", err
}
if _, err := s.saveCurrentRestorePoint("restore"); err != nil {
return "", err
}
if err := database.ApplySnapshot(database.GetDB(), snapshot); err != nil {
return "", err
}
inboundService := &InboundService{}
inboundService.MigrateDB()
logger.Infof("Database import completed using %s backup", backupType)
return backupType, nil
}

View file

@ -108,13 +108,14 @@ func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (
db.Model(model.Inbound{}).Where(
"listen = ?", listen,
).Or(
"listen = \"\"",
"listen = ?", "",
).Or(
"listen = \"0.0.0.0\"",
"listen = ?", "0.0.0.0",
).Or(
"listen = \"::\"",
"listen = ?", "::",
).Or(
"listen = \"::0\""))
"listen = ?", "::0",
))
}
if ignoreId > 0 {
db = db.Where("id != ?", ignoreId)
@ -143,15 +144,24 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
func (s *InboundService) getAllEmails() ([]string, error) {
db := database.GetDB()
var emails []string
err := db.Raw(`
SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
`).Scan(&emails).Error
var inbounds []model.Inbound
err := db.Model(model.Inbound{}).Find(&inbounds).Error
if err != nil {
return nil, err
}
emails := make([]string, 0)
for i := range inbounds {
clients, clientsErr := s.GetClients(&inbounds[i])
if clientsErr != nil {
return nil, clientsErr
}
for _, client := range clients {
if client.Email != "" {
emails = append(emails, client.Email)
}
}
}
return emails, nil
}
@ -1312,14 +1322,18 @@ func (s *InboundService) GetInboundTags() (string, error) {
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
db := database.GetDB()
db.Exec(`
DELETE FROM client_traffics
WHERE email NOT IN (
SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
)
`)
allEmails, err := s.getAllEmails()
if err != nil {
logger.Warningf("Failed to load client emails for orphan cleanup: %v", err)
return
}
query := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&xray.ClientTraffic{})
if len(allEmails) == 0 {
query.Delete(&xray.ClientTraffic{})
return
}
query.Not("email IN ?", allEmails).Delete(&xray.ClientTraffic{})
}
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
@ -2057,14 +2071,30 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64,
func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
db := database.GetDB()
var traffics []xray.ClientTraffic
var inbounds []model.Inbound
if err := db.Model(model.Inbound{}).Find(&inbounds).Error; err != nil {
logger.Debug(err)
return nil, err
}
err := db.Model(xray.ClientTraffic{}).Where(`email IN(
SELECT JSON_EXTRACT(client.value, '$.email') as email
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE
JSON_EXTRACT(client.value, '$.id') in (?)
)`, id).Find(&traffics).Error
emails := make([]string, 0)
for i := range inbounds {
clients, clientsErr := s.GetClients(&inbounds[i])
if clientsErr != nil {
logger.Debug(clientsErr)
return nil, clientsErr
}
for _, client := range clients {
if client.ID == id && client.Email != "" {
emails = append(emails, client.Email)
}
}
}
if len(emails) == 0 {
return traffics, nil
}
err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error
if err != nil {
logger.Debug(err)
@ -2210,72 +2240,66 @@ func (s *InboundService) MigrationRequirements() {
defer func() {
if err == nil {
tx.Commit()
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
logger.Warningf("VACUUM failed: %v", dbErr)
if database.IsSQLite() {
if dbErr := db.Exec(`VACUUM`).Error; dbErr != nil {
logger.Warningf("VACUUM failed: %v", dbErr)
}
}
} else {
tx.Rollback()
}
}()
// Calculate and backfill all_time from up+down for inbounds and clients
// Calculate and backfill all_time from up+down for inbounds and clients.
err = tx.Exec(`
UPDATE inbounds
SET all_time = IFNULL(up, 0) + IFNULL(down, 0)
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0
SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
`).Error
if err != nil {
return
}
err = tx.Exec(`
UPDATE client_traffics
SET all_time = IFNULL(up, 0) + IFNULL(down, 0)
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0
SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
`).Error
if err != nil {
return
}
// Fix inbounds based problems
// Fix inbounds based problems.
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
err = tx.Model(model.Inbound{}).Where("protocol IN ?", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return
}
for inbound_index := range inbounds {
for inboundIndex := range inbounds {
settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
json.Unmarshal([]byte(inbounds[inboundIndex].Settings), &settings)
clients, ok := settings["clients"].([]any)
if ok {
// Fix Client configuration problems
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
for clientIndex := range clients {
c := clients[clientIndex].(map[string]any)
// Add email='' if it is not exists
if _, ok := c["email"]; !ok {
c["email"] = ""
}
// Convert string tgId to int64
if _, ok := c["tgId"]; ok {
var tgId any = c["tgId"]
if tgIdStr, ok2 := tgId.(string); ok2 {
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
if err == nil {
tgIdInt64, parseErr := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
if parseErr == nil {
c["tgId"] = tgIdInt64
}
}
}
// Remove "flow": "xtls-rprx-direct"
if _, ok := c["flow"]; ok {
if c["flow"] == "xtls-rprx-direct" {
c["flow"] = ""
}
if _, ok := c["flow"]; ok && c["flow"] == "xtls-rprx-direct" {
c["flow"] = ""
}
// Backfill created_at and updated_at
if _, ok := c["created_at"]; !ok {
c["created_at"] = time.Now().Unix() * 1000
}
@ -2283,17 +2307,17 @@ func (s *InboundService) MigrationRequirements() {
newClients = append(newClients, any(c))
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
modifiedSettings, marshalErr := json.MarshalIndent(settings, "", " ")
if marshalErr != nil {
err = marshalErr
return
}
inbounds[inbound_index].Settings = string(modifiedSettings)
inbounds[inboundIndex].Settings = string(modifiedSettings)
}
// Add client traffic row for all clients which has email
modelClients, err := s.GetClients(inbounds[inbound_index])
if err != nil {
modelClients, clientsErr := s.GetClients(inbounds[inboundIndex])
if clientsErr != nil {
err = clientsErr
return
}
for _, modelClient := range modelClients {
@ -2301,62 +2325,82 @@ func (s *InboundService) MigrationRequirements() {
var count int64
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
if count == 0 {
s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
}
}
}
}
tx.Save(inbounds)
// Remove orphaned traffics
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
// Migrate old MultiDomain to External Proxy
var externalProxy []struct {
Id int
Port int
StreamSettings []byte
}
err = tx.Raw(`select id, port, stream_settings
from inbounds
WHERE protocol in ('vmess','vless','trojan')
AND json_extract(stream_settings, '$.security') = 'tls'
AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error
if err != nil || len(externalProxy) == 0 {
return
}
for _, ep := range externalProxy {
var reverses any
var stream map[string]any
json.Unmarshal(ep.StreamSettings, &stream)
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
if domains, ok := settings["domains"].([]any); ok {
for _, domain := range domains {
if domainMap, ok := domain.(map[string]any); ok {
domainMap["forceTls"] = "same"
domainMap["port"] = ep.Port
domainMap["dest"] = domainMap["domain"].(string)
delete(domainMap, "domain")
}
if addErr := s.AddClientStat(tx, inbounds[inboundIndex].Id, &modelClient); addErr != nil {
err = addErr
return
}
}
reverses = settings["domains"]
delete(settings, "domains")
}
}
stream["externalProxy"] = reverses
newStream, _ := json.MarshalIndent(stream, " ", " ")
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
}
if err = tx.Save(inbounds).Error; err != nil {
return
}
err = tx.Raw(`UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error
if err != nil {
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
for _, inbound := range inbounds {
var reverses any
var stream map[string]any
if json.Unmarshal([]byte(inbound.StreamSettings), &stream) != nil {
continue
}
if security, _ := stream["security"].(string); security != "tls" {
continue
}
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
if !ok {
continue
}
tlsInnerSettings, ok := tlsSettings["settings"].(map[string]any)
if !ok {
continue
}
domains, ok := tlsInnerSettings["domains"].([]any)
if !ok || len(domains) == 0 {
continue
}
for _, domain := range domains {
domainMap, ok := domain.(map[string]any)
if !ok {
continue
}
domainMap["forceTls"] = "same"
domainMap["port"] = inbound.Port
if domainName, ok := domainMap["domain"].(string); ok {
domainMap["dest"] = domainName
}
delete(domainMap, "domain")
}
reverses = domains
delete(tlsInnerSettings, "domains")
stream["externalProxy"] = reverses
newStream, marshalErr := json.MarshalIndent(stream, " ", " ")
if marshalErr != nil {
err = marshalErr
return
}
if err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("stream_settings", newStream).Error; err != nil {
return
}
}
var allInbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Find(&allInbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return
}
for _, inbound := range allInbounds {
if !strings.Contains(inbound.Tag, "0.0.0.0:") {
continue
}
newTag := strings.Replace(inbound.Tag, "0.0.0.0:", "", 1)
if err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("tag", newTag).Error; err != nil {
return
}
}
}
func (s *InboundService) MigrateDB() {

View file

@ -1009,7 +1009,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
}
// Open & migrate new DB
if err = database.InitDB(config.GetDBPath()); err != nil {
if err = database.InitDB(); err != nil {
if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
}

View file

@ -13,6 +13,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/random"
@ -204,12 +205,15 @@ func (s *SettingService) ResetSettings() error {
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
db := database.GetDB()
setting := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
var settings []*model.Setting
err := db.Model(model.Setting{}).Where("key = ?", key).Limit(1).Find(&settings).Error
if err != nil {
return nil, err
}
return setting, nil
if len(settings) == 0 {
return nil, gorm.ErrRecordNotFound
}
return settings[0], nil
}
func (s *SettingService) saveSetting(key string, value string) error {
@ -499,10 +503,18 @@ func (s *SettingService) GetSubListen() (string, error) {
return s.getString("subListen")
}
func (s *SettingService) SetSubListen(ip string) error {
return s.setString("subListen", ip)
}
func (s *SettingService) GetSubPort() (int, error) {
return s.getInt("subPort")
}
func (s *SettingService) SetSubPort(port int) error {
return s.setInt("subPort", port)
}
func (s *SettingService) GetSubPath() (string, error) {
return s.getString("subPath")
}

View file

@ -3636,29 +3636,47 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in trigger a checkpoint operation: ", err)
}
// Send database backup
file, err := os.Open(config.GetDBPath())
// Send portable database backup
backupData, err := database.EncodeCurrentPortableBackup()
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
tu.File(file),
tu.FileFromBytes(backupData, fmt.Sprintf("x-ui-%s.xui-backup", time.Now().Format("20060102-150405"))),
)
_, err = bot.SendDocument(ctx, document)
if err != nil {
logger.Error("Error in uploading backup: ", err)
logger.Error("Error in uploading portable backup: ", err)
}
} else {
logger.Error("Error in opening db file for backup: ", err)
logger.Error("Error in creating portable backup: ", err)
}
if database.IsSQLite() {
file, err := os.Open(config.GetDBPath())
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(ctx, document)
if err != nil {
logger.Error("Error in uploading native SQLite backup: ", err)
}
} else {
logger.Error("Error in opening db file for backup: ", err)
}
}
// Small delay between file sends
time.Sleep(500 * time.Millisecond)
// Send config.json backup
file, err = os.Open(xray.GetConfigPath())
file, err := os.Open(xray.GetConfigPath())
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View file

@ -589,6 +589,50 @@
"ipPool" = "نطاق IP Pool"
"poolSize" = "حجم المجموعة"
[pages.settings.database]
"sectionTitle" = "قاعدة البيانات"
"backend" = "الواجهة الخلفية"
"backendDesc" = "يتم تخزين إعدادات قاعدة البيانات وقت التشغيل خارج قاعدة بيانات اللوحة."
"configSource" = "مصدر الإعدادات"
"configSourceEnvDesc" = "يتم التحكم في واجهة قاعدة البيانات الخلفية هذه من خلال متغيرات البيئة ولا يمكن تغييرها هنا."
"configSourceDefaultDesc" = "لم يتم حفظ أي ملف إعدادات بعد. الإعدادات الافتراضية المدمجة مفعلة (SQLite)."
"configSourceDesc" = "المصدر الحالي:"
"sqlitePath" = "مسار SQLite"
"postgresMode" = "وضع PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL مُدار بواسطة اللوحة على هذا الخادم (127.0.0.1:5432)."
"postgresModeExternalDesc" = "الاتصال بخادم PostgreSQL موجود بالفعل."
"postgresModeLocal" = "محلي (تديره اللوحة)"
"postgresModeExternal" = "خارجي"
"postgresInstallation" = "تثبيت PostgreSQL"
"postgresInstallReady" = "PostgreSQL جاهز للاستخدام."
"postgresInstallNeedRoot" = "التثبيت التلقائي يتطلب صلاحيات root وبيئة غير Docker."
"postgresInstallHint" = "اضغط على تثبيت لإعداد PostgreSQL تلقائيًا."
"postgresAlreadyInstalled" = "مثبّت بالفعل"
"postgresInstallBtn" = "تثبيت PostgreSQL"
"host" = "المضيف"
"port" = "المنفذ"
"dbName" = "اسم قاعدة البيانات"
"user" = "اسم المستخدم"
"password" = "كلمة المرور"
"passwordHint" = "اتركه فارغًا للاحتفاظ بكلمة المرور المخزنة."
"sslMode" = "وضع SSL"
"actions" = "الإجراءات"
"actionsDesc" = "يتم ترحيل البيانات تلقائيًا. يتم حفظ نسخة احتياطية قابلة للنقل قبل التبديل، ثم تُعاد تشغيل اللوحة."
"testConnection" = "اختبار الاتصال"
"switchDatabase" = "تبديل قاعدة البيانات"
"switchDatabaseTitle" = "تبديل الواجهة الخلفية لقاعدة البيانات"
"switchDatabaseConfirm" = "ستقوم اللوحة بإنشاء نسخة احتياطية قابلة للنقل، وترحيل جميع البيانات، ثم إعادة تشغيل نفسها. هل تريد المتابعة؟"
"backupRestore" = "النسخ الاحتياطي والاستعادة"
"exportPortableLabel" = "تصدير نسخة قابلة للنقل"
"exportPortableDesc" = "نسخة احتياطية متعددة المنصات (تعمل مع SQLite وPostgreSQL، ويتم إرسالها أيضًا عبر بوت تيليجرام)."
"exportNativeSQLiteLabel" = "تصدير SQLite الأصلي"
"exportNativeSQLiteDesc" = "ملف قاعدة البيانات الخام، متاح فقط أثناء تفعيل SQLite."
"importLabel" = "استيراد"
"importDesc" = "الاستعادة من ملف احتياطي قابل للنقل (.xui-backup) أو ملف SQLite قديم (.db)."
"exportPortableBtn" = "تصدير نسخة احتياطية قابلة للنقل"
"exportNativeSQLiteBtn" = "تصدير SQLite الأصلي"
"importBtn" = "استيراد نسخة احتياطية"
[pages.settings.security]
"admin" = "بيانات الأدمن"
"twoFactor" = "المصادقة الثنائية"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP Pool Subnet"
"poolSize" = "Pool Size"
[pages.settings.database]
"sectionTitle" = "Database"
"backend" = "Backend"
"backendDesc" = "Runtime database configuration is stored outside the panel database."
"configSource" = "Config Source"
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
"configSourceDesc" = "Current source:"
"sqlitePath" = "SQLite Path"
"postgresMode" = "PostgreSQL Mode"
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
"postgresModeLocal" = "Local (panel-managed)"
"postgresModeExternal" = "External"
"postgresInstallation" = "PostgreSQL Installation"
"postgresInstallReady" = "PostgreSQL is ready to use."
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
"postgresAlreadyInstalled" = "Already installed"
"postgresInstallBtn" = "Install PostgreSQL"
"host" = "Host"
"port" = "Port"
"dbName" = "Database Name"
"user" = "User"
"password" = "Password"
"passwordHint" = "Leave empty to keep the stored password."
"sslMode" = "SSL Mode"
"actions" = "Actions"
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
"testConnection" = "Test Connection"
"switchDatabase" = "Switch Database"
"switchDatabaseTitle" = "Switch database backend"
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
"backupRestore" = "Backup & Restore"
"exportPortableLabel" = "Export Portable"
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
"exportNativeSQLiteLabel" = "Export Native SQLite"
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
"importLabel" = "Import"
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
"exportPortableBtn" = "Export Portable Backup"
"exportNativeSQLiteBtn" = "Export Native SQLite"
"importBtn" = "Import Backup"
[pages.settings.security]
"admin" = "Admin credentials"
"twoFactor" = "Two-factor authentication"

View file

@ -589,6 +589,50 @@
"ipPool" = "Subred del grupo de IP"
"poolSize" = "Tamaño del grupo"
[pages.settings.database]
"sectionTitle" = "Base de datos"
"backend" = "Backend"
"backendDesc" = "La configuración de la base de datos en tiempo de ejecución se almacena fuera de la base de datos del panel."
"configSource" = "Origen de la configuración"
"configSourceEnvDesc" = "Este backend de base de datos está controlado por variables de entorno y no se puede cambiar aquí."
"configSourceDefaultDesc" = "Aún no se ha guardado ningún archivo de configuración. Están activos los valores predeterminados integrados (SQLite)."
"configSourceDesc" = "Origen actual:"
"sqlitePath" = "Ruta de SQLite"
"postgresMode" = "Modo de PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL gestionado por el panel en este servidor (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Conectarse a un servidor PostgreSQL existente."
"postgresModeLocal" = "Local (gestionado por el panel)"
"postgresModeExternal" = "Externo"
"postgresInstallation" = "Instalación de PostgreSQL"
"postgresInstallReady" = "PostgreSQL está listo para usarse."
"postgresInstallNeedRoot" = "La instalación automática requiere root y un entorno sin Docker."
"postgresInstallHint" = "Haz clic en Instalar para configurar PostgreSQL automáticamente."
"postgresAlreadyInstalled" = "Ya instalado"
"postgresInstallBtn" = "Instalar PostgreSQL"
"host" = "Host"
"port" = "Puerto"
"dbName" = "Nombre de la base de datos"
"user" = "Usuario"
"password" = "Contraseña"
"passwordHint" = "Déjalo vacío para conservar la contraseña almacenada."
"sslMode" = "Modo SSL"
"actions" = "Acciones"
"actionsDesc" = "Los datos se migran automáticamente. Se guarda una copia de seguridad portable antes del cambio y el panel se reinicia."
"testConnection" = "Probar conexión"
"switchDatabase" = "Cambiar base de datos"
"switchDatabaseTitle" = "Cambiar el backend de la base de datos"
"switchDatabaseConfirm" = "El panel creará una copia de seguridad portable, migrará todos los datos y se reiniciará. ¿Continuar?"
"backupRestore" = "Copia de seguridad y restauración"
"exportPortableLabel" = "Exportar portable"
"exportPortableDesc" = "Copia de seguridad multiplataforma (funciona tanto con SQLite como con PostgreSQL, y también se envía por el bot de Telegram)."
"exportNativeSQLiteLabel" = "Exportar SQLite nativo"
"exportNativeSQLiteDesc" = "Archivo bruto de la base de datos, disponible solo mientras SQLite esté activo."
"importLabel" = "Importar"
"importDesc" = "Restaurar desde un archivo portable (.xui-backup) o un archivo SQLite heredado (.db)."
"exportPortableBtn" = "Exportar copia de seguridad portable"
"exportNativeSQLiteBtn" = "Exportar SQLite nativo"
"importBtn" = "Importar copia de seguridad"
[pages.settings.security]
"admin" = "Credenciales de administrador"
"twoFactor" = "Autenticación de dos factores"

View file

@ -589,6 +589,50 @@
"ipPool" = "زیرشبکه استخر آی‌پی"
"poolSize" = "اندازه استخر"
[pages.settings.database]
"sectionTitle" = "پایگاه داده"
"backend" = "بک‌اند"
"backendDesc" = "پیکربندی پایگاه داده در زمان اجرا خارج از پایگاه داده پنل ذخیره می‌شود."
"configSource" = "منبع پیکربندی"
"configSourceEnvDesc" = "این بک‌اند پایگاه داده توسط متغیرهای محیطی کنترل می‌شود و از اینجا قابل تغییر نیست."
"configSourceDefaultDesc" = "هنوز هیچ فایل پیکربندی ذخیره نشده است. تنظیمات پیش‌فرض داخلی فعال هستند (SQLite)."
"configSourceDesc" = "منبع فعلی:"
"sqlitePath" = "مسیر SQLite"
"postgresMode" = "حالت PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL مدیریت‌شده توسط پنل روی این سرور (127.0.0.1:5432)."
"postgresModeExternalDesc" = "اتصال به یک سرور PostgreSQL موجود."
"postgresModeLocal" = "محلی (مدیریت‌شده توسط پنل)"
"postgresModeExternal" = "خارجی"
"postgresInstallation" = "نصب PostgreSQL"
"postgresInstallReady" = "PostgreSQL آماده استفاده است."
"postgresInstallNeedRoot" = "نصب خودکار به دسترسی root و محیط غیر Docker نیاز دارد."
"postgresInstallHint" = "برای راه‌اندازی خودکار PostgreSQL روی نصب کلیک کنید."
"postgresAlreadyInstalled" = "از قبل نصب شده"
"postgresInstallBtn" = "نصب PostgreSQL"
"host" = "میزبان"
"port" = "پورت"
"dbName" = "نام پایگاه داده"
"user" = "کاربر"
"password" = "گذرواژه"
"passwordHint" = "برای حفظ گذرواژه ذخیره‌شده، این قسمت را خالی بگذارید."
"sslMode" = "حالت SSL"
"actions" = "اقدامات"
"actionsDesc" = "داده‌ها به‌صورت خودکار مهاجرت داده می‌شوند. پیش از تغییر، یک نسخه پشتیبان قابل‌حمل ذخیره می‌شود و پنل دوباره راه‌اندازی خواهد شد."
"testConnection" = "تست اتصال"
"switchDatabase" = "تغییر پایگاه داده"
"switchDatabaseTitle" = "تغییر بک‌اند پایگاه داده"
"switchDatabaseConfirm" = "پنل یک نسخه پشتیبان قابل‌حمل ایجاد می‌کند، همه داده‌ها را مهاجرت می‌دهد و خودش را دوباره راه‌اندازی می‌کند. ادامه می‌دهید؟"
"backupRestore" = "پشتیبان‌گیری و بازیابی"
"exportPortableLabel" = "خروجی قابل‌حمل"
"exportPortableDesc" = "نسخه پشتیبان بین‌پلتفرمی (هم با SQLite و هم با PostgreSQL کار می‌کند، و همچنین توسط ربات تلگرام ارسال می‌شود)."
"exportNativeSQLiteLabel" = "خروجی SQLite بومی"
"exportNativeSQLiteDesc" = "فایل خام پایگاه داده که فقط زمانی در دسترس است که SQLite فعال باشد."
"importLabel" = "درون‌ریزی"
"importDesc" = "بازیابی از فایل قابل‌حمل (.xui-backup) یا فایل قدیمی SQLite (.db)."
"exportPortableBtn" = "خروجی نسخه پشتیبان قابل‌حمل"
"exportNativeSQLiteBtn" = "خروجی SQLite بومی"
"importBtn" = "درون‌ریزی نسخه پشتیبان"
[pages.settings.security]
"admin" = "اعتبارنامه‌های ادمین"
"twoFactor" = "احراز هویت دو مرحله‌ای"

View file

@ -589,6 +589,50 @@
"ipPool" = "Subnet Kumpulan IP"
"poolSize" = "Ukuran Kolam"
[pages.settings.database]
"sectionTitle" = "Database"
"backend" = "Backend"
"backendDesc" = "Konfigurasi database runtime disimpan di luar database panel."
"configSource" = "Sumber Konfigurasi"
"configSourceEnvDesc" = "Backend database ini dikendalikan oleh variabel lingkungan dan tidak dapat diubah di sini."
"configSourceDefaultDesc" = "Belum ada file konfigurasi yang disimpan. Pengaturan bawaan internal sedang aktif (SQLite)."
"configSourceDesc" = "Sumber saat ini:"
"sqlitePath" = "Path SQLite"
"postgresMode" = "Mode PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL yang dikelola panel di server ini (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Hubungkan ke server PostgreSQL yang sudah ada."
"postgresModeLocal" = "Lokal (dikelola panel)"
"postgresModeExternal" = "Eksternal"
"postgresInstallation" = "Instalasi PostgreSQL"
"postgresInstallReady" = "PostgreSQL siap digunakan."
"postgresInstallNeedRoot" = "Instalasi otomatis memerlukan akses root dan lingkungan non-Docker."
"postgresInstallHint" = "Klik Instal untuk menyiapkan PostgreSQL secara otomatis."
"postgresAlreadyInstalled" = "Sudah terinstal"
"postgresInstallBtn" = "Instal PostgreSQL"
"host" = "Host"
"port" = "Port"
"dbName" = "Nama Database"
"user" = "Pengguna"
"password" = "Kata Sandi"
"passwordHint" = "Biarkan kosong untuk mempertahankan kata sandi yang tersimpan."
"sslMode" = "Mode SSL"
"actions" = "Tindakan"
"actionsDesc" = "Data dimigrasikan secara otomatis. Cadangan portabel disimpan sebelum beralih, dan panel akan dimulai ulang."
"testConnection" = "Uji Koneksi"
"switchDatabase" = "Ganti Database"
"switchDatabaseTitle" = "Ganti backend database"
"switchDatabaseConfirm" = "Panel akan membuat cadangan portabel, memigrasikan semua data, lalu memulai ulang dirinya sendiri. Lanjutkan?"
"backupRestore" = "Cadangkan & Pulihkan"
"exportPortableLabel" = "Ekspor Portabel"
"exportPortableDesc" = "Cadangan lintas platform (berfungsi dengan SQLite maupun PostgreSQL, juga dikirim melalui bot Telegram)."
"exportNativeSQLiteLabel" = "Ekspor SQLite Native"
"exportNativeSQLiteDesc" = "File database mentah, hanya tersedia saat SQLite aktif."
"importLabel" = "Impor"
"importDesc" = "Pulihkan dari file portabel (.xui-backup) atau file SQLite lama (.db)."
"exportPortableBtn" = "Ekspor Cadangan Portabel"
"exportNativeSQLiteBtn" = "Ekspor SQLite Native"
"importBtn" = "Impor Cadangan"
[pages.settings.security]
"admin" = "Kredensial admin"
"twoFactor" = "Autentikasi dua faktor"

View file

@ -589,6 +589,50 @@
"ipPool" = "IPプールサブネット"
"poolSize" = "プールサイズ"
[pages.settings.database]
"sectionTitle" = "データベース"
"backend" = "バックエンド"
"backendDesc" = "ランタイムのデータベース設定は、パネルのデータベース外部に保存されています。"
"configSource" = "設定ソース"
"configSourceEnvDesc" = "このデータベースバックエンドは環境変数によって制御されており、ここでは変更できません。"
"configSourceDefaultDesc" = "まだ設定ファイルは保存されていません。組み込みのデフォルト設定SQLiteが有効です。"
"configSourceDesc" = "現在のソース:"
"sqlitePath" = "SQLite パス"
"postgresMode" = "PostgreSQL モード"
"postgresModeLocalDesc" = "このサーバー上でパネル管理の PostgreSQL127.0.0.1:5432。"
"postgresModeExternalDesc" = "既存の PostgreSQL サーバーに接続します。"
"postgresModeLocal" = "ローカル(パネル管理)"
"postgresModeExternal" = "外部"
"postgresInstallation" = "PostgreSQL のインストール"
"postgresInstallReady" = "PostgreSQL は使用可能です。"
"postgresInstallNeedRoot" = "自動インストールには root 権限と非 Docker 環境が必要です。"
"postgresInstallHint" = "インストールをクリックすると、PostgreSQL が自動的にセットアップされます。"
"postgresAlreadyInstalled" = "インストール済み"
"postgresInstallBtn" = "PostgreSQL をインストール"
"host" = "ホスト"
"port" = "ポート"
"dbName" = "データベース名"
"user" = "ユーザー"
"password" = "パスワード"
"passwordHint" = "保存済みのパスワードを維持するには、空欄のままにしてください。"
"sslMode" = "SSL モード"
"actions" = "アクション"
"actionsDesc" = "データは自動的に移行されます。切り替え前にポータブルバックアップが保存され、パネルは再起動します。"
"testConnection" = "接続をテスト"
"switchDatabase" = "データベースを切り替える"
"switchDatabaseTitle" = "データベースバックエンドを切り替える"
"switchDatabaseConfirm" = "パネルはポータブルバックアップを作成し、すべてのデータを移行して、自身を再起動します。続行しますか?"
"backupRestore" = "バックアップと復元"
"exportPortableLabel" = "ポータブル形式でエクスポート"
"exportPortableDesc" = "クロスプラットフォーム対応のバックアップSQLite と PostgreSQL の両方で利用可能で、Telegram ボット経由でも送信されます)。"
"exportNativeSQLiteLabel" = "ネイティブ SQLite をエクスポート"
"exportNativeSQLiteDesc" = "生のデータベースファイル。SQLite が有効な場合のみ利用できます。"
"importLabel" = "インポート"
"importDesc" = "ポータブル形式(.xui-backupまたは従来の SQLite.dbファイルから復元します。"
"exportPortableBtn" = "ポータブルバックアップをエクスポート"
"exportNativeSQLiteBtn" = "ネイティブ SQLite をエクスポート"
"importBtn" = "バックアップをインポート"
[pages.settings.security]
"admin" = "管理者の資格情報"
"twoFactor" = "二段階認証"

View file

@ -589,6 +589,50 @@
"ipPool" = "Sub-rede do Pool de IP"
"poolSize" = "Tamanho do Pool"
[pages.settings.database]
"sectionTitle" = "Banco de dados"
"backend" = "Backend"
"backendDesc" = "A configuração do banco de dados em tempo de execução é armazenada fora do banco de dados do painel."
"configSource" = "Fonte da configuração"
"configSourceEnvDesc" = "Este backend de banco de dados é controlado por variáveis de ambiente e não pode ser alterado aqui."
"configSourceDefaultDesc" = "Nenhum arquivo de configuração foi salvo ainda. Os padrões internos estão ativos (SQLite)."
"configSourceDesc" = "Fonte atual:"
"sqlitePath" = "Caminho do SQLite"
"postgresMode" = "Modo PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL gerenciado pelo painel neste servidor (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Conectar a um servidor PostgreSQL existente."
"postgresModeLocal" = "Local (gerenciado pelo painel)"
"postgresModeExternal" = "Externo"
"postgresInstallation" = "Instalação do PostgreSQL"
"postgresInstallReady" = "O PostgreSQL está pronto para uso."
"postgresInstallNeedRoot" = "A instalação automática requer root e um ambiente sem Docker."
"postgresInstallHint" = "Clique em Instalar para configurar o PostgreSQL automaticamente."
"postgresAlreadyInstalled" = "Já instalado"
"postgresInstallBtn" = "Instalar PostgreSQL"
"host" = "Host"
"port" = "Porta"
"dbName" = "Nome do banco de dados"
"user" = "Usuário"
"password" = "Senha"
"passwordHint" = "Deixe em branco para manter a senha armazenada."
"sslMode" = "Modo SSL"
"actions" = "Ações"
"actionsDesc" = "Os dados são migrados automaticamente. Um backup portátil é salvo antes da troca, e o painel é reiniciado."
"testConnection" = "Testar conexão"
"switchDatabase" = "Trocar banco de dados"
"switchDatabaseTitle" = "Trocar backend do banco de dados"
"switchDatabaseConfirm" = "O painel criará um backup portátil, migrará todos os dados e será reiniciado. Continuar?"
"backupRestore" = "Backup e restauração"
"exportPortableLabel" = "Exportar portátil"
"exportPortableDesc" = "Backup multiplataforma (funciona com SQLite e PostgreSQL, e também é enviado pelo bot do Telegram)."
"exportNativeSQLiteLabel" = "Exportar SQLite nativo"
"exportNativeSQLiteDesc" = "Arquivo bruto do banco de dados, disponível apenas enquanto o SQLite estiver ativo."
"importLabel" = "Importar"
"importDesc" = "Restaurar a partir de um arquivo portátil (.xui-backup) ou de um arquivo SQLite legado (.db)."
"exportPortableBtn" = "Exportar backup portátil"
"exportNativeSQLiteBtn" = "Exportar SQLite nativo"
"importBtn" = "Importar backup"
[pages.settings.security]
"admin" = "Credenciais de administrador"
"twoFactor" = "Autenticação de dois fatores"

View file

@ -589,6 +589,50 @@
"ipPool" = "Подсеть пула IP"
"poolSize" = "Размер пула"
[pages.settings.database]
"sectionTitle" = "База данных"
"backend" = "Бэкенд"
"backendDesc" = "Конфигурация базы данных хранится вне самой базы данных панели."
"configSource" = "Источник конфигурации"
"configSourceEnvDesc" = "Бэкенд базы данных управляется переменными окружения и не может быть изменён здесь."
"configSourceDefaultDesc" = "Файл конфигурации ещё не сохранён. Активны встроенные настройки по умолчанию (SQLite)."
"configSourceDesc" = "Текущий источник:"
"sqlitePath" = "Путь к SQLite"
"postgresMode" = "Режим PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL, управляемый панелью на этом сервере (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Подключение к существующему серверу PostgreSQL."
"postgresModeLocal" = "Локальный (управляется панелью)"
"postgresModeExternal" = "Внешний"
"postgresInstallation" = "Установка PostgreSQL"
"postgresInstallReady" = "PostgreSQL установлен и готов к работе."
"postgresInstallNeedRoot" = "Автоустановка требует прав root и среды не-Docker."
"postgresInstallHint" = "Нажмите «Установить» для автоматической настройки PostgreSQL."
"postgresAlreadyInstalled" = "Уже установлен"
"postgresInstallBtn" = "Установить PostgreSQL"
"host" = "Хост"
"port" = "Порт"
"dbName" = "Имя базы данных"
"user" = "Пользователь"
"password" = "Пароль"
"passwordHint" = "Оставьте пустым, чтобы сохранить текущий пароль."
"sslMode" = "Режим SSL"
"actions" = "Действия"
"actionsDesc" = "Данные мигрируют автоматически. Перед переключением создаётся портативная резервная копия, затем панель перезапускается."
"testConnection" = "Проверить подключение"
"switchDatabase" = "Переключить базу данных"
"switchDatabaseTitle" = "Переключение бэкенда базы данных"
"switchDatabaseConfirm" = "Панель создаст портативную резервную копию, перенесёт все данные и перезапустится. Продолжить?"
"backupRestore" = "Резервное копирование и восстановление"
"exportPortableLabel" = "Портативный экспорт"
"exportPortableDesc" = "Кроссплатформенная резервная копия (работает с SQLite и PostgreSQL, также отправляется Telegram-ботом)."
"exportNativeSQLiteLabel" = "Нативный SQLite"
"exportNativeSQLiteDesc" = "Файл базы данных SQLite, доступен только при активном SQLite-бэкенде."
"importLabel" = "Импорт"
"importDesc" = "Восстановление из портативной резервной копии (.xui-backup) или устаревшего файла SQLite (.db)."
"exportPortableBtn" = "Экспорт портативной копии"
"exportNativeSQLiteBtn" = "Экспорт SQLite"
"importBtn" = "Импортировать резервную копию"
[pages.settings.security]
"admin" = "Учетные данные администратора"
"twoFactor" = "Двухфакторная аутентификация"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP Havuzu Alt Ağı"
"poolSize" = "Havuz Boyutu"
[pages.settings.database]
"sectionTitle" = "Veritabanı"
"backend" = "Arka Uç"
"backendDesc" = "Çalışma zamanı veritabanı yapılandırması panel veritabanının dışında saklanır."
"configSource" = "Yapılandırma Kaynağı"
"configSourceEnvDesc" = "Bu veritabanı arka ucu ortam değişkenleri tarafından kontrol edilir ve burada değiştirilemez."
"configSourceDefaultDesc" = "Henüz kaydedilmiş bir yapılandırma dosyası yok. Yerleşik varsayılanlar etkin (SQLite)."
"configSourceDesc" = "Geçerli kaynak:"
"sqlitePath" = "SQLite Yolu"
"postgresMode" = "PostgreSQL Modu"
"postgresModeLocalDesc" = "Bu sunucuda panel tarafından yönetilen PostgreSQL (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Mevcut bir PostgreSQL sunucusuna bağlan."
"postgresModeLocal" = "Yerel (panel tarafından yönetilen)"
"postgresModeExternal" = "Harici"
"postgresInstallation" = "PostgreSQL Kurulumu"
"postgresInstallReady" = "PostgreSQL kullanıma hazır."
"postgresInstallNeedRoot" = "Otomatik kurulum için root yetkisi ve Docker olmayan bir ortam gerekir."
"postgresInstallHint" = "PostgreSQLi otomatik olarak kurmak için Kura tıklayın."
"postgresAlreadyInstalled" = "Zaten kurulu"
"postgresInstallBtn" = "PostgreSQL Kur"
"host" = "Ana Makine"
"port" = "Bağlantı Noktası"
"dbName" = "Veritabanı Adı"
"user" = "Kullanıcı"
"password" = "Parola"
"passwordHint" = "Kaydedilmiş parolayı korumak için boş bırakın."
"sslMode" = "SSL Modu"
"actions" = "İşlemler"
"actionsDesc" = "Veriler otomatik olarak taşınır. Geçişten önce taşınabilir bir yedek kaydedilir ve panel yeniden başlatılır."
"testConnection" = "Bağlantıyı Test Et"
"switchDatabase" = "Veritabanını Değiştir"
"switchDatabaseTitle" = "Veritabanı arka ucunu değiştir"
"switchDatabaseConfirm" = "Panel taşınabilir bir yedek oluşturacak, tüm verileri taşıyacak ve kendini yeniden başlatacaktır. Devam edilsin mi?"
"backupRestore" = "Yedekleme ve Geri Yükleme"
"exportPortableLabel" = "Taşınabilir Olarak Dışa Aktar"
"exportPortableDesc" = "Platformlar arası yedek (hem SQLite hem de PostgreSQL ile çalışır, ayrıca Telegram botu üzerinden de gönderilir)."
"exportNativeSQLiteLabel" = "Yerel SQLiteı Dışa Aktar"
"exportNativeSQLiteDesc" = "Ham veritabanı dosyası, yalnızca SQLite etkin olduğunda kullanılabilir."
"importLabel" = "İçe Aktar"
"importDesc" = "Taşınabilir (.xui-backup) veya eski SQLite (.db) dosyasından geri yükleyin."
"exportPortableBtn" = "Taşınabilir Yedeği Dışa Aktar"
"exportNativeSQLiteBtn" = "Yerel SQLiteı Dışa Aktar"
"importBtn" = "Yedeği İçe Aktar"
[pages.settings.security]
"admin" = "Yönetici kimlik bilgileri"
"twoFactor" = "İki adımlı doğrulama"

View file

@ -589,6 +589,50 @@
"ipPool" = "Підмережа IP-пулу"
"poolSize" = "Розмір пулу"
[pages.settings.database]
"sectionTitle" = "База даних"
"backend" = "Бекенд"
"backendDesc" = "Конфігурація бази даних під час виконання зберігається поза базою даних панелі."
"configSource" = "Джерело конфігурації"
"configSourceEnvDesc" = "Цей бекенд бази даних керується змінними середовища й не може бути змінений тут."
"configSourceDefaultDesc" = "Файл конфігурації ще не збережено. Використовуються вбудовані значення за замовчуванням (SQLite)."
"configSourceDesc" = "Поточне джерело:"
"sqlitePath" = "Шлях до SQLite"
"postgresMode" = "Режим PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL, яким керує панель, на цьому сервері (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Підключення до наявного сервера PostgreSQL."
"postgresModeLocal" = "Локальний (керується панеллю)"
"postgresModeExternal" = "Зовнішній"
"postgresInstallation" = "Встановлення PostgreSQL"
"postgresInstallReady" = "PostgreSQL готовий до використання."
"postgresInstallNeedRoot" = "Автоматичне встановлення потребує root-доступу та середовища без Docker."
"postgresInstallHint" = "Натисніть «Встановити», щоб автоматично налаштувати PostgreSQL."
"postgresAlreadyInstalled" = "Уже встановлено"
"postgresInstallBtn" = "Встановити PostgreSQL"
"host" = "Хост"
"port" = "Порт"
"dbName" = "Назва бази даних"
"user" = "Користувач"
"password" = "Пароль"
"passwordHint" = "Залиште порожнім, щоб зберегти поточний пароль."
"sslMode" = "Режим SSL"
"actions" = "Дії"
"actionsDesc" = "Дані мігруються автоматично. Перед перемиканням зберігається переносна резервна копія, після чого панель перезапускається."
"testConnection" = "Перевірити з’єднання"
"switchDatabase" = "Перемкнути базу даних"
"switchDatabaseTitle" = "Перемкнути бекенд бази даних"
"switchDatabaseConfirm" = "Панель створить переносну резервну копію, перенесе всі дані та перезапуститься. Продовжити?"
"backupRestore" = "Резервне копіювання та відновлення"
"exportPortableLabel" = "Експортувати переносну копію"
"exportPortableDesc" = "Кросплатформна резервна копія (працює і з SQLite, і з PostgreSQL, також надсилається через Telegram-бота)."
"exportNativeSQLiteLabel" = "Експортувати рідний SQLite"
"exportNativeSQLiteDesc" = "Необроблений файл бази даних, доступний лише коли активний SQLite."
"importLabel" = "Імпорт"
"importDesc" = "Відновлення з переносного файлу (.xui-backup) або застарілого файлу SQLite (.db)."
"exportPortableBtn" = "Експортувати переносну резервну копію"
"exportNativeSQLiteBtn" = "Експортувати рідний SQLite"
"importBtn" = "Імпортувати резервну копію"
[pages.settings.security]
"admin" = "Облікові дані адміністратора"
"twoFactor" = "Двофакторна аутентифікація"

View file

@ -589,6 +589,50 @@
"ipPool" = "Mạng con nhóm IP"
"poolSize" = "Kích thước bể bơi"
[pages.settings.database]
"sectionTitle" = "Cơ sở dữ liệu"
"backend" = "Phần phụ trợ"
"backendDesc" = "Cấu hình cơ sở dữ liệu khi chạy được lưu bên ngoài cơ sở dữ liệu của bảng điều khiển."
"configSource" = "Nguồn cấu hình"
"configSourceEnvDesc" = "Phần phụ trợ cơ sở dữ liệu này được kiểm soát bởi các biến môi trường và không thể thay đổi tại đây."
"configSourceDefaultDesc" = "Chưa có tệp cấu hình nào được lưu. Cài đặt mặc định tích hợp sẵn đang hoạt động (SQLite)."
"configSourceDesc" = "Nguồn hiện tại:"
"sqlitePath" = "Đường dẫn SQLite"
"postgresMode" = "Chế độ PostgreSQL"
"postgresModeLocalDesc" = "PostgreSQL do bảng điều khiển quản lý trên máy chủ này (127.0.0.1:5432)."
"postgresModeExternalDesc" = "Kết nối tới một máy chủ PostgreSQL hiện có."
"postgresModeLocal" = "Cục bộ (do bảng điều khiển quản lý)"
"postgresModeExternal" = "Bên ngoài"
"postgresInstallation" = "Cài đặt PostgreSQL"
"postgresInstallReady" = "PostgreSQL đã sẵn sàng để sử dụng."
"postgresInstallNeedRoot" = "Cài đặt tự động yêu cầu quyền root và môi trường không phải Docker."
"postgresInstallHint" = "Nhấn Cài đặt để thiết lập PostgreSQL tự động."
"postgresAlreadyInstalled" = "Đã được cài đặt"
"postgresInstallBtn" = "Cài đặt PostgreSQL"
"host" = "Máy chủ"
"port" = "Cổng"
"dbName" = "Tên cơ sở dữ liệu"
"user" = "Người dùng"
"password" = "Mật khẩu"
"passwordHint" = "Để trống để giữ nguyên mật khẩu đã lưu."
"sslMode" = "Chế độ SSL"
"actions" = "Thao tác"
"actionsDesc" = "Dữ liệu sẽ được di chuyển tự động. Một bản sao lưu di động sẽ được lưu trước khi chuyển đổi, và bảng điều khiển sẽ khởi động lại."
"testConnection" = "Kiểm tra kết nối"
"switchDatabase" = "Chuyển cơ sở dữ liệu"
"switchDatabaseTitle" = "Chuyển phần phụ trợ cơ sở dữ liệu"
"switchDatabaseConfirm" = "Bảng điều khiển sẽ tạo một bản sao lưu di động, di chuyển toàn bộ dữ liệu và tự khởi động lại. Tiếp tục?"
"backupRestore" = "Sao lưu & Khôi phục"
"exportPortableLabel" = "Xuất bản sao lưu di động"
"exportPortableDesc" = "Bản sao lưu đa nền tảng (hoạt động với cả SQLite và PostgreSQL, cũng được gửi qua bot Telegram)."
"exportNativeSQLiteLabel" = "Xuất SQLite gốc"
"exportNativeSQLiteDesc" = "Tệp cơ sở dữ liệu thô, chỉ khả dụng khi SQLite đang hoạt động."
"importLabel" = "Nhập"
"importDesc" = "Khôi phục từ tệp sao lưu di động (.xui-backup) hoặc tệp SQLite cũ (.db)."
"exportPortableBtn" = "Xuất bản sao lưu di động"
"exportNativeSQLiteBtn" = "Xuất SQLite gốc"
"importBtn" = "Nhập bản sao lưu"
[pages.settings.security]
"admin" = "Thông tin đăng nhập quản trị viên"
"twoFactor" = "Xác thực hai yếu tố"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP 池子网"
"poolSize" = "池大小"
[pages.settings.database]
"sectionTitle" = "数据库"
"backend" = "后端"
"backendDesc" = "运行时数据库配置存储在面板数据库之外。"
"configSource" = "配置来源"
"configSourceEnvDesc" = "此数据库后端由环境变量控制,无法在此处更改。"
"configSourceDefaultDesc" = "尚未保存配置文件。当前使用内置默认值SQLite。"
"configSourceDesc" = "当前来源:"
"sqlitePath" = "SQLite 路径"
"postgresMode" = "PostgreSQL 模式"
"postgresModeLocalDesc" = "此服务器上的面板托管 PostgreSQL127.0.0.1:5432。"
"postgresModeExternalDesc" = "连接到现有的 PostgreSQL 服务器。"
"postgresModeLocal" = "本地(面板托管)"
"postgresModeExternal" = "外部"
"postgresInstallation" = "PostgreSQL 安装"
"postgresInstallReady" = "PostgreSQL 已可使用。"
"postgresInstallNeedRoot" = "自动安装需要 root 权限且不能在 Docker 环境中运行。"
"postgresInstallHint" = "点击“安装”以自动设置 PostgreSQL。"
"postgresAlreadyInstalled" = "已安装"
"postgresInstallBtn" = "安装 PostgreSQL"
"host" = "主机"
"port" = "端口"
"dbName" = "数据库名称"
"user" = "用户"
"password" = "密码"
"passwordHint" = "留空以保留已保存的密码。"
"sslMode" = "SSL 模式"
"actions" = "操作"
"actionsDesc" = "数据会自动迁移。切换前会保存一个可移植备份,然后面板会重启。"
"testConnection" = "测试连接"
"switchDatabase" = "切换数据库"
"switchDatabaseTitle" = "切换数据库后端"
"switchDatabaseConfirm" = "面板将创建一个可移植备份,迁移所有数据,并自行重启。是否继续?"
"backupRestore" = "备份与恢复"
"exportPortableLabel" = "导出可移植备份"
"exportPortableDesc" = "跨平台备份(兼容 SQLite 和 PostgreSQL也会通过 Telegram 机器人发送)。"
"exportNativeSQLiteLabel" = "导出原生 SQLite"
"exportNativeSQLiteDesc" = "原始数据库文件,仅在当前使用 SQLite 时可用。"
"importLabel" = "导入"
"importDesc" = "从可移植备份(.xui-backup或旧版 SQLite.db文件恢复。"
"exportPortableBtn" = "导出可移植备份"
"exportNativeSQLiteBtn" = "导出原生 SQLite"
"importBtn" = "导入备份"
[pages.settings.security]
"admin" = "管理员凭据"
"twoFactor" = "双重验证"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP 池子網"
"poolSize" = "池大小"
[pages.settings.database]
"sectionTitle" = "資料庫"
"backend" = "後端"
"backendDesc" = "執行階段的資料庫設定儲存在面板資料庫之外。"
"configSource" = "設定來源"
"configSourceEnvDesc" = "此資料庫後端由環境變數控制,無法在此處變更。"
"configSourceDefaultDesc" = "尚未儲存設定檔。目前使用內建預設值SQLite。"
"configSourceDesc" = "目前來源:"
"sqlitePath" = "SQLite 路徑"
"postgresMode" = "PostgreSQL 模式"
"postgresModeLocalDesc" = "此伺服器上的面板管理 PostgreSQL127.0.0.1:5432。"
"postgresModeExternalDesc" = "連線到現有的 PostgreSQL 伺服器。"
"postgresModeLocal" = "本機(由面板管理)"
"postgresModeExternal" = "外部"
"postgresInstallation" = "PostgreSQL 安裝"
"postgresInstallReady" = "PostgreSQL 已可使用。"
"postgresInstallNeedRoot" = "自動安裝需要 root 權限且不得在 Docker 環境中執行。"
"postgresInstallHint" = "點擊「安裝」以自動設定 PostgreSQL。"
"postgresAlreadyInstalled" = "已安裝"
"postgresInstallBtn" = "安裝 PostgreSQL"
"host" = "主機"
"port" = "連接埠"
"dbName" = "資料庫名稱"
"user" = "使用者"
"password" = "密碼"
"passwordHint" = "留空可保留已儲存的密碼。"
"sslMode" = "SSL 模式"
"actions" = "操作"
"actionsDesc" = "資料會自動遷移。切換前會先儲存可攜式備份,然後重新啟動面板。"
"testConnection" = "測試連線"
"switchDatabase" = "切換資料庫"
"switchDatabaseTitle" = "切換資料庫後端"
"switchDatabaseConfirm" = "面板將建立可攜式備份、遷移所有資料,並重新啟動自身。要繼續嗎?"
"backupRestore" = "備份與還原"
"exportPortableLabel" = "匯出可攜式備份"
"exportPortableDesc" = "跨平台備份(可同時用於 SQLite 與 PostgreSQL也可由 Telegram 機器人傳送)。"
"exportNativeSQLiteLabel" = "匯出原生 SQLite"
"exportNativeSQLiteDesc" = "原始資料庫檔案,僅在啟用 SQLite 時可用。"
"importLabel" = "匯入"
"importDesc" = "從可攜式(.xui-backup或舊版 SQLite.db檔案還原。"
"exportPortableBtn" = "匯出可攜式備份"
"exportNativeSQLiteBtn" = "匯出原生 SQLite"
"importBtn" = "匯入備份"
[pages.settings.security]
"admin" = "管理員憑證"
"twoFactor" = "雙重驗證"

135
x-ui.sh
View file

@ -1672,6 +1672,133 @@ run_speedtest() {
speedtest
}
database_management() {
echo -e "\n${green}\t1.${plain} Show Current Database Backend"
echo -e "${green}\t2.${plain} Install Local PostgreSQL"
echo -e "${green}\t3.${plain} Switch Database Backend"
echo -e "${green}\t4.${plain} Export Portable Backup"
echo -e "${green}\t5.${plain} Export Native SQLite"
echo -e "${green}\t6.${plain} Import Backup"
echo -e "${green}\t0.${plain} Back to Main Menu"
read -rp "Choose an option: " db_choice
case "${db_choice}" in
0)
show_menu
;;
1)
${xui_folder}/x-ui database show
before_show_menu
;;
2)
${xui_folder}/x-ui database install-postgres
before_show_menu
;;
3)
local db_backend=""
local pg_mode=""
local sqlite_path=""
local pg_host="127.0.0.1"
local pg_port="5432"
local pg_db="xui"
local pg_user="xui"
local pg_pass=""
local pg_sslmode="disable"
echo -e "${green}1.${plain} SQLite"
echo -e "${green}2.${plain} PostgreSQL"
read -rp "Choose database backend (default 1): " db_backend
db_backend="${db_backend// /}"
if [[ "${db_backend}" != "2" ]]; then
read -rp "SQLite path [/etc/x-ui/x-ui.db]: " sqlite_path
sqlite_path="${sqlite_path:-/etc/x-ui/x-ui.db}"
${xui_folder}/x-ui database switch -driver sqlite -sqlite-path "${sqlite_path}"
confirm_restart
return
fi
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
read -rp "Host [127.0.0.1]: " pg_host
pg_host="${pg_host:-127.0.0.1}"
read -rp "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 [random]: " pg_pass
[[ -z "${pg_pass}" ]] && pg_pass=$(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_pass}" \
-postgres-local true
else
read -rp "Host: " pg_host
if [[ -z "${pg_host}" ]]; then
LOGE "Host is required."
before_show_menu
return
fi
read -rp "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_pass
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_pass}" \
-postgres-sslmode "${pg_sslmode}" \
-postgres-local false
fi
confirm_restart
;;
4)
read -rp "Output path or directory [current directory]: " export_path
${xui_folder}/x-ui database export -type portable -out "${export_path}"
before_show_menu
;;
5)
read -rp "Output path or directory [current directory]: " export_path
${xui_folder}/x-ui database export -type sqlite -out "${export_path}"
before_show_menu
;;
6)
read -rp "Backup file path: " import_path
if [[ -z "${import_path}" ]]; then
LOGE "Backup file path is required."
before_show_menu
return
fi
${xui_folder}/x-ui database import -file "${import_path}"
confirm_restart
;;
*)
LOGE "Invalid option."
database_management
;;
esac
}
ip_validation() {
@ -2217,10 +2344,11 @@ show_menu() {
${green}24.${plain} Enable BBR │
${green}25.${plain} Update Geo Files │
${green}26.${plain} Speedtest by Ookla │
${green}27.${plain} Database Management │
╚────────────────────────────────────────────────╝
"
show_status
echo && read -rp "Please enter your selection [0-26]: " num
echo && read -rp "Please enter your selection [0-27]: " num
case "${num}" in
0)
@ -2304,8 +2432,11 @@ show_menu() {
26)
run_speedtest
;;
27)
database_management
;;
*)
LOGE "Please enter the correct number [0-26]"
LOGE "Please enter the correct number [0-27]"
;;
esac
}