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/build/ /app/
COPY --from=builder /app/DockerEntrypoint.sh /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 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 \ RUN chmod +x \
/app/DockerEntrypoint.sh \ /app/DockerEntrypoint.sh \
/app/postgres-manager.sh \
/app/x-ui \ /app/x-ui \
/usr/bin/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). 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 ## A Special Thanks to
- [alireza0](https://github.com/alireza0/) - [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). Полную документацию смотрите в [вики проекта](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/) - [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 // Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite. // for the 3x-ui panel using GORM with SQLite or PostgreSQL.
package database package database
import ( import (
@ -8,28 +8,119 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"net"
"net/url"
"os" "os"
"path" "path/filepath"
"slices" "slices"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" gormlogger "gorm.io/gorm/logger"
) )
var db *gorm.DB var (
db *gorm.DB
dbConfig *config.DatabaseConfig
)
const ( const (
defaultUsername = "admin" defaultUsername = "admin"
defaultPassword = "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{ models := []any{
&model.User{}, &model.User{},
&model.Inbound{}, &model.Inbound{},
@ -39,8 +130,8 @@ func initModels() error {
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
} }
for _, model := range models { for _, item := range models {
if err := db.AutoMigrate(model); err != nil { if err := conn.AutoMigrate(item); err != nil {
log.Printf("Error auto migrating model: %v", err) log.Printf("Error auto migrating model: %v", err)
return err return err
} }
@ -48,16 +139,26 @@ func initModels() error {
return nil return nil
} }
// initUser creates a default admin user if the users table is empty. func isTableEmpty(conn *gorm.DB, tableName string) (bool, error) {
func initUser() error { if !conn.Migrator().HasTable(tableName) {
empty, err := isTableEmpty("users") 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 { if err != nil {
log.Printf("Error checking if users table is empty: %v", err) log.Printf("Error checking if users table is empty: %v", err)
return err return err
} }
if empty { if !empty {
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword) return nil
}
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
if err != nil { if err != nil {
log.Printf("Error hashing default password: %v", err) log.Printf("Error hashing default password: %v", err)
return err return err
@ -67,14 +168,11 @@ func initUser() error {
Username: defaultUsername, Username: defaultUsername,
Password: hashedPassword, Password: hashedPassword,
} }
return db.Create(user).Error return conn.Create(user).Error
}
return nil
} }
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running. func runSeeders(conn *gorm.DB, isUsersEmpty bool) error {
func runSeeders(isUsersEmpty bool) error { empty, err := isTableEmpty(conn, "history_of_seeders")
empty, err := isTableEmpty("history_of_seeders")
if err != nil { if err != nil {
log.Printf("Error checking if users table is empty: %v", err) log.Printf("Error checking if users table is empty: %v", err)
return err return err
@ -84,97 +182,184 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error return conn.Create(hashSeeder).Error
} else { }
var seedersHistory []string var seedersHistory []string
db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory) if err := conn.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
return err
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { }
var users []model.User
db.Find(&users) if slices.Contains(seedersHistory, "UserPasswordHash") || isUsersEmpty {
return nil
for _, user := range users { }
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil { var users []model.User
log.Printf("Error hashing password for user '%s': %v", user.Username, err) 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 err
} }
db.Model(&user).Update("password", hashedPassword)
} }
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error return conn.Create(hashSeeder).Error
}
} }
// 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 return nil
} }
// isTableEmpty returns true if the named table contains zero rows. isUsersEmpty, err := isTableEmpty(conn, "users")
func isTableEmpty(tableName string) (bool, error) { if err != nil {
var count int64 return err
err := db.Table(tableName).Count(&count).Error }
return count == 0, 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. // InitDB sets up the database connection, migrates models, and runs seeders.
func InitDB(dbPath string) error { func InitDB() error {
dir := path.Dir(dbPath) cfg, err := config.LoadDatabaseConfig()
err := os.MkdirAll(dir, fs.ModePerm)
if err != nil { if err != nil {
return err return err
} }
return InitDBWithConfig(cfg)
var gormLogger logger.Interface
if config.IsDebug() {
gormLogger = logger.Default
} else {
gormLogger = logger.Discard
} }
c := &gorm.Config{ // InitDBWithConfig sets up the database using an explicit runtime config.
Logger: gormLogger, func InitDBWithConfig(cfg *config.DatabaseConfig) error {
} conn, err := OpenDatabase(cfg)
db, err = gorm.Open(sqlite.Open(dbPath), c)
if err != nil { if err != nil {
return err return err
} }
if err := PrepareDatabase(conn, true); err != nil {
if err := initModels(); err != nil { _ = CloseConnection(conn)
return err return err
} }
isUsersEmpty, err := isTableEmpty("users") if err := CloseDB(); err != nil {
if err != nil { _ = CloseConnection(conn)
return err return err
} }
if err := initUser(); err != nil { db = conn
return err dbConfig = cfg.Clone().Normalize()
}
return runSeeders(isUsersEmpty)
}
// 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()
}
return nil 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. // GetDB returns the global GORM database instance.
func GetDB() *gorm.DB { func GetDB() *gorm.DB {
return 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. // IsNotFound checks if the given error is a GORM record not found error.
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
return err == gorm.ErrRecordNotFound 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. // Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
func Checkpoint() error { func Checkpoint() error {
// Update WAL if !IsSQLite() || db == nil {
err := db.Exec("PRAGMA wal_checkpoint;").Error
if err != nil {
return err
}
return nil return nil
} }
return db.Exec("PRAGMA wal_checkpoint;").Error
}
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection // ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
// and runs a PRAGMA integrity_check to ensure the file is structurally sound. // and runs a PRAGMA integrity_check to ensure the file is structurally sound.
// It does not mutate global state or run migrations. // It does not mutate global state or run migrations.
func ValidateSQLiteDB(dbPath string) error { func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil { // file must exist if _, err := os.Stat(dbPath); err != nil {
return err 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 { if err != nil {
return err return err
} }
@ -217,6 +400,7 @@ func ValidateSQLiteDB(dbPath string) error {
return err return err
} }
defer sqlDB.Close() defer sqlDB.Close()
var res string var res string
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil { if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
return err 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: environment:
XRAY_VMESS_AEAD_FORCED: "false" XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true" 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 tty: true
network_mode: host network_mode: host
restart: unless-stopped 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/sys v0.42.0
golang.org/x/text v0.35.0 golang.org/x/text v0.35.0
google.golang.org/grpc v1.80.0 google.golang.org/grpc v1.80.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@ -53,6 +54,10 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect
github.com/grbit/go-json v0.11.0 // indirect github.com/grbit/go-json v0.11.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect

11
go.sum
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/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.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 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@ -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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=

View file

@ -639,7 +639,121 @@ prompt_and_setup_ssl() {
esac 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() { config_after_install() {
configure_database_backend
local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') 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_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}') 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 cd x-ui
chmod +x x-ui chmod +x x-ui
chmod +x x-ui.sh 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 # Check the system's architecture and rename the file accordingly
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then

218
main.go
View file

@ -3,11 +3,13 @@
package main package main
import ( import (
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
_ "unsafe" _ "unsafe"
@ -18,6 +20,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/mhsanaei/3x-ui/v2/util/sys" "github.com/mhsanaei/3x-ui/v2/util/sys"
"github.com/mhsanaei/3x-ui/v2/web" "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/global"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
@ -46,7 +49,7 @@ func runWebServer() {
godotenv.Load() godotenv.Load()
err := database.InitDB(config.GetDBPath()) err := database.InitDB()
if err != nil { if err != nil {
log.Fatalf("Error initializing database: %v", err) log.Fatalf("Error initializing database: %v", err)
} }
@ -131,7 +134,7 @@ func runWebServer() {
// resetSetting resets all panel settings to their default values. // resetSetting resets all panel settings to their default values.
func resetSetting() { func resetSetting() {
err := database.InitDB(config.GetDBPath()) err := database.InitDB()
if err != nil { if err != nil {
fmt.Println("Failed to initialize database:", err) fmt.Println("Failed to initialize database:", err)
return return
@ -154,11 +157,23 @@ func showSetting(show bool) {
if err != nil { if err != nil {
fmt.Println("get current port failed, error info:", err) 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() webBasePath, err := settingService.GetBasePath()
if err != nil { if err != nil {
fmt.Println("get webBasePath failed, error info:", err) 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() certFile, err := settingService.GetCertFile()
if err != nil { if err != nil {
@ -192,6 +207,9 @@ func showSetting(show bool) {
fmt.Println("hasDefaultCredential:", hasDefaultCredential) fmt.Println("hasDefaultCredential:", hasDefaultCredential)
fmt.Println("port:", port) fmt.Println("port:", port)
fmt.Println("listen:", listen)
fmt.Println("subPort:", subPort)
fmt.Println("subListen:", subListen)
fmt.Println("webBasePath:", webBasePath) fmt.Println("webBasePath:", webBasePath)
} }
} }
@ -218,7 +236,7 @@ func updateTgbotEnableSts(status bool) {
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule. // updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) { func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
err := database.InitDB(config.GetDBPath()) err := database.InitDB()
if err != nil { if err != nil {
fmt.Println("Error initializing database:", err) fmt.Println("Error initializing database:", err)
return 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. // updateSetting updates various panel settings including ports, credentials, base path, listen IPs, and two-factor authentication.
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) { func updateSetting(port int, subPort int, username string, password string, webBasePath string, listenIP string, subListenIP string, resetTwoFactor bool) {
err := database.InitDB(config.GetDBPath()) err := database.InitDB()
if err != nil { if err != nil {
fmt.Println("Database initialization failed:", err) fmt.Println("Database initialization failed:", err)
return return
@ -308,14 +326,32 @@ func updateSetting(port int, username string, password string, webBasePath strin
if err != nil { if err != nil {
fmt.Println("Failed to set listen IP:", err) fmt.Println("Failed to set listen IP:", err)
} else { } 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. // updateCert updates the SSL certificate files for the panel.
func updateCert(publicKey string, privateKey string) { func updateCert(publicKey string, privateKey string) {
err := database.InitDB(config.GetDBPath()) err := database.InitDB()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return return
@ -392,7 +428,7 @@ func GetListenIP(getListen bool) {
func migrateDb() { func migrateDb() {
inboundService := service.InboundService{} inboundService := service.InboundService{}
err := database.InitDB(config.GetDBPath()) err := database.InitDB()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -401,6 +437,161 @@ func migrateDb() {
fmt.Println("Migration done!") 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. // 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. // It parses command-line arguments to run the web server, migrate database, or update settings.
func main() { func main() {
@ -416,10 +607,12 @@ func main() {
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
var port int var port int
var subPort int
var username string var username string
var password string var password string
var webBasePath string var webBasePath string
var listenIP string var listenIP string
var subListenIP string
var getListen bool var getListen bool
var webCertFile string var webCertFile string
var webKeyFile string var webKeyFile string
@ -434,10 +627,12 @@ func main() {
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings") settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
settingCmd.BoolVar(&show, "show", false, "Display current settings") settingCmd.BoolVar(&show, "show", false, "Display current settings")
settingCmd.IntVar(&port, "port", 0, "Set panel port number") 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(&username, "username", "", "Set login username")
settingCmd.StringVar(&password, "password", "", "Set login password") settingCmd.StringVar(&password, "password", "", "Set login password")
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel") settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP") 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(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP") settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings") settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
@ -456,6 +651,7 @@ func main() {
fmt.Println(" run run web panel") fmt.Println(" run run web panel")
fmt.Println(" migrate migrate form other/old x-ui") fmt.Println(" migrate migrate form other/old x-ui")
fmt.Println(" setting set settings") fmt.Println(" setting set settings")
fmt.Println(" database manage database backend")
} }
flag.Parse() flag.Parse()
@ -483,7 +679,7 @@ func main() {
if reset { if reset {
resetSetting() resetSetting()
} else { } else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) updateSetting(port, subPort, username, password, webBasePath, listenIP, subListenIP, resetTwoFactor)
} }
if show { if show {
showSetting(show) showSetting(show)
@ -511,6 +707,8 @@ func main() {
} else { } else {
updateCert(webCertFile, webKeyFile) updateCert(webCertFile, webKeyFile)
} }
case "database":
handleDatabaseCommand(os.Args[2:])
default: default:
fmt.Println("Invalid subcommands") fmt.Println("Invalid subcommands")
fmt.Println() 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) { func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB() db := database.GetDB()
var inbounds []*model.Inbound var candidates []*model.Inbound
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( err := db.Model(model.Inbound{}).
SELECT DISTINCT inbounds.id Preload("ClientStats").
FROM inbounds, Where("protocol IN ?", []string{"vmess", "vless", "trojan", "shadowsocks"}).
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client Where("enable = ?", true).
WHERE Find(&candidates).Error
protocol in ('vmess','vless','trojan','shadowsocks')
AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
)`, subId, true).Find(&inbounds).Error
if err != nil { if err != nil {
return nil, err 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 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) { func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) {
db := database.GetDB() db := database.GetDB()
var inbound *model.Inbound var inbounds []model.Inbound
err := db.Model(model.Inbound{}). err := db.Model(model.Inbound{}).Find(&inbounds).Error
Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", dest).
Find(&inbound).Error
if err != nil { if err != nil {
return "", 0, "", err 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 var stream map[string]any
json.Unmarshal([]byte(streamSettings), &stream) json.Unmarshal([]byte(streamSettings), &stream)
var masterStream map[string]any 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 rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
cd x-ui >/dev/null 2>&1 cd x-ui >/dev/null 2>&1
chmod +x 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 # Check the system's architecture and rename the file accordingly
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then

View file

@ -87,3 +87,34 @@ class AllSetting {
return ObjectUtil.equals(this, 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

@ -22,6 +22,7 @@ type ServerController struct {
serverService service.ServerService serverService service.ServerService
settingService service.SettingService settingService service.SettingService
databaseService service.DatabaseService
lastStatus *service.Status lastStatus *service.Status
@ -253,14 +254,27 @@ func (a *ServerController) getConfigJson(c *gin.Context) {
// getDb downloads the database file. // getDb downloads the database file.
func (a *ServerController) getDb(c *gin.Context) { 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 { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err) jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
return return
} }
filename := "x-ui.db"
if !isValidFilename(filename) { if !isValidFilename(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename")) c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return return
@ -271,7 +285,7 @@ func (a *ServerController) getDb(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename="+filename) c.Header("Content-Disposition", "attachment; filename="+filename)
// Write the file contents to the response // Write the file contents to the response
c.Writer.Write(db) c.Writer.Write(data)
} }
func isValidFilename(filename string) bool { func isValidFilename(filename string) bool {
@ -283,16 +297,15 @@ func isValidFilename(filename string) bool {
func (a *ServerController) importDB(c *gin.Context) { func (a *ServerController) importDB(c *gin.Context) {
// Get the file from the request body // Get the file from the request body
file, _, err := c.Request.FormFile("db") file, _, err := c.Request.FormFile("db")
if err != nil {
file, _, err = c.Request.FormFile("backup")
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err) jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
return return
} }
}
defer file.Close() defer file.Close()
// Always restart Xray before return _, err = a.databaseService.ImportBackup(file)
defer a.serverService.RestartXrayService()
// lastGetStatusTime removed; no longer needed
// Import it
err = a.serverService.ImportDB(file)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err) jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
return return

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"errors" "errors"
"net/http"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
@ -25,6 +26,7 @@ type SettingController struct {
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
panelService service.PanelService panelService service.PanelService
databaseService service.DatabaseService
} }
// NewSettingController creates a new SettingController and initializes its routes. // 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("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel) g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) 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. // getAllSetting retrieves all current settings.
@ -119,3 +130,97 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
} }
jsonObj(c, defaultJsonConfig, nil) 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 class="ant-backup-list w-100" bordered>
<a-list-item class="ant-backup-list-item"> <a-list-item class="ant-backup-list-item">
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ i18n "pages.index.exportDatabase" }}</template> <template #title>Export Portable Backup</template>
<template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template> <template #description>Portable backups work for both SQLite and PostgreSQL.</template>
</a-list-item-meta> </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>
<a-list-item class="ant-backup-list-item"> <a-list-item class="ant-backup-list-item">
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ i18n "pages.index.importDatabase" }}</template> <template #title>Export Native SQLite</template>
<template #description>{{ i18n "pages.index.importDatabaseDesc" }}</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-list-item-meta>
<a-button @click="importDatabase()" type="primary" icon="upload" /> <a-button @click="importDatabase()" type="primary" icon="upload" />
</a-list-item> </a-list-item>
@ -864,6 +871,7 @@
}; };
const backupModal = { const backupModal = {
visible: false, visible: false,
nativeSQLiteExportAvailable: false,
show() { show() {
this.visible = true; this.visible = true;
}, },
@ -1064,24 +1072,28 @@
} }
txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json'); 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(); backupModal.show();
}, },
exportDatabase() { exportDatabase(type = 'portable') {
window.location = basePath + 'panel/api/server/getDb'; window.location = `${basePath}panel/setting/database/export?type=${type}`;
}, },
importDatabase() { importDatabase() {
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
fileInput.accept = '.db'; fileInput.accept = '.xui-backup,.db,.zip';
fileInput.addEventListener('change', async (event) => { fileInput.addEventListener('change', async (event) => {
const dbFile = event.target.files[0]; const backupFile = event.target.files[0];
if (dbFile) { if (backupFile) {
const formData = new FormData(); const formData = new FormData();
formData.append('db', dbFile); formData.append('backup', backupFile);
backupModal.hide(); backupModal.hide();
this.loading(true); this.loading(true);
const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, { const uploadMsg = await HttpUtil.post('/panel/setting/database/import', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
} }
@ -1090,15 +1102,10 @@
if (!uploadMsg.success) { if (!uploadMsg.success) {
return; return;
} }
this.loading(true);
const restartMsg = await HttpUtil.post("/panel/setting/restartPanel");
this.loading(false);
if (restartMsg.success) {
this.loading(true); this.loading(true);
await PromiseUtil.sleep(5000); await PromiseUtil.sleep(5000);
location.reload(); location.reload();
} }
}
}); });
fileInput.click(); fileInput.click();
}, },

View file

@ -119,6 +119,8 @@
}, },
oldAllSetting: new AllSetting(), oldAllSetting: new AllSetting(),
allSetting: new AllSetting(), allSetting: new AllSetting(),
oldDatabaseSetting: new DatabaseSetting(),
databaseSetting: new DatabaseSetting(),
saveBtnDisable: true, saveBtnDisable: true,
entryHost: null, entryHost: null,
entryPort: null, entryPort: null,
@ -276,6 +278,14 @@
this.saveBtnDisable = true; 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() { async loadInboundTags() {
const msg = await HttpUtil.get("/panel/api/inbounds/list"); const msg = await HttpUtil.get("/panel/api/inbounds/list");
if (msg && msg.success && Array.isArray(msg.obj)) { if (msg && msg.success && Array.isArray(msg.obj)) {
@ -295,6 +305,78 @@
await this.getAllSetting(); 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() { async updateUser() {
const sendUpdateUserRequest = async () => { const sendUpdateUserRequest = async () => {
this.loading(true); this.loading(true);
@ -627,6 +709,7 @@
this.entryProtocol = window.location.protocol; this.entryProtocol = window.location.protocol;
this.entryIsIP = this._isIp(this.entryHost); this.entryIsIP = this._isIp(this.entryHost);
await this.getAllSetting(); await this.getAllSetting();
await this.getDatabaseSetting();
await this.loadInboundTags(); await this.loadInboundTags();
while (true) { while (true) {
await PromiseUtil.sleep(1000); await PromiseUtil.sleep(1000);

View file

@ -108,7 +108,130 @@
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </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"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.externalTrafficInformEnable"}}</template> <template #title>{{ i18n "pages.settings.externalTrafficInformEnable"}}</template>
<template #description>{{ i18n "pages.settings.externalTrafficInformEnableDesc"}}</template> <template #description>{{ i18n "pages.settings.externalTrafficInformEnableDesc"}}</template>
@ -125,7 +248,7 @@
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </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"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.timeZone"}}</template> <template #title>{{ i18n "pages.settings.timeZone"}}</template>
<template #description>{{ i18n "pages.settings.timeZoneDesc"}}</template> <template #description>{{ i18n "pages.settings.timeZoneDesc"}}</template>
@ -146,7 +269,7 @@
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="6" header='LDAP'> <a-collapse-panel key="7" header='LDAP'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>Enable LDAP sync</template> <template #title>Enable LDAP sync</template>
<template #control> <template #control>

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( db.Model(model.Inbound{}).Where(
"listen = ?", listen, "listen = ?", listen,
).Or( ).Or(
"listen = \"\"", "listen = ?", "",
).Or( ).Or(
"listen = \"0.0.0.0\"", "listen = ?", "0.0.0.0",
).Or( ).Or(
"listen = \"::\"", "listen = ?", "::",
).Or( ).Or(
"listen = \"::0\"")) "listen = ?", "::0",
))
} }
if ignoreId > 0 { if ignoreId > 0 {
db = db.Where("id != ?", ignoreId) 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) { func (s *InboundService) getAllEmails() ([]string, error) {
db := database.GetDB() db := database.GetDB()
var emails []string var inbounds []model.Inbound
err := db.Raw(` err := db.Model(model.Inbound{}).Find(&inbounds).Error
SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
`).Scan(&emails).Error
if err != nil { if err != nil {
return nil, err 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 return emails, nil
} }
@ -1312,14 +1322,18 @@ func (s *InboundService) GetInboundTags() (string, error) {
func (s *InboundService) MigrationRemoveOrphanedTraffics() { func (s *InboundService) MigrationRemoveOrphanedTraffics() {
db := database.GetDB() db := database.GetDB()
db.Exec(` allEmails, err := s.getAllEmails()
DELETE FROM client_traffics if err != nil {
WHERE email NOT IN ( logger.Warningf("Failed to load client emails for orphan cleanup: %v", err)
SELECT JSON_EXTRACT(client.value, '$.email') return
FROM inbounds, }
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
) 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 { 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) { func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
db := database.GetDB() db := database.GetDB()
var traffics []xray.ClientTraffic 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( emails := make([]string, 0)
SELECT JSON_EXTRACT(client.value, '$.email') as email for i := range inbounds {
FROM inbounds, clients, clientsErr := s.GetClients(&inbounds[i])
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client if clientsErr != nil {
WHERE logger.Debug(clientsErr)
JSON_EXTRACT(client.value, '$.id') in (?) return nil, clientsErr
)`, id).Find(&traffics).Error }
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 { if err != nil {
logger.Debug(err) logger.Debug(err)
@ -2210,72 +2240,66 @@ func (s *InboundService) MigrationRequirements() {
defer func() { defer func() {
if err == nil { if err == nil {
tx.Commit() tx.Commit()
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil { if database.IsSQLite() {
if dbErr := db.Exec(`VACUUM`).Error; dbErr != nil {
logger.Warningf("VACUUM failed: %v", dbErr) logger.Warningf("VACUUM failed: %v", dbErr)
} }
}
} else { } else {
tx.Rollback() 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(` err = tx.Exec(`
UPDATE inbounds UPDATE inbounds
SET all_time = IFNULL(up, 0) + IFNULL(down, 0) SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
`).Error `).Error
if err != nil { if err != nil {
return return
} }
err = tx.Exec(` err = tx.Exec(`
UPDATE client_traffics UPDATE client_traffics
SET all_time = IFNULL(up, 0) + IFNULL(down, 0) SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
`).Error `).Error
if err != nil { if err != nil {
return return
} }
// Fix inbounds based problems // Fix inbounds based problems.
var inbounds []*model.Inbound 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 { if err != nil && err != gorm.ErrRecordNotFound {
return return
} }
for inbound_index := range inbounds { for inboundIndex := range inbounds {
settings := map[string]any{} settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings) json.Unmarshal([]byte(inbounds[inboundIndex].Settings), &settings)
clients, ok := settings["clients"].([]any) clients, ok := settings["clients"].([]any)
if ok { if ok {
// Fix Client configuration problems
var newClients []any var newClients []any
for client_index := range clients { for clientIndex := range clients {
c := clients[client_index].(map[string]any) c := clients[clientIndex].(map[string]any)
// Add email='' if it is not exists
if _, ok := c["email"]; !ok { if _, ok := c["email"]; !ok {
c["email"] = "" c["email"] = ""
} }
// Convert string tgId to int64
if _, ok := c["tgId"]; ok { if _, ok := c["tgId"]; ok {
var tgId any = c["tgId"] var tgId any = c["tgId"]
if tgIdStr, ok2 := tgId.(string); ok2 { if tgIdStr, ok2 := tgId.(string); ok2 {
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64) tgIdInt64, parseErr := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
if err == nil { if parseErr == nil {
c["tgId"] = tgIdInt64 c["tgId"] = tgIdInt64
} }
} }
} }
// Remove "flow": "xtls-rprx-direct" if _, ok := c["flow"]; ok && c["flow"] == "xtls-rprx-direct" {
if _, ok := c["flow"]; ok {
if c["flow"] == "xtls-rprx-direct" {
c["flow"] = "" c["flow"] = ""
} }
}
// Backfill created_at and updated_at
if _, ok := c["created_at"]; !ok { if _, ok := c["created_at"]; !ok {
c["created_at"] = time.Now().Unix() * 1000 c["created_at"] = time.Now().Unix() * 1000
} }
@ -2283,17 +2307,17 @@ func (s *InboundService) MigrationRequirements() {
newClients = append(newClients, any(c)) newClients = append(newClients, any(c))
} }
settings["clients"] = newClients settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ") modifiedSettings, marshalErr := json.MarshalIndent(settings, "", " ")
if err != nil { if marshalErr != nil {
err = marshalErr
return return
} }
inbounds[inboundIndex].Settings = string(modifiedSettings)
inbounds[inbound_index].Settings = string(modifiedSettings)
} }
// Add client traffic row for all clients which has email modelClients, clientsErr := s.GetClients(inbounds[inboundIndex])
modelClients, err := s.GetClients(inbounds[inbound_index]) if clientsErr != nil {
if err != nil { err = clientsErr
return return
} }
for _, modelClient := range modelClients { for _, modelClient := range modelClients {
@ -2301,62 +2325,82 @@ func (s *InboundService) MigrationRequirements() {
var count int64 var count int64
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count) tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
if count == 0 { if count == 0 {
s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient) if addErr := s.AddClientStat(tx, inbounds[inboundIndex].Id, &modelClient); addErr != nil {
err = addErr
return
} }
} }
} }
} }
tx.Save(inbounds) }
if err = tx.Save(inbounds).Error; err != nil {
return
}
// Remove orphaned traffics
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}) tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
// Migrate old MultiDomain to External Proxy for _, inbound := range inbounds {
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 reverses any
var stream map[string]any var stream map[string]any
json.Unmarshal(ep.StreamSettings, &stream) if json.Unmarshal([]byte(inbound.StreamSettings), &stream) != nil {
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok { continue
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 security, _ := stream["security"].(string); security != "tls" {
} continue
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)
} }
err = tx.Raw(`UPDATE inbounds tlsSettings, ok := stream["tlsSettings"].(map[string]any)
SET tag = REPLACE(tag, '0.0.0.0:', '') if !ok {
WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error continue
if err != nil { }
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 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() { func (s *InboundService) MigrateDB() {

View file

@ -1009,7 +1009,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
} }
// Open & migrate new DB // 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 { if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename) 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"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/random" "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) { func (s *SettingService) getSetting(key string) (*model.Setting, error) {
db := database.GetDB() db := database.GetDB()
setting := &model.Setting{} var settings []*model.Setting
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error err := db.Model(model.Setting{}).Where("key = ?", key).Limit(1).Find(&settings).Error
if err != nil { if err != nil {
return nil, err 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 { func (s *SettingService) saveSetting(key string, value string) error {
@ -499,10 +503,18 @@ func (s *SettingService) GetSubListen() (string, error) {
return s.getString("subListen") return s.getString("subListen")
} }
func (s *SettingService) SetSubListen(ip string) error {
return s.setString("subListen", ip)
}
func (s *SettingService) GetSubPort() (int, error) { func (s *SettingService) GetSubPort() (int, error) {
return s.getInt("subPort") return s.getInt("subPort")
} }
func (s *SettingService) SetSubPort(port int) error {
return s.setInt("subPort", port)
}
func (s *SettingService) GetSubPath() (string, error) { func (s *SettingService) GetSubPath() (string, error) {
return s.getString("subPath") return s.getString("subPath")
} }

View file

@ -3636,7 +3636,24 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in trigger a checkpoint operation: ", err) logger.Error("Error in trigger a checkpoint operation: ", err)
} }
// Send database backup // Send portable database backup
backupData, err := database.EncodeCurrentPortableBackup()
if err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
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 portable backup: ", err)
}
} else {
logger.Error("Error in creating portable backup: ", err)
}
if database.IsSQLite() {
file, err := os.Open(config.GetDBPath()) file, err := os.Open(config.GetDBPath())
if err == nil { if err == nil {
defer file.Close() defer file.Close()
@ -3648,17 +3665,18 @@ func (t *Tgbot) sendBackup(chatId int64) {
) )
_, err = bot.SendDocument(ctx, document) _, err = bot.SendDocument(ctx, document)
if err != nil { if err != nil {
logger.Error("Error in uploading backup: ", err) logger.Error("Error in uploading native SQLite backup: ", err)
} }
} else { } else {
logger.Error("Error in opening db file for backup: ", err) logger.Error("Error in opening db file for backup: ", err)
} }
}
// Small delay between file sends // Small delay between file sends
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
// Send config.json backup // Send config.json backup
file, err = os.Open(xray.GetConfigPath()) file, err := os.Open(xray.GetConfigPath())
if err == nil { if err == nil {
defer file.Close() defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View file

@ -589,6 +589,50 @@
"ipPool" = "نطاق IP Pool" "ipPool" = "نطاق IP Pool"
"poolSize" = "حجم المجموعة" "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] [pages.settings.security]
"admin" = "بيانات الأدمن" "admin" = "بيانات الأدمن"
"twoFactor" = "المصادقة الثنائية" "twoFactor" = "المصادقة الثنائية"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP Pool Subnet" "ipPool" = "IP Pool Subnet"
"poolSize" = "Pool Size" "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] [pages.settings.security]
"admin" = "Admin credentials" "admin" = "Admin credentials"
"twoFactor" = "Two-factor authentication" "twoFactor" = "Two-factor authentication"

View file

@ -589,6 +589,50 @@
"ipPool" = "Subred del grupo de IP" "ipPool" = "Subred del grupo de IP"
"poolSize" = "Tamaño del grupo" "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] [pages.settings.security]
"admin" = "Credenciales de administrador" "admin" = "Credenciales de administrador"
"twoFactor" = "Autenticación de dos factores" "twoFactor" = "Autenticación de dos factores"

View file

@ -589,6 +589,50 @@
"ipPool" = "زیرشبکه استخر آی‌پی" "ipPool" = "زیرشبکه استخر آی‌پی"
"poolSize" = "اندازه استخر" "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] [pages.settings.security]
"admin" = "اعتبارنامه‌های ادمین" "admin" = "اعتبارنامه‌های ادمین"
"twoFactor" = "احراز هویت دو مرحله‌ای" "twoFactor" = "احراز هویت دو مرحله‌ای"

View file

@ -589,6 +589,50 @@
"ipPool" = "Subnet Kumpulan IP" "ipPool" = "Subnet Kumpulan IP"
"poolSize" = "Ukuran Kolam" "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] [pages.settings.security]
"admin" = "Kredensial admin" "admin" = "Kredensial admin"
"twoFactor" = "Autentikasi dua faktor" "twoFactor" = "Autentikasi dua faktor"

View file

@ -589,6 +589,50 @@
"ipPool" = "IPプールサブネット" "ipPool" = "IPプールサブネット"
"poolSize" = "プールサイズ" "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] [pages.settings.security]
"admin" = "管理者の資格情報" "admin" = "管理者の資格情報"
"twoFactor" = "二段階認証" "twoFactor" = "二段階認証"

View file

@ -589,6 +589,50 @@
"ipPool" = "Sub-rede do Pool de IP" "ipPool" = "Sub-rede do Pool de IP"
"poolSize" = "Tamanho do Pool" "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] [pages.settings.security]
"admin" = "Credenciais de administrador" "admin" = "Credenciais de administrador"
"twoFactor" = "Autenticação de dois fatores" "twoFactor" = "Autenticação de dois fatores"

View file

@ -589,6 +589,50 @@
"ipPool" = "Подсеть пула IP" "ipPool" = "Подсеть пула IP"
"poolSize" = "Размер пула" "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] [pages.settings.security]
"admin" = "Учетные данные администратора" "admin" = "Учетные данные администратора"
"twoFactor" = "Двухфакторная аутентификация" "twoFactor" = "Двухфакторная аутентификация"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP Havuzu Alt Ağı" "ipPool" = "IP Havuzu Alt Ağı"
"poolSize" = "Havuz Boyutu" "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] [pages.settings.security]
"admin" = "Yönetici kimlik bilgileri" "admin" = "Yönetici kimlik bilgileri"
"twoFactor" = "İki adımlı doğrulama" "twoFactor" = "İki adımlı doğrulama"

View file

@ -589,6 +589,50 @@
"ipPool" = "Підмережа IP-пулу" "ipPool" = "Підмережа IP-пулу"
"poolSize" = "Розмір пулу" "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] [pages.settings.security]
"admin" = "Облікові дані адміністратора" "admin" = "Облікові дані адміністратора"
"twoFactor" = "Двофакторна аутентифікація" "twoFactor" = "Двофакторна аутентифікація"

View file

@ -589,6 +589,50 @@
"ipPool" = "Mạng con nhóm IP" "ipPool" = "Mạng con nhóm IP"
"poolSize" = "Kích thước bể bơi" "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] [pages.settings.security]
"admin" = "Thông tin đăng nhập quản trị viên" "admin" = "Thông tin đăng nhập quản trị viên"
"twoFactor" = "Xác thực hai yếu tố" "twoFactor" = "Xác thực hai yếu tố"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP 池子网" "ipPool" = "IP 池子网"
"poolSize" = "池大小" "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] [pages.settings.security]
"admin" = "管理员凭据" "admin" = "管理员凭据"
"twoFactor" = "双重验证" "twoFactor" = "双重验证"

View file

@ -589,6 +589,50 @@
"ipPool" = "IP 池子網" "ipPool" = "IP 池子網"
"poolSize" = "池大小" "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] [pages.settings.security]
"admin" = "管理員憑證" "admin" = "管理員憑證"
"twoFactor" = "雙重驗證" "twoFactor" = "雙重驗證"

135
x-ui.sh
View file

@ -1672,6 +1672,133 @@ run_speedtest() {
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() { ip_validation() {
@ -2217,10 +2344,11 @@ show_menu() {
${green}24.${plain} Enable BBR │ ${green}24.${plain} Enable BBR │
${green}25.${plain} Update Geo Files │ ${green}25.${plain} Update Geo Files │
${green}26.${plain} Speedtest by Ookla │ ${green}26.${plain} Speedtest by Ookla │
${green}27.${plain} Database Management │
╚────────────────────────────────────────────────╝ ╚────────────────────────────────────────────────╝
" "
show_status 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 case "${num}" in
0) 0)
@ -2304,8 +2432,11 @@ show_menu() {
26) 26)
run_speedtest run_speedtest
;; ;;
27)
database_management
;;
*) *)
LOGE "Please enter the correct number [0-26]" LOGE "Please enter the correct number [0-27]"
;; ;;
esac esac
} }