mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-08-23 11:26:52 +00:00
![google-labs-jules[bot]](/assets/img/avatar_default.png)
This commit introduces a multi-server architecture to the Sanai panel, allowing you to manage clients across multiple servers from a central panel. Key changes include: - **Database Schema:** Added a `servers` table to store information about slave servers. - **Server Management:** Implemented a new service and controller (`MultiServerService` and `MultiServerController`) for CRUD operations on servers. - **Web UI:** Created a new web page for managing servers, accessible from the sidebar. - **Client Synchronization:** Modified the `InboundService` to synchronize client additions, updates, and deletions across all active slave servers via a REST API. - **API Security:** Added an API key authentication middleware to secure the communication between the master and slave panels. - **Multi-Server Subscriptions:** Updated the subscription service to generate links that include configurations for all active servers. - **Installation Script:** Modified the `install.sh` script to generate a random API key during installation. **Known Issues:** - The integration test for client synchronization (`TestInboundServiceSync`) is currently failing. It seems that the API request to the mock slave server is not being sent correctly or the API key is not being included in the request header. Further investigation is needed to resolve this issue.
649 lines
16 KiB
Go
649 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"x-ui/database"
|
|
"x-ui/database/model"
|
|
"x-ui/logger"
|
|
"x-ui/util/common"
|
|
"x-ui/util/random"
|
|
"x-ui/util/reflect_util"
|
|
"x-ui/web/entity"
|
|
"x-ui/xray"
|
|
)
|
|
|
|
//go:embed config.json
|
|
var xrayTemplateConfig string
|
|
|
|
var defaultValueMap = map[string]string{
|
|
"xrayTemplateConfig": xrayTemplateConfig,
|
|
"webListen": "",
|
|
"webDomain": "",
|
|
"webPort": "2053",
|
|
"webCertFile": "",
|
|
"webKeyFile": "",
|
|
"secret": random.Seq(32),
|
|
"webBasePath": "/",
|
|
"sessionMaxAge": "60",
|
|
"pageSize": "50",
|
|
"expireDiff": "0",
|
|
"trafficDiff": "0",
|
|
"remarkModel": "-ieo",
|
|
"timeLocation": "Local",
|
|
"tgBotEnable": "false",
|
|
"tgBotToken": "",
|
|
"tgBotProxy": "",
|
|
"tgBotAPIServer": "",
|
|
"tgBotChatId": "",
|
|
"tgRunTime": "@daily",
|
|
"tgBotBackup": "false",
|
|
"tgBotLoginNotify": "true",
|
|
"tgCpu": "80",
|
|
"tgLang": "en-US",
|
|
"twoFactorEnable": "false",
|
|
"twoFactorToken": "",
|
|
"subEnable": "false",
|
|
"subTitle": "",
|
|
"subListen": "",
|
|
"subPort": "2096",
|
|
"subPath": "/sub/",
|
|
"subDomain": "",
|
|
"subCertFile": "",
|
|
"subKeyFile": "",
|
|
"subUpdates": "12",
|
|
"subEncrypt": "true",
|
|
"subShowInfo": "true",
|
|
"subURI": "",
|
|
"subJsonPath": "/json/",
|
|
"subJsonURI": "",
|
|
"subJsonFragment": "",
|
|
"subJsonNoises": "",
|
|
"subJsonMux": "",
|
|
"subJsonRules": "",
|
|
"datepicker": "gregorian",
|
|
"warp": "",
|
|
"externalTrafficInformEnable": "false",
|
|
"externalTrafficInformURI": "",
|
|
}
|
|
|
|
type SettingService struct{}
|
|
|
|
func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
|
var jsonData any
|
|
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return jsonData, nil
|
|
}
|
|
|
|
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
|
db := database.GetDB()
|
|
settings := make([]*model.Setting, 0)
|
|
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allSetting := &entity.AllSetting{}
|
|
t := reflect.TypeOf(allSetting).Elem()
|
|
v := reflect.ValueOf(allSetting).Elem()
|
|
fields := reflect_util.GetFields(t)
|
|
|
|
setSetting := func(key, value string) (err error) {
|
|
defer func() {
|
|
panicErr := recover()
|
|
if panicErr != nil {
|
|
err = errors.New(fmt.Sprint(panicErr))
|
|
}
|
|
}()
|
|
|
|
var found bool
|
|
var field reflect.StructField
|
|
for _, f := range fields {
|
|
if f.Tag.Get("json") == key {
|
|
field = f
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
// Some settings are automatically generated, no need to return to the front end to modify the user
|
|
return nil
|
|
}
|
|
|
|
fieldV := v.FieldByName(field.Name)
|
|
switch t := fieldV.Interface().(type) {
|
|
case int:
|
|
n, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fieldV.SetInt(n)
|
|
case string:
|
|
fieldV.SetString(value)
|
|
case bool:
|
|
fieldV.SetBool(value == "true")
|
|
default:
|
|
return common.NewErrorf("unknown field %v type %v", key, t)
|
|
}
|
|
return
|
|
}
|
|
|
|
keyMap := map[string]bool{}
|
|
for _, setting := range settings {
|
|
err := setSetting(setting.Key, setting.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyMap[setting.Key] = true
|
|
}
|
|
|
|
for key, value := range defaultValueMap {
|
|
if keyMap[key] {
|
|
continue
|
|
}
|
|
err := setSetting(key, value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return allSetting, nil
|
|
}
|
|
|
|
func (s *SettingService) ResetSettings() error {
|
|
db := database.GetDB()
|
|
err := db.Where("1 = 1").Delete(model.Setting{}).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return db.Model(model.User{}).
|
|
Where("1 = 1").Error
|
|
}
|
|
|
|
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
|
db := database.GetDB()
|
|
setting := &model.Setting{}
|
|
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return setting, nil
|
|
}
|
|
|
|
func (s *SettingService) GetAPIKey() (string, error) {
|
|
setting, err := s.getSetting("ApiKey")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if setting == nil {
|
|
return "", nil
|
|
}
|
|
return setting.Value, nil
|
|
}
|
|
|
|
func (s *SettingService) SetAPIKey(apiKey string) error {
|
|
return s.saveSetting("ApiKey", apiKey)
|
|
}
|
|
|
|
func (s *SettingService) saveSetting(key string, value string) error {
|
|
setting, err := s.getSetting(key)
|
|
db := database.GetDB()
|
|
if database.IsNotFound(err) {
|
|
return db.Create(&model.Setting{
|
|
Key: key,
|
|
Value: value,
|
|
}).Error
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
setting.Key = key
|
|
setting.Value = value
|
|
return db.Save(setting).Error
|
|
}
|
|
|
|
func (s *SettingService) getString(key string) (string, error) {
|
|
setting, err := s.getSetting(key)
|
|
if database.IsNotFound(err) {
|
|
value, ok := defaultValueMap[key]
|
|
if !ok {
|
|
return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
|
|
}
|
|
return value, nil
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
return setting.Value, nil
|
|
}
|
|
|
|
func (s *SettingService) setString(key string, value string) error {
|
|
return s.saveSetting(key, value)
|
|
}
|
|
|
|
func (s *SettingService) getBool(key string) (bool, error) {
|
|
str, err := s.getString(key)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strconv.ParseBool(str)
|
|
}
|
|
|
|
func (s *SettingService) setBool(key string, value bool) error {
|
|
return s.setString(key, strconv.FormatBool(value))
|
|
}
|
|
|
|
func (s *SettingService) getInt(key string) (int, error) {
|
|
str, err := s.getString(key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.Atoi(str)
|
|
}
|
|
|
|
func (s *SettingService) setInt(key string, value int) error {
|
|
return s.setString(key, strconv.Itoa(value))
|
|
}
|
|
|
|
func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
|
return s.getString("xrayTemplateConfig")
|
|
}
|
|
|
|
func (s *SettingService) GetListen() (string, error) {
|
|
return s.getString("webListen")
|
|
}
|
|
|
|
func (s *SettingService) SetListen(ip string) error {
|
|
return s.setString("webListen", ip)
|
|
}
|
|
|
|
func (s *SettingService) GetWebDomain() (string, error) {
|
|
return s.getString("webDomain")
|
|
}
|
|
|
|
func (s *SettingService) GetTgBotToken() (string, error) {
|
|
return s.getString("tgBotToken")
|
|
}
|
|
|
|
func (s *SettingService) SetTgBotToken(token string) error {
|
|
return s.setString("tgBotToken", token)
|
|
}
|
|
|
|
func (s *SettingService) GetTgBotProxy() (string, error) {
|
|
return s.getString("tgBotProxy")
|
|
}
|
|
|
|
func (s *SettingService) SetTgBotProxy(token string) error {
|
|
return s.setString("tgBotProxy", token)
|
|
}
|
|
|
|
func (s *SettingService) GetTgBotAPIServer() (string, error) {
|
|
return s.getString("tgBotAPIServer")
|
|
}
|
|
|
|
func (s *SettingService) SetTgBotAPIServer(token string) error {
|
|
return s.setString("tgBotAPIServer", token)
|
|
}
|
|
|
|
func (s *SettingService) GetTgBotChatId() (string, error) {
|
|
return s.getString("tgBotChatId")
|
|
}
|
|
|
|
func (s *SettingService) SetTgBotChatId(chatIds string) error {
|
|
return s.setString("tgBotChatId", chatIds)
|
|
}
|
|
|
|
func (s *SettingService) GetTgbotEnabled() (bool, error) {
|
|
return s.getBool("tgBotEnable")
|
|
}
|
|
|
|
func (s *SettingService) SetTgbotEnabled(value bool) error {
|
|
return s.setBool("tgBotEnable", value)
|
|
}
|
|
|
|
func (s *SettingService) GetTgbotRuntime() (string, error) {
|
|
return s.getString("tgRunTime")
|
|
}
|
|
|
|
func (s *SettingService) SetTgbotRuntime(time string) error {
|
|
return s.setString("tgRunTime", time)
|
|
}
|
|
|
|
func (s *SettingService) GetTgBotBackup() (bool, error) {
|
|
return s.getBool("tgBotBackup")
|
|
}
|
|
|
|
func (s *SettingService) GetTgBotLoginNotify() (bool, error) {
|
|
return s.getBool("tgBotLoginNotify")
|
|
}
|
|
|
|
func (s *SettingService) GetTgCpu() (int, error) {
|
|
return s.getInt("tgCpu")
|
|
}
|
|
|
|
func (s *SettingService) GetTgLang() (string, error) {
|
|
return s.getString("tgLang")
|
|
}
|
|
|
|
func (s *SettingService) GetTwoFactorEnable() (bool, error) {
|
|
return s.getBool("twoFactorEnable")
|
|
}
|
|
|
|
func (s *SettingService) SetTwoFactorEnable(value bool) error {
|
|
return s.setBool("twoFactorEnable", value)
|
|
}
|
|
|
|
func (s *SettingService) GetTwoFactorToken() (string, error) {
|
|
return s.getString("twoFactorToken")
|
|
}
|
|
|
|
func (s *SettingService) SetTwoFactorToken(value string) error {
|
|
return s.setString("twoFactorToken", value)
|
|
}
|
|
|
|
func (s *SettingService) GetPort() (int, error) {
|
|
return s.getInt("webPort")
|
|
}
|
|
|
|
func (s *SettingService) SetPort(port int) error {
|
|
return s.setInt("webPort", port)
|
|
}
|
|
|
|
func (s *SettingService) SetCertFile(webCertFile string) error {
|
|
return s.setString("webCertFile", webCertFile)
|
|
}
|
|
|
|
func (s *SettingService) GetCertFile() (string, error) {
|
|
return s.getString("webCertFile")
|
|
}
|
|
|
|
func (s *SettingService) SetKeyFile(webKeyFile string) error {
|
|
return s.setString("webKeyFile", webKeyFile)
|
|
}
|
|
|
|
func (s *SettingService) GetKeyFile() (string, error) {
|
|
return s.getString("webKeyFile")
|
|
}
|
|
|
|
func (s *SettingService) GetExpireDiff() (int, error) {
|
|
return s.getInt("expireDiff")
|
|
}
|
|
|
|
func (s *SettingService) GetTrafficDiff() (int, error) {
|
|
return s.getInt("trafficDiff")
|
|
}
|
|
|
|
func (s *SettingService) GetSessionMaxAge() (int, error) {
|
|
return s.getInt("sessionMaxAge")
|
|
}
|
|
|
|
func (s *SettingService) GetRemarkModel() (string, error) {
|
|
return s.getString("remarkModel")
|
|
}
|
|
|
|
func (s *SettingService) GetSecret() ([]byte, error) {
|
|
secret, err := s.getString("secret")
|
|
if secret == defaultValueMap["secret"] {
|
|
err := s.saveSetting("secret", secret)
|
|
if err != nil {
|
|
logger.Warning("save secret failed:", err)
|
|
}
|
|
}
|
|
return []byte(secret), err
|
|
}
|
|
|
|
func (s *SettingService) SetBasePath(basePath string) error {
|
|
if !strings.HasPrefix(basePath, "/") {
|
|
basePath = "/" + basePath
|
|
}
|
|
if !strings.HasSuffix(basePath, "/") {
|
|
basePath += "/"
|
|
}
|
|
return s.setString("webBasePath", basePath)
|
|
}
|
|
|
|
func (s *SettingService) GetBasePath() (string, error) {
|
|
basePath, err := s.getString("webBasePath")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasPrefix(basePath, "/") {
|
|
basePath = "/" + basePath
|
|
}
|
|
if !strings.HasSuffix(basePath, "/") {
|
|
basePath += "/"
|
|
}
|
|
return basePath, nil
|
|
}
|
|
|
|
func (s *SettingService) GetTimeLocation() (*time.Location, error) {
|
|
l, err := s.getString("timeLocation")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
location, err := time.LoadLocation(l)
|
|
if err != nil {
|
|
defaultLocation := defaultValueMap["timeLocation"]
|
|
logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
|
|
return time.LoadLocation(defaultLocation)
|
|
}
|
|
return location, nil
|
|
}
|
|
|
|
func (s *SettingService) GetSubEnable() (bool, error) {
|
|
return s.getBool("subEnable")
|
|
}
|
|
|
|
func (s *SettingService) GetSubTitle() (string, error) {
|
|
return s.getString("subTitle")
|
|
}
|
|
|
|
func (s *SettingService) GetSubListen() (string, error) {
|
|
return s.getString("subListen")
|
|
}
|
|
|
|
func (s *SettingService) GetSubPort() (int, error) {
|
|
return s.getInt("subPort")
|
|
}
|
|
|
|
func (s *SettingService) GetSubPath() (string, error) {
|
|
return s.getString("subPath")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonPath() (string, error) {
|
|
return s.getString("subJsonPath")
|
|
}
|
|
|
|
func (s *SettingService) GetSubDomain() (string, error) {
|
|
return s.getString("subDomain")
|
|
}
|
|
|
|
func (s *SettingService) GetSubCertFile() (string, error) {
|
|
return s.getString("subCertFile")
|
|
}
|
|
|
|
func (s *SettingService) GetSubKeyFile() (string, error) {
|
|
return s.getString("subKeyFile")
|
|
}
|
|
|
|
func (s *SettingService) GetSubUpdates() (string, error) {
|
|
return s.getString("subUpdates")
|
|
}
|
|
|
|
func (s *SettingService) GetSubEncrypt() (bool, error) {
|
|
return s.getBool("subEncrypt")
|
|
}
|
|
|
|
func (s *SettingService) GetSubShowInfo() (bool, error) {
|
|
return s.getBool("subShowInfo")
|
|
}
|
|
|
|
func (s *SettingService) GetPageSize() (int, error) {
|
|
return s.getInt("pageSize")
|
|
}
|
|
|
|
func (s *SettingService) GetSubURI() (string, error) {
|
|
return s.getString("subURI")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonURI() (string, error) {
|
|
return s.getString("subJsonURI")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
|
return s.getString("subJsonFragment")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonNoises() (string, error) {
|
|
return s.getString("subJsonNoises")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonMux() (string, error) {
|
|
return s.getString("subJsonMux")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonRules() (string, error) {
|
|
return s.getString("subJsonRules")
|
|
}
|
|
|
|
func (s *SettingService) GetDatepicker() (string, error) {
|
|
return s.getString("datepicker")
|
|
}
|
|
|
|
func (s *SettingService) GetWarp() (string, error) {
|
|
return s.getString("warp")
|
|
}
|
|
|
|
func (s *SettingService) SetWarp(data string) error {
|
|
return s.setString("warp", data)
|
|
}
|
|
|
|
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
|
|
return s.getBool("externalTrafficInformEnable")
|
|
}
|
|
|
|
func (s *SettingService) SetExternalTrafficInformEnable(value bool) error {
|
|
return s.setBool("externalTrafficInformEnable", value)
|
|
}
|
|
|
|
func (s *SettingService) GetExternalTrafficInformURI() (string, error) {
|
|
return s.getString("externalTrafficInformURI")
|
|
}
|
|
|
|
func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
|
|
return s.setString("externalTrafficInformURI", InformURI)
|
|
}
|
|
|
|
func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
|
accessLogPath, err := xray.GetAccessLogPath()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return (accessLogPath != "none" && accessLogPath != ""), nil
|
|
}
|
|
|
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
|
if err := allSetting.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
|
|
v := reflect.ValueOf(allSetting).Elem()
|
|
t := reflect.TypeOf(allSetting).Elem()
|
|
fields := reflect_util.GetFields(t)
|
|
errs := make([]error, 0)
|
|
for _, field := range fields {
|
|
key := field.Tag.Get("json")
|
|
fieldV := v.FieldByName(field.Name)
|
|
value := fmt.Sprint(fieldV.Interface())
|
|
err := s.saveSetting(key, value)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return common.Combine(errs...)
|
|
}
|
|
|
|
func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
|
var jsonData any
|
|
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return jsonData, nil
|
|
}
|
|
|
|
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|
type settingFunc func() (any, error)
|
|
settings := map[string]settingFunc{
|
|
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
|
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
|
"pageSize": func() (any, error) { return s.GetPageSize() },
|
|
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
|
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
|
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
|
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
|
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
|
"subURI": func() (any, error) { return s.GetSubURI() },
|
|
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
|
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
|
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
|
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
|
}
|
|
|
|
result := make(map[string]any)
|
|
|
|
for key, fn := range settings {
|
|
value, err := fn()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
result[key] = value
|
|
}
|
|
|
|
if result["subEnable"].(bool) && (result["subURI"].(string) == "" || result["subJsonURI"].(string) == "") {
|
|
subURI := ""
|
|
subTitle, _ := s.GetSubTitle()
|
|
subPort, _ := s.GetSubPort()
|
|
subPath, _ := s.GetSubPath()
|
|
subJsonPath, _ := s.GetSubJsonPath()
|
|
subDomain, _ := s.GetSubDomain()
|
|
subKeyFile, _ := s.GetSubKeyFile()
|
|
subCertFile, _ := s.GetSubCertFile()
|
|
subTLS := false
|
|
if subKeyFile != "" && subCertFile != "" {
|
|
subTLS = true
|
|
}
|
|
if subDomain == "" {
|
|
subDomain = strings.Split(host, ":")[0]
|
|
}
|
|
if subTLS {
|
|
subURI = "https://"
|
|
} else {
|
|
subURI = "http://"
|
|
}
|
|
if (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
|
|
subURI += subDomain
|
|
} else {
|
|
subURI += fmt.Sprintf("%s:%d", subDomain, subPort)
|
|
}
|
|
if result["subURI"].(string) == "" {
|
|
result["subURI"] = subURI + subPath
|
|
}
|
|
if result["subTitle"].(string) == "" {
|
|
result["subTitle"] = subTitle
|
|
}
|
|
if result["subJsonURI"].(string) == "" {
|
|
result["subJsonURI"] = subURI + subJsonPath
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|