mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-14 19:45:47 +00:00
add PostgreSQL backend and portable backup system.
- Add SQLite/PostgreSQL switching via panel UI and env variables - Introduce portable .xui-backup format for cross-backend backups - Add connection pooling and PrepareStmt cache for PostgreSQL - Fix raw SQL double-quote bug breaking queries on PostgreSQL - Fix GORM record-not-found log spam on every Xray config poll - Add database section to Settings with full EN/RU i18n
This commit is contained in:
parent
169b216d7e
commit
6c9ef87fbe
41 changed files with 3530 additions and 281 deletions
|
|
@ -35,6 +35,7 @@ RUN apk add --no-cache --update \
|
|||
|
||||
COPY --from=builder /app/build/ /app/
|
||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||
COPY --from=builder /app/postgres-manager.sh /app/
|
||||
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
||||
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ RUN rm -f /etc/fail2ban/jail.d/alpine-ssh.conf \
|
|||
|
||||
RUN chmod +x \
|
||||
/app/DockerEntrypoint.sh \
|
||||
/app/postgres-manager.sh \
|
||||
/app/x-ui \
|
||||
/usr/bin/x-ui
|
||||
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -30,6 +30,76 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
|||
|
||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Database Backends
|
||||
|
||||
3X-UI supports both `SQLite` and `PostgreSQL` as interchangeable backends. All application logic is written against [GORM](https://gorm.io/) — Go's database-agnostic ORM — so queries work identically on either engine. You can switch backends at any time through the panel UI without data loss.
|
||||
|
||||
### Choosing a Backend
|
||||
|
||||
| | SQLite | PostgreSQL |
|
||||
|---|---|---|
|
||||
| Setup | Zero config, file-based | Requires a running PG server |
|
||||
| Best for | Single-node, low traffic | Multi-node, high concurrency |
|
||||
| Backups | Portable + native file export | Portable export |
|
||||
|
||||
### Switching Backends (Panel UI)
|
||||
|
||||
1. Open **Settings → General → Database**.
|
||||
2. Select a backend (`SQLite` or `PostgreSQL`) and fill in connection details.
|
||||
3. Click **Test Connection** to verify.
|
||||
4. Click **Switch Database** — the panel will:
|
||||
- Save a portable backup of current data automatically.
|
||||
- Migrate all data to the new backend.
|
||||
- Restart itself.
|
||||
|
||||
> The target database must be empty before switching. Use **Test Connection** before switching to catch misconfigurations early.
|
||||
|
||||
### Local PostgreSQL (panel-managed)
|
||||
|
||||
When selecting **Local (panel-managed)** mode, the panel installs and manages PostgreSQL automatically (Linux, root only):
|
||||
|
||||
```bash
|
||||
# The panel uses postgres-manager.sh internally.
|
||||
# No manual PostgreSQL setup required.
|
||||
```
|
||||
|
||||
### External PostgreSQL
|
||||
|
||||
Point the panel at any existing PostgreSQL 13+ server:
|
||||
|
||||
1. Create a dedicated database and user.
|
||||
2. Enter the connection details in Settings → Database.
|
||||
3. Use **Test Connection**, then **Switch Database**.
|
||||
|
||||
### Environment Variable Override
|
||||
|
||||
For Docker and infrastructure-as-code deployments, set these environment variables to control the backend without touching the UI:
|
||||
|
||||
```bash
|
||||
XUI_DB_DRIVER=postgres # or: sqlite
|
||||
XUI_DB_HOST=127.0.0.1
|
||||
XUI_DB_PORT=5432
|
||||
XUI_DB_NAME=x-ui
|
||||
XUI_DB_USER=x-ui
|
||||
XUI_DB_PASSWORD=change-me
|
||||
XUI_DB_SSLMODE=disable # or: require, verify-ca, verify-full
|
||||
XUI_DB_MODE=external # or: local
|
||||
XUI_DB_PATH=/etc/x-ui/db/x-ui.db # SQLite only
|
||||
```
|
||||
|
||||
When any `XUI_DB_*` variable is set, the Database section in the panel UI becomes read-only.
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
| Format | Works with | When to use |
|
||||
|---|---|---|
|
||||
| **Portable** (`.xui-backup`) | SQLite + PostgreSQL | Switching backends, Telegram bot backups, long-term storage |
|
||||
| **Native SQLite** (`.db`) | SQLite only | Quick raw file backup while on SQLite |
|
||||
|
||||
- The Telegram bot sends a **portable backup** automatically — this works regardless of which backend is active.
|
||||
- Portable backups can be imported back on either SQLite or PostgreSQL.
|
||||
- Legacy `.db` files from older 3x-ui versions can be imported even while PostgreSQL is active.
|
||||
|
||||
## A Special Thanks to
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,76 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
|||
|
||||
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Базы данных
|
||||
|
||||
3X-UI поддерживает `SQLite` и `PostgreSQL` как взаимозаменяемые бэкенды. Вся логика приложения написана через [GORM](https://gorm.io/) — ORM для Go с поддержкой множества СУБД, — поэтому запросы работают одинаково на любом движке. Переключиться между бэкендами можно в любой момент через UI панели без потери данных.
|
||||
|
||||
### Выбор бэкенда
|
||||
|
||||
| | SQLite | PostgreSQL |
|
||||
|---|---|---|
|
||||
| Настройка | Ноль конфигурации, файловая БД | Требует работающий PG-сервер |
|
||||
| Подходит для | Один узел, малый трафик | Несколько узлов, высокая нагрузка |
|
||||
| Резервные копии | Портативный + нативный файл | Только портативный |
|
||||
|
||||
### Переключение бэкенда через UI
|
||||
|
||||
1. Откройте **Настройки → Панель → База данных**.
|
||||
2. Выберите бэкенд (`SQLite` или `PostgreSQL`) и заполните параметры подключения.
|
||||
3. Нажмите **Проверить подключение**.
|
||||
4. Нажмите **Переключить базу данных** — панель автоматически:
|
||||
- Сохранит портативную резервную копию текущих данных.
|
||||
- Мигрирует все данные в новый бэкенд.
|
||||
- Перезапустится.
|
||||
|
||||
> Целевая база данных должна быть пустой перед переключением. Всегда используйте **Проверить подключение** перед переключением.
|
||||
|
||||
### Локальный PostgreSQL (управляется панелью)
|
||||
|
||||
Режим **Локальный (управляется панелью)** — панель устанавливает и настраивает PostgreSQL автоматически (только Linux, root):
|
||||
|
||||
```bash
|
||||
# Панель использует postgres-manager.sh внутри себя.
|
||||
# Ручная настройка PostgreSQL не требуется.
|
||||
```
|
||||
|
||||
### Внешний PostgreSQL
|
||||
|
||||
Подключение к существующему серверу PostgreSQL 13+:
|
||||
|
||||
1. Создайте отдельную БД и пользователя.
|
||||
2. Введите параметры подключения в Настройки → База данных.
|
||||
3. Нажмите **Проверить подключение**, затем **Переключить базу данных**.
|
||||
|
||||
### Переопределение через переменные окружения
|
||||
|
||||
Для Docker и IaC-деплоев можно управлять бэкендом через переменные окружения:
|
||||
|
||||
```bash
|
||||
XUI_DB_DRIVER=postgres # или: sqlite
|
||||
XUI_DB_HOST=127.0.0.1
|
||||
XUI_DB_PORT=5432
|
||||
XUI_DB_NAME=x-ui
|
||||
XUI_DB_USER=x-ui
|
||||
XUI_DB_PASSWORD=change-me
|
||||
XUI_DB_SSLMODE=disable # или: require, verify-ca, verify-full
|
||||
XUI_DB_MODE=external # или: local
|
||||
XUI_DB_PATH=/etc/x-ui/db/x-ui.db # только для SQLite
|
||||
```
|
||||
|
||||
Если задана любая переменная `XUI_DB_*`, раздел «База данных» в UI становится read-only.
|
||||
|
||||
### Резервное копирование и восстановление
|
||||
|
||||
| Формат | Совместим с | Когда использовать |
|
||||
|---|---|---|
|
||||
| **Портативный** (`.xui-backup`) | SQLite + PostgreSQL | Переключение бэкендов, резервные копии через Telegram-бот, долгосрочное хранение |
|
||||
| **Нативный SQLite** (`.db`) | Только SQLite | Быстрый файловый бэкап при активном SQLite |
|
||||
|
||||
- Telegram-бот отправляет **портативную резервную копию** автоматически — это работает независимо от активного бэкенда.
|
||||
- Портативные копии можно импортировать на SQLite и на PostgreSQL.
|
||||
- Устаревшие `.db`-файлы от старых версий 3x-ui можно импортировать даже при активном PostgreSQL.
|
||||
|
||||
## Особая благодарность
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
|
|
|||
251
config/database.go
Normal file
251
config/database.go
Normal 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
396
database/backup.go
Normal 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)
|
||||
}
|
||||
384
database/db.go
384
database/db.go
|
|
@ -1,5 +1,5 @@
|
|||
// Package database provides database initialization, migration, and management utilities
|
||||
// for the 3x-ui panel using GORM with SQLite.
|
||||
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||
package database
|
||||
|
||||
import (
|
||||
|
|
@ -8,28 +8,119 @@ import (
|
|||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
var (
|
||||
db *gorm.DB
|
||||
dbConfig *config.DatabaseConfig
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
)
|
||||
|
||||
func initModels() error {
|
||||
func gormConfig() *gorm.Config {
|
||||
var loggerImpl gormlogger.Interface
|
||||
if config.IsDebug() {
|
||||
loggerImpl = gormlogger.Default
|
||||
} else {
|
||||
loggerImpl = gormlogger.Discard
|
||||
}
|
||||
return &gorm.Config{Logger: loggerImpl}
|
||||
}
|
||||
|
||||
func openSQLiteDatabase(path string) (*gorm.DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, fs.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gorm.Open(sqlite.Open(path), gormConfig())
|
||||
}
|
||||
|
||||
func buildPostgresDSN(cfg *config.DatabaseConfig) string {
|
||||
user := url.User(cfg.Postgres.User)
|
||||
if cfg.Postgres.Password != "" {
|
||||
user = url.UserPassword(cfg.Postgres.User, cfg.Postgres.Password)
|
||||
}
|
||||
authority := net.JoinHostPort(cfg.Postgres.Host, strconv.Itoa(cfg.Postgres.Port))
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
User: user,
|
||||
Host: authority,
|
||||
Path: cfg.Postgres.DBName,
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set("sslmode", cfg.Postgres.SSLMode)
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func openPostgresDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
gormCfg := gormConfig()
|
||||
gormCfg.PrepareStmt = true
|
||||
conn, err := gorm.Open(postgres.Open(buildPostgresDSN(cfg)), gormCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB, err := conn.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// OpenDatabase opens a database connection from the provided runtime configuration.
|
||||
func OpenDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
if cfg == nil {
|
||||
cfg = config.DefaultDatabaseConfig()
|
||||
}
|
||||
cfg = cfg.Clone().Normalize()
|
||||
|
||||
switch cfg.Driver {
|
||||
case config.DatabaseDriverSQLite:
|
||||
return openSQLiteDatabase(cfg.SQLite.Path)
|
||||
case config.DatabaseDriverPostgres:
|
||||
return openPostgresDatabase(cfg)
|
||||
default:
|
||||
return nil, errors.New("unsupported database driver: " + cfg.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
// CloseConnection closes a standalone gorm connection.
|
||||
func CloseConnection(conn *gorm.DB) error {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := conn.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func initModels(conn *gorm.DB) error {
|
||||
models := []any{
|
||||
&model.User{},
|
||||
&model.Inbound{},
|
||||
|
|
@ -39,8 +130,8 @@ func initModels() error {
|
|||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
for _, item := range models {
|
||||
if err := conn.AutoMigrate(item); err != nil {
|
||||
log.Printf("Error auto migrating model: %v", err)
|
||||
return err
|
||||
}
|
||||
|
|
@ -48,33 +139,40 @@ func initModels() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// initUser creates a default admin user if the users table is empty.
|
||||
func initUser() error {
|
||||
empty, err := isTableEmpty("users")
|
||||
func isTableEmpty(conn *gorm.DB, tableName string) (bool, error) {
|
||||
if !conn.Migrator().HasTable(tableName) {
|
||||
return true, nil
|
||||
}
|
||||
var count int64
|
||||
err := conn.Table(tableName).Count(&count).Error
|
||||
return count == 0, err
|
||||
}
|
||||
|
||||
func initUser(conn *gorm.DB) error {
|
||||
empty, err := isTableEmpty(conn, "users")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", err)
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error hashing default password: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: defaultUsername,
|
||||
Password: hashedPassword,
|
||||
}
|
||||
return db.Create(user).Error
|
||||
if !empty {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing default password: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: defaultUsername,
|
||||
Password: hashedPassword,
|
||||
}
|
||||
return conn.Create(user).Error
|
||||
}
|
||||
|
||||
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
|
||||
func runSeeders(isUsersEmpty bool) error {
|
||||
empty, err := isTableEmpty("history_of_seeders")
|
||||
func runSeeders(conn *gorm.DB, isUsersEmpty bool) error {
|
||||
empty, err := isTableEmpty(conn, "history_of_seeders")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", err)
|
||||
return err
|
||||
|
|
@ -84,97 +182,184 @@ func runSeeders(isUsersEmpty bool) error {
|
|||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
} else {
|
||||
var seedersHistory []string
|
||||
db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory)
|
||||
return conn.Create(hashSeeder).Error
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
db.Find(&users)
|
||||
var seedersHistory []string
|
||||
if err := conn.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
db.Model(&user).Update("password", hashedPassword)
|
||||
}
|
||||
if slices.Contains(seedersHistory, "UserPasswordHash") || isUsersEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
var users []model.User
|
||||
if err := conn.Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, hashErr := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if hashErr != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, hashErr)
|
||||
return hashErr
|
||||
}
|
||||
if err := conn.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return conn.Create(hashSeeder).Error
|
||||
}
|
||||
|
||||
// isTableEmpty returns true if the named table contains zero rows.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Table(tableName).Count(&count).Error
|
||||
return count == 0, err
|
||||
// MigrateModels migrates the database schema for all panel models.
|
||||
func MigrateModels(conn *gorm.DB) error {
|
||||
return initModels(conn)
|
||||
}
|
||||
|
||||
// PrepareDatabase migrates the schema and optionally seeds the database.
|
||||
func PrepareDatabase(conn *gorm.DB, seed bool) error {
|
||||
if err := initModels(conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if !seed {
|
||||
return nil
|
||||
}
|
||||
|
||||
isUsersEmpty, err := isTableEmpty(conn, "users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := initUser(conn); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSeeders(conn, isUsersEmpty)
|
||||
}
|
||||
|
||||
// TestConnection verifies that the provided database configuration is reachable.
|
||||
func TestConnection(cfg *config.DatabaseConfig) error {
|
||||
conn, err := OpenDatabase(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer CloseConnection(conn)
|
||||
|
||||
sqlDB, err := conn.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Ping()
|
||||
}
|
||||
|
||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||
func InitDB(dbPath string) error {
|
||||
dir := path.Dir(dbPath)
|
||||
err := os.MkdirAll(dir, fs.ModePerm)
|
||||
func InitDB() error {
|
||||
cfg, err := config.LoadDatabaseConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var gormLogger logger.Interface
|
||||
|
||||
if config.IsDebug() {
|
||||
gormLogger = logger.Default
|
||||
} else {
|
||||
gormLogger = logger.Discard
|
||||
}
|
||||
|
||||
c := &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
}
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := initModels(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isUsersEmpty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := initUser(); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSeeders(isUsersEmpty)
|
||||
return InitDBWithConfig(cfg)
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection if it exists.
|
||||
func CloseDB() error {
|
||||
if db != nil {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
// InitDBWithConfig sets up the database using an explicit runtime config.
|
||||
func InitDBWithConfig(cfg *config.DatabaseConfig) error {
|
||||
conn, err := OpenDatabase(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := PrepareDatabase(conn, true); err != nil {
|
||||
_ = CloseConnection(conn)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := CloseDB(); err != nil {
|
||||
_ = CloseConnection(conn)
|
||||
return err
|
||||
}
|
||||
|
||||
db = conn
|
||||
dbConfig = cfg.Clone().Normalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseDB closes the global database connection if it exists.
|
||||
func CloseDB() error {
|
||||
if db == nil {
|
||||
dbConfig = nil
|
||||
return nil
|
||||
}
|
||||
err := CloseConnection(db)
|
||||
db = nil
|
||||
dbConfig = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDB returns the global GORM database instance.
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// GetDBConfig returns a copy of the active database runtime configuration.
|
||||
func GetDBConfig() *config.DatabaseConfig {
|
||||
if dbConfig == nil {
|
||||
return nil
|
||||
}
|
||||
return dbConfig.Clone()
|
||||
}
|
||||
|
||||
// GetDriver returns the active GORM dialector name.
|
||||
func GetDriver() string {
|
||||
if db != nil && db.Dialector != nil {
|
||||
return db.Dialector.Name()
|
||||
}
|
||||
if dbConfig != nil {
|
||||
switch dbConfig.Driver {
|
||||
case config.DatabaseDriverPostgres:
|
||||
return "postgres"
|
||||
case config.DatabaseDriverSQLite:
|
||||
return "sqlite"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsSQLite reports whether the active database uses SQLite.
|
||||
func IsSQLite() bool {
|
||||
return GetDriver() == "sqlite"
|
||||
}
|
||||
|
||||
// IsPostgres reports whether the active database uses PostgreSQL.
|
||||
func IsPostgres() bool {
|
||||
return GetDriver() == "postgres"
|
||||
}
|
||||
|
||||
// IsDatabaseEmpty reports whether the provided database contains any application rows.
|
||||
func IsDatabaseEmpty(conn *gorm.DB) (bool, error) {
|
||||
tables := []string{
|
||||
"users",
|
||||
"inbounds",
|
||||
"outbound_traffics",
|
||||
"settings",
|
||||
"inbound_client_ips",
|
||||
"client_traffics",
|
||||
"history_of_seeders",
|
||||
}
|
||||
for _, table := range tables {
|
||||
empty, err := isTableEmpty(conn, table)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !empty {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsNotFound checks if the given error is a GORM record not found error.
|
||||
func IsNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
|
|
@ -193,22 +378,20 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
|||
|
||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||
func Checkpoint() error {
|
||||
// Update WAL
|
||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
if err != nil {
|
||||
return err
|
||||
if !IsSQLite() || db == nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
}
|
||||
|
||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
||||
// It does not mutate global state or run migrations.
|
||||
func ValidateSQLiteDB(dbPath string) error {
|
||||
if _, err := os.Stat(dbPath); err != nil { // file must exist
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return err
|
||||
}
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -217,6 +400,7 @@ func ValidateSQLiteDB(dbPath string) error {
|
|||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
var res string
|
||||
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
||||
return err
|
||||
|
|
|
|||
118
database/manager.go
Normal file
118
database/manager.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
)
|
||||
|
||||
func configsEqual(a, b *config.DatabaseConfig) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
a = a.Clone().Normalize()
|
||||
b = b.Clone().Normalize()
|
||||
if a.Driver != b.Driver {
|
||||
return false
|
||||
}
|
||||
if a.Driver == config.DatabaseDriverSQLite {
|
||||
return a.SQLite.Path == b.SQLite.Path
|
||||
}
|
||||
return a.Postgres.Mode == b.Postgres.Mode &&
|
||||
a.Postgres.Host == b.Postgres.Host &&
|
||||
a.Postgres.Port == b.Postgres.Port &&
|
||||
a.Postgres.DBName == b.Postgres.DBName &&
|
||||
a.Postgres.User == b.Postgres.User &&
|
||||
a.Postgres.Password == b.Postgres.Password &&
|
||||
a.Postgres.SSLMode == b.Postgres.SSLMode &&
|
||||
a.Postgres.ManagedLocally == b.Postgres.ManagedLocally
|
||||
}
|
||||
|
||||
func loadSnapshotFromConfig(cfg *config.DatabaseConfig) (*BackupSnapshot, error) {
|
||||
cfg = cfg.Clone().Normalize()
|
||||
if cfg.UsesSQLite() {
|
||||
if _, err := os.Stat(cfg.SQLite.Path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return newBackupSnapshot(cfg.Driver), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := OpenDatabase(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer CloseConnection(conn)
|
||||
if err := MigrateModels(conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExportSnapshot(conn, cfg.Driver)
|
||||
}
|
||||
|
||||
func saveSwitchBackup(snapshot *BackupSnapshot, prefix string) error {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := EncodePortableBackup(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := fmt.Sprintf("%s-%s.xui-backup", prefix, time.Now().UTC().Format("20060102-150405"))
|
||||
return SavePortableBackup(filepath.Join(config.GetBackupFolderPath(), name), data)
|
||||
}
|
||||
|
||||
// SwitchDatabase migrates panel data into a new backend and writes the new runtime configuration.
|
||||
func SwitchDatabase(target *config.DatabaseConfig) error {
|
||||
if target == nil {
|
||||
return errors.New("target database configuration is nil")
|
||||
}
|
||||
target = target.Clone().Normalize()
|
||||
|
||||
currentCfg, err := config.LoadDatabaseConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configsEqual(currentCfg, target) {
|
||||
return config.SaveDatabaseConfig(target)
|
||||
}
|
||||
|
||||
sourceSnapshot, err := loadSnapshotFromConfig(currentCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveSwitchBackup(sourceSnapshot, "switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := TestConnection(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetConn, err := OpenDatabase(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer CloseConnection(targetConn)
|
||||
|
||||
if err := MigrateModels(targetConn); err != nil {
|
||||
return err
|
||||
}
|
||||
isEmpty, err := IsDatabaseEmpty(targetConn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isEmpty {
|
||||
return errors.New("target database must be empty before switching")
|
||||
}
|
||||
|
||||
if err := ApplySnapshot(targetConn, sourceSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return config.SaveDatabaseConfig(target)
|
||||
}
|
||||
|
|
@ -11,6 +11,31 @@ services:
|
|||
environment:
|
||||
XRAY_VMESS_AEAD_FORCED: "false"
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
# XUI_DB_DRIVER: "postgres"
|
||||
# XUI_DB_MODE: "external"
|
||||
# XUI_DB_HOST: "127.0.0.1"
|
||||
# XUI_DB_PORT: "5432"
|
||||
# XUI_DB_NAME: "xui"
|
||||
# XUI_DB_USER: "xui"
|
||||
# XUI_DB_PASSWORD: "change-me"
|
||||
# XUI_DB_SSLMODE: "disable"
|
||||
tty: true
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: 3xui_postgres
|
||||
environment:
|
||||
POSTGRES_DB: xui
|
||||
POSTGRES_USER: xui
|
||||
POSTGRES_PASSWORD: change-me
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -26,6 +26,7 @@ require (
|
|||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
|
@ -53,6 +54,10 @@ require (
|
|||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
|
|
|
|||
11
go.sum
11
go.sum
|
|
@ -85,6 +85,14 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
|||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
|
|
@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
|
|
@ -271,6 +280,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
|
|
|
|||
117
install.sh
117
install.sh
|
|
@ -639,7 +639,121 @@ prompt_and_setup_ssl() {
|
|||
esac
|
||||
}
|
||||
|
||||
configure_database_backend() {
|
||||
local db_config_path="/etc/x-ui/database.json"
|
||||
if [[ -f "${db_config_path}" ]] || compgen -G "/etc/x-ui/*.db" >/dev/null; then
|
||||
echo -e "${green}Existing database configuration detected. Keeping current backend.${plain}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local db_choice=""
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} Database Backend Setup ${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green}1.${plain} SQLite"
|
||||
echo -e "${green}2.${plain} PostgreSQL"
|
||||
read -rp "Choose database backend (default 1): " db_choice
|
||||
db_choice="${db_choice// /}"
|
||||
|
||||
if [[ "${db_choice}" != "2" ]]; then
|
||||
echo -e "${green}Using SQLite as the panel database backend.${plain}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pg_mode=""
|
||||
local pg_host="127.0.0.1"
|
||||
local pg_port="5432"
|
||||
local pg_db="xui"
|
||||
local pg_user="xui"
|
||||
local pg_password=""
|
||||
local pg_sslmode="disable"
|
||||
|
||||
echo -e "${green}1.${plain} Local PostgreSQL"
|
||||
echo -e "${green}2.${plain} External PostgreSQL"
|
||||
read -rp "Choose PostgreSQL mode (default 1): " pg_mode
|
||||
pg_mode="${pg_mode// /}"
|
||||
|
||||
if [[ "${pg_mode}" != "2" ]]; then
|
||||
if [[ ! -x "${xui_folder}/postgres-manager.sh" ]]; then
|
||||
echo -e "${red}postgres-manager.sh is missing from the installation package.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pg_status
|
||||
pg_status=$(bash "${xui_folder}/postgres-manager.sh" status 2>/dev/null || true)
|
||||
if [[ "${pg_status}" != *"installed=true"* ]]; then
|
||||
local install_pg=""
|
||||
read -rp "PostgreSQL is not installed. Install automatically now? [Y/n]: " install_pg
|
||||
if [[ -z "${install_pg}" || "${install_pg}" == "y" || "${install_pg}" == "Y" ]]; then
|
||||
bash "${xui_folder}/postgres-manager.sh" install
|
||||
else
|
||||
echo -e "${red}PostgreSQL installation was declined. Aborting PostgreSQL setup.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
read -rp "PostgreSQL host [127.0.0.1]: " pg_host
|
||||
pg_host="${pg_host:-127.0.0.1}"
|
||||
read -rp "PostgreSQL port [5432]: " pg_port
|
||||
pg_port="${pg_port:-5432}"
|
||||
read -rp "PostgreSQL database name [xui]: " pg_db
|
||||
pg_db="${pg_db:-xui}"
|
||||
read -rp "PostgreSQL username [xui]: " pg_user
|
||||
pg_user="${pg_user:-xui}"
|
||||
read -rp "PostgreSQL password [random]: " pg_password
|
||||
[[ -z "${pg_password}" ]] && pg_password=$(gen_random_string 18)
|
||||
|
||||
${xui_folder}/x-ui database switch \
|
||||
-driver postgres \
|
||||
-postgres-mode local \
|
||||
-postgres-host "${pg_host}" \
|
||||
-postgres-port "${pg_port}" \
|
||||
-postgres-db "${pg_db}" \
|
||||
-postgres-user "${pg_user}" \
|
||||
-postgres-password "${pg_password}" \
|
||||
-postgres-local true
|
||||
else
|
||||
read -rp "External PostgreSQL host: " pg_host
|
||||
if [[ -z "${pg_host}" ]]; then
|
||||
echo -e "${red}PostgreSQL host is required.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
read -rp "External PostgreSQL port [5432]: " pg_port
|
||||
pg_port="${pg_port:-5432}"
|
||||
read -rp "Database name [xui]: " pg_db
|
||||
pg_db="${pg_db:-xui}"
|
||||
read -rp "Username [xui]: " pg_user
|
||||
pg_user="${pg_user:-xui}"
|
||||
read -rp "Password: " pg_password
|
||||
if [[ -z "${pg_password}" ]]; then
|
||||
echo -e "${red}PostgreSQL password is required for external connections.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
read -rp "SSL mode [disable]: " pg_sslmode
|
||||
pg_sslmode="${pg_sslmode:-disable}"
|
||||
|
||||
${xui_folder}/x-ui database switch \
|
||||
-driver postgres \
|
||||
-postgres-mode external \
|
||||
-postgres-host "${pg_host}" \
|
||||
-postgres-port "${pg_port}" \
|
||||
-postgres-db "${pg_db}" \
|
||||
-postgres-user "${pg_user}" \
|
||||
-postgres-password "${pg_password}" \
|
||||
-postgres-sslmode "${pg_sslmode}" \
|
||||
-postgres-local false
|
||||
fi
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to configure PostgreSQL backend.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${green}Database backend configured successfully.${plain}"
|
||||
}
|
||||
|
||||
config_after_install() {
|
||||
configure_database_backend
|
||||
local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
|
|
@ -822,6 +936,9 @@ install_x-ui() {
|
|||
cd x-ui
|
||||
chmod +x x-ui
|
||||
chmod +x x-ui.sh
|
||||
if [[ -f postgres-manager.sh ]]; then
|
||||
chmod +x postgres-manager.sh
|
||||
fi
|
||||
|
||||
# Check the system's architecture and rename the file accordingly
|
||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||
|
|
|
|||
218
main.go
218
main.go
|
|
@ -3,11 +3,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
_ "unsafe"
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/sys"
|
||||
"github.com/mhsanaei/3x-ui/v2/web"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
|
||||
|
|
@ -46,7 +49,7 @@ func runWebServer() {
|
|||
|
||||
godotenv.Load()
|
||||
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
err := database.InitDB()
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
|
|
@ -131,7 +134,7 @@ func runWebServer() {
|
|||
|
||||
// resetSetting resets all panel settings to their default values.
|
||||
func resetSetting() {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
err := database.InitDB()
|
||||
if err != nil {
|
||||
fmt.Println("Failed to initialize database:", err)
|
||||
return
|
||||
|
|
@ -154,11 +157,23 @@ func showSetting(show bool) {
|
|||
if err != nil {
|
||||
fmt.Println("get current port failed, error info:", err)
|
||||
}
|
||||
subPort, err := settingService.GetSubPort()
|
||||
if err != nil {
|
||||
fmt.Println("get current sub port failed, error info:", err)
|
||||
}
|
||||
|
||||
webBasePath, err := settingService.GetBasePath()
|
||||
if err != nil {
|
||||
fmt.Println("get webBasePath failed, error info:", err)
|
||||
}
|
||||
listen, err := settingService.GetListen()
|
||||
if err != nil {
|
||||
fmt.Println("get current listen failed, error info:", err)
|
||||
}
|
||||
subListen, err := settingService.GetSubListen()
|
||||
if err != nil {
|
||||
fmt.Println("get current sub listen failed, error info:", err)
|
||||
}
|
||||
|
||||
certFile, err := settingService.GetCertFile()
|
||||
if err != nil {
|
||||
|
|
@ -192,6 +207,9 @@ func showSetting(show bool) {
|
|||
|
||||
fmt.Println("hasDefaultCredential:", hasDefaultCredential)
|
||||
fmt.Println("port:", port)
|
||||
fmt.Println("listen:", listen)
|
||||
fmt.Println("subPort:", subPort)
|
||||
fmt.Println("subListen:", subListen)
|
||||
fmt.Println("webBasePath:", webBasePath)
|
||||
}
|
||||
}
|
||||
|
|
@ -218,7 +236,7 @@ func updateTgbotEnableSts(status bool) {
|
|||
|
||||
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
||||
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
err := database.InitDB()
|
||||
if err != nil {
|
||||
fmt.Println("Error initializing database:", err)
|
||||
return
|
||||
|
|
@ -254,9 +272,9 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
|||
}
|
||||
}
|
||||
|
||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
// updateSetting updates various panel settings including ports, credentials, base path, listen IPs, and two-factor authentication.
|
||||
func updateSetting(port int, subPort int, username string, password string, webBasePath string, listenIP string, subListenIP string, resetTwoFactor bool) {
|
||||
err := database.InitDB()
|
||||
if err != nil {
|
||||
fmt.Println("Database initialization failed:", err)
|
||||
return
|
||||
|
|
@ -308,14 +326,32 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
|||
if err != nil {
|
||||
fmt.Println("Failed to set listen IP:", err)
|
||||
} else {
|
||||
fmt.Printf("listen %v set successfully", listenIP)
|
||||
fmt.Printf("listen %v set successfully\n", listenIP)
|
||||
}
|
||||
}
|
||||
|
||||
if subPort > 0 {
|
||||
err := settingService.SetSubPort(subPort)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to set sub port:", err)
|
||||
} else {
|
||||
fmt.Printf("Sub port set successfully: %v\n", subPort)
|
||||
}
|
||||
}
|
||||
|
||||
if subListenIP != "" {
|
||||
err := settingService.SetSubListen(subListenIP)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to set sub listen IP:", err)
|
||||
} else {
|
||||
fmt.Printf("sub listen %v set successfully\n", subListenIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateCert updates the SSL certificate files for the panel.
|
||||
func updateCert(publicKey string, privateKey string) {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
err := database.InitDB()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
|
@ -392,7 +428,7 @@ func GetListenIP(getListen bool) {
|
|||
func migrateDb() {
|
||||
inboundService := service.InboundService{}
|
||||
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
err := database.InitDB()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
|
@ -401,6 +437,161 @@ func migrateDb() {
|
|||
fmt.Println("Migration done!")
|
||||
}
|
||||
|
||||
func defaultDatabaseSetting() *entity.DatabaseSetting {
|
||||
databaseService := &service.DatabaseService{}
|
||||
setting, err := databaseService.GetSetting()
|
||||
if err == nil && setting != nil {
|
||||
return setting
|
||||
}
|
||||
return entity.DatabaseSettingFromConfig(config.DefaultDatabaseConfig())
|
||||
}
|
||||
|
||||
func addDatabaseFlags(fs *flag.FlagSet, setting *entity.DatabaseSetting) {
|
||||
fs.StringVar(&setting.Driver, "driver", setting.Driver, "Database driver: sqlite or postgres")
|
||||
fs.StringVar(&setting.SQLitePath, "sqlite-path", setting.SQLitePath, "SQLite database path")
|
||||
fs.StringVar(&setting.PostgresMode, "postgres-mode", setting.PostgresMode, "PostgreSQL mode: local or external")
|
||||
fs.StringVar(&setting.PostgresHost, "postgres-host", setting.PostgresHost, "PostgreSQL host")
|
||||
fs.IntVar(&setting.PostgresPort, "postgres-port", setting.PostgresPort, "PostgreSQL port")
|
||||
fs.StringVar(&setting.PostgresDBName, "postgres-db", setting.PostgresDBName, "PostgreSQL database name")
|
||||
fs.StringVar(&setting.PostgresUser, "postgres-user", setting.PostgresUser, "PostgreSQL user")
|
||||
fs.StringVar(&setting.PostgresPassword, "postgres-password", "", "PostgreSQL password")
|
||||
fs.StringVar(&setting.PostgresSSLMode, "postgres-sslmode", setting.PostgresSSLMode, "PostgreSQL sslmode")
|
||||
fs.BoolVar(&setting.ManagedLocally, "postgres-local", setting.ManagedLocally, "Treat PostgreSQL as locally managed")
|
||||
}
|
||||
|
||||
func writeExportFile(outputPath string, defaultName string, data []byte) (string, error) {
|
||||
targetPath := outputPath
|
||||
if targetPath == "" {
|
||||
targetPath = defaultName
|
||||
} else if info, err := os.Stat(targetPath); err == nil && info.IsDir() {
|
||||
targetPath = filepath.Join(targetPath, defaultName)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(targetPath, data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
func handleDatabaseCommand(args []string) {
|
||||
if len(args) == 0 {
|
||||
fmt.Println("Usage:")
|
||||
fmt.Println(" x-ui database show")
|
||||
fmt.Println(" x-ui database test [database flags]")
|
||||
fmt.Println(" x-ui database switch [database flags]")
|
||||
fmt.Println(" x-ui database export -type portable|sqlite [-out path]")
|
||||
fmt.Println(" x-ui database import -file backup.xui-backup")
|
||||
fmt.Println(" x-ui database install-postgres")
|
||||
return
|
||||
}
|
||||
|
||||
databaseService := service.DatabaseService{}
|
||||
|
||||
switch args[0] {
|
||||
case "show":
|
||||
setting, err := databaseService.GetSetting()
|
||||
if err != nil {
|
||||
fmt.Println("Failed to load database settings:", err)
|
||||
return
|
||||
}
|
||||
contents, err := json.MarshalIndent(setting, "", " ")
|
||||
if err != nil {
|
||||
fmt.Println("Failed to serialize database settings:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(string(contents))
|
||||
case "test":
|
||||
setting := defaultDatabaseSetting()
|
||||
testCmd := flag.NewFlagSet("database test", flag.ExitOnError)
|
||||
addDatabaseFlags(testCmd, setting)
|
||||
_ = testCmd.Parse(args[1:])
|
||||
if err := databaseService.TestSetting(setting); err != nil {
|
||||
fmt.Println("Database connection test failed:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Database connection test succeeded.")
|
||||
case "switch":
|
||||
setting := defaultDatabaseSetting()
|
||||
switchCmd := flag.NewFlagSet("database switch", flag.ExitOnError)
|
||||
addDatabaseFlags(switchCmd, setting)
|
||||
_ = switchCmd.Parse(args[1:])
|
||||
if err := databaseService.SwitchDatabase(setting); err != nil {
|
||||
fmt.Println("Database switch failed:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Database configuration updated. Restart the panel service to apply changes.")
|
||||
case "export":
|
||||
exportCmd := flag.NewFlagSet("database export", flag.ExitOnError)
|
||||
exportType := exportCmd.String("type", "portable", "Export type: portable or sqlite")
|
||||
outputPath := exportCmd.String("out", "", "Output path or directory")
|
||||
_ = exportCmd.Parse(args[1:])
|
||||
|
||||
if err := database.InitDB(); err != nil {
|
||||
fmt.Println("Failed to initialize database:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
data []byte
|
||||
filename string
|
||||
err error
|
||||
)
|
||||
switch *exportType {
|
||||
case "sqlite":
|
||||
data, filename, err = databaseService.ExportNativeSQLite()
|
||||
default:
|
||||
data, filename, err = databaseService.ExportPortableBackup()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Println("Export failed:", err)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath, err := writeExportFile(*outputPath, filename, data)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to write backup:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Backup exported to:", targetPath)
|
||||
case "import":
|
||||
importCmd := flag.NewFlagSet("database import", flag.ExitOnError)
|
||||
backupFile := importCmd.String("file", "", "Path to .xui-backup or legacy SQLite .db file")
|
||||
_ = importCmd.Parse(args[1:])
|
||||
if *backupFile == "" {
|
||||
fmt.Println("Import requires -file")
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.InitDB(); err != nil {
|
||||
fmt.Println("Failed to initialize database:", err)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(*backupFile)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to open backup file:", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
backupType, err := databaseService.ImportBackup(file)
|
||||
if err != nil {
|
||||
fmt.Println("Import failed:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Import completed using", backupType, "backup. Restart the panel service to apply changes.")
|
||||
case "install-postgres":
|
||||
output, err := databaseService.InstallLocalPostgres()
|
||||
if err != nil {
|
||||
fmt.Println("PostgreSQL installation failed:", err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
default:
|
||||
fmt.Println("Unknown database subcommand:", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// main is the entry point of the 3x-ui application.
|
||||
// It parses command-line arguments to run the web server, migrate database, or update settings.
|
||||
func main() {
|
||||
|
|
@ -416,10 +607,12 @@ func main() {
|
|||
|
||||
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
||||
var port int
|
||||
var subPort int
|
||||
var username string
|
||||
var password string
|
||||
var webBasePath string
|
||||
var listenIP string
|
||||
var subListenIP string
|
||||
var getListen bool
|
||||
var webCertFile string
|
||||
var webKeyFile string
|
||||
|
|
@ -434,10 +627,12 @@ func main() {
|
|||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
||||
settingCmd.IntVar(&subPort, "subPort", 0, "Set subscription port number")
|
||||
settingCmd.StringVar(&username, "username", "", "Set login username")
|
||||
settingCmd.StringVar(&password, "password", "", "Set login password")
|
||||
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
||||
settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP")
|
||||
settingCmd.StringVar(&subListenIP, "subListenIP", "", "set subscription listenIP IP")
|
||||
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
||||
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
||||
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
||||
|
|
@ -456,6 +651,7 @@ func main() {
|
|||
fmt.Println(" run run web panel")
|
||||
fmt.Println(" migrate migrate form other/old x-ui")
|
||||
fmt.Println(" setting set settings")
|
||||
fmt.Println(" database manage database backend")
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
|
@ -483,7 +679,7 @@ func main() {
|
|||
if reset {
|
||||
resetSetting()
|
||||
} else {
|
||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
|
||||
updateSetting(port, subPort, username, password, webBasePath, listenIP, subListenIP, resetTwoFactor)
|
||||
}
|
||||
if show {
|
||||
showSetting(show)
|
||||
|
|
@ -511,6 +707,8 @@ func main() {
|
|||
} else {
|
||||
updateCert(webCertFile, webKeyFile)
|
||||
}
|
||||
case "database":
|
||||
handleDatabaseCommand(os.Args[2:])
|
||||
default:
|
||||
fmt.Println("Invalid subcommands")
|
||||
fmt.Println()
|
||||
|
|
|
|||
271
postgres-manager.sh
Normal file
271
postgres-manager.sh
Normal 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
|
||||
|
|
@ -114,18 +114,29 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
|||
|
||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||
db := database.GetDB()
|
||||
var inbounds []*model.Inbound
|
||||
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
|
||||
SELECT DISTINCT inbounds.id
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
WHERE
|
||||
protocol in ('vmess','vless','trojan','shadowsocks')
|
||||
AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
|
||||
)`, subId, true).Find(&inbounds).Error
|
||||
var candidates []*model.Inbound
|
||||
err := db.Model(model.Inbound{}).
|
||||
Preload("ClientStats").
|
||||
Where("protocol IN ?", []string{"vmess", "vless", "trojan", "shadowsocks"}).
|
||||
Where("enable = ?", true).
|
||||
Find(&candidates).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inbounds := make([]*model.Inbound, 0)
|
||||
for _, inbound := range candidates {
|
||||
clients, clientsErr := s.inboundService.GetClients(inbound)
|
||||
if clientsErr != nil {
|
||||
return nil, clientsErr
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.SubID == subId {
|
||||
inbounds = append(inbounds, inbound)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return inbounds, nil
|
||||
}
|
||||
|
||||
|
|
@ -140,15 +151,42 @@ func (s *SubService) getClientTraffics(traffics []xray.ClientTraffic, email stri
|
|||
|
||||
func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) {
|
||||
db := database.GetDB()
|
||||
var inbound *model.Inbound
|
||||
err := db.Model(model.Inbound{}).
|
||||
Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
|
||||
Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", dest).
|
||||
Find(&inbound).Error
|
||||
var inbounds []model.Inbound
|
||||
err := db.Model(model.Inbound{}).Find(&inbounds).Error
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
|
||||
var inbound *model.Inbound
|
||||
for i := range inbounds {
|
||||
settings := map[string]any{}
|
||||
if json.Unmarshal([]byte(inbounds[i].Settings), &settings) != nil {
|
||||
continue
|
||||
}
|
||||
fallbacks, ok := settings["fallbacks"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, fallback := range fallbacks {
|
||||
fallbackMap, ok := fallback.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if fallbackDest, ok := fallbackMap["dest"].(string); ok && fallbackDest == dest {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
inbound = &inbounds[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if inbound == nil {
|
||||
return "", 0, "", fmt.Errorf("fallback master not found")
|
||||
}
|
||||
|
||||
var stream map[string]any
|
||||
json.Unmarshal([]byte(streamSettings), &stream)
|
||||
var masterStream map[string]any
|
||||
|
|
|
|||
|
|
@ -822,6 +822,9 @@ update_x-ui() {
|
|||
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
||||
cd x-ui >/dev/null 2>&1
|
||||
chmod +x x-ui >/dev/null 2>&1
|
||||
if [[ -f postgres-manager.sh ]]; then
|
||||
chmod +x postgres-manager.sh >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Check the system's architecture and rename the file accordingly
|
||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||
|
|
|
|||
|
|
@ -86,4 +86,35 @@ class AllSetting {
|
|||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseSetting {
|
||||
|
||||
constructor(data) {
|
||||
this.driver = "sqlite";
|
||||
this.configSource = "default";
|
||||
this.readOnly = false;
|
||||
this.sqlitePath = "";
|
||||
this.postgresMode = "external";
|
||||
this.postgresHost = "127.0.0.1";
|
||||
this.postgresPort = 5432;
|
||||
this.postgresDBName = "";
|
||||
this.postgresUser = "";
|
||||
this.postgresPassword = "";
|
||||
this.postgresPasswordSet = false;
|
||||
this.postgresSSLMode = "disable";
|
||||
this.managedLocally = false;
|
||||
this.localInstalled = false;
|
||||
this.canInstallLocally = false;
|
||||
this.nativeSQLiteExportAvailable = true;
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
|||
type ServerController struct {
|
||||
BaseController
|
||||
|
||||
serverService service.ServerService
|
||||
settingService service.SettingService
|
||||
serverService service.ServerService
|
||||
settingService service.SettingService
|
||||
databaseService service.DatabaseService
|
||||
|
||||
lastStatus *service.Status
|
||||
|
||||
|
|
@ -253,14 +254,27 @@ func (a *ServerController) getConfigJson(c *gin.Context) {
|
|||
|
||||
// getDb downloads the database file.
|
||||
func (a *ServerController) getDb(c *gin.Context) {
|
||||
db, err := a.serverService.GetDb()
|
||||
exportType := c.Query("type")
|
||||
if exportType == "" {
|
||||
exportType = "portable"
|
||||
}
|
||||
|
||||
var (
|
||||
data []byte
|
||||
filename string
|
||||
err error
|
||||
)
|
||||
switch exportType {
|
||||
case "sqlite":
|
||||
data, filename, err = a.databaseService.ExportNativeSQLite()
|
||||
default:
|
||||
data, filename, err = a.databaseService.ExportPortableBackup()
|
||||
}
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := "x-ui.db"
|
||||
|
||||
if !isValidFilename(filename) {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||
return
|
||||
|
|
@ -271,7 +285,7 @@ func (a *ServerController) getDb(c *gin.Context) {
|
|||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
|
||||
// Write the file contents to the response
|
||||
c.Writer.Write(db)
|
||||
c.Writer.Write(data)
|
||||
}
|
||||
|
||||
func isValidFilename(filename string) bool {
|
||||
|
|
@ -284,15 +298,14 @@ func (a *ServerController) importDB(c *gin.Context) {
|
|||
// Get the file from the request body
|
||||
file, _, err := c.Request.FormFile("db")
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
||||
return
|
||||
file, _, err = c.Request.FormFile("backup")
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
// Always restart Xray before return
|
||||
defer a.serverService.RestartXrayService()
|
||||
// lastGetStatusTime removed; no longer needed
|
||||
// Import it
|
||||
err = a.serverService.ImportDB(file)
|
||||
_, err = a.databaseService.ImportBackup(file)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||
|
|
@ -25,6 +26,7 @@ type SettingController struct {
|
|||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
panelService service.PanelService
|
||||
databaseService service.DatabaseService
|
||||
}
|
||||
|
||||
// NewSettingController creates a new SettingController and initializes its routes.
|
||||
|
|
@ -44,6 +46,15 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/updateUser", a.updateUser)
|
||||
g.POST("/restartPanel", a.restartPanel)
|
||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||
|
||||
database := g.Group("/database")
|
||||
database.POST("/get", a.getDatabaseSetting)
|
||||
database.POST("/test", a.testDatabaseSetting)
|
||||
database.POST("/install-postgres", a.installLocalPostgres)
|
||||
database.POST("/switch", a.switchDatabase)
|
||||
database.POST("/export", a.exportDatabase)
|
||||
database.GET("/export", a.exportDatabase)
|
||||
database.POST("/import", a.importDatabase)
|
||||
}
|
||||
|
||||
// getAllSetting retrieves all current settings.
|
||||
|
|
@ -119,3 +130,97 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
|
|||
}
|
||||
jsonObj(c, defaultJsonConfig, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) getDatabaseSetting(c *gin.Context) {
|
||||
setting, err := a.databaseService.GetSetting()
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to load database settings", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, setting, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) testDatabaseSetting(c *gin.Context) {
|
||||
setting := &entity.DatabaseSetting{}
|
||||
if err := c.ShouldBind(setting); err != nil {
|
||||
jsonMsg(c, "Failed to parse database settings", err)
|
||||
return
|
||||
}
|
||||
if err := a.databaseService.TestSetting(setting); err != nil {
|
||||
jsonMsg(c, "Connection test failed", err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, "Database connection test succeeded", nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) installLocalPostgres(c *gin.Context) {
|
||||
output, err := a.databaseService.InstallLocalPostgres()
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to install PostgreSQL", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, output, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) switchDatabase(c *gin.Context) {
|
||||
setting := &entity.DatabaseSetting{}
|
||||
if err := c.ShouldBind(setting); err != nil {
|
||||
jsonMsg(c, "Failed to parse database settings", err)
|
||||
return
|
||||
}
|
||||
err := a.databaseService.SwitchDatabase(setting)
|
||||
if err == nil {
|
||||
err = a.panelService.RestartPanel(3 * time.Second)
|
||||
}
|
||||
jsonMsg(c, "Database switch scheduled", err)
|
||||
}
|
||||
|
||||
func (a *SettingController) exportDatabase(c *gin.Context) {
|
||||
exportType := c.Query("type")
|
||||
if exportType == "" {
|
||||
exportType = c.PostForm("type")
|
||||
}
|
||||
if exportType == "" {
|
||||
exportType = "portable"
|
||||
}
|
||||
|
||||
var (
|
||||
data []byte
|
||||
filename string
|
||||
err error
|
||||
)
|
||||
|
||||
switch exportType {
|
||||
case "sqlite":
|
||||
data, filename, err = a.databaseService.ExportNativeSQLite()
|
||||
default:
|
||||
data, filename, err = a.databaseService.ExportPortableBackup()
|
||||
}
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to export database", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
func (a *SettingController) importDatabase(c *gin.Context) {
|
||||
file, _, err := c.Request.FormFile("backup")
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to read uploaded backup", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
backupType, err := a.databaseService.ImportBackup(file)
|
||||
if err == nil {
|
||||
err = a.panelService.RestartPanel(3 * time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to import database backup", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, backupType, nil)
|
||||
}
|
||||
|
|
|
|||
99
web/entity/database.go
Normal file
99
web/entity/database.go
Normal 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()
|
||||
}
|
||||
|
|
@ -410,15 +410,22 @@
|
|||
<a-list class="ant-backup-list w-100" bordered>
|
||||
<a-list-item class="ant-backup-list-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ i18n "pages.index.exportDatabase" }}</template>
|
||||
<template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template>
|
||||
<template #title>Export Portable Backup</template>
|
||||
<template #description>Portable backups work for both SQLite and PostgreSQL.</template>
|
||||
</a-list-item-meta>
|
||||
<a-button @click="exportDatabase()" type="primary" icon="download" />
|
||||
<a-button @click="exportDatabase('portable')" type="primary" icon="download" />
|
||||
</a-list-item>
|
||||
<a-list-item class="ant-backup-list-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ i18n "pages.index.importDatabase" }}</template>
|
||||
<template #description>{{ i18n "pages.index.importDatabaseDesc" }}</template>
|
||||
<template #title>Export Native SQLite</template>
|
||||
<template #description>Downloads the raw SQLite database file when SQLite is the active backend.</template>
|
||||
</a-list-item-meta>
|
||||
<a-button @click="exportDatabase('sqlite')" type="primary" icon="download" :disabled="!backupModal.nativeSQLiteExportAvailable" />
|
||||
</a-list-item>
|
||||
<a-list-item class="ant-backup-list-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>Import Backup</template>
|
||||
<template #description>Accepts portable `.xui-backup` files and legacy SQLite `.db` backups.</template>
|
||||
</a-list-item-meta>
|
||||
<a-button @click="importDatabase()" type="primary" icon="upload" />
|
||||
</a-list-item>
|
||||
|
|
@ -864,6 +871,7 @@
|
|||
};
|
||||
const backupModal = {
|
||||
visible: false,
|
||||
nativeSQLiteExportAvailable: false,
|
||||
show() {
|
||||
this.visible = true;
|
||||
},
|
||||
|
|
@ -1064,24 +1072,28 @@
|
|||
}
|
||||
txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json');
|
||||
},
|
||||
openBackup() {
|
||||
async openBackup() {
|
||||
const dbMsg = await HttpUtil.post('/panel/setting/database/get');
|
||||
if (dbMsg.success) {
|
||||
backupModal.nativeSQLiteExportAvailable = !!dbMsg.obj.nativeSQLiteExportAvailable;
|
||||
}
|
||||
backupModal.show();
|
||||
},
|
||||
exportDatabase() {
|
||||
window.location = basePath + 'panel/api/server/getDb';
|
||||
exportDatabase(type = 'portable') {
|
||||
window.location = `${basePath}panel/setting/database/export?type=${type}`;
|
||||
},
|
||||
importDatabase() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.db';
|
||||
fileInput.accept = '.xui-backup,.db,.zip';
|
||||
fileInput.addEventListener('change', async (event) => {
|
||||
const dbFile = event.target.files[0];
|
||||
if (dbFile) {
|
||||
const backupFile = event.target.files[0];
|
||||
if (backupFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('db', dbFile);
|
||||
formData.append('backup', backupFile);
|
||||
backupModal.hide();
|
||||
this.loading(true);
|
||||
const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
||||
const uploadMsg = await HttpUtil.post('/panel/setting/database/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
|
|
@ -1091,13 +1103,8 @@
|
|||
return;
|
||||
}
|
||||
this.loading(true);
|
||||
const restartMsg = await HttpUtil.post("/panel/setting/restartPanel");
|
||||
this.loading(false);
|
||||
if (restartMsg.success) {
|
||||
this.loading(true);
|
||||
await PromiseUtil.sleep(5000);
|
||||
location.reload();
|
||||
}
|
||||
await PromiseUtil.sleep(5000);
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
},
|
||||
oldAllSetting: new AllSetting(),
|
||||
allSetting: new AllSetting(),
|
||||
oldDatabaseSetting: new DatabaseSetting(),
|
||||
databaseSetting: new DatabaseSetting(),
|
||||
saveBtnDisable: true,
|
||||
entryHost: null,
|
||||
entryPort: null,
|
||||
|
|
@ -276,6 +278,14 @@
|
|||
this.saveBtnDisable = true;
|
||||
}
|
||||
},
|
||||
async getDatabaseSetting() {
|
||||
const msg = await HttpUtil.post("/panel/setting/database/get");
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
this.oldDatabaseSetting = new DatabaseSetting(msg.obj);
|
||||
this.databaseSetting = new DatabaseSetting(msg.obj);
|
||||
},
|
||||
async loadInboundTags() {
|
||||
const msg = await HttpUtil.get("/panel/api/inbounds/list");
|
||||
if (msg && msg.success && Array.isArray(msg.obj)) {
|
||||
|
|
@ -295,6 +305,78 @@
|
|||
await this.getAllSetting();
|
||||
}
|
||||
},
|
||||
async testDatabaseSetting() {
|
||||
this.loading(true);
|
||||
await HttpUtil.post("/panel/setting/database/test", this.databaseSetting);
|
||||
this.loading(false);
|
||||
},
|
||||
async installLocalPostgres() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post("/panel/setting/database/install-postgres");
|
||||
this.loading(false);
|
||||
if (msg.success) {
|
||||
await this.getDatabaseSetting();
|
||||
}
|
||||
},
|
||||
async switchDatabase() {
|
||||
await new Promise(resolve => {
|
||||
this.$confirm({
|
||||
title: '{{ i18n "pages.settings.database.switchDatabaseTitle" }}',
|
||||
content: '{{ i18n "pages.settings.database.switchDatabaseConfirm" }}',
|
||||
class: themeSwitcher.currentTheme,
|
||||
okText: '{{ i18n "sure" }}',
|
||||
cancelText: '{{ i18n "cancel" }}',
|
||||
onOk: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post("/panel/setting/database/switch", this.databaseSetting);
|
||||
this.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
await PromiseUtil.sleep(5000);
|
||||
window.location.reload();
|
||||
},
|
||||
downloadDatabaseExport(type) {
|
||||
window.location = `${basePath}panel/setting/database/export?type=${type}`;
|
||||
},
|
||||
exportPortableBackup() {
|
||||
this.downloadDatabaseExport('portable');
|
||||
},
|
||||
exportNativeSQLite() {
|
||||
this.downloadDatabaseExport('sqlite');
|
||||
},
|
||||
importDatabaseBackup() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.xui-backup,.db,.zip';
|
||||
fileInput.addEventListener('change', async (event) => {
|
||||
const backupFile = event.target.files[0];
|
||||
if (!backupFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('backup', backupFile);
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/setting/database/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
});
|
||||
this.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
await PromiseUtil.sleep(5000);
|
||||
window.location.reload();
|
||||
});
|
||||
fileInput.click();
|
||||
},
|
||||
async updateUser() {
|
||||
const sendUpdateUserRequest = async () => {
|
||||
this.loading(true);
|
||||
|
|
@ -627,6 +709,7 @@
|
|||
this.entryProtocol = window.location.protocol;
|
||||
this.entryIsIP = this._isIp(this.entryHost);
|
||||
await this.getAllSetting();
|
||||
await this.getDatabaseSetting();
|
||||
await this.loadInboundTags();
|
||||
while (true) {
|
||||
await PromiseUtil.sleep(1000);
|
||||
|
|
@ -635,4 +718,4 @@
|
|||
}
|
||||
});
|
||||
</script>
|
||||
{{ template "page/body_end" .}}
|
||||
{{ template "page/body_end" .}}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,130 @@
|
|||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.externalTraffic" }}'>
|
||||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.database.sectionTitle" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.backend" }}</template>
|
||||
<template #description>{{ i18n "pages.settings.database.backendDesc" }}</template>
|
||||
<template #control>
|
||||
<a-radio-group v-model="databaseSetting.driver" :disabled="databaseSetting.readOnly" button-style="solid">
|
||||
<a-radio-button value="sqlite">SQLite</a-radio-button>
|
||||
<a-radio-button value="postgres">PostgreSQL</a-radio-button>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="databaseSetting.readOnly || databaseSetting.configSource === 'default'">
|
||||
<template #title>{{ i18n "pages.settings.database.configSource" }}</template>
|
||||
<template #description v-if="databaseSetting.readOnly">{{ i18n "pages.settings.database.configSourceEnvDesc" }}</template>
|
||||
<template #description v-else>{{ i18n "pages.settings.database.configSourceDefaultDesc" }}</template>
|
||||
<template #control>
|
||||
<a-tag :color="databaseSetting.readOnly ? 'red' : 'orange'">[[ databaseSetting.readOnly ? 'env' : 'default' ]]</a-tag>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="databaseSetting.driver === 'sqlite'">
|
||||
<template #title>{{ i18n "pages.settings.database.sqlitePath" }}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="databaseSetting.sqlitePath" :disabled="databaseSetting.readOnly"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<template v-if="databaseSetting.driver === 'postgres'">
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.postgresMode" }}</template>
|
||||
<template #description>
|
||||
<span v-if="databaseSetting.postgresMode === 'local'">{{ i18n "pages.settings.database.postgresModeLocalDesc" }}</span>
|
||||
<span v-else>{{ i18n "pages.settings.database.postgresModeExternalDesc" }}</span>
|
||||
</template>
|
||||
<template #control>
|
||||
<a-radio-group v-model="databaseSetting.postgresMode" :disabled="databaseSetting.readOnly" button-style="solid">
|
||||
<a-radio-button value="local">{{ i18n "pages.settings.database.postgresModeLocal" }}</a-radio-button>
|
||||
<a-radio-button value="external">{{ i18n "pages.settings.database.postgresModeExternal" }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'local'">
|
||||
<template #title>{{ i18n "pages.settings.database.postgresInstallation" }}</template>
|
||||
<template #description>
|
||||
<span v-if="databaseSetting.localInstalled"><a-tag color="green">✓</a-tag> {{ i18n "pages.settings.database.postgresInstallReady" }}</span>
|
||||
<span v-else-if="!databaseSetting.canInstallLocally"><a-tag color="orange">!</a-tag> {{ i18n "pages.settings.database.postgresInstallNeedRoot" }}</span>
|
||||
<span v-else><a-tag color="orange">!</a-tag> {{ i18n "pages.settings.database.postgresInstallHint" }}</span>
|
||||
</template>
|
||||
<template #control>
|
||||
<a-button @click="installLocalPostgres" :disabled="databaseSetting.readOnly || !databaseSetting.canInstallLocally || databaseSetting.localInstalled">
|
||||
<span v-if="databaseSetting.localInstalled">{{ i18n "pages.settings.database.postgresAlreadyInstalled" }}</span>
|
||||
<span v-else>{{ i18n "pages.settings.database.postgresInstallBtn" }}</span>
|
||||
</a-button>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
|
||||
<template #title>{{ i18n "pages.settings.database.host" }}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="databaseSetting.postgresHost" :disabled="databaseSetting.readOnly"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
|
||||
<template #title>{{ i18n "pages.settings.database.port" }}</template>
|
||||
<template #control>
|
||||
<a-input-number :min="1" :max="65535" v-model="databaseSetting.postgresPort" :style="{ width: '100%' }" :disabled="databaseSetting.readOnly"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.dbName" }}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="databaseSetting.postgresDBName" :disabled="databaseSetting.readOnly"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.user" }}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="databaseSetting.postgresUser" :disabled="databaseSetting.readOnly"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.password" }}</template>
|
||||
<template #description v-if="databaseSetting.postgresPasswordSet">{{ i18n "pages.settings.database.passwordHint" }}</template>
|
||||
<template #control>
|
||||
<a-input type="password" v-model="databaseSetting.postgresPassword" :disabled="databaseSetting.readOnly"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
|
||||
<template #title>{{ i18n "pages.settings.database.sslMode" }}</template>
|
||||
<template #control>
|
||||
<a-select v-model="databaseSetting.postgresSSLMode" :disabled="databaseSetting.readOnly"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||
<a-select-option value="disable">disable</a-select-option>
|
||||
<a-select-option value="require">require</a-select-option>
|
||||
<a-select-option value="verify-ca">verify-ca</a-select-option>
|
||||
<a-select-option value="verify-full">verify-full</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</template>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.actions" }}</template>
|
||||
<template #description>{{ i18n "pages.settings.database.actionsDesc" }}</template>
|
||||
<template #control>
|
||||
<a-space wrap>
|
||||
<a-button @click="testDatabaseSetting" :disabled="databaseSetting.readOnly">{{ i18n "pages.settings.database.testConnection" }}</a-button>
|
||||
<a-button type="primary" @click="switchDatabase" :disabled="databaseSetting.readOnly">{{ i18n "pages.settings.database.switchDatabase" }}</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.database.backupRestore" }}</template>
|
||||
<template #description>
|
||||
<b>{{ i18n "pages.settings.database.exportPortableLabel" }}</b> — {{ i18n "pages.settings.database.exportPortableDesc" }}<br>
|
||||
<b>{{ i18n "pages.settings.database.exportNativeSQLiteLabel" }}</b> — {{ i18n "pages.settings.database.exportNativeSQLiteDesc" }}<br>
|
||||
<b>{{ i18n "pages.settings.database.importLabel" }}</b> — {{ i18n "pages.settings.database.importDesc" }}
|
||||
</template>
|
||||
<template #control>
|
||||
<a-space wrap>
|
||||
<a-button @click="exportPortableBackup">{{ i18n "pages.settings.database.exportPortableBtn" }}</a-button>
|
||||
<a-button @click="exportNativeSQLite" :disabled="!databaseSetting.nativeSQLiteExportAvailable">{{ i18n "pages.settings.database.exportNativeSQLiteBtn" }}</a-button>
|
||||
<a-button @click="importDatabaseBackup">{{ i18n "pages.settings.database.importBtn" }}</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="5" header='{{ i18n "pages.settings.externalTraffic" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.externalTrafficInformEnable"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.externalTrafficInformEnableDesc"}}</template>
|
||||
|
|
@ -125,7 +248,7 @@
|
|||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="5" header='{{ i18n "pages.settings.dateAndTime" }}'>
|
||||
<a-collapse-panel key="6" header='{{ i18n "pages.settings.dateAndTime" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.timeZone"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.timeZoneDesc"}}</template>
|
||||
|
|
@ -146,7 +269,7 @@
|
|||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="6" header='LDAP'>
|
||||
<a-collapse-panel key="7" header='LDAP'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>Enable LDAP sync</template>
|
||||
<template #control>
|
||||
|
|
@ -277,4 +400,4 @@
|
|||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
|||
252
web/service/database.go
Normal file
252
web/service/database.go
Normal 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
|
||||
}
|
||||
|
|
@ -108,13 +108,14 @@ func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (
|
|||
db.Model(model.Inbound{}).Where(
|
||||
"listen = ?", listen,
|
||||
).Or(
|
||||
"listen = \"\"",
|
||||
"listen = ?", "",
|
||||
).Or(
|
||||
"listen = \"0.0.0.0\"",
|
||||
"listen = ?", "0.0.0.0",
|
||||
).Or(
|
||||
"listen = \"::\"",
|
||||
"listen = ?", "::",
|
||||
).Or(
|
||||
"listen = \"::0\""))
|
||||
"listen = ?", "::0",
|
||||
))
|
||||
}
|
||||
if ignoreId > 0 {
|
||||
db = db.Where("id != ?", ignoreId)
|
||||
|
|
@ -143,15 +144,24 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
|
|||
|
||||
func (s *InboundService) getAllEmails() ([]string, error) {
|
||||
db := database.GetDB()
|
||||
var emails []string
|
||||
err := db.Raw(`
|
||||
SELECT JSON_EXTRACT(client.value, '$.email')
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
`).Scan(&emails).Error
|
||||
var inbounds []model.Inbound
|
||||
err := db.Model(model.Inbound{}).Find(&inbounds).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
emails := make([]string, 0)
|
||||
for i := range inbounds {
|
||||
clients, clientsErr := s.GetClients(&inbounds[i])
|
||||
if clientsErr != nil {
|
||||
return nil, clientsErr
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Email != "" {
|
||||
emails = append(emails, client.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
return emails, nil
|
||||
}
|
||||
|
||||
|
|
@ -1312,14 +1322,18 @@ func (s *InboundService) GetInboundTags() (string, error) {
|
|||
|
||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||
db := database.GetDB()
|
||||
db.Exec(`
|
||||
DELETE FROM client_traffics
|
||||
WHERE email NOT IN (
|
||||
SELECT JSON_EXTRACT(client.value, '$.email')
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
)
|
||||
`)
|
||||
allEmails, err := s.getAllEmails()
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to load client emails for orphan cleanup: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
query := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&xray.ClientTraffic{})
|
||||
if len(allEmails) == 0 {
|
||||
query.Delete(&xray.ClientTraffic{})
|
||||
return
|
||||
}
|
||||
query.Not("email IN ?", allEmails).Delete(&xray.ClientTraffic{})
|
||||
}
|
||||
|
||||
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
|
||||
|
|
@ -2057,14 +2071,30 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64,
|
|||
func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
var traffics []xray.ClientTraffic
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Model(model.Inbound{}).Find(&inbounds).Error; err != nil {
|
||||
logger.Debug(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := db.Model(xray.ClientTraffic{}).Where(`email IN(
|
||||
SELECT JSON_EXTRACT(client.value, '$.email') as email
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
WHERE
|
||||
JSON_EXTRACT(client.value, '$.id') in (?)
|
||||
)`, id).Find(&traffics).Error
|
||||
emails := make([]string, 0)
|
||||
for i := range inbounds {
|
||||
clients, clientsErr := s.GetClients(&inbounds[i])
|
||||
if clientsErr != nil {
|
||||
logger.Debug(clientsErr)
|
||||
return nil, clientsErr
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.ID == id && client.Email != "" {
|
||||
emails = append(emails, client.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error
|
||||
|
||||
if err != nil {
|
||||
logger.Debug(err)
|
||||
|
|
@ -2210,72 +2240,66 @@ func (s *InboundService) MigrationRequirements() {
|
|||
defer func() {
|
||||
if err == nil {
|
||||
tx.Commit()
|
||||
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
|
||||
logger.Warningf("VACUUM failed: %v", dbErr)
|
||||
if database.IsSQLite() {
|
||||
if dbErr := db.Exec(`VACUUM`).Error; dbErr != nil {
|
||||
logger.Warningf("VACUUM failed: %v", dbErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Calculate and backfill all_time from up+down for inbounds and clients
|
||||
// Calculate and backfill all_time from up+down for inbounds and clients.
|
||||
err = tx.Exec(`
|
||||
UPDATE inbounds
|
||||
SET all_time = IFNULL(up, 0) + IFNULL(down, 0)
|
||||
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0
|
||||
SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
|
||||
WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
|
||||
`).Error
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = tx.Exec(`
|
||||
UPDATE client_traffics
|
||||
SET all_time = IFNULL(up, 0) + IFNULL(down, 0)
|
||||
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0
|
||||
SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
|
||||
WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
|
||||
`).Error
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Fix inbounds based problems
|
||||
// Fix inbounds based problems.
|
||||
var inbounds []*model.Inbound
|
||||
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
|
||||
err = tx.Model(model.Inbound{}).Where("protocol IN ?", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return
|
||||
}
|
||||
for inbound_index := range inbounds {
|
||||
for inboundIndex := range inbounds {
|
||||
settings := map[string]any{}
|
||||
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
|
||||
json.Unmarshal([]byte(inbounds[inboundIndex].Settings), &settings)
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if ok {
|
||||
// Fix Client configuration problems
|
||||
var newClients []any
|
||||
for client_index := range clients {
|
||||
c := clients[client_index].(map[string]any)
|
||||
for clientIndex := range clients {
|
||||
c := clients[clientIndex].(map[string]any)
|
||||
|
||||
// Add email='' if it is not exists
|
||||
if _, ok := c["email"]; !ok {
|
||||
c["email"] = ""
|
||||
}
|
||||
|
||||
// Convert string tgId to int64
|
||||
if _, ok := c["tgId"]; ok {
|
||||
var tgId any = c["tgId"]
|
||||
if tgIdStr, ok2 := tgId.(string); ok2 {
|
||||
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
|
||||
if err == nil {
|
||||
tgIdInt64, parseErr := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
|
||||
if parseErr == nil {
|
||||
c["tgId"] = tgIdInt64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove "flow": "xtls-rprx-direct"
|
||||
if _, ok := c["flow"]; ok {
|
||||
if c["flow"] == "xtls-rprx-direct" {
|
||||
c["flow"] = ""
|
||||
}
|
||||
if _, ok := c["flow"]; ok && c["flow"] == "xtls-rprx-direct" {
|
||||
c["flow"] = ""
|
||||
}
|
||||
// Backfill created_at and updated_at
|
||||
if _, ok := c["created_at"]; !ok {
|
||||
c["created_at"] = time.Now().Unix() * 1000
|
||||
}
|
||||
|
|
@ -2283,17 +2307,17 @@ func (s *InboundService) MigrationRequirements() {
|
|||
newClients = append(newClients, any(c))
|
||||
}
|
||||
settings["clients"] = newClients
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
modifiedSettings, marshalErr := json.MarshalIndent(settings, "", " ")
|
||||
if marshalErr != nil {
|
||||
err = marshalErr
|
||||
return
|
||||
}
|
||||
|
||||
inbounds[inbound_index].Settings = string(modifiedSettings)
|
||||
inbounds[inboundIndex].Settings = string(modifiedSettings)
|
||||
}
|
||||
|
||||
// Add client traffic row for all clients which has email
|
||||
modelClients, err := s.GetClients(inbounds[inbound_index])
|
||||
if err != nil {
|
||||
modelClients, clientsErr := s.GetClients(inbounds[inboundIndex])
|
||||
if clientsErr != nil {
|
||||
err = clientsErr
|
||||
return
|
||||
}
|
||||
for _, modelClient := range modelClients {
|
||||
|
|
@ -2301,62 +2325,82 @@ func (s *InboundService) MigrationRequirements() {
|
|||
var count int64
|
||||
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
|
||||
if count == 0 {
|
||||
s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.Save(inbounds)
|
||||
|
||||
// Remove orphaned traffics
|
||||
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
|
||||
|
||||
// Migrate old MultiDomain to External Proxy
|
||||
var externalProxy []struct {
|
||||
Id int
|
||||
Port int
|
||||
StreamSettings []byte
|
||||
}
|
||||
err = tx.Raw(`select id, port, stream_settings
|
||||
from inbounds
|
||||
WHERE protocol in ('vmess','vless','trojan')
|
||||
AND json_extract(stream_settings, '$.security') = 'tls'
|
||||
AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error
|
||||
if err != nil || len(externalProxy) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, ep := range externalProxy {
|
||||
var reverses any
|
||||
var stream map[string]any
|
||||
json.Unmarshal(ep.StreamSettings, &stream)
|
||||
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
|
||||
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
|
||||
if domains, ok := settings["domains"].([]any); ok {
|
||||
for _, domain := range domains {
|
||||
if domainMap, ok := domain.(map[string]any); ok {
|
||||
domainMap["forceTls"] = "same"
|
||||
domainMap["port"] = ep.Port
|
||||
domainMap["dest"] = domainMap["domain"].(string)
|
||||
delete(domainMap, "domain")
|
||||
}
|
||||
if addErr := s.AddClientStat(tx, inbounds[inboundIndex].Id, &modelClient); addErr != nil {
|
||||
err = addErr
|
||||
return
|
||||
}
|
||||
}
|
||||
reverses = settings["domains"]
|
||||
delete(settings, "domains")
|
||||
}
|
||||
}
|
||||
stream["externalProxy"] = reverses
|
||||
newStream, _ := json.MarshalIndent(stream, " ", " ")
|
||||
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
|
||||
}
|
||||
if err = tx.Save(inbounds).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Raw(`UPDATE inbounds
|
||||
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
||||
WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error
|
||||
if err != nil {
|
||||
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
var reverses any
|
||||
var stream map[string]any
|
||||
if json.Unmarshal([]byte(inbound.StreamSettings), &stream) != nil {
|
||||
continue
|
||||
}
|
||||
if security, _ := stream["security"].(string); security != "tls" {
|
||||
continue
|
||||
}
|
||||
|
||||
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tlsInnerSettings, ok := tlsSettings["settings"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
domains, ok := tlsInnerSettings["domains"].([]any)
|
||||
if !ok || len(domains) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, domain := range domains {
|
||||
domainMap, ok := domain.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
domainMap["forceTls"] = "same"
|
||||
domainMap["port"] = inbound.Port
|
||||
if domainName, ok := domainMap["domain"].(string); ok {
|
||||
domainMap["dest"] = domainName
|
||||
}
|
||||
delete(domainMap, "domain")
|
||||
}
|
||||
reverses = domains
|
||||
delete(tlsInnerSettings, "domains")
|
||||
stream["externalProxy"] = reverses
|
||||
newStream, marshalErr := json.MarshalIndent(stream, " ", " ")
|
||||
if marshalErr != nil {
|
||||
err = marshalErr
|
||||
return
|
||||
}
|
||||
if err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("stream_settings", newStream).Error; err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var allInbounds []*model.Inbound
|
||||
err = tx.Model(model.Inbound{}).Find(&allInbounds).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return
|
||||
}
|
||||
for _, inbound := range allInbounds {
|
||||
if !strings.Contains(inbound.Tag, "0.0.0.0:") {
|
||||
continue
|
||||
}
|
||||
newTag := strings.Replace(inbound.Tag, "0.0.0.0:", "", 1)
|
||||
if err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("tag", newTag).Error; err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) MigrateDB() {
|
||||
|
|
|
|||
|
|
@ -1009,7 +1009,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
|||
}
|
||||
|
||||
// Open & migrate new DB
|
||||
if err = database.InitDB(config.GetDBPath()); err != nil {
|
||||
if err = database.InitDB(); err != nil {
|
||||
if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
|
||||
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"gorm.io/gorm"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||
|
|
@ -204,12 +205,15 @@ func (s *SettingService) ResetSettings() error {
|
|||
|
||||
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
||||
db := database.GetDB()
|
||||
setting := &model.Setting{}
|
||||
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
||||
var settings []*model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", key).Limit(1).Find(&settings).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return setting, nil
|
||||
if len(settings) == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return settings[0], nil
|
||||
}
|
||||
|
||||
func (s *SettingService) saveSetting(key string, value string) error {
|
||||
|
|
@ -499,10 +503,18 @@ func (s *SettingService) GetSubListen() (string, error) {
|
|||
return s.getString("subListen")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSubListen(ip string) error {
|
||||
return s.setString("subListen", ip)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubPort() (int, error) {
|
||||
return s.getInt("subPort")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSubPort(port int) error {
|
||||
return s.setInt("subPort", port)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubPath() (string, error) {
|
||||
return s.getString("subPath")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3636,29 +3636,47 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
|||
logger.Error("Error in trigger a checkpoint operation: ", err)
|
||||
}
|
||||
|
||||
// Send database backup
|
||||
file, err := os.Open(config.GetDBPath())
|
||||
// Send portable database backup
|
||||
backupData, err := database.EncodeCurrentPortableBackup()
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
tu.FileFromBytes(backupData, fmt.Sprintf("x-ui-%s.xui-backup", time.Now().Format("20060102-150405"))),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
logger.Error("Error in uploading portable backup: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in opening db file for backup: ", err)
|
||||
logger.Error("Error in creating portable backup: ", err)
|
||||
}
|
||||
|
||||
if database.IsSQLite() {
|
||||
file, err := os.Open(config.GetDBPath())
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading native SQLite backup: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in opening db file for backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between file sends
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Send config.json backup
|
||||
file, err = os.Open(xray.GetConfigPath())
|
||||
file, err := os.Open(xray.GetConfigPath())
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "نطاق IP Pool"
|
||||
"poolSize" = "حجم المجموعة"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "بيانات الأدمن"
|
||||
"twoFactor" = "المصادقة الثنائية"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "IP Pool Subnet"
|
||||
"poolSize" = "Pool Size"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Admin credentials"
|
||||
"twoFactor" = "Two-factor authentication"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "Subred del grupo de IP"
|
||||
"poolSize" = "Tamaño del grupo"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Credenciales de administrador"
|
||||
"twoFactor" = "Autenticación de dos factores"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "زیرشبکه استخر آیپی"
|
||||
"poolSize" = "اندازه استخر"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "اعتبارنامههای ادمین"
|
||||
"twoFactor" = "احراز هویت دو مرحلهای"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "Subnet Kumpulan IP"
|
||||
"poolSize" = "Ukuran Kolam"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Kredensial admin"
|
||||
"twoFactor" = "Autentikasi dua faktor"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "IPプールサブネット"
|
||||
"poolSize" = "プールサイズ"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "管理者の資格情報"
|
||||
"twoFactor" = "二段階認証"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "Sub-rede do Pool de IP"
|
||||
"poolSize" = "Tamanho do Pool"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Credenciais de administrador"
|
||||
"twoFactor" = "Autenticação de dois fatores"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "Подсеть пула IP"
|
||||
"poolSize" = "Размер пула"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "База данных"
|
||||
"backend" = "Бэкенд"
|
||||
"backendDesc" = "Конфигурация базы данных хранится вне самой базы данных панели."
|
||||
"configSource" = "Источник конфигурации"
|
||||
"configSourceEnvDesc" = "Бэкенд базы данных управляется переменными окружения и не может быть изменён здесь."
|
||||
"configSourceDefaultDesc" = "Файл конфигурации ещё не сохранён. Активны встроенные настройки по умолчанию (SQLite)."
|
||||
"configSourceDesc" = "Текущий источник:"
|
||||
"sqlitePath" = "Путь к SQLite"
|
||||
"postgresMode" = "Режим PostgreSQL"
|
||||
"postgresModeLocalDesc" = "PostgreSQL, управляемый панелью на этом сервере (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Подключение к существующему серверу PostgreSQL."
|
||||
"postgresModeLocal" = "Локальный (управляется панелью)"
|
||||
"postgresModeExternal" = "Внешний"
|
||||
"postgresInstallation" = "Установка PostgreSQL"
|
||||
"postgresInstallReady" = "PostgreSQL установлен и готов к работе."
|
||||
"postgresInstallNeedRoot" = "Автоустановка требует прав root и среды не-Docker."
|
||||
"postgresInstallHint" = "Нажмите «Установить» для автоматической настройки PostgreSQL."
|
||||
"postgresAlreadyInstalled" = "Уже установлен"
|
||||
"postgresInstallBtn" = "Установить PostgreSQL"
|
||||
"host" = "Хост"
|
||||
"port" = "Порт"
|
||||
"dbName" = "Имя базы данных"
|
||||
"user" = "Пользователь"
|
||||
"password" = "Пароль"
|
||||
"passwordHint" = "Оставьте пустым, чтобы сохранить текущий пароль."
|
||||
"sslMode" = "Режим SSL"
|
||||
"actions" = "Действия"
|
||||
"actionsDesc" = "Данные мигрируют автоматически. Перед переключением создаётся портативная резервная копия, затем панель перезапускается."
|
||||
"testConnection" = "Проверить подключение"
|
||||
"switchDatabase" = "Переключить базу данных"
|
||||
"switchDatabaseTitle" = "Переключение бэкенда базы данных"
|
||||
"switchDatabaseConfirm" = "Панель создаст портативную резервную копию, перенесёт все данные и перезапустится. Продолжить?"
|
||||
"backupRestore" = "Резервное копирование и восстановление"
|
||||
"exportPortableLabel" = "Портативный экспорт"
|
||||
"exportPortableDesc" = "Кроссплатформенная резервная копия (работает с SQLite и PostgreSQL, также отправляется Telegram-ботом)."
|
||||
"exportNativeSQLiteLabel" = "Нативный SQLite"
|
||||
"exportNativeSQLiteDesc" = "Файл базы данных SQLite, доступен только при активном SQLite-бэкенде."
|
||||
"importLabel" = "Импорт"
|
||||
"importDesc" = "Восстановление из портативной резервной копии (.xui-backup) или устаревшего файла SQLite (.db)."
|
||||
"exportPortableBtn" = "Экспорт портативной копии"
|
||||
"exportNativeSQLiteBtn" = "Экспорт SQLite"
|
||||
"importBtn" = "Импортировать резервную копию"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Учетные данные администратора"
|
||||
"twoFactor" = "Двухфакторная аутентификация"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "IP Havuzu Alt Ağı"
|
||||
"poolSize" = "Havuz Boyutu"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Yönetici kimlik bilgileri"
|
||||
"twoFactor" = "İki adımlı doğrulama"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "Підмережа IP-пулу"
|
||||
"poolSize" = "Розмір пулу"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Облікові дані адміністратора"
|
||||
"twoFactor" = "Двофакторна аутентифікація"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "Mạng con nhóm IP"
|
||||
"poolSize" = "Kích thước bể bơi"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Thông tin đăng nhập quản trị viên"
|
||||
"twoFactor" = "Xác thực hai yếu tố"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "IP 池子网"
|
||||
"poolSize" = "池大小"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "管理员凭据"
|
||||
"twoFactor" = "双重验证"
|
||||
|
|
|
|||
|
|
@ -589,6 +589,50 @@
|
|||
"ipPool" = "IP 池子網"
|
||||
"poolSize" = "池大小"
|
||||
|
||||
[pages.settings.database]
|
||||
"sectionTitle" = "Database"
|
||||
"backend" = "Backend"
|
||||
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||
"configSource" = "Config Source"
|
||||
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||
"configSourceDesc" = "Current source:"
|
||||
"sqlitePath" = "SQLite Path"
|
||||
"postgresMode" = "PostgreSQL Mode"
|
||||
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||
"postgresModeLocal" = "Local (panel-managed)"
|
||||
"postgresModeExternal" = "External"
|
||||
"postgresInstallation" = "PostgreSQL Installation"
|
||||
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||
"postgresAlreadyInstalled" = "Already installed"
|
||||
"postgresInstallBtn" = "Install PostgreSQL"
|
||||
"host" = "Host"
|
||||
"port" = "Port"
|
||||
"dbName" = "Database Name"
|
||||
"user" = "User"
|
||||
"password" = "Password"
|
||||
"passwordHint" = "Leave empty to keep the stored password."
|
||||
"sslMode" = "SSL Mode"
|
||||
"actions" = "Actions"
|
||||
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||
"testConnection" = "Test Connection"
|
||||
"switchDatabase" = "Switch Database"
|
||||
"switchDatabaseTitle" = "Switch database backend"
|
||||
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||
"backupRestore" = "Backup & Restore"
|
||||
"exportPortableLabel" = "Export Portable"
|
||||
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||
"importLabel" = "Import"
|
||||
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||
"exportPortableBtn" = "Export Portable Backup"
|
||||
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||
"importBtn" = "Import Backup"
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "管理員憑證"
|
||||
"twoFactor" = "雙重驗證"
|
||||
|
|
|
|||
135
x-ui.sh
135
x-ui.sh
|
|
@ -1672,6 +1672,133 @@ run_speedtest() {
|
|||
speedtest
|
||||
}
|
||||
|
||||
database_management() {
|
||||
echo -e "\n${green}\t1.${plain} Show Current Database Backend"
|
||||
echo -e "${green}\t2.${plain} Install Local PostgreSQL"
|
||||
echo -e "${green}\t3.${plain} Switch Database Backend"
|
||||
echo -e "${green}\t4.${plain} Export Portable Backup"
|
||||
echo -e "${green}\t5.${plain} Export Native SQLite"
|
||||
echo -e "${green}\t6.${plain} Import Backup"
|
||||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||||
read -rp "Choose an option: " db_choice
|
||||
|
||||
case "${db_choice}" in
|
||||
0)
|
||||
show_menu
|
||||
;;
|
||||
1)
|
||||
${xui_folder}/x-ui database show
|
||||
before_show_menu
|
||||
;;
|
||||
2)
|
||||
${xui_folder}/x-ui database install-postgres
|
||||
before_show_menu
|
||||
;;
|
||||
3)
|
||||
local db_backend=""
|
||||
local pg_mode=""
|
||||
local sqlite_path=""
|
||||
local pg_host="127.0.0.1"
|
||||
local pg_port="5432"
|
||||
local pg_db="xui"
|
||||
local pg_user="xui"
|
||||
local pg_pass=""
|
||||
local pg_sslmode="disable"
|
||||
|
||||
echo -e "${green}1.${plain} SQLite"
|
||||
echo -e "${green}2.${plain} PostgreSQL"
|
||||
read -rp "Choose database backend (default 1): " db_backend
|
||||
db_backend="${db_backend// /}"
|
||||
|
||||
if [[ "${db_backend}" != "2" ]]; then
|
||||
read -rp "SQLite path [/etc/x-ui/x-ui.db]: " sqlite_path
|
||||
sqlite_path="${sqlite_path:-/etc/x-ui/x-ui.db}"
|
||||
${xui_folder}/x-ui database switch -driver sqlite -sqlite-path "${sqlite_path}"
|
||||
confirm_restart
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${green}1.${plain} Local PostgreSQL"
|
||||
echo -e "${green}2.${plain} External PostgreSQL"
|
||||
read -rp "Choose PostgreSQL mode (default 1): " pg_mode
|
||||
pg_mode="${pg_mode// /}"
|
||||
|
||||
if [[ "${pg_mode}" != "2" ]]; then
|
||||
read -rp "Host [127.0.0.1]: " pg_host
|
||||
pg_host="${pg_host:-127.0.0.1}"
|
||||
read -rp "Port [5432]: " pg_port
|
||||
pg_port="${pg_port:-5432}"
|
||||
read -rp "Database name [xui]: " pg_db
|
||||
pg_db="${pg_db:-xui}"
|
||||
read -rp "Username [xui]: " pg_user
|
||||
pg_user="${pg_user:-xui}"
|
||||
read -rp "Password [random]: " pg_pass
|
||||
[[ -z "${pg_pass}" ]] && pg_pass=$(gen_random_string 18)
|
||||
${xui_folder}/x-ui database switch \
|
||||
-driver postgres \
|
||||
-postgres-mode local \
|
||||
-postgres-host "${pg_host}" \
|
||||
-postgres-port "${pg_port}" \
|
||||
-postgres-db "${pg_db}" \
|
||||
-postgres-user "${pg_user}" \
|
||||
-postgres-password "${pg_pass}" \
|
||||
-postgres-local true
|
||||
else
|
||||
read -rp "Host: " pg_host
|
||||
if [[ -z "${pg_host}" ]]; then
|
||||
LOGE "Host is required."
|
||||
before_show_menu
|
||||
return
|
||||
fi
|
||||
read -rp "Port [5432]: " pg_port
|
||||
pg_port="${pg_port:-5432}"
|
||||
read -rp "Database name [xui]: " pg_db
|
||||
pg_db="${pg_db:-xui}"
|
||||
read -rp "Username [xui]: " pg_user
|
||||
pg_user="${pg_user:-xui}"
|
||||
read -rp "Password: " pg_pass
|
||||
read -rp "SSL mode [disable]: " pg_sslmode
|
||||
pg_sslmode="${pg_sslmode:-disable}"
|
||||
${xui_folder}/x-ui database switch \
|
||||
-driver postgres \
|
||||
-postgres-mode external \
|
||||
-postgres-host "${pg_host}" \
|
||||
-postgres-port "${pg_port}" \
|
||||
-postgres-db "${pg_db}" \
|
||||
-postgres-user "${pg_user}" \
|
||||
-postgres-password "${pg_pass}" \
|
||||
-postgres-sslmode "${pg_sslmode}" \
|
||||
-postgres-local false
|
||||
fi
|
||||
confirm_restart
|
||||
;;
|
||||
4)
|
||||
read -rp "Output path or directory [current directory]: " export_path
|
||||
${xui_folder}/x-ui database export -type portable -out "${export_path}"
|
||||
before_show_menu
|
||||
;;
|
||||
5)
|
||||
read -rp "Output path or directory [current directory]: " export_path
|
||||
${xui_folder}/x-ui database export -type sqlite -out "${export_path}"
|
||||
before_show_menu
|
||||
;;
|
||||
6)
|
||||
read -rp "Backup file path: " import_path
|
||||
if [[ -z "${import_path}" ]]; then
|
||||
LOGE "Backup file path is required."
|
||||
before_show_menu
|
||||
return
|
||||
fi
|
||||
${xui_folder}/x-ui database import -file "${import_path}"
|
||||
confirm_restart
|
||||
;;
|
||||
*)
|
||||
LOGE "Invalid option."
|
||||
database_management
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
|
||||
ip_validation() {
|
||||
|
|
@ -2217,10 +2344,11 @@ show_menu() {
|
|||
│ ${green}24.${plain} Enable BBR │
|
||||
│ ${green}25.${plain} Update Geo Files │
|
||||
│ ${green}26.${plain} Speedtest by Ookla │
|
||||
│ ${green}27.${plain} Database Management │
|
||||
╚────────────────────────────────────────────────╝
|
||||
"
|
||||
show_status
|
||||
echo && read -rp "Please enter your selection [0-26]: " num
|
||||
echo && read -rp "Please enter your selection [0-27]: " num
|
||||
|
||||
case "${num}" in
|
||||
0)
|
||||
|
|
@ -2304,8 +2432,11 @@ show_menu() {
|
|||
26)
|
||||
run_speedtest
|
||||
;;
|
||||
27)
|
||||
database_management
|
||||
;;
|
||||
*)
|
||||
LOGE "Please enter the correct number [0-26]"
|
||||
LOGE "Please enter the correct number [0-27]"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue