mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
1127 lines
32 KiB
Go
1127 lines
32 KiB
Go
// Package main is the entry point for the 3x-ui web panel application.
|
|
// It initializes the database, web server, and handles command-line operations for managing the panel.
|
|
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
_ "unsafe"
|
|
|
|
"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/sub"
|
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
|
"github.com/mhsanaei/3x-ui/v2/util/sys"
|
|
"github.com/mhsanaei/3x-ui/v2/web"
|
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
|
|
|
"github.com/joho/godotenv"
|
|
"github.com/op/go-logging"
|
|
)
|
|
|
|
type settingCommandOptions struct {
|
|
port int
|
|
username string
|
|
password string
|
|
webBasePath string
|
|
webDomain string
|
|
listenIP string
|
|
reset bool
|
|
show bool
|
|
getListen bool
|
|
getCert bool
|
|
resetTwoFactor bool
|
|
tgbotToken string
|
|
tgbotChatID string
|
|
tgbotRuntime string
|
|
enableTgbot bool
|
|
dbType string
|
|
dbHost string
|
|
dbPort string
|
|
dbUser string
|
|
dbPassword string
|
|
dbName string
|
|
nodeRoleSet bool
|
|
nodeIDSet bool
|
|
syncIntervalSet bool
|
|
trafficFlushIntervalSet bool
|
|
settingStatus bool
|
|
}
|
|
|
|
func (o settingCommandOptions) needsDBInit() bool {
|
|
return o.port > 0 ||
|
|
o.username != "" ||
|
|
o.password != "" ||
|
|
o.webBasePath != "" ||
|
|
o.webDomain != "" ||
|
|
o.listenIP != "" ||
|
|
o.show ||
|
|
o.getListen ||
|
|
o.getCert ||
|
|
o.settingStatus ||
|
|
o.resetTwoFactor ||
|
|
o.tgbotToken != "" ||
|
|
o.tgbotChatID != "" ||
|
|
o.tgbotRuntime != "" ||
|
|
o.enableTgbot
|
|
}
|
|
|
|
// runWebServer initializes and starts the web server for the 3x-ui panel.
|
|
func runWebServer() {
|
|
log.Printf("Starting %v %v", config.GetName(), config.GetVersion())
|
|
|
|
dbCfg := config.GetDBConfigFromJSON()
|
|
nodeCfg := config.GetNodeConfigFromJSON()
|
|
if err := config.ValidateNodeConfig(nodeCfg, dbCfg); err != nil {
|
|
log.Fatalf("invalid node configuration: %v", err)
|
|
}
|
|
|
|
switch config.GetLogLevel() {
|
|
case config.Debug:
|
|
logger.InitLogger(logging.DEBUG)
|
|
case config.Info:
|
|
logger.InitLogger(logging.INFO)
|
|
case config.Notice:
|
|
logger.InitLogger(logging.NOTICE)
|
|
case config.Warning:
|
|
logger.InitLogger(logging.WARNING)
|
|
case config.Error:
|
|
logger.InitLogger(logging.ERROR)
|
|
default:
|
|
log.Fatalf("Unknown log level: %v", config.GetLogLevel())
|
|
}
|
|
|
|
godotenv.Load()
|
|
|
|
err := database.InitDB()
|
|
if err != nil {
|
|
log.Fatalf("Error initializing database: %v", err)
|
|
}
|
|
|
|
var server *web.Server
|
|
server = web.NewServer()
|
|
global.SetWebServer(server)
|
|
err = server.Start()
|
|
if err != nil {
|
|
log.Fatalf("Error starting web server: %v", err)
|
|
return
|
|
}
|
|
|
|
var subServer *sub.Server
|
|
subServer = sub.NewServer()
|
|
global.SetSubServer(subServer)
|
|
err = subServer.Start()
|
|
if err != nil {
|
|
log.Fatalf("Error starting sub server: %v", err)
|
|
return
|
|
}
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
// Trap shutdown signals
|
|
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
|
|
for {
|
|
sig := <-sigCh
|
|
|
|
switch sig {
|
|
case syscall.SIGHUP:
|
|
logger.Info("Received SIGHUP signal. Restarting servers...")
|
|
|
|
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
|
|
service.StopBot()
|
|
// --
|
|
|
|
err := server.Stop()
|
|
if err != nil {
|
|
logger.Debug("Error stopping web server:", err)
|
|
}
|
|
err = subServer.Stop()
|
|
if err != nil {
|
|
logger.Debug("Error stopping sub server:", err)
|
|
}
|
|
|
|
server = web.NewServer()
|
|
global.SetWebServer(server)
|
|
err = server.Start()
|
|
if err != nil {
|
|
log.Fatalf("Error restarting web server: %v", err)
|
|
return
|
|
}
|
|
log.Println("Web server restarted successfully.")
|
|
|
|
subServer = sub.NewServer()
|
|
global.SetSubServer(subServer)
|
|
err = subServer.Start()
|
|
if err != nil {
|
|
log.Fatalf("Error restarting sub server: %v", err)
|
|
return
|
|
}
|
|
log.Println("Sub server restarted successfully.")
|
|
case sys.SIGUSR1:
|
|
logger.Info("Received USR1 signal, restarting xray-core...")
|
|
err := server.RestartXray()
|
|
if err != nil {
|
|
logger.Error("Failed to restart xray-core:", err)
|
|
}
|
|
|
|
default:
|
|
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
|
service.StopBot()
|
|
// ------------------------------------------------------------
|
|
|
|
server.Stop()
|
|
subServer.Stop()
|
|
log.Println("Shutting down servers.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// resetSetting resets all panel settings to their default values.
|
|
func resetSetting() {
|
|
err := database.InitDB()
|
|
if err != nil {
|
|
fmt.Println("Failed to initialize database:", err)
|
|
return
|
|
}
|
|
|
|
settingService := service.SettingService{}
|
|
err = settingService.ResetSettings()
|
|
if err != nil {
|
|
fmt.Println("Failed to reset settings:", err)
|
|
} else {
|
|
fmt.Println("Settings successfully reset.")
|
|
}
|
|
}
|
|
|
|
// showSetting displays the current panel settings if show is true.
|
|
func showSetting(show bool) {
|
|
if show {
|
|
settingService := service.SettingService{}
|
|
port, err := settingService.GetPort()
|
|
if err != nil {
|
|
fmt.Println("get current port failed, error info:", err)
|
|
}
|
|
|
|
webBasePath, err := settingService.GetBasePath()
|
|
if err != nil {
|
|
fmt.Println("get webBasePath failed, error info:", err)
|
|
}
|
|
|
|
webDomain, err := settingService.GetWebDomain()
|
|
if err != nil {
|
|
fmt.Println("get webDomain failed, error info:", err)
|
|
}
|
|
|
|
certFile, err := settingService.GetCertFile()
|
|
if err != nil {
|
|
fmt.Println("get cert file failed, error info:", err)
|
|
}
|
|
keyFile, err := settingService.GetKeyFile()
|
|
if err != nil {
|
|
fmt.Println("get key file failed, error info:", err)
|
|
}
|
|
|
|
userService := service.UserService{}
|
|
userModel, err := userService.GetFirstUser()
|
|
if err != nil {
|
|
fmt.Println("get current user info failed, error info:", err)
|
|
}
|
|
|
|
if userModel.Username == "" || userModel.Password == "" {
|
|
fmt.Println("current username or password is empty")
|
|
}
|
|
|
|
fmt.Println("current panel settings as follows:")
|
|
if certFile == "" || keyFile == "" {
|
|
fmt.Println("Warning: Panel is not secure with SSL")
|
|
} else {
|
|
fmt.Println("Panel is secure with SSL")
|
|
}
|
|
|
|
hasDefaultCredential := func() bool {
|
|
return userModel.Username == "admin" && crypto.CheckPasswordHash(userModel.Password, "admin")
|
|
}()
|
|
|
|
fmt.Println("hasDefaultCredential:", hasDefaultCredential)
|
|
fmt.Println("port:", port)
|
|
fmt.Println("webDomain:", webDomain)
|
|
fmt.Println("webBasePath:", webBasePath)
|
|
nodeCfg := config.GetNodeConfigFromJSON()
|
|
fmt.Println("nodeRole:", nodeCfg.Role)
|
|
fmt.Println("nodeId:", nodeCfg.NodeID)
|
|
fmt.Println("syncInterval:", nodeCfg.SyncIntervalSeconds)
|
|
fmt.Println("trafficFlushInterval:", nodeCfg.TrafficFlushSeconds)
|
|
}
|
|
}
|
|
|
|
// showSettingStatus outputs all settings in a single machine-parseable call.
|
|
// This avoids multiple CLI invocations that each re-init the database.
|
|
func showSettingStatus() {
|
|
settingService := service.SettingService{}
|
|
|
|
port, _ := settingService.GetPort()
|
|
webBasePath, _ := settingService.GetBasePath()
|
|
webDomain, _ := settingService.GetWebDomain()
|
|
certFile, _ := settingService.GetCertFile()
|
|
keyFile, _ := settingService.GetKeyFile()
|
|
|
|
userService := service.UserService{}
|
|
userModel, _ := userService.GetFirstUser()
|
|
|
|
hasDefaultCredential := userModel.Username == "admin" && crypto.CheckPasswordHash(userModel.Password, "admin")
|
|
|
|
nodeCfg := config.GetNodeConfigFromJSON()
|
|
|
|
fmt.Printf("port:%d\n", port)
|
|
fmt.Printf("webBasePath:%s\n", webBasePath)
|
|
fmt.Printf("webDomain:%s\n", webDomain)
|
|
fmt.Printf("certFile:%s\n", certFile)
|
|
fmt.Printf("keyFile:%s\n", keyFile)
|
|
fmt.Printf("hasDefaultCredential:%v\n", hasDefaultCredential)
|
|
fmt.Printf("username:%s\n", userModel.Username)
|
|
fmt.Printf("nodeRole:%s\n", nodeCfg.Role)
|
|
fmt.Printf("nodeId:%s\n", nodeCfg.NodeID)
|
|
fmt.Printf("syncInterval:%d\n", nodeCfg.SyncIntervalSeconds)
|
|
fmt.Printf("trafficFlushInterval:%d\n", nodeCfg.TrafficFlushSeconds)
|
|
}
|
|
|
|
// updateTgbotEnableSts enables or disables the Telegram bot notifications based on the status parameter.
|
|
func updateTgbotEnableSts(status bool) {
|
|
settingService := service.SettingService{}
|
|
currentTgSts, err := settingService.GetTgbotEnabled()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
logger.Infof("current enabletgbot status[%v],need update to status[%v]", currentTgSts, status)
|
|
if currentTgSts != status {
|
|
err := settingService.SetTgbotEnabled(status)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
} else {
|
|
logger.Infof("SetTgbotEnabled[%v] success", status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
|
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
|
settingService := service.SettingService{}
|
|
|
|
if tgBotToken != "" {
|
|
err := settingService.SetTgBotToken(tgBotToken)
|
|
if err != nil {
|
|
fmt.Printf("Error setting Telegram bot token: %v\n", err)
|
|
return
|
|
}
|
|
logger.Info("Successfully updated Telegram bot token.")
|
|
}
|
|
|
|
if tgBotRuntime != "" {
|
|
err := settingService.SetTgbotRuntime(tgBotRuntime)
|
|
if err != nil {
|
|
fmt.Printf("Error setting Telegram bot runtime: %v\n", err)
|
|
return
|
|
}
|
|
logger.Infof("Successfully updated Telegram bot runtime to [%s].", tgBotRuntime)
|
|
}
|
|
|
|
if tgBotChatid != "" {
|
|
err := settingService.SetTgBotChatId(tgBotChatid)
|
|
if err != nil {
|
|
fmt.Printf("Error setting Telegram bot chat ID: %v\n", err)
|
|
return
|
|
}
|
|
logger.Info("Successfully updated Telegram bot chat ID.")
|
|
}
|
|
}
|
|
|
|
// updateSetting updates various panel settings including port, domain, credentials, base path, listen IP, and two-factor authentication.
|
|
func updateSetting(port int, username string, password string, webBasePath string, webDomain string, listenIP string, resetTwoFactor bool) {
|
|
settingService := service.SettingService{}
|
|
userService := service.UserService{}
|
|
|
|
if port > 0 {
|
|
err := settingService.SetPort(port)
|
|
if err != nil {
|
|
fmt.Println("Failed to set port:", err)
|
|
} else {
|
|
fmt.Printf("Port set successfully: %v\n", port)
|
|
}
|
|
}
|
|
|
|
if username != "" || password != "" {
|
|
err := userService.UpdateFirstUser(username, password)
|
|
if err != nil {
|
|
fmt.Println("Failed to update username and password:", err)
|
|
} else {
|
|
fmt.Println("Username and password updated successfully")
|
|
}
|
|
}
|
|
|
|
if webBasePath != "" {
|
|
err := settingService.SetBasePath(webBasePath)
|
|
if err != nil {
|
|
fmt.Println("Failed to set base URI path:", err)
|
|
} else {
|
|
fmt.Println("Base URI path set successfully")
|
|
}
|
|
}
|
|
|
|
if webDomain != "" {
|
|
err := settingService.SetWebDomain(webDomain)
|
|
if err != nil {
|
|
fmt.Println("Failed to set web domain:", err)
|
|
} else {
|
|
fmt.Printf("Web domain set successfully: %v\n", webDomain)
|
|
}
|
|
}
|
|
|
|
if resetTwoFactor {
|
|
err := settingService.SetTwoFactorEnable(false)
|
|
|
|
if err != nil {
|
|
fmt.Println("Failed to reset two-factor authentication:", err)
|
|
} else {
|
|
settingService.SetTwoFactorToken("")
|
|
fmt.Println("Two-factor authentication reset successfully")
|
|
}
|
|
}
|
|
|
|
if listenIP != "" {
|
|
err := settingService.SetListen(listenIP)
|
|
if err != nil {
|
|
fmt.Println("Failed to set listen IP:", err)
|
|
} else {
|
|
fmt.Printf("listen %v set successfully", listenIP)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateCert updates the SSL certificate files for the panel.
|
|
func updateCert(publicKey string, privateKey string) {
|
|
err := database.InitDB()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
|
|
if (privateKey != "" && publicKey != "") || (privateKey == "" && publicKey == "") {
|
|
settingService := service.SettingService{}
|
|
err = settingService.SetCertFile(publicKey)
|
|
if err != nil {
|
|
fmt.Println("set certificate public key failed:", err)
|
|
} else {
|
|
fmt.Println("set certificate public key success")
|
|
}
|
|
|
|
err = settingService.SetKeyFile(privateKey)
|
|
if err != nil {
|
|
fmt.Println("set certificate private key failed:", err)
|
|
} else {
|
|
fmt.Println("set certificate private key success")
|
|
}
|
|
|
|
err = settingService.SetSubCertFile(publicKey)
|
|
if err != nil {
|
|
fmt.Println("set certificate for subscription public key failed:", err)
|
|
} else {
|
|
fmt.Println("set certificate for subscription public key success")
|
|
}
|
|
|
|
err = settingService.SetSubKeyFile(privateKey)
|
|
if err != nil {
|
|
fmt.Println("set certificate for subscription private key failed:", err)
|
|
} else {
|
|
fmt.Println("set certificate for subscription private key success")
|
|
}
|
|
} else {
|
|
fmt.Println("both public and private key should be entered.")
|
|
}
|
|
}
|
|
|
|
// GetCertificate displays the current SSL certificate settings if getCert is true.
|
|
func GetCertificate(getCert bool) {
|
|
if getCert {
|
|
settingService := service.SettingService{}
|
|
certFile, err := settingService.GetCertFile()
|
|
if err != nil {
|
|
fmt.Println("get cert file failed, error info:", err)
|
|
}
|
|
keyFile, err := settingService.GetKeyFile()
|
|
if err != nil {
|
|
fmt.Println("get key file failed, error info:", err)
|
|
}
|
|
|
|
fmt.Println("cert:", certFile)
|
|
fmt.Println("key:", keyFile)
|
|
}
|
|
}
|
|
|
|
// GetListenIP displays the current panel listen IP address if getListen is true.
|
|
func GetListenIP(getListen bool) {
|
|
if getListen {
|
|
|
|
settingService := service.SettingService{}
|
|
ListenIP, err := settingService.GetListen()
|
|
if err != nil {
|
|
log.Printf("Failed to retrieve listen IP: %v", err)
|
|
return
|
|
}
|
|
|
|
fmt.Println("listenIP:", ListenIP)
|
|
}
|
|
}
|
|
|
|
// migrateDb performs database migration operations for the 3x-ui panel.
|
|
func migrateDb() {
|
|
inboundService := service.InboundService{}
|
|
switch config.GetLogLevel() {
|
|
case config.Debug:
|
|
logger.InitLogger(logging.DEBUG)
|
|
case config.Info:
|
|
logger.InitLogger(logging.INFO)
|
|
case config.Notice:
|
|
logger.InitLogger(logging.NOTICE)
|
|
case config.Warning:
|
|
logger.InitLogger(logging.WARNING)
|
|
case config.Error:
|
|
logger.InitLogger(logging.ERROR)
|
|
default:
|
|
logger.InitLogger(logging.INFO)
|
|
}
|
|
|
|
err := database.InitDB()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Println("Start migrating database...")
|
|
if err := inboundService.MigrateDB(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Println("Migration done!")
|
|
}
|
|
|
|
// migrateDbBetweenDrivers migrates data between SQLite and MariaDB.
|
|
// The direction can be specified via --direction flag, otherwise it falls back to dbType from config.
|
|
func migrateDbBetweenDrivers(direction string) {
|
|
switch direction {
|
|
case "sqlite-to-mariadb":
|
|
fmt.Println("Migrating data from SQLite to MariaDB...")
|
|
if err := database.MigrateSQLiteToMariaDB(); err != nil {
|
|
log.Fatal("Migration failed: ", err)
|
|
}
|
|
fmt.Println("Migration to MariaDB completed successfully.")
|
|
case "mariadb-to-sqlite":
|
|
fmt.Println("Migrating data from MariaDB to SQLite...")
|
|
if err := database.MigrateMariaDBToSQLite(); err != nil {
|
|
log.Fatal("Migration failed: ", err)
|
|
}
|
|
fmt.Println("Migration to SQLite completed successfully.")
|
|
default:
|
|
// Fall back to inferring from dbType config
|
|
dbType := config.GetDBTypeFromJSON()
|
|
switch dbType {
|
|
case "mariadb":
|
|
fmt.Println("Migrating data from SQLite to MariaDB...")
|
|
if err := database.MigrateSQLiteToMariaDB(); err != nil {
|
|
log.Fatal("Migration failed: ", err)
|
|
}
|
|
fmt.Println("Migration to MariaDB completed successfully.")
|
|
case "sqlite":
|
|
fmt.Println("Migrating data from MariaDB to SQLite...")
|
|
if err := database.MigrateMariaDBToSQLite(); err != nil {
|
|
log.Fatal("Migration failed: ", err)
|
|
}
|
|
fmt.Println("Migration to SQLite completed successfully.")
|
|
default:
|
|
log.Fatalf("Unknown dbType: %s", dbType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// main is the entry point of the 3x-ui application.
|
|
// It parses command-line arguments to run the web server, migrate database, or update settings.
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
runWebServer()
|
|
return
|
|
}
|
|
|
|
var showVersion bool
|
|
flag.BoolVar(&showVersion, "v", false, "show version")
|
|
|
|
runCmd := flag.NewFlagSet("run", flag.ExitOnError)
|
|
|
|
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
|
var port int
|
|
var username string
|
|
var password string
|
|
var webBasePath string
|
|
var webDomain string
|
|
var listenIP string
|
|
var getListen bool
|
|
var webCertFile string
|
|
var webKeyFile string
|
|
var tgbottoken string
|
|
var tgbotchatid string
|
|
var enabletgbot bool
|
|
var tgbotRuntime string
|
|
var reset bool
|
|
var show bool
|
|
var getCert bool
|
|
var resetTwoFactor bool
|
|
var settingStatus bool
|
|
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
|
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
|
settingCmd.BoolVar(&settingStatus, "settingStatus", false, "Display all settings and cert info in one call")
|
|
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
|
settingCmd.StringVar(&username, "username", "", "Set login username")
|
|
settingCmd.StringVar(&password, "password", "", "Set login password")
|
|
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
|
settingCmd.StringVar(&webDomain, "webDomain", "", "Set panel domain")
|
|
settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP")
|
|
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
|
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
|
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
|
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
|
|
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
|
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
|
|
settingCmd.StringVar(&tgbotRuntime, "tgbotRuntime", "", "Set cron time for Telegram bot notifications")
|
|
settingCmd.StringVar(&tgbotchatid, "tgbotchatid", "", "Set chat ID for Telegram bot notifications")
|
|
settingCmd.BoolVar(&enabletgbot, "enabletgbot", false, "Enable notifications via Telegram bot")
|
|
var dbTypeFlag string
|
|
var dbHost string
|
|
var dbPort string
|
|
var dbUser string
|
|
var dbPassword string
|
|
var dbName string
|
|
var showDbType bool
|
|
var nodeRoleFlag string
|
|
var nodeIDFlag string
|
|
var syncIntervalFlag int
|
|
var trafficFlushIntervalFlag int
|
|
settingCmd.StringVar(&dbTypeFlag, "dbType", "", "Set database type (sqlite or mariadb)")
|
|
settingCmd.StringVar(&dbHost, "dbHost", "", "Set MariaDB host")
|
|
settingCmd.StringVar(&dbPort, "dbPort", "", "Set MariaDB port")
|
|
settingCmd.StringVar(&dbUser, "dbUser", "", "Set MariaDB username")
|
|
settingCmd.StringVar(&dbPassword, "dbPassword", "", "Set MariaDB password")
|
|
settingCmd.StringVar(&dbName, "dbName", "", "Set MariaDB database name")
|
|
settingCmd.BoolVar(&showDbType, "showDbType", false, "Print current database type and exit")
|
|
settingCmd.StringVar(&nodeRoleFlag, "nodeRole", "", "Set node role (master or worker)")
|
|
settingCmd.StringVar(&nodeIDFlag, "nodeId", "", "Set node identifier")
|
|
settingCmd.IntVar(&syncIntervalFlag, "syncInterval", 0, "Set shared sync interval in seconds")
|
|
settingCmd.IntVar(&trafficFlushIntervalFlag, "trafficFlushInterval", 0, "Set traffic flush interval in seconds")
|
|
|
|
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
|
|
var migrateDirection string
|
|
migrateDbCmd.StringVar(&migrateDirection, "direction", "", "Migration direction: sqlite-to-mariadb or mariadb-to-sqlite")
|
|
|
|
backupCmd := flag.NewFlagSet("backup", flag.ExitOnError)
|
|
|
|
restoreCmd := flag.NewFlagSet("restore", flag.ExitOnError)
|
|
var restoreFile string
|
|
restoreCmd.StringVar(&restoreFile, "file", "", "Backup file name to restore from")
|
|
|
|
// Allow dbPassword to be passed via env var to avoid leaking it in process args
|
|
if p := os.Getenv("XUI_DB_PASSWORD"); p != "" {
|
|
dbPassword = p
|
|
}
|
|
|
|
oldUsage := flag.Usage
|
|
flag.Usage = func() {
|
|
oldUsage()
|
|
fmt.Println()
|
|
fmt.Println("Commands:")
|
|
fmt.Println(" run run web panel")
|
|
fmt.Println(" migrate migrate form other/old x-ui")
|
|
fmt.Println(" migrate-db migrate data between SQLite and MariaDB")
|
|
fmt.Println(" backup create a database backup")
|
|
fmt.Println(" restore restore database from backup")
|
|
fmt.Println(" setting set settings")
|
|
}
|
|
|
|
flag.Parse()
|
|
if showVersion {
|
|
fmt.Println(config.GetVersion())
|
|
return
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "run":
|
|
err := runCmd.Parse(os.Args[2:])
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
runWebServer()
|
|
case "migrate":
|
|
migrateDb()
|
|
case "migrate-db":
|
|
err := migrateDbCmd.Parse(os.Args[2:])
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
migrateDbBetweenDrivers(migrateDirection)
|
|
case "setting":
|
|
err := settingCmd.Parse(os.Args[2:])
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
nodeRoleSet := false
|
|
nodeIDSet := false
|
|
syncIntervalSet := false
|
|
trafficFlushIntervalSet := false
|
|
settingCmd.Visit(func(f *flag.Flag) {
|
|
switch f.Name {
|
|
case "nodeRole":
|
|
nodeRoleSet = true
|
|
case "nodeId":
|
|
nodeIDSet = true
|
|
case "syncInterval":
|
|
syncIntervalSet = true
|
|
case "trafficFlushInterval":
|
|
trafficFlushIntervalSet = true
|
|
}
|
|
})
|
|
if showDbType {
|
|
fmt.Println(config.GetDBTypeFromJSON())
|
|
return
|
|
}
|
|
if dbTypeFlag != "" {
|
|
if err := config.WriteSettingToJSON("dbType", dbTypeFlag); err != nil {
|
|
fmt.Println("Failed to set dbType:", err)
|
|
} else {
|
|
fmt.Println("dbType set to:", dbTypeFlag)
|
|
}
|
|
}
|
|
if dbHost != "" {
|
|
if err := config.WriteSettingToJSON("dbHost", dbHost); err != nil {
|
|
fmt.Println("Failed to set dbHost:", err)
|
|
} else {
|
|
fmt.Println("dbHost set to:", dbHost)
|
|
}
|
|
}
|
|
if dbPort != "" {
|
|
if err := config.WriteSettingToJSON("dbPort", dbPort); err != nil {
|
|
fmt.Println("Failed to set dbPort:", err)
|
|
} else {
|
|
fmt.Println("dbPort set to:", dbPort)
|
|
}
|
|
}
|
|
if dbUser != "" {
|
|
if err := config.WriteSettingToJSON("dbUser", dbUser); err != nil {
|
|
fmt.Println("Failed to set dbUser:", err)
|
|
} else {
|
|
fmt.Println("dbUser set to:", dbUser)
|
|
}
|
|
}
|
|
if dbPassword != "" {
|
|
if err := config.WriteSettingToJSON("dbPassword", dbPassword); err != nil {
|
|
fmt.Println("Failed to set dbPassword:", err)
|
|
} else {
|
|
fmt.Println("dbPassword set")
|
|
}
|
|
}
|
|
if dbName != "" {
|
|
if err := config.WriteSettingToJSON("dbName", dbName); err != nil {
|
|
fmt.Println("Failed to set dbName:", err)
|
|
} else {
|
|
fmt.Println("dbName set to:", dbName)
|
|
}
|
|
}
|
|
if nodeRoleSet || nodeIDSet || syncIntervalSet || trafficFlushIntervalSet {
|
|
candidate := config.GetNodeConfigFromJSON()
|
|
if nodeRoleSet {
|
|
candidate.Role = config.NodeRole(nodeRoleFlag)
|
|
}
|
|
if nodeIDSet {
|
|
candidate.NodeID = nodeIDFlag
|
|
}
|
|
if syncIntervalSet {
|
|
candidate.SyncIntervalSeconds = syncIntervalFlag
|
|
}
|
|
if trafficFlushIntervalSet {
|
|
candidate.TrafficFlushSeconds = trafficFlushIntervalFlag
|
|
}
|
|
if err := config.ValidateNodeConfig(candidate, config.GetDBConfigFromJSON()); err != nil {
|
|
fmt.Println("Invalid node settings:", err)
|
|
return
|
|
}
|
|
if nodeRoleSet {
|
|
if err := config.WriteSettingToJSON("nodeRole", nodeRoleFlag); err != nil {
|
|
fmt.Println("Failed to set nodeRole:", err)
|
|
} else {
|
|
fmt.Println("nodeRole set to:", nodeRoleFlag)
|
|
}
|
|
}
|
|
if nodeIDSet {
|
|
if err := config.WriteSettingToJSON("nodeId", nodeIDFlag); err != nil {
|
|
fmt.Println("Failed to set nodeId:", err)
|
|
} else {
|
|
fmt.Println("nodeId set to:", nodeIDFlag)
|
|
}
|
|
}
|
|
if syncIntervalSet {
|
|
if err := config.WriteSettingToJSON("syncInterval", fmt.Sprintf("%d", syncIntervalFlag)); err != nil {
|
|
fmt.Println("Failed to set syncInterval:", err)
|
|
} else {
|
|
fmt.Println("syncInterval set to:", syncIntervalFlag)
|
|
}
|
|
}
|
|
if trafficFlushIntervalSet {
|
|
if err := config.WriteSettingToJSON("trafficFlushInterval", fmt.Sprintf("%d", trafficFlushIntervalFlag)); err != nil {
|
|
fmt.Println("Failed to set trafficFlushInterval:", err)
|
|
} else {
|
|
fmt.Println("trafficFlushInterval set to:", trafficFlushIntervalFlag)
|
|
}
|
|
}
|
|
}
|
|
opts := settingCommandOptions{
|
|
port: port,
|
|
username: username,
|
|
password: password,
|
|
webBasePath: webBasePath,
|
|
webDomain: webDomain,
|
|
listenIP: listenIP,
|
|
reset: reset,
|
|
show: show,
|
|
getListen: getListen,
|
|
getCert: getCert,
|
|
resetTwoFactor: resetTwoFactor,
|
|
tgbotToken: tgbottoken,
|
|
tgbotChatID: tgbotchatid,
|
|
tgbotRuntime: tgbotRuntime,
|
|
enableTgbot: enabletgbot,
|
|
dbType: dbTypeFlag,
|
|
dbHost: dbHost,
|
|
dbPort: dbPort,
|
|
dbUser: dbUser,
|
|
dbPassword: dbPassword,
|
|
dbName: dbName,
|
|
nodeRoleSet: nodeRoleSet,
|
|
nodeIDSet: nodeIDSet,
|
|
syncIntervalSet: syncIntervalSet,
|
|
trafficFlushIntervalSet: trafficFlushIntervalSet,
|
|
settingStatus: settingStatus,
|
|
}
|
|
if opts.needsDBInit() {
|
|
if err := database.InitDB(); err != nil {
|
|
fmt.Println("Database initialization failed:", err)
|
|
return
|
|
}
|
|
}
|
|
if settingStatus {
|
|
showSettingStatus()
|
|
return
|
|
}
|
|
if reset {
|
|
resetSetting()
|
|
} else {
|
|
updateSetting(port, username, password, webBasePath, webDomain, listenIP, resetTwoFactor)
|
|
}
|
|
if show {
|
|
showSetting(show)
|
|
}
|
|
if getListen {
|
|
GetListenIP(getListen)
|
|
}
|
|
if getCert {
|
|
GetCertificate(getCert)
|
|
}
|
|
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
|
|
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
|
|
}
|
|
if enabletgbot {
|
|
updateTgbotEnableSts(enabletgbot)
|
|
}
|
|
case "cert":
|
|
err := settingCmd.Parse(os.Args[2:])
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
if reset {
|
|
updateCert("", "")
|
|
} else {
|
|
updateCert(webCertFile, webKeyFile)
|
|
}
|
|
case "backup":
|
|
err := backupCmd.Parse(os.Args[2:])
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
runBackup()
|
|
case "restore":
|
|
err := restoreCmd.Parse(os.Args[2:])
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
if restoreFile == "" {
|
|
fmt.Println("--file flag is required")
|
|
return
|
|
}
|
|
runRestore(restoreFile)
|
|
default:
|
|
fmt.Println("Invalid subcommands")
|
|
fmt.Println()
|
|
runCmd.Usage()
|
|
fmt.Println()
|
|
settingCmd.Usage()
|
|
}
|
|
}
|
|
|
|
func runBackup() {
|
|
backupDir := "/etc/x-ui/backups"
|
|
os.MkdirAll(backupDir, 0755)
|
|
|
|
dbCfg := config.GetDBConfigFromJSON()
|
|
if dbCfg.Type == "" {
|
|
dbCfg.Type = "sqlite"
|
|
}
|
|
|
|
timestamp := time.Now().Format("2006-01-02-150405")
|
|
filename := fmt.Sprintf("backup-%s.tar.gz", timestamp)
|
|
filePath := filepath.Join(backupDir, filename)
|
|
|
|
var dumpSQL string
|
|
var err error
|
|
|
|
switch dbCfg.Type {
|
|
case "mariadb":
|
|
dumpSQL, err = dumpMariaDBCLI(dbCfg)
|
|
case "sqlite":
|
|
dumpSQL, err = dumpSQLiteCLI(config.GetDBPath())
|
|
default:
|
|
fmt.Println("unsupported database type:", dbCfg.Type)
|
|
os.Exit(1)
|
|
}
|
|
if err != nil {
|
|
fmt.Println("dump failed:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
meta := map[string]string{
|
|
"dbType": dbCfg.Type,
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
"version": config.GetVersion(),
|
|
}
|
|
|
|
if err := createTarGzCLI(filePath, meta, dumpSQL); err != nil {
|
|
fmt.Println("archive creation failed:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Println("backup created:", filePath)
|
|
}
|
|
|
|
func runRestore(filename string) {
|
|
nodeCfg := config.GetNodeConfigFromJSON()
|
|
if nodeCfg.Role == config.NodeRoleWorker {
|
|
fmt.Println("backup and restore can only be performed on the master node")
|
|
os.Exit(1)
|
|
}
|
|
|
|
backupDir := "/etc/x-ui/backups"
|
|
filePath := filepath.Join(backupDir, filename)
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
fmt.Println("backup file not found:", filePath)
|
|
os.Exit(1)
|
|
}
|
|
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
fmt.Println("cannot open backup:", err)
|
|
os.Exit(1)
|
|
}
|
|
defer f.Close()
|
|
|
|
gr, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
fmt.Println("invalid backup file:", err)
|
|
os.Exit(1)
|
|
}
|
|
defer gr.Close()
|
|
|
|
tr := tar.NewReader(gr)
|
|
meta := make(map[string]string)
|
|
var dumpSQL strings.Builder
|
|
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
fmt.Println("invalid backup:", err)
|
|
os.Exit(1)
|
|
}
|
|
var itemBuf strings.Builder
|
|
if _, err := io.Copy(&itemBuf, tr); err != nil {
|
|
fmt.Println("read error:", err)
|
|
os.Exit(1)
|
|
}
|
|
switch hdr.Name {
|
|
case "metadata.json":
|
|
json.Unmarshal([]byte(itemBuf.String()), &meta)
|
|
case "dump.sql":
|
|
dumpSQL.WriteString(itemBuf.String())
|
|
}
|
|
}
|
|
|
|
currentDBType := config.GetDBConfigFromJSON().Type
|
|
if currentDBType == "" {
|
|
currentDBType = "sqlite"
|
|
}
|
|
if meta["dbType"] != currentDBType {
|
|
fmt.Printf("backup type (%s) does not match current database (%s)\n", meta["dbType"], currentDBType)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if dumpSQL.Len() == 0 {
|
|
fmt.Println("dump.sql not found in backup")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Create safety backup
|
|
safetyTimestamp := time.Now().Format("2006-01-02-150405")
|
|
safetyFile := filepath.Join(backupDir, "pre-restore-"+safetyTimestamp+".tar.gz")
|
|
var safetySQL string
|
|
var safetyErr error
|
|
switch currentDBType {
|
|
case "mariadb":
|
|
safetySQL, safetyErr = dumpMariaDBCLI(config.GetDBConfigFromJSON())
|
|
default:
|
|
safetySQL, safetyErr = dumpSQLiteCLI(config.GetDBPath())
|
|
}
|
|
if safetyErr == nil {
|
|
safetyMeta := map[string]string{
|
|
"dbType": currentDBType,
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
"version": config.GetVersion(),
|
|
}
|
|
if err := createTarGzCLI(safetyFile, safetyMeta, safetySQL); err == nil {
|
|
fmt.Println("safety backup created:", safetyFile)
|
|
}
|
|
}
|
|
|
|
// Restore
|
|
switch currentDBType {
|
|
case "mariadb":
|
|
dbCfg := config.GetDBConfigFromJSON()
|
|
args := []string{
|
|
fmt.Sprintf("-h%s", dbCfg.Host), fmt.Sprintf("-P%s", dbCfg.Port),
|
|
}
|
|
if dbCfg.User != "" {
|
|
args = append(args, fmt.Sprintf("-u%s", dbCfg.User))
|
|
}
|
|
if dbCfg.Password != "" {
|
|
args = append(args, fmt.Sprintf("-p%s", dbCfg.Password))
|
|
}
|
|
args = append(args, dbCfg.Name)
|
|
cmd := exec.Command("mysql", args...)
|
|
cmd.Stdin = strings.NewReader(dumpSQL.String())
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Println("restore failed:", err, stderr.String())
|
|
os.Exit(1)
|
|
}
|
|
default:
|
|
cmd := exec.Command("sqlite3", config.GetDBPath())
|
|
cmd.Stdin = strings.NewReader(dumpSQL.String())
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Println("restore failed:", err, stderr.String())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
fmt.Println("restore completed successfully")
|
|
}
|
|
|
|
func dumpMariaDBCLI(dbCfg config.DBConfig) (string, error) {
|
|
args := []string{
|
|
"--single-transaction", "--routines", "--triggers", "--no-tablespaces",
|
|
fmt.Sprintf("-h%s", dbCfg.Host), fmt.Sprintf("-P%s", dbCfg.Port),
|
|
}
|
|
if dbCfg.User != "" {
|
|
args = append(args, fmt.Sprintf("-u%s", dbCfg.User))
|
|
}
|
|
if dbCfg.Password != "" {
|
|
args = append(args, fmt.Sprintf("-p%s", dbCfg.Password))
|
|
}
|
|
args = append(args, dbCfg.Name)
|
|
cmd := exec.Command("mysqldump", args...)
|
|
var out, stderr strings.Builder
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("%w: %s", err, stderr.String())
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
func dumpSQLiteCLI(dbPath string) (string, error) {
|
|
cmd := exec.Command("sqlite3", dbPath, ".dump")
|
|
var out, stderr strings.Builder
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("%w: %s", err, stderr.String())
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
func createTarGzCLI(filePath string, meta map[string]string, dumpSQL string) error {
|
|
f, err := os.Create(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
gw := gzip.NewWriter(f)
|
|
defer gw.Close()
|
|
tw := tar.NewWriter(gw)
|
|
defer tw.Close()
|
|
|
|
metaBytes, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tw.WriteHeader(&tar.Header{Name: "metadata.json", Size: int64(len(metaBytes)), Mode: 0644, Typeflag: tar.TypeReg}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tw.Write(metaBytes); err != nil {
|
|
return err
|
|
}
|
|
|
|
dumpBytes := []byte(dumpSQL)
|
|
if err := tw.WriteHeader(&tar.Header{Name: "dump.sql", Size: int64(len(dumpBytes)), Mode: 0644, Typeflag: tar.TypeReg}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tw.Write(dumpBytes); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|