mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-14 19:45:47 +00:00
Merge c9b14da955 into 169b216d7e
This commit is contained in:
commit
9be83ebfcc
41 changed files with 3523 additions and 281 deletions
|
|
@ -35,6 +35,7 @@ RUN apk add --no-cache --update \
|
||||||
|
|
||||||
COPY --from=builder /app/build/ /app/
|
COPY --from=builder /app/build/ /app/
|
||||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||||
|
COPY --from=builder /app/postgres-manager.sh /app/
|
||||||
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ RUN rm -f /etc/fail2ban/jail.d/alpine-ssh.conf \
|
||||||
|
|
||||||
RUN chmod +x \
|
RUN chmod +x \
|
||||||
/app/DockerEntrypoint.sh \
|
/app/DockerEntrypoint.sh \
|
||||||
|
/app/postgres-manager.sh \
|
||||||
/app/x-ui \
|
/app/x-ui \
|
||||||
/usr/bin/x-ui
|
/usr/bin/x-ui
|
||||||
|
|
||||||
|
|
|
||||||
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).
|
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||||
|
|
||||||
|
## Database Backends
|
||||||
|
|
||||||
|
3X-UI supports both `SQLite` and `PostgreSQL` as interchangeable backends. All application logic is written against [GORM](https://gorm.io/) — Go's database-agnostic ORM — so queries work identically on either engine. You can switch backends at any time through the panel UI without data loss.
|
||||||
|
|
||||||
|
### Choosing a Backend
|
||||||
|
|
||||||
|
| | SQLite | PostgreSQL |
|
||||||
|
|---|---|---|
|
||||||
|
| Setup | Zero config, file-based | Requires a running PG server |
|
||||||
|
| Best for | Single-node, low traffic | Multi-node, high concurrency |
|
||||||
|
| Backups | Portable + native file export | Portable export |
|
||||||
|
|
||||||
|
### Switching Backends (Panel UI)
|
||||||
|
|
||||||
|
1. Open **Settings → General → Database**.
|
||||||
|
2. Select a backend (`SQLite` or `PostgreSQL`) and fill in connection details.
|
||||||
|
3. Click **Test Connection** to verify.
|
||||||
|
4. Click **Switch Database** — the panel will:
|
||||||
|
- Save a portable backup of current data automatically.
|
||||||
|
- Migrate all data to the new backend.
|
||||||
|
- Restart itself.
|
||||||
|
|
||||||
|
> The target database must be empty before switching. Use **Test Connection** before switching to catch misconfigurations early.
|
||||||
|
|
||||||
|
### Local PostgreSQL (panel-managed)
|
||||||
|
|
||||||
|
When selecting **Local (panel-managed)** mode, the panel installs and manages PostgreSQL automatically (Linux, root only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The panel uses postgres-manager.sh internally.
|
||||||
|
# No manual PostgreSQL setup required.
|
||||||
|
```
|
||||||
|
|
||||||
|
### External PostgreSQL
|
||||||
|
|
||||||
|
Point the panel at any existing PostgreSQL 13+ server:
|
||||||
|
|
||||||
|
1. Create a dedicated database and user.
|
||||||
|
2. Enter the connection details in Settings → Database.
|
||||||
|
3. Use **Test Connection**, then **Switch Database**.
|
||||||
|
|
||||||
|
### Environment Variable Override
|
||||||
|
|
||||||
|
For Docker and infrastructure-as-code deployments, set these environment variables to control the backend without touching the UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XUI_DB_DRIVER=postgres # or: sqlite
|
||||||
|
XUI_DB_HOST=127.0.0.1
|
||||||
|
XUI_DB_PORT=5432
|
||||||
|
XUI_DB_NAME=x-ui
|
||||||
|
XUI_DB_USER=x-ui
|
||||||
|
XUI_DB_PASSWORD=change-me
|
||||||
|
XUI_DB_SSLMODE=disable # or: require, verify-ca, verify-full
|
||||||
|
XUI_DB_MODE=external # or: local
|
||||||
|
XUI_DB_PATH=/etc/x-ui/db/x-ui.db # SQLite only
|
||||||
|
```
|
||||||
|
|
||||||
|
When any `XUI_DB_*` variable is set, the Database section in the panel UI becomes read-only.
|
||||||
|
|
||||||
|
### Backup & Restore
|
||||||
|
|
||||||
|
| Format | Works with | When to use |
|
||||||
|
|---|---|---|
|
||||||
|
| **Portable** (`.xui-backup`) | SQLite + PostgreSQL | Switching backends, Telegram bot backups, long-term storage |
|
||||||
|
| **Native SQLite** (`.db`) | SQLite only | Quick raw file backup while on SQLite |
|
||||||
|
|
||||||
|
- The Telegram bot sends a **portable backup** automatically — this works regardless of which backend is active.
|
||||||
|
- Portable backups can be imported back on either SQLite or PostgreSQL.
|
||||||
|
- Legacy `.db` files from older 3x-ui versions can be imported even while PostgreSQL is active.
|
||||||
|
|
||||||
## A Special Thanks to
|
## A Special Thanks to
|
||||||
|
|
||||||
- [alireza0](https://github.com/alireza0/)
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,76 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||||
|
|
||||||
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
||||||
|
|
||||||
|
## Базы данных
|
||||||
|
|
||||||
|
3X-UI поддерживает `SQLite` и `PostgreSQL` как взаимозаменяемые бэкенды. Вся логика приложения написана через [GORM](https://gorm.io/) — ORM для Go с поддержкой множества СУБД, — поэтому запросы работают одинаково на любом движке. Переключиться между бэкендами можно в любой момент через UI панели без потери данных.
|
||||||
|
|
||||||
|
### Выбор бэкенда
|
||||||
|
|
||||||
|
| | SQLite | PostgreSQL |
|
||||||
|
|---|---|---|
|
||||||
|
| Настройка | Ноль конфигурации, файловая БД | Требует работающий PG-сервер |
|
||||||
|
| Подходит для | Один узел, малый трафик | Несколько узлов, высокая нагрузка |
|
||||||
|
| Резервные копии | Портативный + нативный файл | Только портативный |
|
||||||
|
|
||||||
|
### Переключение бэкенда через UI
|
||||||
|
|
||||||
|
1. Откройте **Настройки → Панель → База данных**.
|
||||||
|
2. Выберите бэкенд (`SQLite` или `PostgreSQL`) и заполните параметры подключения.
|
||||||
|
3. Нажмите **Проверить подключение**.
|
||||||
|
4. Нажмите **Переключить базу данных** — панель автоматически:
|
||||||
|
- Сохранит портативную резервную копию текущих данных.
|
||||||
|
- Мигрирует все данные в новый бэкенд.
|
||||||
|
- Перезапустится.
|
||||||
|
|
||||||
|
> Целевая база данных должна быть пустой перед переключением. Всегда используйте **Проверить подключение** перед переключением.
|
||||||
|
|
||||||
|
### Локальный PostgreSQL (управляется панелью)
|
||||||
|
|
||||||
|
Режим **Локальный (управляется панелью)** — панель устанавливает и настраивает PostgreSQL автоматически (только Linux, root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Панель использует postgres-manager.sh внутри себя.
|
||||||
|
# Ручная настройка PostgreSQL не требуется.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Внешний PostgreSQL
|
||||||
|
|
||||||
|
Подключение к существующему серверу PostgreSQL 13+:
|
||||||
|
|
||||||
|
1. Создайте отдельную БД и пользователя.
|
||||||
|
2. Введите параметры подключения в Настройки → База данных.
|
||||||
|
3. Нажмите **Проверить подключение**, затем **Переключить базу данных**.
|
||||||
|
|
||||||
|
### Переопределение через переменные окружения
|
||||||
|
|
||||||
|
Для Docker и IaC-деплоев можно управлять бэкендом через переменные окружения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XUI_DB_DRIVER=postgres # или: sqlite
|
||||||
|
XUI_DB_HOST=127.0.0.1
|
||||||
|
XUI_DB_PORT=5432
|
||||||
|
XUI_DB_NAME=x-ui
|
||||||
|
XUI_DB_USER=x-ui
|
||||||
|
XUI_DB_PASSWORD=change-me
|
||||||
|
XUI_DB_SSLMODE=disable # или: require, verify-ca, verify-full
|
||||||
|
XUI_DB_MODE=external # или: local
|
||||||
|
XUI_DB_PATH=/etc/x-ui/db/x-ui.db # только для SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
Если задана любая переменная `XUI_DB_*`, раздел «База данных» в UI становится read-only.
|
||||||
|
|
||||||
|
### Резервное копирование и восстановление
|
||||||
|
|
||||||
|
| Формат | Совместим с | Когда использовать |
|
||||||
|
|---|---|---|
|
||||||
|
| **Портативный** (`.xui-backup`) | SQLite + PostgreSQL | Переключение бэкендов, резервные копии через Telegram-бот, долгосрочное хранение |
|
||||||
|
| **Нативный SQLite** (`.db`) | Только SQLite | Быстрый файловый бэкап при активном SQLite |
|
||||||
|
|
||||||
|
- Telegram-бот отправляет **портативную резервную копию** автоматически — это работает независимо от активного бэкенда.
|
||||||
|
- Портативные копии можно импортировать на SQLite и на PostgreSQL.
|
||||||
|
- Устаревшие `.db`-файлы от старых версий 3x-ui можно импортировать даже при активном PostgreSQL.
|
||||||
|
|
||||||
## Особая благодарность
|
## Особая благодарность
|
||||||
|
|
||||||
- [alireza0](https://github.com/alireza0/)
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
|
||||||
251
config/database.go
Normal file
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)
|
||||||
|
}
|
||||||
340
database/db.go
340
database/db.go
|
|
@ -1,5 +1,5 @@
|
||||||
// Package database provides database initialization, migration, and management utilities
|
// Package database provides database initialization, migration, and management utilities
|
||||||
// for the 3x-ui panel using GORM with SQLite.
|
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -8,28 +8,119 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
gormlogger "gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var db *gorm.DB
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
dbConfig *config.DatabaseConfig
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultUsername = "admin"
|
defaultUsername = "admin"
|
||||||
defaultPassword = "admin"
|
defaultPassword = "admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initModels() error {
|
func gormConfig() *gorm.Config {
|
||||||
|
var loggerImpl gormlogger.Interface
|
||||||
|
if config.IsDebug() {
|
||||||
|
loggerImpl = gormlogger.Default
|
||||||
|
} else {
|
||||||
|
loggerImpl = gormlogger.Discard
|
||||||
|
}
|
||||||
|
return &gorm.Config{Logger: loggerImpl}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSQLiteDatabase(path string) (*gorm.DB, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, fs.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gorm.Open(sqlite.Open(path), gormConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPostgresDSN(cfg *config.DatabaseConfig) string {
|
||||||
|
user := url.User(cfg.Postgres.User)
|
||||||
|
if cfg.Postgres.Password != "" {
|
||||||
|
user = url.UserPassword(cfg.Postgres.User, cfg.Postgres.Password)
|
||||||
|
}
|
||||||
|
authority := net.JoinHostPort(cfg.Postgres.Host, strconv.Itoa(cfg.Postgres.Port))
|
||||||
|
u := &url.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
User: user,
|
||||||
|
Host: authority,
|
||||||
|
Path: cfg.Postgres.DBName,
|
||||||
|
}
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("sslmode", cfg.Postgres.SSLMode)
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openPostgresDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||||
|
gormCfg := gormConfig()
|
||||||
|
gormCfg.PrepareStmt = true
|
||||||
|
conn, err := gorm.Open(postgres.Open(buildPostgresDSN(cfg)), gormCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB, err := conn.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxIdleConns(5)
|
||||||
|
sqlDB.SetMaxOpenConns(25)
|
||||||
|
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDatabase opens a database connection from the provided runtime configuration.
|
||||||
|
func OpenDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = config.DefaultDatabaseConfig()
|
||||||
|
}
|
||||||
|
cfg = cfg.Clone().Normalize()
|
||||||
|
|
||||||
|
switch cfg.Driver {
|
||||||
|
case config.DatabaseDriverSQLite:
|
||||||
|
return openSQLiteDatabase(cfg.SQLite.Path)
|
||||||
|
case config.DatabaseDriverPostgres:
|
||||||
|
return openPostgresDatabase(cfg)
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported database driver: " + cfg.Driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseConnection closes a standalone gorm connection.
|
||||||
|
func CloseConnection(conn *gorm.DB) error {
|
||||||
|
if conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sqlDB, err := conn.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initModels(conn *gorm.DB) error {
|
||||||
models := []any{
|
models := []any{
|
||||||
&model.User{},
|
&model.User{},
|
||||||
&model.Inbound{},
|
&model.Inbound{},
|
||||||
|
|
@ -39,8 +130,8 @@ func initModels() error {
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, item := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := conn.AutoMigrate(item); err != nil {
|
||||||
log.Printf("Error auto migrating model: %v", err)
|
log.Printf("Error auto migrating model: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -48,16 +139,26 @@ func initModels() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initUser creates a default admin user if the users table is empty.
|
func isTableEmpty(conn *gorm.DB, tableName string) (bool, error) {
|
||||||
func initUser() error {
|
if !conn.Migrator().HasTable(tableName) {
|
||||||
empty, err := isTableEmpty("users")
|
return true, nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
err := conn.Table(tableName).Count(&count).Error
|
||||||
|
return count == 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func initUser(conn *gorm.DB) error {
|
||||||
|
empty, err := isTableEmpty(conn, "users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking if users table is empty: %v", err)
|
log.Printf("Error checking if users table is empty: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if empty {
|
if !empty {
|
||||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error hashing default password: %v", err)
|
log.Printf("Error hashing default password: %v", err)
|
||||||
return err
|
return err
|
||||||
|
|
@ -67,14 +168,11 @@ func initUser() error {
|
||||||
Username: defaultUsername,
|
Username: defaultUsername,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
}
|
}
|
||||||
return db.Create(user).Error
|
return conn.Create(user).Error
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
|
func runSeeders(conn *gorm.DB, isUsersEmpty bool) error {
|
||||||
func runSeeders(isUsersEmpty bool) error {
|
empty, err := isTableEmpty(conn, "history_of_seeders")
|
||||||
empty, err := isTableEmpty("history_of_seeders")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking if users table is empty: %v", err)
|
log.Printf("Error checking if users table is empty: %v", err)
|
||||||
return err
|
return err
|
||||||
|
|
@ -84,97 +182,184 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
hashSeeder := &model.HistoryOfSeeders{
|
hashSeeder := &model.HistoryOfSeeders{
|
||||||
SeederName: "UserPasswordHash",
|
SeederName: "UserPasswordHash",
|
||||||
}
|
}
|
||||||
return db.Create(hashSeeder).Error
|
return conn.Create(hashSeeder).Error
|
||||||
} else {
|
}
|
||||||
|
|
||||||
var seedersHistory []string
|
var seedersHistory []string
|
||||||
db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory)
|
if err := conn.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||||
|
return err
|
||||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
}
|
||||||
var users []model.User
|
|
||||||
db.Find(&users)
|
if slices.Contains(seedersHistory, "UserPasswordHash") || isUsersEmpty {
|
||||||
|
return nil
|
||||||
for _, user := range users {
|
}
|
||||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
|
||||||
if err != nil {
|
var users []model.User
|
||||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
if err := conn.Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
hashedPassword, hashErr := crypto.HashPasswordAsBcrypt(user.Password)
|
||||||
|
if hashErr != nil {
|
||||||
|
log.Printf("Error hashing password for user '%s': %v", user.Username, hashErr)
|
||||||
|
return hashErr
|
||||||
|
}
|
||||||
|
if err := conn.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
db.Model(&user).Update("password", hashedPassword)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hashSeeder := &model.HistoryOfSeeders{
|
hashSeeder := &model.HistoryOfSeeders{
|
||||||
SeederName: "UserPasswordHash",
|
SeederName: "UserPasswordHash",
|
||||||
}
|
}
|
||||||
return db.Create(hashSeeder).Error
|
return conn.Create(hashSeeder).Error
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MigrateModels migrates the database schema for all panel models.
|
||||||
|
func MigrateModels(conn *gorm.DB) error {
|
||||||
|
return initModels(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareDatabase migrates the schema and optionally seeds the database.
|
||||||
|
func PrepareDatabase(conn *gorm.DB, seed bool) error {
|
||||||
|
if err := initModels(conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !seed {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTableEmpty returns true if the named table contains zero rows.
|
isUsersEmpty, err := isTableEmpty(conn, "users")
|
||||||
func isTableEmpty(tableName string) (bool, error) {
|
if err != nil {
|
||||||
var count int64
|
return err
|
||||||
err := db.Table(tableName).Count(&count).Error
|
}
|
||||||
return count == 0, err
|
|
||||||
|
if err := initUser(conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runSeeders(conn, isUsersEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection verifies that the provided database configuration is reachable.
|
||||||
|
func TestConnection(cfg *config.DatabaseConfig) error {
|
||||||
|
conn, err := OpenDatabase(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer CloseConnection(conn)
|
||||||
|
|
||||||
|
sqlDB, err := conn.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||||
func InitDB(dbPath string) error {
|
func InitDB() error {
|
||||||
dir := path.Dir(dbPath)
|
cfg, err := config.LoadDatabaseConfig()
|
||||||
err := os.MkdirAll(dir, fs.ModePerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return InitDBWithConfig(cfg)
|
||||||
var gormLogger logger.Interface
|
|
||||||
|
|
||||||
if config.IsDebug() {
|
|
||||||
gormLogger = logger.Default
|
|
||||||
} else {
|
|
||||||
gormLogger = logger.Discard
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &gorm.Config{
|
// InitDBWithConfig sets up the database using an explicit runtime config.
|
||||||
Logger: gormLogger,
|
func InitDBWithConfig(cfg *config.DatabaseConfig) error {
|
||||||
}
|
conn, err := OpenDatabase(cfg)
|
||||||
db, err = gorm.Open(sqlite.Open(dbPath), c)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := PrepareDatabase(conn, true); err != nil {
|
||||||
if err := initModels(); err != nil {
|
_ = CloseConnection(conn)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
isUsersEmpty, err := isTableEmpty("users")
|
if err := CloseDB(); err != nil {
|
||||||
if err != nil {
|
_ = CloseConnection(conn)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := initUser(); err != nil {
|
db = conn
|
||||||
return err
|
dbConfig = cfg.Clone().Normalize()
|
||||||
}
|
|
||||||
return runSeeders(isUsersEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseDB closes the database connection if it exists.
|
|
||||||
func CloseDB() error {
|
|
||||||
if db != nil {
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return sqlDB.Close()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseDB closes the global database connection if it exists.
|
||||||
|
func CloseDB() error {
|
||||||
|
if db == nil {
|
||||||
|
dbConfig = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := CloseConnection(db)
|
||||||
|
db = nil
|
||||||
|
dbConfig = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetDB returns the global GORM database instance.
|
// GetDB returns the global GORM database instance.
|
||||||
func GetDB() *gorm.DB {
|
func GetDB() *gorm.DB {
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDBConfig returns a copy of the active database runtime configuration.
|
||||||
|
func GetDBConfig() *config.DatabaseConfig {
|
||||||
|
if dbConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dbConfig.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDriver returns the active GORM dialector name.
|
||||||
|
func GetDriver() string {
|
||||||
|
if db != nil && db.Dialector != nil {
|
||||||
|
return db.Dialector.Name()
|
||||||
|
}
|
||||||
|
if dbConfig != nil {
|
||||||
|
switch dbConfig.Driver {
|
||||||
|
case config.DatabaseDriverPostgres:
|
||||||
|
return "postgres"
|
||||||
|
case config.DatabaseDriverSQLite:
|
||||||
|
return "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSQLite reports whether the active database uses SQLite.
|
||||||
|
func IsSQLite() bool {
|
||||||
|
return GetDriver() == "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPostgres reports whether the active database uses PostgreSQL.
|
||||||
|
func IsPostgres() bool {
|
||||||
|
return GetDriver() == "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDatabaseEmpty reports whether the provided database contains any application rows.
|
||||||
|
func IsDatabaseEmpty(conn *gorm.DB) (bool, error) {
|
||||||
|
tables := []string{
|
||||||
|
"users",
|
||||||
|
"inbounds",
|
||||||
|
"outbound_traffics",
|
||||||
|
"settings",
|
||||||
|
"inbound_client_ips",
|
||||||
|
"client_traffics",
|
||||||
|
"history_of_seeders",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
empty, err := isTableEmpty(conn, table)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !empty {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsNotFound checks if the given error is a GORM record not found error.
|
// IsNotFound checks if the given error is a GORM record not found error.
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
return err == gorm.ErrRecordNotFound
|
return err == gorm.ErrRecordNotFound
|
||||||
|
|
@ -193,22 +378,20 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||||
|
|
||||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||||
func Checkpoint() error {
|
func Checkpoint() error {
|
||||||
// Update WAL
|
if !IsSQLite() || db == nil {
|
||||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||||
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
||||||
// It does not mutate global state or run migrations.
|
// It does not mutate global state or run migrations.
|
||||||
func ValidateSQLiteDB(dbPath string) error {
|
func ValidateSQLiteDB(dbPath string) error {
|
||||||
if _, err := os.Stat(dbPath); err != nil { // file must exist
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.Discard})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +400,7 @@ func ValidateSQLiteDB(dbPath string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer sqlDB.Close()
|
defer sqlDB.Close()
|
||||||
|
|
||||||
var res string
|
var res string
|
||||||
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
111
database/manager.go
Normal file
111
database/manager.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func configsEqual(a, b *config.DatabaseConfig) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a = a.Clone().Normalize()
|
||||||
|
b = b.Clone().Normalize()
|
||||||
|
if a.Driver != b.Driver {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Driver == config.DatabaseDriverSQLite {
|
||||||
|
return a.SQLite.Path == b.SQLite.Path
|
||||||
|
}
|
||||||
|
return a.Postgres.Mode == b.Postgres.Mode &&
|
||||||
|
a.Postgres.Host == b.Postgres.Host &&
|
||||||
|
a.Postgres.Port == b.Postgres.Port &&
|
||||||
|
a.Postgres.DBName == b.Postgres.DBName &&
|
||||||
|
a.Postgres.User == b.Postgres.User &&
|
||||||
|
a.Postgres.Password == b.Postgres.Password &&
|
||||||
|
a.Postgres.SSLMode == b.Postgres.SSLMode &&
|
||||||
|
a.Postgres.ManagedLocally == b.Postgres.ManagedLocally
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSnapshotFromConfig(cfg *config.DatabaseConfig) (*BackupSnapshot, error) {
|
||||||
|
cfg = cfg.Clone().Normalize()
|
||||||
|
if cfg.UsesSQLite() {
|
||||||
|
if _, err := os.Stat(cfg.SQLite.Path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return newBackupSnapshot(cfg.Driver), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := OpenDatabase(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer CloseConnection(conn)
|
||||||
|
if err := MigrateModels(conn); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ExportSnapshot(conn, cfg.Driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSwitchBackup(snapshot *BackupSnapshot, prefix string) error {
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := EncodePortableBackup(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("%s-%s.xui-backup", prefix, time.Now().UTC().Format("20060102-150405"))
|
||||||
|
return SavePortableBackup(filepath.Join(config.GetBackupFolderPath(), name), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchDatabase migrates panel data into a new backend and writes the new runtime configuration.
|
||||||
|
func SwitchDatabase(target *config.DatabaseConfig) error {
|
||||||
|
if target == nil {
|
||||||
|
return errors.New("target database configuration is nil")
|
||||||
|
}
|
||||||
|
target = target.Clone().Normalize()
|
||||||
|
|
||||||
|
currentCfg, err := config.LoadDatabaseConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if configsEqual(currentCfg, target) {
|
||||||
|
return config.SaveDatabaseConfig(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSnapshot, err := loadSnapshotFromConfig(currentCfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveSwitchBackup(sourceSnapshot, "switch"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := TestConnection(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetConn, err := OpenDatabase(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer CloseConnection(targetConn)
|
||||||
|
|
||||||
|
if err := MigrateModels(targetConn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ApplySnapshot(targetConn, sourceSnapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.SaveDatabaseConfig(target)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,31 @@ services:
|
||||||
environment:
|
environment:
|
||||||
XRAY_VMESS_AEAD_FORCED: "false"
|
XRAY_VMESS_AEAD_FORCED: "false"
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
|
# XUI_DB_DRIVER: "postgres"
|
||||||
|
# XUI_DB_MODE: "external"
|
||||||
|
# XUI_DB_HOST: "127.0.0.1"
|
||||||
|
# XUI_DB_PORT: "5432"
|
||||||
|
# XUI_DB_NAME: "xui"
|
||||||
|
# XUI_DB_USER: "xui"
|
||||||
|
# XUI_DB_PASSWORD: "change-me"
|
||||||
|
# XUI_DB_SSLMODE: "disable"
|
||||||
tty: true
|
tty: true
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: 3xui_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: xui
|
||||||
|
POSTGRES_USER: xui
|
||||||
|
POSTGRES_PASSWORD: change-me
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -26,6 +26,7 @@ require (
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
golang.org/x/text v0.35.0
|
golang.org/x/text v0.35.0
|
||||||
google.golang.org/grpc v1.80.0
|
google.golang.org/grpc v1.80.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
@ -53,6 +54,10 @@ require (
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // indirect
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
|
|
||||||
11
go.sum
11
go.sum
|
|
@ -85,6 +85,14 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
|
@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
|
@ -271,6 +280,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
|
|
||||||
117
install.sh
117
install.sh
|
|
@ -639,7 +639,121 @@ prompt_and_setup_ssl() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_database_backend() {
|
||||||
|
local db_config_path="/etc/x-ui/database.json"
|
||||||
|
if [[ -f "${db_config_path}" ]] || compgen -G "/etc/x-ui/*.db" >/dev/null; then
|
||||||
|
echo -e "${green}Existing database configuration detected. Keeping current backend.${plain}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local db_choice=""
|
||||||
|
echo ""
|
||||||
|
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||||
|
echo -e "${green} Database Backend Setup ${plain}"
|
||||||
|
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||||
|
echo -e "${green}1.${plain} SQLite"
|
||||||
|
echo -e "${green}2.${plain} PostgreSQL"
|
||||||
|
read -rp "Choose database backend (default 1): " db_choice
|
||||||
|
db_choice="${db_choice// /}"
|
||||||
|
|
||||||
|
if [[ "${db_choice}" != "2" ]]; then
|
||||||
|
echo -e "${green}Using SQLite as the panel database backend.${plain}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pg_mode=""
|
||||||
|
local pg_host="127.0.0.1"
|
||||||
|
local pg_port="5432"
|
||||||
|
local pg_db="xui"
|
||||||
|
local pg_user="xui"
|
||||||
|
local pg_password=""
|
||||||
|
local pg_sslmode="disable"
|
||||||
|
|
||||||
|
echo -e "${green}1.${plain} Local PostgreSQL"
|
||||||
|
echo -e "${green}2.${plain} External PostgreSQL"
|
||||||
|
read -rp "Choose PostgreSQL mode (default 1): " pg_mode
|
||||||
|
pg_mode="${pg_mode// /}"
|
||||||
|
|
||||||
|
if [[ "${pg_mode}" != "2" ]]; then
|
||||||
|
if [[ ! -x "${xui_folder}/postgres-manager.sh" ]]; then
|
||||||
|
echo -e "${red}postgres-manager.sh is missing from the installation package.${plain}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pg_status
|
||||||
|
pg_status=$(bash "${xui_folder}/postgres-manager.sh" status 2>/dev/null || true)
|
||||||
|
if [[ "${pg_status}" != *"installed=true"* ]]; then
|
||||||
|
local install_pg=""
|
||||||
|
read -rp "PostgreSQL is not installed. Install automatically now? [Y/n]: " install_pg
|
||||||
|
if [[ -z "${install_pg}" || "${install_pg}" == "y" || "${install_pg}" == "Y" ]]; then
|
||||||
|
bash "${xui_folder}/postgres-manager.sh" install
|
||||||
|
else
|
||||||
|
echo -e "${red}PostgreSQL installation was declined. Aborting PostgreSQL setup.${plain}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -rp "PostgreSQL host [127.0.0.1]: " pg_host
|
||||||
|
pg_host="${pg_host:-127.0.0.1}"
|
||||||
|
read -rp "PostgreSQL port [5432]: " pg_port
|
||||||
|
pg_port="${pg_port:-5432}"
|
||||||
|
read -rp "PostgreSQL database name [xui]: " pg_db
|
||||||
|
pg_db="${pg_db:-xui}"
|
||||||
|
read -rp "PostgreSQL username [xui]: " pg_user
|
||||||
|
pg_user="${pg_user:-xui}"
|
||||||
|
read -rp "PostgreSQL password [random]: " pg_password
|
||||||
|
[[ -z "${pg_password}" ]] && pg_password=$(gen_random_string 18)
|
||||||
|
|
||||||
|
${xui_folder}/x-ui database switch \
|
||||||
|
-driver postgres \
|
||||||
|
-postgres-mode local \
|
||||||
|
-postgres-host "${pg_host}" \
|
||||||
|
-postgres-port "${pg_port}" \
|
||||||
|
-postgres-db "${pg_db}" \
|
||||||
|
-postgres-user "${pg_user}" \
|
||||||
|
-postgres-password "${pg_password}" \
|
||||||
|
-postgres-local true
|
||||||
|
else
|
||||||
|
read -rp "External PostgreSQL host: " pg_host
|
||||||
|
if [[ -z "${pg_host}" ]]; then
|
||||||
|
echo -e "${red}PostgreSQL host is required.${plain}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
read -rp "External PostgreSQL port [5432]: " pg_port
|
||||||
|
pg_port="${pg_port:-5432}"
|
||||||
|
read -rp "Database name [xui]: " pg_db
|
||||||
|
pg_db="${pg_db:-xui}"
|
||||||
|
read -rp "Username [xui]: " pg_user
|
||||||
|
pg_user="${pg_user:-xui}"
|
||||||
|
read -rp "Password: " pg_password
|
||||||
|
if [[ -z "${pg_password}" ]]; then
|
||||||
|
echo -e "${red}PostgreSQL password is required for external connections.${plain}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
read -rp "SSL mode [disable]: " pg_sslmode
|
||||||
|
pg_sslmode="${pg_sslmode:-disable}"
|
||||||
|
|
||||||
|
${xui_folder}/x-ui database switch \
|
||||||
|
-driver postgres \
|
||||||
|
-postgres-mode external \
|
||||||
|
-postgres-host "${pg_host}" \
|
||||||
|
-postgres-port "${pg_port}" \
|
||||||
|
-postgres-db "${pg_db}" \
|
||||||
|
-postgres-user "${pg_user}" \
|
||||||
|
-postgres-password "${pg_password}" \
|
||||||
|
-postgres-sslmode "${pg_sslmode}" \
|
||||||
|
-postgres-local false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $? -ne 0 ]]; then
|
||||||
|
echo -e "${red}Failed to configure PostgreSQL backend.${plain}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${green}Database backend configured successfully.${plain}"
|
||||||
|
}
|
||||||
|
|
||||||
config_after_install() {
|
config_after_install() {
|
||||||
|
configure_database_backend
|
||||||
local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
|
local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
|
||||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
|
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
|
||||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||||
|
|
@ -822,6 +936,9 @@ install_x-ui() {
|
||||||
cd x-ui
|
cd x-ui
|
||||||
chmod +x x-ui
|
chmod +x x-ui
|
||||||
chmod +x x-ui.sh
|
chmod +x x-ui.sh
|
||||||
|
if [[ -f postgres-manager.sh ]]; then
|
||||||
|
chmod +x postgres-manager.sh
|
||||||
|
fi
|
||||||
|
|
||||||
# Check the system's architecture and rename the file accordingly
|
# Check the system's architecture and rename the file accordingly
|
||||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||||
|
|
|
||||||
218
main.go
218
main.go
|
|
@ -3,11 +3,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
_ "unsafe"
|
_ "unsafe"
|
||||||
|
|
||||||
|
|
@ -18,6 +20,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/sys"
|
"github.com/mhsanaei/3x-ui/v2/util/sys"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web"
|
"github.com/mhsanaei/3x-ui/v2/web"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
||||||
|
|
@ -46,7 +49,7 @@ func runWebServer() {
|
||||||
|
|
||||||
godotenv.Load()
|
godotenv.Load()
|
||||||
|
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +134,7 @@ func runWebServer() {
|
||||||
|
|
||||||
// resetSetting resets all panel settings to their default values.
|
// resetSetting resets all panel settings to their default values.
|
||||||
func resetSetting() {
|
func resetSetting() {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Failed to initialize database:", err)
|
fmt.Println("Failed to initialize database:", err)
|
||||||
return
|
return
|
||||||
|
|
@ -154,11 +157,23 @@ func showSetting(show bool) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("get current port failed, error info:", err)
|
fmt.Println("get current port failed, error info:", err)
|
||||||
}
|
}
|
||||||
|
subPort, err := settingService.GetSubPort()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("get current sub port failed, error info:", err)
|
||||||
|
}
|
||||||
|
|
||||||
webBasePath, err := settingService.GetBasePath()
|
webBasePath, err := settingService.GetBasePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("get webBasePath failed, error info:", err)
|
fmt.Println("get webBasePath failed, error info:", err)
|
||||||
}
|
}
|
||||||
|
listen, err := settingService.GetListen()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("get current listen failed, error info:", err)
|
||||||
|
}
|
||||||
|
subListen, err := settingService.GetSubListen()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("get current sub listen failed, error info:", err)
|
||||||
|
}
|
||||||
|
|
||||||
certFile, err := settingService.GetCertFile()
|
certFile, err := settingService.GetCertFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -192,6 +207,9 @@ func showSetting(show bool) {
|
||||||
|
|
||||||
fmt.Println("hasDefaultCredential:", hasDefaultCredential)
|
fmt.Println("hasDefaultCredential:", hasDefaultCredential)
|
||||||
fmt.Println("port:", port)
|
fmt.Println("port:", port)
|
||||||
|
fmt.Println("listen:", listen)
|
||||||
|
fmt.Println("subPort:", subPort)
|
||||||
|
fmt.Println("subListen:", subListen)
|
||||||
fmt.Println("webBasePath:", webBasePath)
|
fmt.Println("webBasePath:", webBasePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +236,7 @@ func updateTgbotEnableSts(status bool) {
|
||||||
|
|
||||||
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
||||||
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error initializing database:", err)
|
fmt.Println("Error initializing database:", err)
|
||||||
return
|
return
|
||||||
|
|
@ -254,9 +272,9 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
// updateSetting updates various panel settings including ports, credentials, base path, listen IPs, and two-factor authentication.
|
||||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
|
func updateSetting(port int, subPort int, username string, password string, webBasePath string, listenIP string, subListenIP string, resetTwoFactor bool) {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Database initialization failed:", err)
|
fmt.Println("Database initialization failed:", err)
|
||||||
return
|
return
|
||||||
|
|
@ -308,14 +326,32 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Failed to set listen IP:", err)
|
fmt.Println("Failed to set listen IP:", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("listen %v set successfully", listenIP)
|
fmt.Printf("listen %v set successfully\n", listenIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subPort > 0 {
|
||||||
|
err := settingService.SetSubPort(subPort)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to set sub port:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Sub port set successfully: %v\n", subPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subListenIP != "" {
|
||||||
|
err := settingService.SetSubListen(subListenIP)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to set sub listen IP:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("sub listen %v set successfully\n", subListenIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCert updates the SSL certificate files for the panel.
|
// updateCert updates the SSL certificate files for the panel.
|
||||||
func updateCert(publicKey string, privateKey string) {
|
func updateCert(publicKey string, privateKey string) {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
|
|
@ -392,7 +428,7 @@ func GetListenIP(getListen bool) {
|
||||||
func migrateDb() {
|
func migrateDb() {
|
||||||
inboundService := service.InboundService{}
|
inboundService := service.InboundService{}
|
||||||
|
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +437,161 @@ func migrateDb() {
|
||||||
fmt.Println("Migration done!")
|
fmt.Println("Migration done!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultDatabaseSetting() *entity.DatabaseSetting {
|
||||||
|
databaseService := &service.DatabaseService{}
|
||||||
|
setting, err := databaseService.GetSetting()
|
||||||
|
if err == nil && setting != nil {
|
||||||
|
return setting
|
||||||
|
}
|
||||||
|
return entity.DatabaseSettingFromConfig(config.DefaultDatabaseConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDatabaseFlags(fs *flag.FlagSet, setting *entity.DatabaseSetting) {
|
||||||
|
fs.StringVar(&setting.Driver, "driver", setting.Driver, "Database driver: sqlite or postgres")
|
||||||
|
fs.StringVar(&setting.SQLitePath, "sqlite-path", setting.SQLitePath, "SQLite database path")
|
||||||
|
fs.StringVar(&setting.PostgresMode, "postgres-mode", setting.PostgresMode, "PostgreSQL mode: local or external")
|
||||||
|
fs.StringVar(&setting.PostgresHost, "postgres-host", setting.PostgresHost, "PostgreSQL host")
|
||||||
|
fs.IntVar(&setting.PostgresPort, "postgres-port", setting.PostgresPort, "PostgreSQL port")
|
||||||
|
fs.StringVar(&setting.PostgresDBName, "postgres-db", setting.PostgresDBName, "PostgreSQL database name")
|
||||||
|
fs.StringVar(&setting.PostgresUser, "postgres-user", setting.PostgresUser, "PostgreSQL user")
|
||||||
|
fs.StringVar(&setting.PostgresPassword, "postgres-password", "", "PostgreSQL password")
|
||||||
|
fs.StringVar(&setting.PostgresSSLMode, "postgres-sslmode", setting.PostgresSSLMode, "PostgreSQL sslmode")
|
||||||
|
fs.BoolVar(&setting.ManagedLocally, "postgres-local", setting.ManagedLocally, "Treat PostgreSQL as locally managed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExportFile(outputPath string, defaultName string, data []byte) (string, error) {
|
||||||
|
targetPath := outputPath
|
||||||
|
if targetPath == "" {
|
||||||
|
targetPath = defaultName
|
||||||
|
} else if info, err := os.Stat(targetPath); err == nil && info.IsDir() {
|
||||||
|
targetPath = filepath.Join(targetPath, defaultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(targetPath, data, 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return targetPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDatabaseCommand(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Println("Usage:")
|
||||||
|
fmt.Println(" x-ui database show")
|
||||||
|
fmt.Println(" x-ui database test [database flags]")
|
||||||
|
fmt.Println(" x-ui database switch [database flags]")
|
||||||
|
fmt.Println(" x-ui database export -type portable|sqlite [-out path]")
|
||||||
|
fmt.Println(" x-ui database import -file backup.xui-backup")
|
||||||
|
fmt.Println(" x-ui database install-postgres")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseService := service.DatabaseService{}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "show":
|
||||||
|
setting, err := databaseService.GetSetting()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to load database settings:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := json.MarshalIndent(setting, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to serialize database settings:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(string(contents))
|
||||||
|
case "test":
|
||||||
|
setting := defaultDatabaseSetting()
|
||||||
|
testCmd := flag.NewFlagSet("database test", flag.ExitOnError)
|
||||||
|
addDatabaseFlags(testCmd, setting)
|
||||||
|
_ = testCmd.Parse(args[1:])
|
||||||
|
if err := databaseService.TestSetting(setting); err != nil {
|
||||||
|
fmt.Println("Database connection test failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Database connection test succeeded.")
|
||||||
|
case "switch":
|
||||||
|
setting := defaultDatabaseSetting()
|
||||||
|
switchCmd := flag.NewFlagSet("database switch", flag.ExitOnError)
|
||||||
|
addDatabaseFlags(switchCmd, setting)
|
||||||
|
_ = switchCmd.Parse(args[1:])
|
||||||
|
if err := databaseService.SwitchDatabase(setting); err != nil {
|
||||||
|
fmt.Println("Database switch failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Database configuration updated. Restart the panel service to apply changes.")
|
||||||
|
case "export":
|
||||||
|
exportCmd := flag.NewFlagSet("database export", flag.ExitOnError)
|
||||||
|
exportType := exportCmd.String("type", "portable", "Export type: portable or sqlite")
|
||||||
|
outputPath := exportCmd.String("out", "", "Output path or directory")
|
||||||
|
_ = exportCmd.Parse(args[1:])
|
||||||
|
|
||||||
|
if err := database.InitDB(); err != nil {
|
||||||
|
fmt.Println("Failed to initialize database:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
data []byte
|
||||||
|
filename string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch *exportType {
|
||||||
|
case "sqlite":
|
||||||
|
data, filename, err = databaseService.ExportNativeSQLite()
|
||||||
|
default:
|
||||||
|
data, filename, err = databaseService.ExportPortableBackup()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Export failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath, err := writeExportFile(*outputPath, filename, data)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to write backup:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Backup exported to:", targetPath)
|
||||||
|
case "import":
|
||||||
|
importCmd := flag.NewFlagSet("database import", flag.ExitOnError)
|
||||||
|
backupFile := importCmd.String("file", "", "Path to .xui-backup or legacy SQLite .db file")
|
||||||
|
_ = importCmd.Parse(args[1:])
|
||||||
|
if *backupFile == "" {
|
||||||
|
fmt.Println("Import requires -file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.InitDB(); err != nil {
|
||||||
|
fmt.Println("Failed to initialize database:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(*backupFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to open backup file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
backupType, err := databaseService.ImportBackup(file)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Import failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Import completed using", backupType, "backup. Restart the panel service to apply changes.")
|
||||||
|
case "install-postgres":
|
||||||
|
output, err := databaseService.InstallLocalPostgres()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("PostgreSQL installation failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(output)
|
||||||
|
default:
|
||||||
|
fmt.Println("Unknown database subcommand:", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// main is the entry point of the 3x-ui application.
|
// main is the entry point of the 3x-ui application.
|
||||||
// It parses command-line arguments to run the web server, migrate database, or update settings.
|
// It parses command-line arguments to run the web server, migrate database, or update settings.
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -416,10 +607,12 @@ func main() {
|
||||||
|
|
||||||
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
||||||
var port int
|
var port int
|
||||||
|
var subPort int
|
||||||
var username string
|
var username string
|
||||||
var password string
|
var password string
|
||||||
var webBasePath string
|
var webBasePath string
|
||||||
var listenIP string
|
var listenIP string
|
||||||
|
var subListenIP string
|
||||||
var getListen bool
|
var getListen bool
|
||||||
var webCertFile string
|
var webCertFile string
|
||||||
var webKeyFile string
|
var webKeyFile string
|
||||||
|
|
@ -434,10 +627,12 @@ func main() {
|
||||||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||||
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
||||||
|
settingCmd.IntVar(&subPort, "subPort", 0, "Set subscription port number")
|
||||||
settingCmd.StringVar(&username, "username", "", "Set login username")
|
settingCmd.StringVar(&username, "username", "", "Set login username")
|
||||||
settingCmd.StringVar(&password, "password", "", "Set login password")
|
settingCmd.StringVar(&password, "password", "", "Set login password")
|
||||||
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
||||||
settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP")
|
settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP")
|
||||||
|
settingCmd.StringVar(&subListenIP, "subListenIP", "", "set subscription listenIP IP")
|
||||||
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
||||||
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
||||||
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
||||||
|
|
@ -456,6 +651,7 @@ func main() {
|
||||||
fmt.Println(" run run web panel")
|
fmt.Println(" run run web panel")
|
||||||
fmt.Println(" migrate migrate form other/old x-ui")
|
fmt.Println(" migrate migrate form other/old x-ui")
|
||||||
fmt.Println(" setting set settings")
|
fmt.Println(" setting set settings")
|
||||||
|
fmt.Println(" database manage database backend")
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
@ -483,7 +679,7 @@ func main() {
|
||||||
if reset {
|
if reset {
|
||||||
resetSetting()
|
resetSetting()
|
||||||
} else {
|
} else {
|
||||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
|
updateSetting(port, subPort, username, password, webBasePath, listenIP, subListenIP, resetTwoFactor)
|
||||||
}
|
}
|
||||||
if show {
|
if show {
|
||||||
showSetting(show)
|
showSetting(show)
|
||||||
|
|
@ -511,6 +707,8 @@ func main() {
|
||||||
} else {
|
} else {
|
||||||
updateCert(webCertFile, webKeyFile)
|
updateCert(webCertFile, webKeyFile)
|
||||||
}
|
}
|
||||||
|
case "database":
|
||||||
|
handleDatabaseCommand(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
fmt.Println("Invalid subcommands")
|
fmt.Println("Invalid subcommands")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
|
||||||
271
postgres-manager.sh
Normal file
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) {
|
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inbounds []*model.Inbound
|
var candidates []*model.Inbound
|
||||||
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
|
err := db.Model(model.Inbound{}).
|
||||||
SELECT DISTINCT inbounds.id
|
Preload("ClientStats").
|
||||||
FROM inbounds,
|
Where("protocol IN ?", []string{"vmess", "vless", "trojan", "shadowsocks"}).
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
Where("enable = ?", true).
|
||||||
WHERE
|
Find(&candidates).Error
|
||||||
protocol in ('vmess','vless','trojan','shadowsocks')
|
|
||||||
AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
|
|
||||||
)`, subId, true).Find(&inbounds).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inbounds := make([]*model.Inbound, 0)
|
||||||
|
for _, inbound := range candidates {
|
||||||
|
clients, clientsErr := s.inboundService.GetClients(inbound)
|
||||||
|
if clientsErr != nil {
|
||||||
|
return nil, clientsErr
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.SubID == subId {
|
||||||
|
inbounds = append(inbounds, inbound)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return inbounds, nil
|
return inbounds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,15 +151,42 @@ func (s *SubService) getClientTraffics(traffics []xray.ClientTraffic, email stri
|
||||||
|
|
||||||
func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) {
|
func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inbound *model.Inbound
|
var inbounds []model.Inbound
|
||||||
err := db.Model(model.Inbound{}).
|
err := db.Model(model.Inbound{}).Find(&inbounds).Error
|
||||||
Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
|
|
||||||
Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", dest).
|
|
||||||
Find(&inbound).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, "", err
|
return "", 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var inbound *model.Inbound
|
||||||
|
for i := range inbounds {
|
||||||
|
settings := map[string]any{}
|
||||||
|
if json.Unmarshal([]byte(inbounds[i].Settings), &settings) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fallbacks, ok := settings["fallbacks"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, fallback := range fallbacks {
|
||||||
|
fallbackMap, ok := fallback.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fallbackDest, ok := fallbackMap["dest"].(string); ok && fallbackDest == dest {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
inbound = &inbounds[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inbound == nil {
|
||||||
|
return "", 0, "", fmt.Errorf("fallback master not found")
|
||||||
|
}
|
||||||
|
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal([]byte(streamSettings), &stream)
|
json.Unmarshal([]byte(streamSettings), &stream)
|
||||||
var masterStream map[string]any
|
var masterStream map[string]any
|
||||||
|
|
|
||||||
|
|
@ -822,6 +822,9 @@ update_x-ui() {
|
||||||
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
||||||
cd x-ui >/dev/null 2>&1
|
cd x-ui >/dev/null 2>&1
|
||||||
chmod +x x-ui >/dev/null 2>&1
|
chmod +x x-ui >/dev/null 2>&1
|
||||||
|
if [[ -f postgres-manager.sh ]]; then
|
||||||
|
chmod +x postgres-manager.sh >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
# Check the system's architecture and rename the file accordingly
|
# Check the system's architecture and rename the file accordingly
|
||||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,34 @@ class AllSetting {
|
||||||
return ObjectUtil.equals(this, other);
|
return ObjectUtil.equals(this, other);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DatabaseSetting {
|
||||||
|
|
||||||
|
constructor(data) {
|
||||||
|
this.driver = "sqlite";
|
||||||
|
this.configSource = "default";
|
||||||
|
this.readOnly = false;
|
||||||
|
this.sqlitePath = "";
|
||||||
|
this.postgresMode = "external";
|
||||||
|
this.postgresHost = "127.0.0.1";
|
||||||
|
this.postgresPort = 5432;
|
||||||
|
this.postgresDBName = "";
|
||||||
|
this.postgresUser = "";
|
||||||
|
this.postgresPassword = "";
|
||||||
|
this.postgresPasswordSet = false;
|
||||||
|
this.postgresSSLMode = "disable";
|
||||||
|
this.managedLocally = false;
|
||||||
|
this.localInstalled = false;
|
||||||
|
this.canInstallLocally = false;
|
||||||
|
this.nativeSQLiteExportAvailable = true;
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectUtil.cloneProps(this, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other) {
|
||||||
|
return ObjectUtil.equals(this, other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type ServerController struct {
|
||||||
|
|
||||||
serverService service.ServerService
|
serverService service.ServerService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
|
databaseService service.DatabaseService
|
||||||
|
|
||||||
lastStatus *service.Status
|
lastStatus *service.Status
|
||||||
|
|
||||||
|
|
@ -253,14 +254,27 @@ func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||||
|
|
||||||
// getDb downloads the database file.
|
// getDb downloads the database file.
|
||||||
func (a *ServerController) getDb(c *gin.Context) {
|
func (a *ServerController) getDb(c *gin.Context) {
|
||||||
db, err := a.serverService.GetDb()
|
exportType := c.Query("type")
|
||||||
|
if exportType == "" {
|
||||||
|
exportType = "portable"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
data []byte
|
||||||
|
filename string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch exportType {
|
||||||
|
case "sqlite":
|
||||||
|
data, filename, err = a.databaseService.ExportNativeSQLite()
|
||||||
|
default:
|
||||||
|
data, filename, err = a.databaseService.ExportPortableBackup()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := "x-ui.db"
|
|
||||||
|
|
||||||
if !isValidFilename(filename) {
|
if !isValidFilename(filename) {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||||
return
|
return
|
||||||
|
|
@ -271,7 +285,7 @@ func (a *ServerController) getDb(c *gin.Context) {
|
||||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||||
|
|
||||||
// Write the file contents to the response
|
// Write the file contents to the response
|
||||||
c.Writer.Write(db)
|
c.Writer.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValidFilename(filename string) bool {
|
func isValidFilename(filename string) bool {
|
||||||
|
|
@ -283,16 +297,15 @@ func isValidFilename(filename string) bool {
|
||||||
func (a *ServerController) importDB(c *gin.Context) {
|
func (a *ServerController) importDB(c *gin.Context) {
|
||||||
// Get the file from the request body
|
// Get the file from the request body
|
||||||
file, _, err := c.Request.FormFile("db")
|
file, _, err := c.Request.FormFile("db")
|
||||||
|
if err != nil {
|
||||||
|
file, _, err = c.Request.FormFile("backup")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
// Always restart Xray before return
|
_, err = a.databaseService.ImportBackup(file)
|
||||||
defer a.serverService.RestartXrayService()
|
|
||||||
// lastGetStatusTime removed; no longer needed
|
|
||||||
// Import it
|
|
||||||
err = a.serverService.ImportDB(file)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||||
|
|
@ -25,6 +26,7 @@ type SettingController struct {
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
panelService service.PanelService
|
panelService service.PanelService
|
||||||
|
databaseService service.DatabaseService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingController creates a new SettingController and initializes its routes.
|
// NewSettingController creates a new SettingController and initializes its routes.
|
||||||
|
|
@ -44,6 +46,15 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/updateUser", a.updateUser)
|
g.POST("/updateUser", a.updateUser)
|
||||||
g.POST("/restartPanel", a.restartPanel)
|
g.POST("/restartPanel", a.restartPanel)
|
||||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||||
|
|
||||||
|
database := g.Group("/database")
|
||||||
|
database.POST("/get", a.getDatabaseSetting)
|
||||||
|
database.POST("/test", a.testDatabaseSetting)
|
||||||
|
database.POST("/install-postgres", a.installLocalPostgres)
|
||||||
|
database.POST("/switch", a.switchDatabase)
|
||||||
|
database.POST("/export", a.exportDatabase)
|
||||||
|
database.GET("/export", a.exportDatabase)
|
||||||
|
database.POST("/import", a.importDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAllSetting retrieves all current settings.
|
// getAllSetting retrieves all current settings.
|
||||||
|
|
@ -119,3 +130,97 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
|
||||||
}
|
}
|
||||||
jsonObj(c, defaultJsonConfig, nil)
|
jsonObj(c, defaultJsonConfig, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) getDatabaseSetting(c *gin.Context) {
|
||||||
|
setting, err := a.databaseService.GetSetting()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to load database settings", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, setting, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) testDatabaseSetting(c *gin.Context) {
|
||||||
|
setting := &entity.DatabaseSetting{}
|
||||||
|
if err := c.ShouldBind(setting); err != nil {
|
||||||
|
jsonMsg(c, "Failed to parse database settings", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.databaseService.TestSetting(setting); err != nil {
|
||||||
|
jsonMsg(c, "Connection test failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, "Database connection test succeeded", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) installLocalPostgres(c *gin.Context) {
|
||||||
|
output, err := a.databaseService.InstallLocalPostgres()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to install PostgreSQL", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, output, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) switchDatabase(c *gin.Context) {
|
||||||
|
setting := &entity.DatabaseSetting{}
|
||||||
|
if err := c.ShouldBind(setting); err != nil {
|
||||||
|
jsonMsg(c, "Failed to parse database settings", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.databaseService.SwitchDatabase(setting)
|
||||||
|
if err == nil {
|
||||||
|
err = a.panelService.RestartPanel(3 * time.Second)
|
||||||
|
}
|
||||||
|
jsonMsg(c, "Database switch scheduled", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) exportDatabase(c *gin.Context) {
|
||||||
|
exportType := c.Query("type")
|
||||||
|
if exportType == "" {
|
||||||
|
exportType = c.PostForm("type")
|
||||||
|
}
|
||||||
|
if exportType == "" {
|
||||||
|
exportType = "portable"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
data []byte
|
||||||
|
filename string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch exportType {
|
||||||
|
case "sqlite":
|
||||||
|
data, filename, err = a.databaseService.ExportNativeSQLite()
|
||||||
|
default:
|
||||||
|
data, filename, err = a.databaseService.ExportPortableBackup()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to export database", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||||
|
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) importDatabase(c *gin.Context) {
|
||||||
|
file, _, err := c.Request.FormFile("backup")
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to read uploaded backup", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
backupType, err := a.databaseService.ImportBackup(file)
|
||||||
|
if err == nil {
|
||||||
|
err = a.panelService.RestartPanel(3 * time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to import database backup", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, backupType, nil)
|
||||||
|
}
|
||||||
|
|
|
||||||
99
web/entity/database.go
Normal file
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 class="ant-backup-list w-100" bordered>
|
||||||
<a-list-item class="ant-backup-list-item">
|
<a-list-item class="ant-backup-list-item">
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
<template #title>{{ i18n "pages.index.exportDatabase" }}</template>
|
<template #title>Export Portable Backup</template>
|
||||||
<template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template>
|
<template #description>Portable backups work for both SQLite and PostgreSQL.</template>
|
||||||
</a-list-item-meta>
|
</a-list-item-meta>
|
||||||
<a-button @click="exportDatabase()" type="primary" icon="download" />
|
<a-button @click="exportDatabase('portable')" type="primary" icon="download" />
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
<a-list-item class="ant-backup-list-item">
|
<a-list-item class="ant-backup-list-item">
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
<template #title>{{ i18n "pages.index.importDatabase" }}</template>
|
<template #title>Export Native SQLite</template>
|
||||||
<template #description>{{ i18n "pages.index.importDatabaseDesc" }}</template>
|
<template #description>Downloads the raw SQLite database file when SQLite is the active backend.</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<a-button @click="exportDatabase('sqlite')" type="primary" icon="download" :disabled="!backupModal.nativeSQLiteExportAvailable" />
|
||||||
|
</a-list-item>
|
||||||
|
<a-list-item class="ant-backup-list-item">
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>Import Backup</template>
|
||||||
|
<template #description>Accepts portable `.xui-backup` files and legacy SQLite `.db` backups.</template>
|
||||||
</a-list-item-meta>
|
</a-list-item-meta>
|
||||||
<a-button @click="importDatabase()" type="primary" icon="upload" />
|
<a-button @click="importDatabase()" type="primary" icon="upload" />
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
|
|
@ -864,6 +871,7 @@
|
||||||
};
|
};
|
||||||
const backupModal = {
|
const backupModal = {
|
||||||
visible: false,
|
visible: false,
|
||||||
|
nativeSQLiteExportAvailable: false,
|
||||||
show() {
|
show() {
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
},
|
},
|
||||||
|
|
@ -1064,24 +1072,28 @@
|
||||||
}
|
}
|
||||||
txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json');
|
txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json');
|
||||||
},
|
},
|
||||||
openBackup() {
|
async openBackup() {
|
||||||
|
const dbMsg = await HttpUtil.post('/panel/setting/database/get');
|
||||||
|
if (dbMsg.success) {
|
||||||
|
backupModal.nativeSQLiteExportAvailable = !!dbMsg.obj.nativeSQLiteExportAvailable;
|
||||||
|
}
|
||||||
backupModal.show();
|
backupModal.show();
|
||||||
},
|
},
|
||||||
exportDatabase() {
|
exportDatabase(type = 'portable') {
|
||||||
window.location = basePath + 'panel/api/server/getDb';
|
window.location = `${basePath}panel/setting/database/export?type=${type}`;
|
||||||
},
|
},
|
||||||
importDatabase() {
|
importDatabase() {
|
||||||
const fileInput = document.createElement('input');
|
const fileInput = document.createElement('input');
|
||||||
fileInput.type = 'file';
|
fileInput.type = 'file';
|
||||||
fileInput.accept = '.db';
|
fileInput.accept = '.xui-backup,.db,.zip';
|
||||||
fileInput.addEventListener('change', async (event) => {
|
fileInput.addEventListener('change', async (event) => {
|
||||||
const dbFile = event.target.files[0];
|
const backupFile = event.target.files[0];
|
||||||
if (dbFile) {
|
if (backupFile) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('db', dbFile);
|
formData.append('backup', backupFile);
|
||||||
backupModal.hide();
|
backupModal.hide();
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
const uploadMsg = await HttpUtil.post('/panel/setting/database/import', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
}
|
}
|
||||||
|
|
@ -1090,15 +1102,10 @@
|
||||||
if (!uploadMsg.success) {
|
if (!uploadMsg.success) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading(true);
|
|
||||||
const restartMsg = await HttpUtil.post("/panel/setting/restartPanel");
|
|
||||||
this.loading(false);
|
|
||||||
if (restartMsg.success) {
|
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
await PromiseUtil.sleep(5000);
|
await PromiseUtil.sleep(5000);
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,8 @@
|
||||||
},
|
},
|
||||||
oldAllSetting: new AllSetting(),
|
oldAllSetting: new AllSetting(),
|
||||||
allSetting: new AllSetting(),
|
allSetting: new AllSetting(),
|
||||||
|
oldDatabaseSetting: new DatabaseSetting(),
|
||||||
|
databaseSetting: new DatabaseSetting(),
|
||||||
saveBtnDisable: true,
|
saveBtnDisable: true,
|
||||||
entryHost: null,
|
entryHost: null,
|
||||||
entryPort: null,
|
entryPort: null,
|
||||||
|
|
@ -276,6 +278,14 @@
|
||||||
this.saveBtnDisable = true;
|
this.saveBtnDisable = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getDatabaseSetting() {
|
||||||
|
const msg = await HttpUtil.post("/panel/setting/database/get");
|
||||||
|
if (!msg.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.oldDatabaseSetting = new DatabaseSetting(msg.obj);
|
||||||
|
this.databaseSetting = new DatabaseSetting(msg.obj);
|
||||||
|
},
|
||||||
async loadInboundTags() {
|
async loadInboundTags() {
|
||||||
const msg = await HttpUtil.get("/panel/api/inbounds/list");
|
const msg = await HttpUtil.get("/panel/api/inbounds/list");
|
||||||
if (msg && msg.success && Array.isArray(msg.obj)) {
|
if (msg && msg.success && Array.isArray(msg.obj)) {
|
||||||
|
|
@ -295,6 +305,78 @@
|
||||||
await this.getAllSetting();
|
await this.getAllSetting();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async testDatabaseSetting() {
|
||||||
|
this.loading(true);
|
||||||
|
await HttpUtil.post("/panel/setting/database/test", this.databaseSetting);
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
async installLocalPostgres() {
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post("/panel/setting/database/install-postgres");
|
||||||
|
this.loading(false);
|
||||||
|
if (msg.success) {
|
||||||
|
await this.getDatabaseSetting();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async switchDatabase() {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
this.$confirm({
|
||||||
|
title: '{{ i18n "pages.settings.database.switchDatabaseTitle" }}',
|
||||||
|
content: '{{ i18n "pages.settings.database.switchDatabaseConfirm" }}',
|
||||||
|
class: themeSwitcher.currentTheme,
|
||||||
|
okText: '{{ i18n "sure" }}',
|
||||||
|
cancelText: '{{ i18n "cancel" }}',
|
||||||
|
onOk: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post("/panel/setting/database/switch", this.databaseSetting);
|
||||||
|
this.loading(false);
|
||||||
|
if (!msg.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PromiseUtil.sleep(5000);
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
downloadDatabaseExport(type) {
|
||||||
|
window.location = `${basePath}panel/setting/database/export?type=${type}`;
|
||||||
|
},
|
||||||
|
exportPortableBackup() {
|
||||||
|
this.downloadDatabaseExport('portable');
|
||||||
|
},
|
||||||
|
exportNativeSQLite() {
|
||||||
|
this.downloadDatabaseExport('sqlite');
|
||||||
|
},
|
||||||
|
importDatabaseBackup() {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = '.xui-backup,.db,.zip';
|
||||||
|
fileInput.addEventListener('change', async (event) => {
|
||||||
|
const backupFile = event.target.files[0];
|
||||||
|
if (!backupFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('backup', backupFile);
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post('/panel/setting/database/import', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.loading(false);
|
||||||
|
if (!msg.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PromiseUtil.sleep(5000);
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
fileInput.click();
|
||||||
|
},
|
||||||
async updateUser() {
|
async updateUser() {
|
||||||
const sendUpdateUserRequest = async () => {
|
const sendUpdateUserRequest = async () => {
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
|
|
@ -627,6 +709,7 @@
|
||||||
this.entryProtocol = window.location.protocol;
|
this.entryProtocol = window.location.protocol;
|
||||||
this.entryIsIP = this._isIp(this.entryHost);
|
this.entryIsIP = this._isIp(this.entryHost);
|
||||||
await this.getAllSetting();
|
await this.getAllSetting();
|
||||||
|
await this.getDatabaseSetting();
|
||||||
await this.loadInboundTags();
|
await this.loadInboundTags();
|
||||||
while (true) {
|
while (true) {
|
||||||
await PromiseUtil.sleep(1000);
|
await PromiseUtil.sleep(1000);
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,130 @@
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.externalTraffic" }}'>
|
<a-collapse-panel key="4" header='{{ i18n "pages.settings.database.sectionTitle" }}'>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.backend" }}</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.database.backendDesc" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-radio-group v-model="databaseSetting.driver" :disabled="databaseSetting.readOnly" button-style="solid">
|
||||||
|
<a-radio-button value="sqlite">SQLite</a-radio-button>
|
||||||
|
<a-radio-button value="postgres">PostgreSQL</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="databaseSetting.readOnly || databaseSetting.configSource === 'default'">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.configSource" }}</template>
|
||||||
|
<template #description v-if="databaseSetting.readOnly">{{ i18n "pages.settings.database.configSourceEnvDesc" }}</template>
|
||||||
|
<template #description v-else>{{ i18n "pages.settings.database.configSourceDefaultDesc" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-tag :color="databaseSetting.readOnly ? 'red' : 'orange'">[[ databaseSetting.readOnly ? 'env' : 'default' ]]</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="databaseSetting.driver === 'sqlite'">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.sqlitePath" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="databaseSetting.sqlitePath" :disabled="databaseSetting.readOnly"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<template v-if="databaseSetting.driver === 'postgres'">
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.postgresMode" }}</template>
|
||||||
|
<template #description>
|
||||||
|
<span v-if="databaseSetting.postgresMode === 'local'">{{ i18n "pages.settings.database.postgresModeLocalDesc" }}</span>
|
||||||
|
<span v-else>{{ i18n "pages.settings.database.postgresModeExternalDesc" }}</span>
|
||||||
|
</template>
|
||||||
|
<template #control>
|
||||||
|
<a-radio-group v-model="databaseSetting.postgresMode" :disabled="databaseSetting.readOnly" button-style="solid">
|
||||||
|
<a-radio-button value="local">{{ i18n "pages.settings.database.postgresModeLocal" }}</a-radio-button>
|
||||||
|
<a-radio-button value="external">{{ i18n "pages.settings.database.postgresModeExternal" }}</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'local'">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.postgresInstallation" }}</template>
|
||||||
|
<template #description>
|
||||||
|
<span v-if="databaseSetting.localInstalled"><a-tag color="green">✓</a-tag> {{ i18n "pages.settings.database.postgresInstallReady" }}</span>
|
||||||
|
<span v-else-if="!databaseSetting.canInstallLocally"><a-tag color="orange">!</a-tag> {{ i18n "pages.settings.database.postgresInstallNeedRoot" }}</span>
|
||||||
|
<span v-else><a-tag color="orange">!</a-tag> {{ i18n "pages.settings.database.postgresInstallHint" }}</span>
|
||||||
|
</template>
|
||||||
|
<template #control>
|
||||||
|
<a-button @click="installLocalPostgres" :disabled="databaseSetting.readOnly || !databaseSetting.canInstallLocally || databaseSetting.localInstalled">
|
||||||
|
<span v-if="databaseSetting.localInstalled">{{ i18n "pages.settings.database.postgresAlreadyInstalled" }}</span>
|
||||||
|
<span v-else>{{ i18n "pages.settings.database.postgresInstallBtn" }}</span>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.host" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="databaseSetting.postgresHost" :disabled="databaseSetting.readOnly"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.port" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input-number :min="1" :max="65535" v-model="databaseSetting.postgresPort" :style="{ width: '100%' }" :disabled="databaseSetting.readOnly"></a-input-number>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.dbName" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="databaseSetting.postgresDBName" :disabled="databaseSetting.readOnly"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.user" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="databaseSetting.postgresUser" :disabled="databaseSetting.readOnly"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.password" }}</template>
|
||||||
|
<template #description v-if="databaseSetting.postgresPasswordSet">{{ i18n "pages.settings.database.passwordHint" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="password" v-model="databaseSetting.postgresPassword" :disabled="databaseSetting.readOnly"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="databaseSetting.postgresMode === 'external'">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.sslMode" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-select v-model="databaseSetting.postgresSSLMode" :disabled="databaseSetting.readOnly"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||||
|
<a-select-option value="disable">disable</a-select-option>
|
||||||
|
<a-select-option value="require">require</a-select-option>
|
||||||
|
<a-select-option value="verify-ca">verify-ca</a-select-option>
|
||||||
|
<a-select-option value="verify-full">verify-full</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
</template>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.actions" }}</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.database.actionsDesc" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-space wrap>
|
||||||
|
<a-button @click="testDatabaseSetting" :disabled="databaseSetting.readOnly">{{ i18n "pages.settings.database.testConnection" }}</a-button>
|
||||||
|
<a-button type="primary" @click="switchDatabase" :disabled="databaseSetting.readOnly">{{ i18n "pages.settings.database.switchDatabase" }}</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.settings.database.backupRestore" }}</template>
|
||||||
|
<template #description>
|
||||||
|
<b>{{ i18n "pages.settings.database.exportPortableLabel" }}</b> — {{ i18n "pages.settings.database.exportPortableDesc" }}<br>
|
||||||
|
<b>{{ i18n "pages.settings.database.exportNativeSQLiteLabel" }}</b> — {{ i18n "pages.settings.database.exportNativeSQLiteDesc" }}<br>
|
||||||
|
<b>{{ i18n "pages.settings.database.importLabel" }}</b> — {{ i18n "pages.settings.database.importDesc" }}
|
||||||
|
</template>
|
||||||
|
<template #control>
|
||||||
|
<a-space wrap>
|
||||||
|
<a-button @click="exportPortableBackup">{{ i18n "pages.settings.database.exportPortableBtn" }}</a-button>
|
||||||
|
<a-button @click="exportNativeSQLite" :disabled="!databaseSetting.nativeSQLiteExportAvailable">{{ i18n "pages.settings.database.exportNativeSQLiteBtn" }}</a-button>
|
||||||
|
<a-button @click="importDatabaseBackup">{{ i18n "pages.settings.database.importBtn" }}</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
</a-collapse-panel>
|
||||||
|
<a-collapse-panel key="5" header='{{ i18n "pages.settings.externalTraffic" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.externalTrafficInformEnable"}}</template>
|
<template #title>{{ i18n "pages.settings.externalTrafficInformEnable"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.externalTrafficInformEnableDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.externalTrafficInformEnableDesc"}}</template>
|
||||||
|
|
@ -125,7 +248,7 @@
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="5" header='{{ i18n "pages.settings.dateAndTime" }}'>
|
<a-collapse-panel key="6" header='{{ i18n "pages.settings.dateAndTime" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.timeZone"}}</template>
|
<template #title>{{ i18n "pages.settings.timeZone"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.timeZoneDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.timeZoneDesc"}}</template>
|
||||||
|
|
@ -146,7 +269,7 @@
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="6" header='LDAP'>
|
<a-collapse-panel key="7" header='LDAP'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>Enable LDAP sync</template>
|
<template #title>Enable LDAP sync</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
|
|
|
||||||
252
web/service/database.go
Normal file
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(
|
db.Model(model.Inbound{}).Where(
|
||||||
"listen = ?", listen,
|
"listen = ?", listen,
|
||||||
).Or(
|
).Or(
|
||||||
"listen = \"\"",
|
"listen = ?", "",
|
||||||
).Or(
|
).Or(
|
||||||
"listen = \"0.0.0.0\"",
|
"listen = ?", "0.0.0.0",
|
||||||
).Or(
|
).Or(
|
||||||
"listen = \"::\"",
|
"listen = ?", "::",
|
||||||
).Or(
|
).Or(
|
||||||
"listen = \"::0\""))
|
"listen = ?", "::0",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
if ignoreId > 0 {
|
if ignoreId > 0 {
|
||||||
db = db.Where("id != ?", ignoreId)
|
db = db.Where("id != ?", ignoreId)
|
||||||
|
|
@ -143,15 +144,24 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
|
||||||
|
|
||||||
func (s *InboundService) getAllEmails() ([]string, error) {
|
func (s *InboundService) getAllEmails() ([]string, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var emails []string
|
var inbounds []model.Inbound
|
||||||
err := db.Raw(`
|
err := db.Model(model.Inbound{}).Find(&inbounds).Error
|
||||||
SELECT JSON_EXTRACT(client.value, '$.email')
|
|
||||||
FROM inbounds,
|
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
|
||||||
`).Scan(&emails).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emails := make([]string, 0)
|
||||||
|
for i := range inbounds {
|
||||||
|
clients, clientsErr := s.GetClients(&inbounds[i])
|
||||||
|
if clientsErr != nil {
|
||||||
|
return nil, clientsErr
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.Email != "" {
|
||||||
|
emails = append(emails, client.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return emails, nil
|
return emails, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1312,14 +1322,18 @@ func (s *InboundService) GetInboundTags() (string, error) {
|
||||||
|
|
||||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
db.Exec(`
|
allEmails, err := s.getAllEmails()
|
||||||
DELETE FROM client_traffics
|
if err != nil {
|
||||||
WHERE email NOT IN (
|
logger.Warningf("Failed to load client emails for orphan cleanup: %v", err)
|
||||||
SELECT JSON_EXTRACT(client.value, '$.email')
|
return
|
||||||
FROM inbounds,
|
}
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
|
||||||
)
|
query := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&xray.ClientTraffic{})
|
||||||
`)
|
if len(allEmails) == 0 {
|
||||||
|
query.Delete(&xray.ClientTraffic{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query.Not("email IN ?", allEmails).Delete(&xray.ClientTraffic{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
|
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
|
||||||
|
|
@ -2057,14 +2071,30 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64,
|
||||||
func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
|
func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var traffics []xray.ClientTraffic
|
var traffics []xray.ClientTraffic
|
||||||
|
var inbounds []model.Inbound
|
||||||
|
if err := db.Model(model.Inbound{}).Find(&inbounds).Error; err != nil {
|
||||||
|
logger.Debug(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err := db.Model(xray.ClientTraffic{}).Where(`email IN(
|
emails := make([]string, 0)
|
||||||
SELECT JSON_EXTRACT(client.value, '$.email') as email
|
for i := range inbounds {
|
||||||
FROM inbounds,
|
clients, clientsErr := s.GetClients(&inbounds[i])
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
if clientsErr != nil {
|
||||||
WHERE
|
logger.Debug(clientsErr)
|
||||||
JSON_EXTRACT(client.value, '$.id') in (?)
|
return nil, clientsErr
|
||||||
)`, id).Find(&traffics).Error
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.ID == id && client.Email != "" {
|
||||||
|
emails = append(emails, client.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(emails) == 0 {
|
||||||
|
return traffics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug(err)
|
logger.Debug(err)
|
||||||
|
|
@ -2210,72 +2240,66 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
|
if database.IsSQLite() {
|
||||||
|
if dbErr := db.Exec(`VACUUM`).Error; dbErr != nil {
|
||||||
logger.Warningf("VACUUM failed: %v", dbErr)
|
logger.Warningf("VACUUM failed: %v", dbErr)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Calculate and backfill all_time from up+down for inbounds and clients
|
// Calculate and backfill all_time from up+down for inbounds and clients.
|
||||||
err = tx.Exec(`
|
err = tx.Exec(`
|
||||||
UPDATE inbounds
|
UPDATE inbounds
|
||||||
SET all_time = IFNULL(up, 0) + IFNULL(down, 0)
|
SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
|
||||||
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0
|
WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
|
||||||
`).Error
|
`).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = tx.Exec(`
|
err = tx.Exec(`
|
||||||
UPDATE client_traffics
|
UPDATE client_traffics
|
||||||
SET all_time = IFNULL(up, 0) + IFNULL(down, 0)
|
SET all_time = COALESCE(up, 0) + COALESCE(down, 0)
|
||||||
WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0
|
WHERE COALESCE(all_time, 0) = 0 AND (COALESCE(up, 0) + COALESCE(down, 0)) > 0
|
||||||
`).Error
|
`).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix inbounds based problems
|
// Fix inbounds based problems.
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
|
err = tx.Model(model.Inbound{}).Where("protocol IN ?", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for inbound_index := range inbounds {
|
for inboundIndex := range inbounds {
|
||||||
settings := map[string]any{}
|
settings := map[string]any{}
|
||||||
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
|
json.Unmarshal([]byte(inbounds[inboundIndex].Settings), &settings)
|
||||||
clients, ok := settings["clients"].([]any)
|
clients, ok := settings["clients"].([]any)
|
||||||
if ok {
|
if ok {
|
||||||
// Fix Client configuration problems
|
|
||||||
var newClients []any
|
var newClients []any
|
||||||
for client_index := range clients {
|
for clientIndex := range clients {
|
||||||
c := clients[client_index].(map[string]any)
|
c := clients[clientIndex].(map[string]any)
|
||||||
|
|
||||||
// Add email='' if it is not exists
|
|
||||||
if _, ok := c["email"]; !ok {
|
if _, ok := c["email"]; !ok {
|
||||||
c["email"] = ""
|
c["email"] = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert string tgId to int64
|
|
||||||
if _, ok := c["tgId"]; ok {
|
if _, ok := c["tgId"]; ok {
|
||||||
var tgId any = c["tgId"]
|
var tgId any = c["tgId"]
|
||||||
if tgIdStr, ok2 := tgId.(string); ok2 {
|
if tgIdStr, ok2 := tgId.(string); ok2 {
|
||||||
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
|
tgIdInt64, parseErr := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
|
||||||
if err == nil {
|
if parseErr == nil {
|
||||||
c["tgId"] = tgIdInt64
|
c["tgId"] = tgIdInt64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove "flow": "xtls-rprx-direct"
|
if _, ok := c["flow"]; ok && c["flow"] == "xtls-rprx-direct" {
|
||||||
if _, ok := c["flow"]; ok {
|
|
||||||
if c["flow"] == "xtls-rprx-direct" {
|
|
||||||
c["flow"] = ""
|
c["flow"] = ""
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Backfill created_at and updated_at
|
|
||||||
if _, ok := c["created_at"]; !ok {
|
if _, ok := c["created_at"]; !ok {
|
||||||
c["created_at"] = time.Now().Unix() * 1000
|
c["created_at"] = time.Now().Unix() * 1000
|
||||||
}
|
}
|
||||||
|
|
@ -2283,17 +2307,17 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
newClients = append(newClients, any(c))
|
newClients = append(newClients, any(c))
|
||||||
}
|
}
|
||||||
settings["clients"] = newClients
|
settings["clients"] = newClients
|
||||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
modifiedSettings, marshalErr := json.MarshalIndent(settings, "", " ")
|
||||||
if err != nil {
|
if marshalErr != nil {
|
||||||
|
err = marshalErr
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
inbounds[inboundIndex].Settings = string(modifiedSettings)
|
||||||
inbounds[inbound_index].Settings = string(modifiedSettings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add client traffic row for all clients which has email
|
modelClients, clientsErr := s.GetClients(inbounds[inboundIndex])
|
||||||
modelClients, err := s.GetClients(inbounds[inbound_index])
|
if clientsErr != nil {
|
||||||
if err != nil {
|
err = clientsErr
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, modelClient := range modelClients {
|
for _, modelClient := range modelClients {
|
||||||
|
|
@ -2301,62 +2325,82 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
var count int64
|
var count int64
|
||||||
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
|
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
|
if addErr := s.AddClientStat(tx, inbounds[inboundIndex].Id, &modelClient); addErr != nil {
|
||||||
|
err = addErr
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.Save(inbounds)
|
}
|
||||||
|
if err = tx.Save(inbounds).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Remove orphaned traffics
|
|
||||||
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
|
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
|
||||||
|
|
||||||
// Migrate old MultiDomain to External Proxy
|
for _, inbound := range inbounds {
|
||||||
var externalProxy []struct {
|
|
||||||
Id int
|
|
||||||
Port int
|
|
||||||
StreamSettings []byte
|
|
||||||
}
|
|
||||||
err = tx.Raw(`select id, port, stream_settings
|
|
||||||
from inbounds
|
|
||||||
WHERE protocol in ('vmess','vless','trojan')
|
|
||||||
AND json_extract(stream_settings, '$.security') = 'tls'
|
|
||||||
AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error
|
|
||||||
if err != nil || len(externalProxy) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ep := range externalProxy {
|
|
||||||
var reverses any
|
var reverses any
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal(ep.StreamSettings, &stream)
|
if json.Unmarshal([]byte(inbound.StreamSettings), &stream) != nil {
|
||||||
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
|
continue
|
||||||
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
|
|
||||||
if domains, ok := settings["domains"].([]any); ok {
|
|
||||||
for _, domain := range domains {
|
|
||||||
if domainMap, ok := domain.(map[string]any); ok {
|
|
||||||
domainMap["forceTls"] = "same"
|
|
||||||
domainMap["port"] = ep.Port
|
|
||||||
domainMap["dest"] = domainMap["domain"].(string)
|
|
||||||
delete(domainMap, "domain")
|
|
||||||
}
|
}
|
||||||
}
|
if security, _ := stream["security"].(string); security != "tls" {
|
||||||
}
|
continue
|
||||||
reverses = settings["domains"]
|
|
||||||
delete(settings, "domains")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stream["externalProxy"] = reverses
|
|
||||||
newStream, _ := json.MarshalIndent(stream, " ", " ")
|
|
||||||
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Raw(`UPDATE inbounds
|
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
|
||||||
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
if !ok {
|
||||||
WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error
|
continue
|
||||||
if err != nil {
|
}
|
||||||
|
tlsInnerSettings, ok := tlsSettings["settings"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domains, ok := tlsInnerSettings["domains"].([]any)
|
||||||
|
if !ok || len(domains) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, domain := range domains {
|
||||||
|
domainMap, ok := domain.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domainMap["forceTls"] = "same"
|
||||||
|
domainMap["port"] = inbound.Port
|
||||||
|
if domainName, ok := domainMap["domain"].(string); ok {
|
||||||
|
domainMap["dest"] = domainName
|
||||||
|
}
|
||||||
|
delete(domainMap, "domain")
|
||||||
|
}
|
||||||
|
reverses = domains
|
||||||
|
delete(tlsInnerSettings, "domains")
|
||||||
|
stream["externalProxy"] = reverses
|
||||||
|
newStream, marshalErr := json.MarshalIndent(stream, " ", " ")
|
||||||
|
if marshalErr != nil {
|
||||||
|
err = marshalErr
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("stream_settings", newStream).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allInbounds []*model.Inbound
|
||||||
|
err = tx.Model(model.Inbound{}).Find(&allInbounds).Error
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, inbound := range allInbounds {
|
||||||
|
if !strings.Contains(inbound.Tag, "0.0.0.0:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newTag := strings.Replace(inbound.Tag, "0.0.0.0:", "", 1)
|
||||||
|
if err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("tag", newTag).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) MigrateDB() {
|
func (s *InboundService) MigrateDB() {
|
||||||
|
|
|
||||||
|
|
@ -1009,7 +1009,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open & migrate new DB
|
// Open & migrate new DB
|
||||||
if err = database.InitDB(config.GetDBPath()); err != nil {
|
if err = database.InitDB(); err != nil {
|
||||||
if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
|
if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
|
||||||
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
|
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/random"
|
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||||
|
|
@ -204,12 +205,15 @@ func (s *SettingService) ResetSettings() error {
|
||||||
|
|
||||||
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
setting := &model.Setting{}
|
var settings []*model.Setting
|
||||||
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
err := db.Model(model.Setting{}).Where("key = ?", key).Limit(1).Find(&settings).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return setting, nil
|
if len(settings) == 0 {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return settings[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) saveSetting(key string, value string) error {
|
func (s *SettingService) saveSetting(key string, value string) error {
|
||||||
|
|
@ -499,10 +503,18 @@ func (s *SettingService) GetSubListen() (string, error) {
|
||||||
return s.getString("subListen")
|
return s.getString("subListen")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetSubListen(ip string) error {
|
||||||
|
return s.setString("subListen", ip)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubPort() (int, error) {
|
func (s *SettingService) GetSubPort() (int, error) {
|
||||||
return s.getInt("subPort")
|
return s.getInt("subPort")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetSubPort(port int) error {
|
||||||
|
return s.setInt("subPort", port)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubPath() (string, error) {
|
func (s *SettingService) GetSubPath() (string, error) {
|
||||||
return s.getString("subPath")
|
return s.getString("subPath")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3636,7 +3636,24 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
||||||
logger.Error("Error in trigger a checkpoint operation: ", err)
|
logger.Error("Error in trigger a checkpoint operation: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send database backup
|
// Send portable database backup
|
||||||
|
backupData, err := database.EncodeCurrentPortableBackup()
|
||||||
|
if err == nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
document := tu.Document(
|
||||||
|
tu.ID(chatId),
|
||||||
|
tu.FileFromBytes(backupData, fmt.Sprintf("x-ui-%s.xui-backup", time.Now().Format("20060102-150405"))),
|
||||||
|
)
|
||||||
|
_, err = bot.SendDocument(ctx, document)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error in uploading portable backup: ", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Error("Error in creating portable backup: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if database.IsSQLite() {
|
||||||
file, err := os.Open(config.GetDBPath())
|
file, err := os.Open(config.GetDBPath())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
@ -3648,17 +3665,18 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
||||||
)
|
)
|
||||||
_, err = bot.SendDocument(ctx, document)
|
_, err = bot.SendDocument(ctx, document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error in uploading backup: ", err)
|
logger.Error("Error in uploading native SQLite backup: ", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Error("Error in opening db file for backup: ", err)
|
logger.Error("Error in opening db file for backup: ", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Small delay between file sends
|
// Small delay between file sends
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
// Send config.json backup
|
// Send config.json backup
|
||||||
file, err = os.Open(xray.GetConfigPath())
|
file, err := os.Open(xray.GetConfigPath())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "نطاق IP Pool"
|
"ipPool" = "نطاق IP Pool"
|
||||||
"poolSize" = "حجم المجموعة"
|
"poolSize" = "حجم المجموعة"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "قاعدة البيانات"
|
||||||
|
"backend" = "الواجهة الخلفية"
|
||||||
|
"backendDesc" = "يتم تخزين إعدادات قاعدة البيانات وقت التشغيل خارج قاعدة بيانات اللوحة."
|
||||||
|
"configSource" = "مصدر الإعدادات"
|
||||||
|
"configSourceEnvDesc" = "يتم التحكم في واجهة قاعدة البيانات الخلفية هذه من خلال متغيرات البيئة ولا يمكن تغييرها هنا."
|
||||||
|
"configSourceDefaultDesc" = "لم يتم حفظ أي ملف إعدادات بعد. الإعدادات الافتراضية المدمجة مفعلة (SQLite)."
|
||||||
|
"configSourceDesc" = "المصدر الحالي:"
|
||||||
|
"sqlitePath" = "مسار SQLite"
|
||||||
|
"postgresMode" = "وضع PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL مُدار بواسطة اللوحة على هذا الخادم (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "الاتصال بخادم PostgreSQL موجود بالفعل."
|
||||||
|
"postgresModeLocal" = "محلي (تديره اللوحة)"
|
||||||
|
"postgresModeExternal" = "خارجي"
|
||||||
|
"postgresInstallation" = "تثبيت PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL جاهز للاستخدام."
|
||||||
|
"postgresInstallNeedRoot" = "التثبيت التلقائي يتطلب صلاحيات root وبيئة غير Docker."
|
||||||
|
"postgresInstallHint" = "اضغط على تثبيت لإعداد PostgreSQL تلقائيًا."
|
||||||
|
"postgresAlreadyInstalled" = "مثبّت بالفعل"
|
||||||
|
"postgresInstallBtn" = "تثبيت PostgreSQL"
|
||||||
|
"host" = "المضيف"
|
||||||
|
"port" = "المنفذ"
|
||||||
|
"dbName" = "اسم قاعدة البيانات"
|
||||||
|
"user" = "اسم المستخدم"
|
||||||
|
"password" = "كلمة المرور"
|
||||||
|
"passwordHint" = "اتركه فارغًا للاحتفاظ بكلمة المرور المخزنة."
|
||||||
|
"sslMode" = "وضع SSL"
|
||||||
|
"actions" = "الإجراءات"
|
||||||
|
"actionsDesc" = "يتم ترحيل البيانات تلقائيًا. يتم حفظ نسخة احتياطية قابلة للنقل قبل التبديل، ثم تُعاد تشغيل اللوحة."
|
||||||
|
"testConnection" = "اختبار الاتصال"
|
||||||
|
"switchDatabase" = "تبديل قاعدة البيانات"
|
||||||
|
"switchDatabaseTitle" = "تبديل الواجهة الخلفية لقاعدة البيانات"
|
||||||
|
"switchDatabaseConfirm" = "ستقوم اللوحة بإنشاء نسخة احتياطية قابلة للنقل، وترحيل جميع البيانات، ثم إعادة تشغيل نفسها. هل تريد المتابعة؟"
|
||||||
|
"backupRestore" = "النسخ الاحتياطي والاستعادة"
|
||||||
|
"exportPortableLabel" = "تصدير نسخة قابلة للنقل"
|
||||||
|
"exportPortableDesc" = "نسخة احتياطية متعددة المنصات (تعمل مع SQLite وPostgreSQL، ويتم إرسالها أيضًا عبر بوت تيليجرام)."
|
||||||
|
"exportNativeSQLiteLabel" = "تصدير SQLite الأصلي"
|
||||||
|
"exportNativeSQLiteDesc" = "ملف قاعدة البيانات الخام، متاح فقط أثناء تفعيل SQLite."
|
||||||
|
"importLabel" = "استيراد"
|
||||||
|
"importDesc" = "الاستعادة من ملف احتياطي قابل للنقل (.xui-backup) أو ملف SQLite قديم (.db)."
|
||||||
|
"exportPortableBtn" = "تصدير نسخة احتياطية قابلة للنقل"
|
||||||
|
"exportNativeSQLiteBtn" = "تصدير SQLite الأصلي"
|
||||||
|
"importBtn" = "استيراد نسخة احتياطية"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "بيانات الأدمن"
|
"admin" = "بيانات الأدمن"
|
||||||
"twoFactor" = "المصادقة الثنائية"
|
"twoFactor" = "المصادقة الثنائية"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "IP Pool Subnet"
|
"ipPool" = "IP Pool Subnet"
|
||||||
"poolSize" = "Pool Size"
|
"poolSize" = "Pool Size"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "Database"
|
||||||
|
"backend" = "Backend"
|
||||||
|
"backendDesc" = "Runtime database configuration is stored outside the panel database."
|
||||||
|
"configSource" = "Config Source"
|
||||||
|
"configSourceEnvDesc" = "This database backend is controlled by environment variables and cannot be changed here."
|
||||||
|
"configSourceDefaultDesc" = "No configuration file saved yet. Built-in defaults are active (SQLite)."
|
||||||
|
"configSourceDesc" = "Current source:"
|
||||||
|
"sqlitePath" = "SQLite Path"
|
||||||
|
"postgresMode" = "PostgreSQL Mode"
|
||||||
|
"postgresModeLocalDesc" = "Panel-managed PostgreSQL on this server (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Connect to an existing PostgreSQL server."
|
||||||
|
"postgresModeLocal" = "Local (panel-managed)"
|
||||||
|
"postgresModeExternal" = "External"
|
||||||
|
"postgresInstallation" = "PostgreSQL Installation"
|
||||||
|
"postgresInstallReady" = "PostgreSQL is ready to use."
|
||||||
|
"postgresInstallNeedRoot" = "Auto-install requires root and a non-Docker environment."
|
||||||
|
"postgresInstallHint" = "Click Install to set up PostgreSQL automatically."
|
||||||
|
"postgresAlreadyInstalled" = "Already installed"
|
||||||
|
"postgresInstallBtn" = "Install PostgreSQL"
|
||||||
|
"host" = "Host"
|
||||||
|
"port" = "Port"
|
||||||
|
"dbName" = "Database Name"
|
||||||
|
"user" = "User"
|
||||||
|
"password" = "Password"
|
||||||
|
"passwordHint" = "Leave empty to keep the stored password."
|
||||||
|
"sslMode" = "SSL Mode"
|
||||||
|
"actions" = "Actions"
|
||||||
|
"actionsDesc" = "Data is migrated automatically. A portable backup is saved before switching, and the panel restarts."
|
||||||
|
"testConnection" = "Test Connection"
|
||||||
|
"switchDatabase" = "Switch Database"
|
||||||
|
"switchDatabaseTitle" = "Switch database backend"
|
||||||
|
"switchDatabaseConfirm" = "The panel will create a portable backup, migrate all data, and restart itself. Continue?"
|
||||||
|
"backupRestore" = "Backup & Restore"
|
||||||
|
"exportPortableLabel" = "Export Portable"
|
||||||
|
"exportPortableDesc" = "Cross-platform backup (works with both SQLite and PostgreSQL, also sent by Telegram bot)."
|
||||||
|
"exportNativeSQLiteLabel" = "Export Native SQLite"
|
||||||
|
"exportNativeSQLiteDesc" = "Raw database file, only available while SQLite is active."
|
||||||
|
"importLabel" = "Import"
|
||||||
|
"importDesc" = "Restore from a portable (.xui-backup) or legacy SQLite (.db) file."
|
||||||
|
"exportPortableBtn" = "Export Portable Backup"
|
||||||
|
"exportNativeSQLiteBtn" = "Export Native SQLite"
|
||||||
|
"importBtn" = "Import Backup"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Admin credentials"
|
"admin" = "Admin credentials"
|
||||||
"twoFactor" = "Two-factor authentication"
|
"twoFactor" = "Two-factor authentication"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "Subred del grupo de IP"
|
"ipPool" = "Subred del grupo de IP"
|
||||||
"poolSize" = "Tamaño del grupo"
|
"poolSize" = "Tamaño del grupo"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "Base de datos"
|
||||||
|
"backend" = "Backend"
|
||||||
|
"backendDesc" = "La configuración de la base de datos en tiempo de ejecución se almacena fuera de la base de datos del panel."
|
||||||
|
"configSource" = "Origen de la configuración"
|
||||||
|
"configSourceEnvDesc" = "Este backend de base de datos está controlado por variables de entorno y no se puede cambiar aquí."
|
||||||
|
"configSourceDefaultDesc" = "Aún no se ha guardado ningún archivo de configuración. Están activos los valores predeterminados integrados (SQLite)."
|
||||||
|
"configSourceDesc" = "Origen actual:"
|
||||||
|
"sqlitePath" = "Ruta de SQLite"
|
||||||
|
"postgresMode" = "Modo de PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL gestionado por el panel en este servidor (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Conectarse a un servidor PostgreSQL existente."
|
||||||
|
"postgresModeLocal" = "Local (gestionado por el panel)"
|
||||||
|
"postgresModeExternal" = "Externo"
|
||||||
|
"postgresInstallation" = "Instalación de PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL está listo para usarse."
|
||||||
|
"postgresInstallNeedRoot" = "La instalación automática requiere root y un entorno sin Docker."
|
||||||
|
"postgresInstallHint" = "Haz clic en Instalar para configurar PostgreSQL automáticamente."
|
||||||
|
"postgresAlreadyInstalled" = "Ya instalado"
|
||||||
|
"postgresInstallBtn" = "Instalar PostgreSQL"
|
||||||
|
"host" = "Host"
|
||||||
|
"port" = "Puerto"
|
||||||
|
"dbName" = "Nombre de la base de datos"
|
||||||
|
"user" = "Usuario"
|
||||||
|
"password" = "Contraseña"
|
||||||
|
"passwordHint" = "Déjalo vacío para conservar la contraseña almacenada."
|
||||||
|
"sslMode" = "Modo SSL"
|
||||||
|
"actions" = "Acciones"
|
||||||
|
"actionsDesc" = "Los datos se migran automáticamente. Se guarda una copia de seguridad portable antes del cambio y el panel se reinicia."
|
||||||
|
"testConnection" = "Probar conexión"
|
||||||
|
"switchDatabase" = "Cambiar base de datos"
|
||||||
|
"switchDatabaseTitle" = "Cambiar el backend de la base de datos"
|
||||||
|
"switchDatabaseConfirm" = "El panel creará una copia de seguridad portable, migrará todos los datos y se reiniciará. ¿Continuar?"
|
||||||
|
"backupRestore" = "Copia de seguridad y restauración"
|
||||||
|
"exportPortableLabel" = "Exportar portable"
|
||||||
|
"exportPortableDesc" = "Copia de seguridad multiplataforma (funciona tanto con SQLite como con PostgreSQL, y también se envía por el bot de Telegram)."
|
||||||
|
"exportNativeSQLiteLabel" = "Exportar SQLite nativo"
|
||||||
|
"exportNativeSQLiteDesc" = "Archivo bruto de la base de datos, disponible solo mientras SQLite esté activo."
|
||||||
|
"importLabel" = "Importar"
|
||||||
|
"importDesc" = "Restaurar desde un archivo portable (.xui-backup) o un archivo SQLite heredado (.db)."
|
||||||
|
"exportPortableBtn" = "Exportar copia de seguridad portable"
|
||||||
|
"exportNativeSQLiteBtn" = "Exportar SQLite nativo"
|
||||||
|
"importBtn" = "Importar copia de seguridad"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Credenciales de administrador"
|
"admin" = "Credenciales de administrador"
|
||||||
"twoFactor" = "Autenticación de dos factores"
|
"twoFactor" = "Autenticación de dos factores"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "زیرشبکه استخر آیپی"
|
"ipPool" = "زیرشبکه استخر آیپی"
|
||||||
"poolSize" = "اندازه استخر"
|
"poolSize" = "اندازه استخر"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "پایگاه داده"
|
||||||
|
"backend" = "بکاند"
|
||||||
|
"backendDesc" = "پیکربندی پایگاه داده در زمان اجرا خارج از پایگاه داده پنل ذخیره میشود."
|
||||||
|
"configSource" = "منبع پیکربندی"
|
||||||
|
"configSourceEnvDesc" = "این بکاند پایگاه داده توسط متغیرهای محیطی کنترل میشود و از اینجا قابل تغییر نیست."
|
||||||
|
"configSourceDefaultDesc" = "هنوز هیچ فایل پیکربندی ذخیره نشده است. تنظیمات پیشفرض داخلی فعال هستند (SQLite)."
|
||||||
|
"configSourceDesc" = "منبع فعلی:"
|
||||||
|
"sqlitePath" = "مسیر SQLite"
|
||||||
|
"postgresMode" = "حالت PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL مدیریتشده توسط پنل روی این سرور (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "اتصال به یک سرور PostgreSQL موجود."
|
||||||
|
"postgresModeLocal" = "محلی (مدیریتشده توسط پنل)"
|
||||||
|
"postgresModeExternal" = "خارجی"
|
||||||
|
"postgresInstallation" = "نصب PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL آماده استفاده است."
|
||||||
|
"postgresInstallNeedRoot" = "نصب خودکار به دسترسی root و محیط غیر Docker نیاز دارد."
|
||||||
|
"postgresInstallHint" = "برای راهاندازی خودکار PostgreSQL روی نصب کلیک کنید."
|
||||||
|
"postgresAlreadyInstalled" = "از قبل نصب شده"
|
||||||
|
"postgresInstallBtn" = "نصب PostgreSQL"
|
||||||
|
"host" = "میزبان"
|
||||||
|
"port" = "پورت"
|
||||||
|
"dbName" = "نام پایگاه داده"
|
||||||
|
"user" = "کاربر"
|
||||||
|
"password" = "گذرواژه"
|
||||||
|
"passwordHint" = "برای حفظ گذرواژه ذخیرهشده، این قسمت را خالی بگذارید."
|
||||||
|
"sslMode" = "حالت SSL"
|
||||||
|
"actions" = "اقدامات"
|
||||||
|
"actionsDesc" = "دادهها بهصورت خودکار مهاجرت داده میشوند. پیش از تغییر، یک نسخه پشتیبان قابلحمل ذخیره میشود و پنل دوباره راهاندازی خواهد شد."
|
||||||
|
"testConnection" = "تست اتصال"
|
||||||
|
"switchDatabase" = "تغییر پایگاه داده"
|
||||||
|
"switchDatabaseTitle" = "تغییر بکاند پایگاه داده"
|
||||||
|
"switchDatabaseConfirm" = "پنل یک نسخه پشتیبان قابلحمل ایجاد میکند، همه دادهها را مهاجرت میدهد و خودش را دوباره راهاندازی میکند. ادامه میدهید؟"
|
||||||
|
"backupRestore" = "پشتیبانگیری و بازیابی"
|
||||||
|
"exportPortableLabel" = "خروجی قابلحمل"
|
||||||
|
"exportPortableDesc" = "نسخه پشتیبان بینپلتفرمی (هم با SQLite و هم با PostgreSQL کار میکند، و همچنین توسط ربات تلگرام ارسال میشود)."
|
||||||
|
"exportNativeSQLiteLabel" = "خروجی SQLite بومی"
|
||||||
|
"exportNativeSQLiteDesc" = "فایل خام پایگاه داده که فقط زمانی در دسترس است که SQLite فعال باشد."
|
||||||
|
"importLabel" = "درونریزی"
|
||||||
|
"importDesc" = "بازیابی از فایل قابلحمل (.xui-backup) یا فایل قدیمی SQLite (.db)."
|
||||||
|
"exportPortableBtn" = "خروجی نسخه پشتیبان قابلحمل"
|
||||||
|
"exportNativeSQLiteBtn" = "خروجی SQLite بومی"
|
||||||
|
"importBtn" = "درونریزی نسخه پشتیبان"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "اعتبارنامههای ادمین"
|
"admin" = "اعتبارنامههای ادمین"
|
||||||
"twoFactor" = "احراز هویت دو مرحلهای"
|
"twoFactor" = "احراز هویت دو مرحلهای"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "Subnet Kumpulan IP"
|
"ipPool" = "Subnet Kumpulan IP"
|
||||||
"poolSize" = "Ukuran Kolam"
|
"poolSize" = "Ukuran Kolam"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "Database"
|
||||||
|
"backend" = "Backend"
|
||||||
|
"backendDesc" = "Konfigurasi database runtime disimpan di luar database panel."
|
||||||
|
"configSource" = "Sumber Konfigurasi"
|
||||||
|
"configSourceEnvDesc" = "Backend database ini dikendalikan oleh variabel lingkungan dan tidak dapat diubah di sini."
|
||||||
|
"configSourceDefaultDesc" = "Belum ada file konfigurasi yang disimpan. Pengaturan bawaan internal sedang aktif (SQLite)."
|
||||||
|
"configSourceDesc" = "Sumber saat ini:"
|
||||||
|
"sqlitePath" = "Path SQLite"
|
||||||
|
"postgresMode" = "Mode PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL yang dikelola panel di server ini (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Hubungkan ke server PostgreSQL yang sudah ada."
|
||||||
|
"postgresModeLocal" = "Lokal (dikelola panel)"
|
||||||
|
"postgresModeExternal" = "Eksternal"
|
||||||
|
"postgresInstallation" = "Instalasi PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL siap digunakan."
|
||||||
|
"postgresInstallNeedRoot" = "Instalasi otomatis memerlukan akses root dan lingkungan non-Docker."
|
||||||
|
"postgresInstallHint" = "Klik Instal untuk menyiapkan PostgreSQL secara otomatis."
|
||||||
|
"postgresAlreadyInstalled" = "Sudah terinstal"
|
||||||
|
"postgresInstallBtn" = "Instal PostgreSQL"
|
||||||
|
"host" = "Host"
|
||||||
|
"port" = "Port"
|
||||||
|
"dbName" = "Nama Database"
|
||||||
|
"user" = "Pengguna"
|
||||||
|
"password" = "Kata Sandi"
|
||||||
|
"passwordHint" = "Biarkan kosong untuk mempertahankan kata sandi yang tersimpan."
|
||||||
|
"sslMode" = "Mode SSL"
|
||||||
|
"actions" = "Tindakan"
|
||||||
|
"actionsDesc" = "Data dimigrasikan secara otomatis. Cadangan portabel disimpan sebelum beralih, dan panel akan dimulai ulang."
|
||||||
|
"testConnection" = "Uji Koneksi"
|
||||||
|
"switchDatabase" = "Ganti Database"
|
||||||
|
"switchDatabaseTitle" = "Ganti backend database"
|
||||||
|
"switchDatabaseConfirm" = "Panel akan membuat cadangan portabel, memigrasikan semua data, lalu memulai ulang dirinya sendiri. Lanjutkan?"
|
||||||
|
"backupRestore" = "Cadangkan & Pulihkan"
|
||||||
|
"exportPortableLabel" = "Ekspor Portabel"
|
||||||
|
"exportPortableDesc" = "Cadangan lintas platform (berfungsi dengan SQLite maupun PostgreSQL, juga dikirim melalui bot Telegram)."
|
||||||
|
"exportNativeSQLiteLabel" = "Ekspor SQLite Native"
|
||||||
|
"exportNativeSQLiteDesc" = "File database mentah, hanya tersedia saat SQLite aktif."
|
||||||
|
"importLabel" = "Impor"
|
||||||
|
"importDesc" = "Pulihkan dari file portabel (.xui-backup) atau file SQLite lama (.db)."
|
||||||
|
"exportPortableBtn" = "Ekspor Cadangan Portabel"
|
||||||
|
"exportNativeSQLiteBtn" = "Ekspor SQLite Native"
|
||||||
|
"importBtn" = "Impor Cadangan"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Kredensial admin"
|
"admin" = "Kredensial admin"
|
||||||
"twoFactor" = "Autentikasi dua faktor"
|
"twoFactor" = "Autentikasi dua faktor"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "IPプールサブネット"
|
"ipPool" = "IPプールサブネット"
|
||||||
"poolSize" = "プールサイズ"
|
"poolSize" = "プールサイズ"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "データベース"
|
||||||
|
"backend" = "バックエンド"
|
||||||
|
"backendDesc" = "ランタイムのデータベース設定は、パネルのデータベース外部に保存されています。"
|
||||||
|
"configSource" = "設定ソース"
|
||||||
|
"configSourceEnvDesc" = "このデータベースバックエンドは環境変数によって制御されており、ここでは変更できません。"
|
||||||
|
"configSourceDefaultDesc" = "まだ設定ファイルは保存されていません。組み込みのデフォルト設定(SQLite)が有効です。"
|
||||||
|
"configSourceDesc" = "現在のソース:"
|
||||||
|
"sqlitePath" = "SQLite パス"
|
||||||
|
"postgresMode" = "PostgreSQL モード"
|
||||||
|
"postgresModeLocalDesc" = "このサーバー上でパネル管理の PostgreSQL(127.0.0.1:5432)。"
|
||||||
|
"postgresModeExternalDesc" = "既存の PostgreSQL サーバーに接続します。"
|
||||||
|
"postgresModeLocal" = "ローカル(パネル管理)"
|
||||||
|
"postgresModeExternal" = "外部"
|
||||||
|
"postgresInstallation" = "PostgreSQL のインストール"
|
||||||
|
"postgresInstallReady" = "PostgreSQL は使用可能です。"
|
||||||
|
"postgresInstallNeedRoot" = "自動インストールには root 権限と非 Docker 環境が必要です。"
|
||||||
|
"postgresInstallHint" = "[インストール]をクリックすると、PostgreSQL が自動的にセットアップされます。"
|
||||||
|
"postgresAlreadyInstalled" = "インストール済み"
|
||||||
|
"postgresInstallBtn" = "PostgreSQL をインストール"
|
||||||
|
"host" = "ホスト"
|
||||||
|
"port" = "ポート"
|
||||||
|
"dbName" = "データベース名"
|
||||||
|
"user" = "ユーザー"
|
||||||
|
"password" = "パスワード"
|
||||||
|
"passwordHint" = "保存済みのパスワードを維持するには、空欄のままにしてください。"
|
||||||
|
"sslMode" = "SSL モード"
|
||||||
|
"actions" = "アクション"
|
||||||
|
"actionsDesc" = "データは自動的に移行されます。切り替え前にポータブルバックアップが保存され、パネルは再起動します。"
|
||||||
|
"testConnection" = "接続をテスト"
|
||||||
|
"switchDatabase" = "データベースを切り替える"
|
||||||
|
"switchDatabaseTitle" = "データベースバックエンドを切り替える"
|
||||||
|
"switchDatabaseConfirm" = "パネルはポータブルバックアップを作成し、すべてのデータを移行して、自身を再起動します。続行しますか?"
|
||||||
|
"backupRestore" = "バックアップと復元"
|
||||||
|
"exportPortableLabel" = "ポータブル形式でエクスポート"
|
||||||
|
"exportPortableDesc" = "クロスプラットフォーム対応のバックアップ(SQLite と PostgreSQL の両方で利用可能で、Telegram ボット経由でも送信されます)。"
|
||||||
|
"exportNativeSQLiteLabel" = "ネイティブ SQLite をエクスポート"
|
||||||
|
"exportNativeSQLiteDesc" = "生のデータベースファイル。SQLite が有効な場合のみ利用できます。"
|
||||||
|
"importLabel" = "インポート"
|
||||||
|
"importDesc" = "ポータブル形式(.xui-backup)または従来の SQLite(.db)ファイルから復元します。"
|
||||||
|
"exportPortableBtn" = "ポータブルバックアップをエクスポート"
|
||||||
|
"exportNativeSQLiteBtn" = "ネイティブ SQLite をエクスポート"
|
||||||
|
"importBtn" = "バックアップをインポート"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "管理者の資格情報"
|
"admin" = "管理者の資格情報"
|
||||||
"twoFactor" = "二段階認証"
|
"twoFactor" = "二段階認証"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "Sub-rede do Pool de IP"
|
"ipPool" = "Sub-rede do Pool de IP"
|
||||||
"poolSize" = "Tamanho do Pool"
|
"poolSize" = "Tamanho do Pool"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "Banco de dados"
|
||||||
|
"backend" = "Backend"
|
||||||
|
"backendDesc" = "A configuração do banco de dados em tempo de execução é armazenada fora do banco de dados do painel."
|
||||||
|
"configSource" = "Fonte da configuração"
|
||||||
|
"configSourceEnvDesc" = "Este backend de banco de dados é controlado por variáveis de ambiente e não pode ser alterado aqui."
|
||||||
|
"configSourceDefaultDesc" = "Nenhum arquivo de configuração foi salvo ainda. Os padrões internos estão ativos (SQLite)."
|
||||||
|
"configSourceDesc" = "Fonte atual:"
|
||||||
|
"sqlitePath" = "Caminho do SQLite"
|
||||||
|
"postgresMode" = "Modo PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL gerenciado pelo painel neste servidor (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Conectar a um servidor PostgreSQL existente."
|
||||||
|
"postgresModeLocal" = "Local (gerenciado pelo painel)"
|
||||||
|
"postgresModeExternal" = "Externo"
|
||||||
|
"postgresInstallation" = "Instalação do PostgreSQL"
|
||||||
|
"postgresInstallReady" = "O PostgreSQL está pronto para uso."
|
||||||
|
"postgresInstallNeedRoot" = "A instalação automática requer root e um ambiente sem Docker."
|
||||||
|
"postgresInstallHint" = "Clique em Instalar para configurar o PostgreSQL automaticamente."
|
||||||
|
"postgresAlreadyInstalled" = "Já instalado"
|
||||||
|
"postgresInstallBtn" = "Instalar PostgreSQL"
|
||||||
|
"host" = "Host"
|
||||||
|
"port" = "Porta"
|
||||||
|
"dbName" = "Nome do banco de dados"
|
||||||
|
"user" = "Usuário"
|
||||||
|
"password" = "Senha"
|
||||||
|
"passwordHint" = "Deixe em branco para manter a senha armazenada."
|
||||||
|
"sslMode" = "Modo SSL"
|
||||||
|
"actions" = "Ações"
|
||||||
|
"actionsDesc" = "Os dados são migrados automaticamente. Um backup portátil é salvo antes da troca, e o painel é reiniciado."
|
||||||
|
"testConnection" = "Testar conexão"
|
||||||
|
"switchDatabase" = "Trocar banco de dados"
|
||||||
|
"switchDatabaseTitle" = "Trocar backend do banco de dados"
|
||||||
|
"switchDatabaseConfirm" = "O painel criará um backup portátil, migrará todos os dados e será reiniciado. Continuar?"
|
||||||
|
"backupRestore" = "Backup e restauração"
|
||||||
|
"exportPortableLabel" = "Exportar portátil"
|
||||||
|
"exportPortableDesc" = "Backup multiplataforma (funciona com SQLite e PostgreSQL, e também é enviado pelo bot do Telegram)."
|
||||||
|
"exportNativeSQLiteLabel" = "Exportar SQLite nativo"
|
||||||
|
"exportNativeSQLiteDesc" = "Arquivo bruto do banco de dados, disponível apenas enquanto o SQLite estiver ativo."
|
||||||
|
"importLabel" = "Importar"
|
||||||
|
"importDesc" = "Restaurar a partir de um arquivo portátil (.xui-backup) ou de um arquivo SQLite legado (.db)."
|
||||||
|
"exportPortableBtn" = "Exportar backup portátil"
|
||||||
|
"exportNativeSQLiteBtn" = "Exportar SQLite nativo"
|
||||||
|
"importBtn" = "Importar backup"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Credenciais de administrador"
|
"admin" = "Credenciais de administrador"
|
||||||
"twoFactor" = "Autenticação de dois fatores"
|
"twoFactor" = "Autenticação de dois fatores"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "Подсеть пула IP"
|
"ipPool" = "Подсеть пула IP"
|
||||||
"poolSize" = "Размер пула"
|
"poolSize" = "Размер пула"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "База данных"
|
||||||
|
"backend" = "Бэкенд"
|
||||||
|
"backendDesc" = "Конфигурация базы данных хранится вне самой базы данных панели."
|
||||||
|
"configSource" = "Источник конфигурации"
|
||||||
|
"configSourceEnvDesc" = "Бэкенд базы данных управляется переменными окружения и не может быть изменён здесь."
|
||||||
|
"configSourceDefaultDesc" = "Файл конфигурации ещё не сохранён. Активны встроенные настройки по умолчанию (SQLite)."
|
||||||
|
"configSourceDesc" = "Текущий источник:"
|
||||||
|
"sqlitePath" = "Путь к SQLite"
|
||||||
|
"postgresMode" = "Режим PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL, управляемый панелью на этом сервере (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Подключение к существующему серверу PostgreSQL."
|
||||||
|
"postgresModeLocal" = "Локальный (управляется панелью)"
|
||||||
|
"postgresModeExternal" = "Внешний"
|
||||||
|
"postgresInstallation" = "Установка PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL установлен и готов к работе."
|
||||||
|
"postgresInstallNeedRoot" = "Автоустановка требует прав root и среды не-Docker."
|
||||||
|
"postgresInstallHint" = "Нажмите «Установить» для автоматической настройки PostgreSQL."
|
||||||
|
"postgresAlreadyInstalled" = "Уже установлен"
|
||||||
|
"postgresInstallBtn" = "Установить PostgreSQL"
|
||||||
|
"host" = "Хост"
|
||||||
|
"port" = "Порт"
|
||||||
|
"dbName" = "Имя базы данных"
|
||||||
|
"user" = "Пользователь"
|
||||||
|
"password" = "Пароль"
|
||||||
|
"passwordHint" = "Оставьте пустым, чтобы сохранить текущий пароль."
|
||||||
|
"sslMode" = "Режим SSL"
|
||||||
|
"actions" = "Действия"
|
||||||
|
"actionsDesc" = "Данные мигрируют автоматически. Перед переключением создаётся портативная резервная копия, затем панель перезапускается."
|
||||||
|
"testConnection" = "Проверить подключение"
|
||||||
|
"switchDatabase" = "Переключить базу данных"
|
||||||
|
"switchDatabaseTitle" = "Переключение бэкенда базы данных"
|
||||||
|
"switchDatabaseConfirm" = "Панель создаст портативную резервную копию, перенесёт все данные и перезапустится. Продолжить?"
|
||||||
|
"backupRestore" = "Резервное копирование и восстановление"
|
||||||
|
"exportPortableLabel" = "Портативный экспорт"
|
||||||
|
"exportPortableDesc" = "Кроссплатформенная резервная копия (работает с SQLite и PostgreSQL, также отправляется Telegram-ботом)."
|
||||||
|
"exportNativeSQLiteLabel" = "Нативный SQLite"
|
||||||
|
"exportNativeSQLiteDesc" = "Файл базы данных SQLite, доступен только при активном SQLite-бэкенде."
|
||||||
|
"importLabel" = "Импорт"
|
||||||
|
"importDesc" = "Восстановление из портативной резервной копии (.xui-backup) или устаревшего файла SQLite (.db)."
|
||||||
|
"exportPortableBtn" = "Экспорт портативной копии"
|
||||||
|
"exportNativeSQLiteBtn" = "Экспорт SQLite"
|
||||||
|
"importBtn" = "Импортировать резервную копию"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Учетные данные администратора"
|
"admin" = "Учетные данные администратора"
|
||||||
"twoFactor" = "Двухфакторная аутентификация"
|
"twoFactor" = "Двухфакторная аутентификация"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "IP Havuzu Alt Ağı"
|
"ipPool" = "IP Havuzu Alt Ağı"
|
||||||
"poolSize" = "Havuz Boyutu"
|
"poolSize" = "Havuz Boyutu"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "Veritabanı"
|
||||||
|
"backend" = "Arka Uç"
|
||||||
|
"backendDesc" = "Çalışma zamanı veritabanı yapılandırması panel veritabanının dışında saklanır."
|
||||||
|
"configSource" = "Yapılandırma Kaynağı"
|
||||||
|
"configSourceEnvDesc" = "Bu veritabanı arka ucu ortam değişkenleri tarafından kontrol edilir ve burada değiştirilemez."
|
||||||
|
"configSourceDefaultDesc" = "Henüz kaydedilmiş bir yapılandırma dosyası yok. Yerleşik varsayılanlar etkin (SQLite)."
|
||||||
|
"configSourceDesc" = "Geçerli kaynak:"
|
||||||
|
"sqlitePath" = "SQLite Yolu"
|
||||||
|
"postgresMode" = "PostgreSQL Modu"
|
||||||
|
"postgresModeLocalDesc" = "Bu sunucuda panel tarafından yönetilen PostgreSQL (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Mevcut bir PostgreSQL sunucusuna bağlan."
|
||||||
|
"postgresModeLocal" = "Yerel (panel tarafından yönetilen)"
|
||||||
|
"postgresModeExternal" = "Harici"
|
||||||
|
"postgresInstallation" = "PostgreSQL Kurulumu"
|
||||||
|
"postgresInstallReady" = "PostgreSQL kullanıma hazır."
|
||||||
|
"postgresInstallNeedRoot" = "Otomatik kurulum için root yetkisi ve Docker olmayan bir ortam gerekir."
|
||||||
|
"postgresInstallHint" = "PostgreSQL’i otomatik olarak kurmak için Kur’a tıklayın."
|
||||||
|
"postgresAlreadyInstalled" = "Zaten kurulu"
|
||||||
|
"postgresInstallBtn" = "PostgreSQL Kur"
|
||||||
|
"host" = "Ana Makine"
|
||||||
|
"port" = "Bağlantı Noktası"
|
||||||
|
"dbName" = "Veritabanı Adı"
|
||||||
|
"user" = "Kullanıcı"
|
||||||
|
"password" = "Parola"
|
||||||
|
"passwordHint" = "Kaydedilmiş parolayı korumak için boş bırakın."
|
||||||
|
"sslMode" = "SSL Modu"
|
||||||
|
"actions" = "İşlemler"
|
||||||
|
"actionsDesc" = "Veriler otomatik olarak taşınır. Geçişten önce taşınabilir bir yedek kaydedilir ve panel yeniden başlatılır."
|
||||||
|
"testConnection" = "Bağlantıyı Test Et"
|
||||||
|
"switchDatabase" = "Veritabanını Değiştir"
|
||||||
|
"switchDatabaseTitle" = "Veritabanı arka ucunu değiştir"
|
||||||
|
"switchDatabaseConfirm" = "Panel taşınabilir bir yedek oluşturacak, tüm verileri taşıyacak ve kendini yeniden başlatacaktır. Devam edilsin mi?"
|
||||||
|
"backupRestore" = "Yedekleme ve Geri Yükleme"
|
||||||
|
"exportPortableLabel" = "Taşınabilir Olarak Dışa Aktar"
|
||||||
|
"exportPortableDesc" = "Platformlar arası yedek (hem SQLite hem de PostgreSQL ile çalışır, ayrıca Telegram botu üzerinden de gönderilir)."
|
||||||
|
"exportNativeSQLiteLabel" = "Yerel SQLite’ı Dışa Aktar"
|
||||||
|
"exportNativeSQLiteDesc" = "Ham veritabanı dosyası, yalnızca SQLite etkin olduğunda kullanılabilir."
|
||||||
|
"importLabel" = "İçe Aktar"
|
||||||
|
"importDesc" = "Taşınabilir (.xui-backup) veya eski SQLite (.db) dosyasından geri yükleyin."
|
||||||
|
"exportPortableBtn" = "Taşınabilir Yedeği Dışa Aktar"
|
||||||
|
"exportNativeSQLiteBtn" = "Yerel SQLite’ı Dışa Aktar"
|
||||||
|
"importBtn" = "Yedeği İçe Aktar"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Yönetici kimlik bilgileri"
|
"admin" = "Yönetici kimlik bilgileri"
|
||||||
"twoFactor" = "İki adımlı doğrulama"
|
"twoFactor" = "İki adımlı doğrulama"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "Підмережа IP-пулу"
|
"ipPool" = "Підмережа IP-пулу"
|
||||||
"poolSize" = "Розмір пулу"
|
"poolSize" = "Розмір пулу"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "База даних"
|
||||||
|
"backend" = "Бекенд"
|
||||||
|
"backendDesc" = "Конфігурація бази даних під час виконання зберігається поза базою даних панелі."
|
||||||
|
"configSource" = "Джерело конфігурації"
|
||||||
|
"configSourceEnvDesc" = "Цей бекенд бази даних керується змінними середовища й не може бути змінений тут."
|
||||||
|
"configSourceDefaultDesc" = "Файл конфігурації ще не збережено. Використовуються вбудовані значення за замовчуванням (SQLite)."
|
||||||
|
"configSourceDesc" = "Поточне джерело:"
|
||||||
|
"sqlitePath" = "Шлях до SQLite"
|
||||||
|
"postgresMode" = "Режим PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL, яким керує панель, на цьому сервері (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Підключення до наявного сервера PostgreSQL."
|
||||||
|
"postgresModeLocal" = "Локальний (керується панеллю)"
|
||||||
|
"postgresModeExternal" = "Зовнішній"
|
||||||
|
"postgresInstallation" = "Встановлення PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL готовий до використання."
|
||||||
|
"postgresInstallNeedRoot" = "Автоматичне встановлення потребує root-доступу та середовища без Docker."
|
||||||
|
"postgresInstallHint" = "Натисніть «Встановити», щоб автоматично налаштувати PostgreSQL."
|
||||||
|
"postgresAlreadyInstalled" = "Уже встановлено"
|
||||||
|
"postgresInstallBtn" = "Встановити PostgreSQL"
|
||||||
|
"host" = "Хост"
|
||||||
|
"port" = "Порт"
|
||||||
|
"dbName" = "Назва бази даних"
|
||||||
|
"user" = "Користувач"
|
||||||
|
"password" = "Пароль"
|
||||||
|
"passwordHint" = "Залиште порожнім, щоб зберегти поточний пароль."
|
||||||
|
"sslMode" = "Режим SSL"
|
||||||
|
"actions" = "Дії"
|
||||||
|
"actionsDesc" = "Дані мігруються автоматично. Перед перемиканням зберігається переносна резервна копія, після чого панель перезапускається."
|
||||||
|
"testConnection" = "Перевірити з’єднання"
|
||||||
|
"switchDatabase" = "Перемкнути базу даних"
|
||||||
|
"switchDatabaseTitle" = "Перемкнути бекенд бази даних"
|
||||||
|
"switchDatabaseConfirm" = "Панель створить переносну резервну копію, перенесе всі дані та перезапуститься. Продовжити?"
|
||||||
|
"backupRestore" = "Резервне копіювання та відновлення"
|
||||||
|
"exportPortableLabel" = "Експортувати переносну копію"
|
||||||
|
"exportPortableDesc" = "Кросплатформна резервна копія (працює і з SQLite, і з PostgreSQL, також надсилається через Telegram-бота)."
|
||||||
|
"exportNativeSQLiteLabel" = "Експортувати рідний SQLite"
|
||||||
|
"exportNativeSQLiteDesc" = "Необроблений файл бази даних, доступний лише коли активний SQLite."
|
||||||
|
"importLabel" = "Імпорт"
|
||||||
|
"importDesc" = "Відновлення з переносного файлу (.xui-backup) або застарілого файлу SQLite (.db)."
|
||||||
|
"exportPortableBtn" = "Експортувати переносну резервну копію"
|
||||||
|
"exportNativeSQLiteBtn" = "Експортувати рідний SQLite"
|
||||||
|
"importBtn" = "Імпортувати резервну копію"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Облікові дані адміністратора"
|
"admin" = "Облікові дані адміністратора"
|
||||||
"twoFactor" = "Двофакторна аутентифікація"
|
"twoFactor" = "Двофакторна аутентифікація"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "Mạng con nhóm IP"
|
"ipPool" = "Mạng con nhóm IP"
|
||||||
"poolSize" = "Kích thước bể bơi"
|
"poolSize" = "Kích thước bể bơi"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "Cơ sở dữ liệu"
|
||||||
|
"backend" = "Phần phụ trợ"
|
||||||
|
"backendDesc" = "Cấu hình cơ sở dữ liệu khi chạy được lưu bên ngoài cơ sở dữ liệu của bảng điều khiển."
|
||||||
|
"configSource" = "Nguồn cấu hình"
|
||||||
|
"configSourceEnvDesc" = "Phần phụ trợ cơ sở dữ liệu này được kiểm soát bởi các biến môi trường và không thể thay đổi tại đây."
|
||||||
|
"configSourceDefaultDesc" = "Chưa có tệp cấu hình nào được lưu. Cài đặt mặc định tích hợp sẵn đang hoạt động (SQLite)."
|
||||||
|
"configSourceDesc" = "Nguồn hiện tại:"
|
||||||
|
"sqlitePath" = "Đường dẫn SQLite"
|
||||||
|
"postgresMode" = "Chế độ PostgreSQL"
|
||||||
|
"postgresModeLocalDesc" = "PostgreSQL do bảng điều khiển quản lý trên máy chủ này (127.0.0.1:5432)."
|
||||||
|
"postgresModeExternalDesc" = "Kết nối tới một máy chủ PostgreSQL hiện có."
|
||||||
|
"postgresModeLocal" = "Cục bộ (do bảng điều khiển quản lý)"
|
||||||
|
"postgresModeExternal" = "Bên ngoài"
|
||||||
|
"postgresInstallation" = "Cài đặt PostgreSQL"
|
||||||
|
"postgresInstallReady" = "PostgreSQL đã sẵn sàng để sử dụng."
|
||||||
|
"postgresInstallNeedRoot" = "Cài đặt tự động yêu cầu quyền root và môi trường không phải Docker."
|
||||||
|
"postgresInstallHint" = "Nhấn Cài đặt để thiết lập PostgreSQL tự động."
|
||||||
|
"postgresAlreadyInstalled" = "Đã được cài đặt"
|
||||||
|
"postgresInstallBtn" = "Cài đặt PostgreSQL"
|
||||||
|
"host" = "Máy chủ"
|
||||||
|
"port" = "Cổng"
|
||||||
|
"dbName" = "Tên cơ sở dữ liệu"
|
||||||
|
"user" = "Người dùng"
|
||||||
|
"password" = "Mật khẩu"
|
||||||
|
"passwordHint" = "Để trống để giữ nguyên mật khẩu đã lưu."
|
||||||
|
"sslMode" = "Chế độ SSL"
|
||||||
|
"actions" = "Thao tác"
|
||||||
|
"actionsDesc" = "Dữ liệu sẽ được di chuyển tự động. Một bản sao lưu di động sẽ được lưu trước khi chuyển đổi, và bảng điều khiển sẽ khởi động lại."
|
||||||
|
"testConnection" = "Kiểm tra kết nối"
|
||||||
|
"switchDatabase" = "Chuyển cơ sở dữ liệu"
|
||||||
|
"switchDatabaseTitle" = "Chuyển phần phụ trợ cơ sở dữ liệu"
|
||||||
|
"switchDatabaseConfirm" = "Bảng điều khiển sẽ tạo một bản sao lưu di động, di chuyển toàn bộ dữ liệu và tự khởi động lại. Tiếp tục?"
|
||||||
|
"backupRestore" = "Sao lưu & Khôi phục"
|
||||||
|
"exportPortableLabel" = "Xuất bản sao lưu di động"
|
||||||
|
"exportPortableDesc" = "Bản sao lưu đa nền tảng (hoạt động với cả SQLite và PostgreSQL, cũng được gửi qua bot Telegram)."
|
||||||
|
"exportNativeSQLiteLabel" = "Xuất SQLite gốc"
|
||||||
|
"exportNativeSQLiteDesc" = "Tệp cơ sở dữ liệu thô, chỉ khả dụng khi SQLite đang hoạt động."
|
||||||
|
"importLabel" = "Nhập"
|
||||||
|
"importDesc" = "Khôi phục từ tệp sao lưu di động (.xui-backup) hoặc tệp SQLite cũ (.db)."
|
||||||
|
"exportPortableBtn" = "Xuất bản sao lưu di động"
|
||||||
|
"exportNativeSQLiteBtn" = "Xuất SQLite gốc"
|
||||||
|
"importBtn" = "Nhập bản sao lưu"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "Thông tin đăng nhập quản trị viên"
|
"admin" = "Thông tin đăng nhập quản trị viên"
|
||||||
"twoFactor" = "Xác thực hai yếu tố"
|
"twoFactor" = "Xác thực hai yếu tố"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "IP 池子网"
|
"ipPool" = "IP 池子网"
|
||||||
"poolSize" = "池大小"
|
"poolSize" = "池大小"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "数据库"
|
||||||
|
"backend" = "后端"
|
||||||
|
"backendDesc" = "运行时数据库配置存储在面板数据库之外。"
|
||||||
|
"configSource" = "配置来源"
|
||||||
|
"configSourceEnvDesc" = "此数据库后端由环境变量控制,无法在此处更改。"
|
||||||
|
"configSourceDefaultDesc" = "尚未保存配置文件。当前使用内置默认值(SQLite)。"
|
||||||
|
"configSourceDesc" = "当前来源:"
|
||||||
|
"sqlitePath" = "SQLite 路径"
|
||||||
|
"postgresMode" = "PostgreSQL 模式"
|
||||||
|
"postgresModeLocalDesc" = "此服务器上的面板托管 PostgreSQL(127.0.0.1:5432)。"
|
||||||
|
"postgresModeExternalDesc" = "连接到现有的 PostgreSQL 服务器。"
|
||||||
|
"postgresModeLocal" = "本地(面板托管)"
|
||||||
|
"postgresModeExternal" = "外部"
|
||||||
|
"postgresInstallation" = "PostgreSQL 安装"
|
||||||
|
"postgresInstallReady" = "PostgreSQL 已可使用。"
|
||||||
|
"postgresInstallNeedRoot" = "自动安装需要 root 权限且不能在 Docker 环境中运行。"
|
||||||
|
"postgresInstallHint" = "点击“安装”以自动设置 PostgreSQL。"
|
||||||
|
"postgresAlreadyInstalled" = "已安装"
|
||||||
|
"postgresInstallBtn" = "安装 PostgreSQL"
|
||||||
|
"host" = "主机"
|
||||||
|
"port" = "端口"
|
||||||
|
"dbName" = "数据库名称"
|
||||||
|
"user" = "用户"
|
||||||
|
"password" = "密码"
|
||||||
|
"passwordHint" = "留空以保留已保存的密码。"
|
||||||
|
"sslMode" = "SSL 模式"
|
||||||
|
"actions" = "操作"
|
||||||
|
"actionsDesc" = "数据会自动迁移。切换前会保存一个可移植备份,然后面板会重启。"
|
||||||
|
"testConnection" = "测试连接"
|
||||||
|
"switchDatabase" = "切换数据库"
|
||||||
|
"switchDatabaseTitle" = "切换数据库后端"
|
||||||
|
"switchDatabaseConfirm" = "面板将创建一个可移植备份,迁移所有数据,并自行重启。是否继续?"
|
||||||
|
"backupRestore" = "备份与恢复"
|
||||||
|
"exportPortableLabel" = "导出可移植备份"
|
||||||
|
"exportPortableDesc" = "跨平台备份(兼容 SQLite 和 PostgreSQL,也会通过 Telegram 机器人发送)。"
|
||||||
|
"exportNativeSQLiteLabel" = "导出原生 SQLite"
|
||||||
|
"exportNativeSQLiteDesc" = "原始数据库文件,仅在当前使用 SQLite 时可用。"
|
||||||
|
"importLabel" = "导入"
|
||||||
|
"importDesc" = "从可移植备份(.xui-backup)或旧版 SQLite(.db)文件恢复。"
|
||||||
|
"exportPortableBtn" = "导出可移植备份"
|
||||||
|
"exportNativeSQLiteBtn" = "导出原生 SQLite"
|
||||||
|
"importBtn" = "导入备份"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "管理员凭据"
|
"admin" = "管理员凭据"
|
||||||
"twoFactor" = "双重验证"
|
"twoFactor" = "双重验证"
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,50 @@
|
||||||
"ipPool" = "IP 池子網"
|
"ipPool" = "IP 池子網"
|
||||||
"poolSize" = "池大小"
|
"poolSize" = "池大小"
|
||||||
|
|
||||||
|
[pages.settings.database]
|
||||||
|
"sectionTitle" = "資料庫"
|
||||||
|
"backend" = "後端"
|
||||||
|
"backendDesc" = "執行階段的資料庫設定儲存在面板資料庫之外。"
|
||||||
|
"configSource" = "設定來源"
|
||||||
|
"configSourceEnvDesc" = "此資料庫後端由環境變數控制,無法在此處變更。"
|
||||||
|
"configSourceDefaultDesc" = "尚未儲存設定檔。目前使用內建預設值(SQLite)。"
|
||||||
|
"configSourceDesc" = "目前來源:"
|
||||||
|
"sqlitePath" = "SQLite 路徑"
|
||||||
|
"postgresMode" = "PostgreSQL 模式"
|
||||||
|
"postgresModeLocalDesc" = "此伺服器上的面板管理 PostgreSQL(127.0.0.1:5432)。"
|
||||||
|
"postgresModeExternalDesc" = "連線到現有的 PostgreSQL 伺服器。"
|
||||||
|
"postgresModeLocal" = "本機(由面板管理)"
|
||||||
|
"postgresModeExternal" = "外部"
|
||||||
|
"postgresInstallation" = "PostgreSQL 安裝"
|
||||||
|
"postgresInstallReady" = "PostgreSQL 已可使用。"
|
||||||
|
"postgresInstallNeedRoot" = "自動安裝需要 root 權限且不得在 Docker 環境中執行。"
|
||||||
|
"postgresInstallHint" = "點擊「安裝」以自動設定 PostgreSQL。"
|
||||||
|
"postgresAlreadyInstalled" = "已安裝"
|
||||||
|
"postgresInstallBtn" = "安裝 PostgreSQL"
|
||||||
|
"host" = "主機"
|
||||||
|
"port" = "連接埠"
|
||||||
|
"dbName" = "資料庫名稱"
|
||||||
|
"user" = "使用者"
|
||||||
|
"password" = "密碼"
|
||||||
|
"passwordHint" = "留空可保留已儲存的密碼。"
|
||||||
|
"sslMode" = "SSL 模式"
|
||||||
|
"actions" = "操作"
|
||||||
|
"actionsDesc" = "資料會自動遷移。切換前會先儲存可攜式備份,然後重新啟動面板。"
|
||||||
|
"testConnection" = "測試連線"
|
||||||
|
"switchDatabase" = "切換資料庫"
|
||||||
|
"switchDatabaseTitle" = "切換資料庫後端"
|
||||||
|
"switchDatabaseConfirm" = "面板將建立可攜式備份、遷移所有資料,並重新啟動自身。要繼續嗎?"
|
||||||
|
"backupRestore" = "備份與還原"
|
||||||
|
"exportPortableLabel" = "匯出可攜式備份"
|
||||||
|
"exportPortableDesc" = "跨平台備份(可同時用於 SQLite 與 PostgreSQL,也可由 Telegram 機器人傳送)。"
|
||||||
|
"exportNativeSQLiteLabel" = "匯出原生 SQLite"
|
||||||
|
"exportNativeSQLiteDesc" = "原始資料庫檔案,僅在啟用 SQLite 時可用。"
|
||||||
|
"importLabel" = "匯入"
|
||||||
|
"importDesc" = "從可攜式(.xui-backup)或舊版 SQLite(.db)檔案還原。"
|
||||||
|
"exportPortableBtn" = "匯出可攜式備份"
|
||||||
|
"exportNativeSQLiteBtn" = "匯出原生 SQLite"
|
||||||
|
"importBtn" = "匯入備份"
|
||||||
|
|
||||||
[pages.settings.security]
|
[pages.settings.security]
|
||||||
"admin" = "管理員憑證"
|
"admin" = "管理員憑證"
|
||||||
"twoFactor" = "雙重驗證"
|
"twoFactor" = "雙重驗證"
|
||||||
|
|
|
||||||
135
x-ui.sh
135
x-ui.sh
|
|
@ -1672,6 +1672,133 @@ run_speedtest() {
|
||||||
speedtest
|
speedtest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
database_management() {
|
||||||
|
echo -e "\n${green}\t1.${plain} Show Current Database Backend"
|
||||||
|
echo -e "${green}\t2.${plain} Install Local PostgreSQL"
|
||||||
|
echo -e "${green}\t3.${plain} Switch Database Backend"
|
||||||
|
echo -e "${green}\t4.${plain} Export Portable Backup"
|
||||||
|
echo -e "${green}\t5.${plain} Export Native SQLite"
|
||||||
|
echo -e "${green}\t6.${plain} Import Backup"
|
||||||
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||||||
|
read -rp "Choose an option: " db_choice
|
||||||
|
|
||||||
|
case "${db_choice}" in
|
||||||
|
0)
|
||||||
|
show_menu
|
||||||
|
;;
|
||||||
|
1)
|
||||||
|
${xui_folder}/x-ui database show
|
||||||
|
before_show_menu
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
${xui_folder}/x-ui database install-postgres
|
||||||
|
before_show_menu
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
local db_backend=""
|
||||||
|
local pg_mode=""
|
||||||
|
local sqlite_path=""
|
||||||
|
local pg_host="127.0.0.1"
|
||||||
|
local pg_port="5432"
|
||||||
|
local pg_db="xui"
|
||||||
|
local pg_user="xui"
|
||||||
|
local pg_pass=""
|
||||||
|
local pg_sslmode="disable"
|
||||||
|
|
||||||
|
echo -e "${green}1.${plain} SQLite"
|
||||||
|
echo -e "${green}2.${plain} PostgreSQL"
|
||||||
|
read -rp "Choose database backend (default 1): " db_backend
|
||||||
|
db_backend="${db_backend// /}"
|
||||||
|
|
||||||
|
if [[ "${db_backend}" != "2" ]]; then
|
||||||
|
read -rp "SQLite path [/etc/x-ui/x-ui.db]: " sqlite_path
|
||||||
|
sqlite_path="${sqlite_path:-/etc/x-ui/x-ui.db}"
|
||||||
|
${xui_folder}/x-ui database switch -driver sqlite -sqlite-path "${sqlite_path}"
|
||||||
|
confirm_restart
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${green}1.${plain} Local PostgreSQL"
|
||||||
|
echo -e "${green}2.${plain} External PostgreSQL"
|
||||||
|
read -rp "Choose PostgreSQL mode (default 1): " pg_mode
|
||||||
|
pg_mode="${pg_mode// /}"
|
||||||
|
|
||||||
|
if [[ "${pg_mode}" != "2" ]]; then
|
||||||
|
read -rp "Host [127.0.0.1]: " pg_host
|
||||||
|
pg_host="${pg_host:-127.0.0.1}"
|
||||||
|
read -rp "Port [5432]: " pg_port
|
||||||
|
pg_port="${pg_port:-5432}"
|
||||||
|
read -rp "Database name [xui]: " pg_db
|
||||||
|
pg_db="${pg_db:-xui}"
|
||||||
|
read -rp "Username [xui]: " pg_user
|
||||||
|
pg_user="${pg_user:-xui}"
|
||||||
|
read -rp "Password [random]: " pg_pass
|
||||||
|
[[ -z "${pg_pass}" ]] && pg_pass=$(gen_random_string 18)
|
||||||
|
${xui_folder}/x-ui database switch \
|
||||||
|
-driver postgres \
|
||||||
|
-postgres-mode local \
|
||||||
|
-postgres-host "${pg_host}" \
|
||||||
|
-postgres-port "${pg_port}" \
|
||||||
|
-postgres-db "${pg_db}" \
|
||||||
|
-postgres-user "${pg_user}" \
|
||||||
|
-postgres-password "${pg_pass}" \
|
||||||
|
-postgres-local true
|
||||||
|
else
|
||||||
|
read -rp "Host: " pg_host
|
||||||
|
if [[ -z "${pg_host}" ]]; then
|
||||||
|
LOGE "Host is required."
|
||||||
|
before_show_menu
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
read -rp "Port [5432]: " pg_port
|
||||||
|
pg_port="${pg_port:-5432}"
|
||||||
|
read -rp "Database name [xui]: " pg_db
|
||||||
|
pg_db="${pg_db:-xui}"
|
||||||
|
read -rp "Username [xui]: " pg_user
|
||||||
|
pg_user="${pg_user:-xui}"
|
||||||
|
read -rp "Password: " pg_pass
|
||||||
|
read -rp "SSL mode [disable]: " pg_sslmode
|
||||||
|
pg_sslmode="${pg_sslmode:-disable}"
|
||||||
|
${xui_folder}/x-ui database switch \
|
||||||
|
-driver postgres \
|
||||||
|
-postgres-mode external \
|
||||||
|
-postgres-host "${pg_host}" \
|
||||||
|
-postgres-port "${pg_port}" \
|
||||||
|
-postgres-db "${pg_db}" \
|
||||||
|
-postgres-user "${pg_user}" \
|
||||||
|
-postgres-password "${pg_pass}" \
|
||||||
|
-postgres-sslmode "${pg_sslmode}" \
|
||||||
|
-postgres-local false
|
||||||
|
fi
|
||||||
|
confirm_restart
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
read -rp "Output path or directory [current directory]: " export_path
|
||||||
|
${xui_folder}/x-ui database export -type portable -out "${export_path}"
|
||||||
|
before_show_menu
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
read -rp "Output path or directory [current directory]: " export_path
|
||||||
|
${xui_folder}/x-ui database export -type sqlite -out "${export_path}"
|
||||||
|
before_show_menu
|
||||||
|
;;
|
||||||
|
6)
|
||||||
|
read -rp "Backup file path: " import_path
|
||||||
|
if [[ -z "${import_path}" ]]; then
|
||||||
|
LOGE "Backup file path is required."
|
||||||
|
before_show_menu
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
${xui_folder}/x-ui database import -file "${import_path}"
|
||||||
|
confirm_restart
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
LOGE "Invalid option."
|
||||||
|
database_management
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ip_validation() {
|
ip_validation() {
|
||||||
|
|
@ -2217,10 +2344,11 @@ show_menu() {
|
||||||
│ ${green}24.${plain} Enable BBR │
|
│ ${green}24.${plain} Enable BBR │
|
||||||
│ ${green}25.${plain} Update Geo Files │
|
│ ${green}25.${plain} Update Geo Files │
|
||||||
│ ${green}26.${plain} Speedtest by Ookla │
|
│ ${green}26.${plain} Speedtest by Ookla │
|
||||||
|
│ ${green}27.${plain} Database Management │
|
||||||
╚────────────────────────────────────────────────╝
|
╚────────────────────────────────────────────────╝
|
||||||
"
|
"
|
||||||
show_status
|
show_status
|
||||||
echo && read -rp "Please enter your selection [0-26]: " num
|
echo && read -rp "Please enter your selection [0-27]: " num
|
||||||
|
|
||||||
case "${num}" in
|
case "${num}" in
|
||||||
0)
|
0)
|
||||||
|
|
@ -2304,8 +2432,11 @@ show_menu() {
|
||||||
26)
|
26)
|
||||||
run_speedtest
|
run_speedtest
|
||||||
;;
|
;;
|
||||||
|
27)
|
||||||
|
database_management
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
LOGE "Please enter the correct number [0-26]"
|
LOGE "Please enter the correct number [0-27]"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue