mirror of
				https://github.com/MHSanaei/3x-ui.git
				synced 2025-10-26 10:04:41 +00:00 
			
		
		
		
	Compare commits
	
		
			13 commits
		
	
	
		
			e6ac973bb9
			...
			ea849d6305
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ea849d6305 | ||
|   | 02bff4db6c | ||
|   | 8ff4e1ff31 | ||
|   | 26c6438ec2 | ||
|   | 4c7249c451 | ||
|   | edd8b12988 | ||
|   | 5e953bae45 | ||
|   | 747af376f2 | ||
|   | a3ccccfe52 | ||
|   | 3299d15f28 | ||
|   | ae82373457 | ||
|   | d65233cc2c | ||
| ![google-labs-jules[bot]](/assets/img/avatar_default.png)  | 11dc06863e | 
					 25 changed files with 693 additions and 52 deletions
				
			
		|  | @ -37,6 +37,7 @@ func initModels() error { | |||
| 		&model.InboundClientIps{}, | ||||
| 		&xray.ClientTraffic{}, | ||||
| 		&model.HistoryOfSeeders{}, | ||||
| 		&model.Server{}, | ||||
| 	} | ||||
| 	for _, model := range models { | ||||
| 		if err := db.AutoMigrate(model); err != nil { | ||||
|  |  | |||
|  | @ -119,3 +119,12 @@ type Client struct { | |||
| 	CreatedAt  int64  `json:"created_at,omitempty"`         // Creation timestamp
 | ||||
| 	UpdatedAt  int64  `json:"updated_at,omitempty"`         // Last update timestamp
 | ||||
| } | ||||
| 
 | ||||
| type Server struct { | ||||
| 	Id      int    `json:"id" gorm:"primaryKey;autoIncrement"` | ||||
| 	Name    string `json:"name" gorm:"unique;not null"` | ||||
| 	Address string `json:"address" gorm:"not null"` | ||||
| 	Port    int    `json:"port" gorm:"not null"` | ||||
| 	APIKey  string `json:"apiKey" gorm:"not null"` | ||||
| 	Enable  bool   `json:"enable" gorm:"default:true"` | ||||
| } | ||||
|  |  | |||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							|  | @ -65,6 +65,7 @@ require ( | |||
| 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||||
| 	github.com/modern-go/reflect2 v1.0.2 // indirect | ||||
| 	github.com/pires/go-proxyproto v0.8.1 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect | ||||
| 	github.com/quic-go/qpack v0.5.1 // indirect | ||||
| 	github.com/quic-go/quic-go v0.54.0 // indirect | ||||
|  |  | |||
|  | @ -140,6 +140,13 @@ config_after_install() { | |||
|     fi | ||||
| 
 | ||||
|     /usr/local/x-ui/x-ui migrate | ||||
| 
 | ||||
|     local existing_apiKey=$(/usr/local/x-ui/x-ui setting -show true | grep -oP 'ApiKey: \K.*') | ||||
|     if [[ -z "$existing_apiKey" ]]; then | ||||
|         local config_apiKey=$(gen_random_string 32) | ||||
|         /usr/local/x-ui/x-ui setting -apiKey "${config_apiKey}" | ||||
|         echo -e "${green}Generated random API Key: ${config_apiKey}${plain}" | ||||
|     fi | ||||
| } | ||||
| 
 | ||||
| install_x-ui() { | ||||
|  |  | |||
							
								
								
									
										16
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								main.go
									
									
									
									
									
								
							|  | @ -240,7 +240,8 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri | |||
| } | ||||
| 
 | ||||
| // updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
 | ||||
| func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) { | ||||
| func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool, apiKey string) { | ||||
| 
 | ||||
| 	err := database.InitDB(config.GetDBPath()) | ||||
| 	if err != nil { | ||||
| 		fmt.Println("Database initialization failed:", err) | ||||
|  | @ -250,6 +251,15 @@ func updateSetting(port int, username string, password string, webBasePath strin | |||
| 	settingService := service.SettingService{} | ||||
| 	userService := service.UserService{} | ||||
| 
 | ||||
| 	if apiKey != "" { | ||||
| 		err := settingService.SetAPIKey(apiKey) | ||||
| 		if err != nil { | ||||
| 			fmt.Println("Failed to set API Key:", err) | ||||
| 		} else { | ||||
| 			fmt.Printf("API Key set successfully: %v\n", apiKey) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if port > 0 { | ||||
| 		err := settingService.SetPort(port) | ||||
| 		if err != nil { | ||||
|  | @ -402,9 +412,11 @@ func main() { | |||
| 	var show bool | ||||
| 	var getCert bool | ||||
| 	var resetTwoFactor bool | ||||
| 	var apiKey string | ||||
| 	settingCmd.BoolVar(&reset, "reset", false, "Reset all settings") | ||||
| 	settingCmd.BoolVar(&show, "show", false, "Display current settings") | ||||
| 	settingCmd.IntVar(&port, "port", 0, "Set panel port number") | ||||
| 	settingCmd.StringVar(&apiKey, "apiKey", "", "Set API Key") | ||||
| 	settingCmd.StringVar(&username, "username", "", "Set login username") | ||||
| 	settingCmd.StringVar(&password, "password", "", "Set login password") | ||||
| 	settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel") | ||||
|  | @ -454,7 +466,7 @@ func main() { | |||
| 		if reset { | ||||
| 			resetSetting() | ||||
| 		} else { | ||||
| 			updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) | ||||
| 			updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey) | ||||
| 		} | ||||
| 		if show { | ||||
| 			showSetting(show) | ||||
|  |  | |||
|  | @ -162,26 +162,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri | |||
| } | ||||
| 
 | ||||
| func (s *SubService) getLink(inbound *model.Inbound, email string) string { | ||||
| 	switch inbound.Protocol { | ||||
| 	case "vmess": | ||||
| 		return s.genVmessLink(inbound, email) | ||||
| 	case "vless": | ||||
| 		return s.genVlessLink(inbound, email) | ||||
| 	case "trojan": | ||||
| 		return s.genTrojanLink(inbound, email) | ||||
| 	case "shadowsocks": | ||||
| 		return s.genShadowsocksLink(inbound, email) | ||||
| 	serverService := service.MultiServerService{} | ||||
| 	servers, err := serverService.GetServers() | ||||
| 	if err != nil { | ||||
| 		logger.Warning("Failed to get servers for subscription:", err) | ||||
| 		return "" | ||||
| 	} | ||||
| 	return "" | ||||
| 
 | ||||
| 	var links []string | ||||
| 	for _, server := range servers { | ||||
| 		if !server.Enable { | ||||
| 			continue | ||||
| 		} | ||||
| 		var link string | ||||
| 		switch inbound.Protocol { | ||||
| 		case "vmess": | ||||
| 			link = s.genVmessLink(inbound, email, server) | ||||
| 		case "vless": | ||||
| 			link = s.genVlessLink(inbound, email, server) | ||||
| 		case "trojan": | ||||
| 			link = s.genTrojanLink(inbound, email, server) | ||||
| 		case "shadowsocks": | ||||
| 			link = s.genShadowsocksLink(inbound, email, server) | ||||
| 		} | ||||
| 		if link != "" { | ||||
| 			links = append(links, link) | ||||
| 		} | ||||
| 	} | ||||
| 	return strings.Join(links, "\n") | ||||
| } | ||||
| 
 | ||||
| func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { | ||||
| func (s *SubService) genVmessLink(inbound *model.Inbound, email string, server *model.Server) string { | ||||
| 	if inbound.Protocol != model.VMESS { | ||||
| 		return "" | ||||
| 	} | ||||
| 	obj := map[string]any{ | ||||
| 		"v":    "2", | ||||
| 		"add":  s.address, | ||||
| 		"add":  server.Address, | ||||
| 		"port": inbound.Port, | ||||
| 		"type": "none", | ||||
| 	} | ||||
|  | @ -294,7 +311,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { | |||
| 					newObj[key] = value | ||||
| 				} | ||||
| 			} | ||||
| 			newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) | ||||
| 			newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string), server.Name) | ||||
| 			newObj["add"] = ep["dest"].(string) | ||||
| 			newObj["port"] = int(ep["port"].(float64)) | ||||
| 
 | ||||
|  | @ -310,14 +327,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { | |||
| 		return links | ||||
| 	} | ||||
| 
 | ||||
| 	obj["ps"] = s.genRemark(inbound, email, "") | ||||
| 	obj["ps"] = s.genRemark(inbound, email, "", server.Name) | ||||
| 
 | ||||
| 	jsonStr, _ := json.MarshalIndent(obj, "", "  ") | ||||
| 	return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) | ||||
| } | ||||
| 
 | ||||
| func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { | ||||
| 	address := s.address | ||||
| func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string { | ||||
| 	address := server.Address | ||||
| 	if inbound.Protocol != model.VLESS { | ||||
| 		return "" | ||||
| 	} | ||||
|  | @ -497,7 +514,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { | |||
| 			// Set the new query values on the URL
 | ||||
| 			url.RawQuery = q.Encode() | ||||
| 
 | ||||
| 			url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) | ||||
| 			url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name) | ||||
| 
 | ||||
| 			if index > 0 { | ||||
| 				links += "\n" | ||||
|  | @ -518,12 +535,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { | |||
| 	// Set the new query values on the URL
 | ||||
| 	url.RawQuery = q.Encode() | ||||
| 
 | ||||
| 	url.Fragment = s.genRemark(inbound, email, "") | ||||
| 	url.Fragment = s.genRemark(inbound, email, "", server.Name) | ||||
| 	return url.String() | ||||
| } | ||||
| 
 | ||||
| func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { | ||||
| 	address := s.address | ||||
| func (s *SubService) genTrojanLink(inbound *model.Inbound, email string, server *model.Server) string { | ||||
| 	address := server.Address | ||||
| 	if inbound.Protocol != model.Trojan { | ||||
| 		return "" | ||||
| 	} | ||||
|  | @ -692,7 +709,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string | |||
| 			// Set the new query values on the URL
 | ||||
| 			url.RawQuery = q.Encode() | ||||
| 
 | ||||
| 			url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) | ||||
| 			url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name) | ||||
| 
 | ||||
| 			if index > 0 { | ||||
| 				links += "\n" | ||||
|  | @ -714,12 +731,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string | |||
| 	// Set the new query values on the URL
 | ||||
| 	url.RawQuery = q.Encode() | ||||
| 
 | ||||
| 	url.Fragment = s.genRemark(inbound, email, "") | ||||
| 	url.Fragment = s.genRemark(inbound, email, "", server.Name) | ||||
| 	return url.String() | ||||
| } | ||||
| 
 | ||||
| func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { | ||||
| 	address := s.address | ||||
| func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string, server *model.Server) string { | ||||
| 	address := server.Address | ||||
| 	if inbound.Protocol != model.Shadowsocks { | ||||
| 		return "" | ||||
| 	} | ||||
|  | @ -859,7 +876,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st | |||
| 			// Set the new query values on the URL
 | ||||
| 			url.RawQuery = q.Encode() | ||||
| 
 | ||||
| 			url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) | ||||
| 			url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name) | ||||
| 
 | ||||
| 			if index > 0 { | ||||
| 				links += "\n" | ||||
|  | @ -880,17 +897,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st | |||
| 	// Set the new query values on the URL
 | ||||
| 	url.RawQuery = q.Encode() | ||||
| 
 | ||||
| 	url.Fragment = s.genRemark(inbound, email, "") | ||||
| 	url.Fragment = s.genRemark(inbound, email, "", server.Name) | ||||
| 	return url.String() | ||||
| } | ||||
| 
 | ||||
| func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { | ||||
| func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, serverName string) string { | ||||
| 	separationChar := string(s.remarkModel[0]) | ||||
| 	orderChars := s.remarkModel[1:] | ||||
| 	orders := map[byte]string{ | ||||
| 		'i': "", | ||||
| 		'e': "", | ||||
| 		'o': "", | ||||
| 		's': "", | ||||
| 	} | ||||
| 	if len(email) > 0 { | ||||
| 		orders['e'] = email | ||||
|  | @ -901,6 +919,9 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin | |||
| 	if len(extra) > 0 { | ||||
| 		orders['o'] = extra | ||||
| 	} | ||||
| 	if len(serverName) > 0 { | ||||
| 		orders['s'] = serverName | ||||
| 	} | ||||
| 
 | ||||
| 	var remark []string | ||||
| 	for i := 0; i < len(orderChars); i++ { | ||||
|  |  | |||
|  | @ -142,7 +142,10 @@ | |||
|       }, | ||||
|       npvtunUrl() { | ||||
|         return this.app.subUrl;  | ||||
|       } | ||||
|       }, | ||||
| 	  happUrl() { | ||||
| 		return `happ://add/${encodeURIComponent(this.app.subUrl)}`; | ||||
| 	  } | ||||
|     }, | ||||
|     methods: { | ||||
|       renderLink, | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import ( | |||
| 	"fmt" | ||||
| 	"strconv" | ||||
| 
 | ||||
| 
 | ||||
| 	"github.com/mhsanaei/3x-ui/v2/database/model" | ||||
| 	"github.com/mhsanaei/3x-ui/v2/web/service" | ||||
| 	"github.com/mhsanaei/3x-ui/v2/web/session" | ||||
|  |  | |||
							
								
								
									
										89
									
								
								web/controller/multi_server_controller.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								web/controller/multi_server_controller.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,89 @@ | |||
| package controller | ||||
| 
 | ||||
| import ( | ||||
| 	"strconv" | ||||
| 
 | ||||
| 	"x-ui/database/model" | ||||
| 	"x-ui/web/service" | ||||
| 
 | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
| 
 | ||||
| type MultiServerController struct { | ||||
| 	multiServerService service.MultiServerService | ||||
| } | ||||
| 
 | ||||
| func NewMultiServerController(g *gin.RouterGroup) *MultiServerController { | ||||
| 	c := &MultiServerController{} | ||||
| 	c.initRouter(g) | ||||
| 	return c | ||||
| } | ||||
| 
 | ||||
| func (c *MultiServerController) initRouter(g *gin.RouterGroup) { | ||||
| 	g = g.Group("/server") | ||||
| 
 | ||||
| 	g.GET("/list", c.getServers) | ||||
| 	g.POST("/add", c.addServer) | ||||
| 	g.POST("/del/:id", c.delServer) | ||||
| 	g.POST("/update/:id", c.updateServer) | ||||
| } | ||||
| 
 | ||||
| func (c *MultiServerController) getServers(ctx *gin.Context) { | ||||
| 	servers, err := c.multiServerService.GetServers() | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Failed to get servers", err) | ||||
| 		return | ||||
| 	} | ||||
| 	jsonObj(ctx, servers, nil) | ||||
| } | ||||
| 
 | ||||
| func (c *MultiServerController) addServer(ctx *gin.Context) { | ||||
| 	server := &model.Server{} | ||||
| 	err := ctx.ShouldBind(server) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Invalid data", err) | ||||
| 		return | ||||
| 	} | ||||
| 	err = c.multiServerService.AddServer(server) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Failed to add server", err) | ||||
| 		return | ||||
| 	} | ||||
| 	jsonMsg(ctx, "Server added successfully", nil) | ||||
| } | ||||
| 
 | ||||
| func (c *MultiServerController) delServer(ctx *gin.Context) { | ||||
| 	id, err := strconv.Atoi(ctx.Param("id")) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Invalid ID", err) | ||||
| 		return | ||||
| 	} | ||||
| 	err = c.multiServerService.DeleteServer(id) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Failed to delete server", err) | ||||
| 		return | ||||
| 	} | ||||
| 	jsonMsg(ctx, "Server deleted successfully", nil) | ||||
| } | ||||
| 
 | ||||
| func (c *MultiServerController) updateServer(ctx *gin.Context) { | ||||
| 	id, err := strconv.Atoi(ctx.Param("id")) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Invalid ID", err) | ||||
| 		return | ||||
| 	} | ||||
| 	server := &model.Server{ | ||||
| 		Id: id, | ||||
| 	} | ||||
| 	err = ctx.ShouldBind(server) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Invalid data", err) | ||||
| 		return | ||||
| 	} | ||||
| 	err = c.multiServerService.UpdateServer(server) | ||||
| 	if err != nil { | ||||
| 		jsonMsg(ctx, "Failed to update server", err) | ||||
| 		return | ||||
| 	} | ||||
| 	jsonMsg(ctx, "Server updated successfully", nil) | ||||
| } | ||||
|  | @ -28,6 +28,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { | |||
| 
 | ||||
| 	g.GET("/", a.index) | ||||
| 	g.GET("/inbounds", a.inbounds) | ||||
| 	g.GET("/servers", a.servers) | ||||
| 	g.GET("/settings", a.settings) | ||||
| 	g.GET("/xray", a.xraySettings) | ||||
| 
 | ||||
|  | @ -56,3 +57,7 @@ func (a *XUIController) settings(c *gin.Context) { | |||
| func (a *XUIController) xraySettings(c *gin.Context) { | ||||
| 	html(c, "xray.html", "pages.xray.title", nil) | ||||
| } | ||||
| 
 | ||||
| func (a *XUIController) servers(c *gin.Context) { | ||||
| 	html(c, "servers.html", "Servers", nil) | ||||
| } | ||||
|  |  | |||
|  | @ -54,6 +54,11 @@ | |||
|                         icon: 'user', | ||||
|                         title: '{{ i18n "menu.inbounds"}}' | ||||
|                     }, | ||||
|                     { | ||||
|                         key: '{{ .base_path }}panel/servers', | ||||
|                         icon: 'cloud-server', | ||||
|                         title: 'Servers' | ||||
|                     }, | ||||
|                     { | ||||
|                         key: '{{ .base_path }}panel/settings', | ||||
|                         icon: 'setting', | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ | |||
|     </a-form-item> | ||||
| 
 | ||||
|     <a-form-item label='{{ i18n "pages.inbounds.port" }}'> | ||||
|         <a-input-number v-model.number="inbound.port" :min="1" :max="65531"></a-input-number> | ||||
|         <a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number> | ||||
|     </a-form-item> | ||||
| 
 | ||||
|     <a-form-item> | ||||
|  | @ -51,8 +51,9 @@ | |||
|                     <span>{{ i18n "pages.inbounds.periodicTrafficResetDesc" }}</span> | ||||
|                     <br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0"> | ||||
|                     <span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0"> | ||||
|                         <strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>  | ||||
|                         <span v-if="datepicker == 'gregorian'">[[ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span> | ||||
|                         <strong>{{ i18n "pages.inbounds.lastReset" }}:</strong> | ||||
|                         <span v-if="datepicker == 'gregorian'">[[ | ||||
|                             moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span> | ||||
|                         <span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span> | ||||
|                     </span> | ||||
|                 </template> | ||||
|  | @ -145,4 +146,4 @@ | |||
|     </a-collapse-panel> | ||||
| </a-collapse> | ||||
| 
 | ||||
| {{end}} | ||||
| {{end}} | ||||
|  | @ -3,12 +3,14 @@ | |||
|   <a-divider :style="{ margin: '5px 0 0' }"></a-divider> | ||||
|   <a-form-item label="External Proxy"> | ||||
|     <a-switch v-model="externalProxy"></a-switch> | ||||
|     <a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small" @click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button> | ||||
|     <a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small" | ||||
|       @click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button> | ||||
|   </a-form-item> | ||||
|   <a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy"> | ||||
|     <template> | ||||
|       <a-tooltip title="Force TLS"> | ||||
|         <a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }" :dropdown-class-name="themeSwitcher.currentTheme"> | ||||
|         <a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }" | ||||
|           :dropdown-class-name="themeSwitcher.currentTheme"> | ||||
|           <a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option> | ||||
|           <a-select-option value="none">{{ i18n "none" }}</a-select-option> | ||||
|           <a-select-option value="tls">TLS</a-select-option> | ||||
|  | @ -17,7 +19,7 @@ | |||
|     </template> | ||||
|     <a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input> | ||||
|     <a-tooltip title='{{ i18n "pages.inbounds.port" }}'> | ||||
|       <a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65531"></a-input-number> | ||||
|       <a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65535"></a-input-number> | ||||
|     </a-tooltip> | ||||
|     <a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'> | ||||
|       <template slot="addonAfter"> | ||||
|  | @ -26,4 +28,4 @@ | |||
|     </a-input> | ||||
|   </a-input-group> | ||||
| </a-form> | ||||
| {{end}} | ||||
| {{end}} | ||||
|  | @ -7,12 +7,13 @@ | |||
|       <a-input v-model.trim="dnsModal.dnsServer.address"></a-input> | ||||
|     </a-form-item> | ||||
|     <a-form-item label='{{ i18n "pages.inbounds.port" }}'> | ||||
|       <a-input-number v-model.number="dnsModal.dnsServer.port" :min="1" :max="65531"></a-input-number> | ||||
|       <a-input-number v-model.number="dnsModal.dnsServer.port" :min="1" :max="65535"></a-input-number> | ||||
|     </a-form-item> | ||||
|     <a-form-item label='{{ i18n "pages.xray.dns.strategy" }}'> | ||||
|       <a-select v-model="dnsModal.dnsServer.queryStrategy" :style="{ width: '100%' }" | ||||
|         :dropdown-class-name="themeSwitcher.currentTheme"> | ||||
|         <a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']"> [[ l ]] </a-select-option> | ||||
|         <a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']"> [[ l ]] | ||||
|         </a-select-option> | ||||
|       </a-select> | ||||
|     </a-form-item> | ||||
|     <a-divider :style="{ margin: '5px 0' }"></a-divider> | ||||
|  | @ -75,7 +76,7 @@ | |||
|     isEdit: false, | ||||
|     confirm: null, | ||||
|     dnsServer: { ...defaultDnsObject }, | ||||
|     ok() {       | ||||
|     ok() { | ||||
|       ObjectUtil.execute(dnsModal.confirm, { ...dnsModal.dnsServer }); | ||||
|     }, | ||||
|     show({ | ||||
|  | @ -106,7 +107,7 @@ | |||
|         } | ||||
|       } else { | ||||
|         this.dnsServer = { ...defaultDnsObject }; | ||||
|        | ||||
| 
 | ||||
|         this.dnsServer.domains = []; | ||||
|         this.dnsServer.expectIPs = []; | ||||
|         this.dnsServer.unexpectedIPs = []; | ||||
|  |  | |||
							
								
								
									
										165
									
								
								web/html/servers.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								web/html/servers.html
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,165 @@ | |||
| {{template "header" .}} | ||||
| 
 | ||||
| <div id="app" class="row" v-cloak> | ||||
|     <div class="col-md-12"> | ||||
|         <div class="card"> | ||||
|             <div class="card-header"> | ||||
|                 <h3 class="card-title">Server Management</h3> | ||||
|                 <div class="card-tools"> | ||||
|                     <button class="btn btn-primary" @click="showAddModal">Add Server</button> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <table class="table table-bordered"> | ||||
|                     <thead> | ||||
|                     <tr> | ||||
|                         <th>#</th> | ||||
|                         <th>Name</th> | ||||
|                         <th>Address</th> | ||||
|                         <th>Port</th> | ||||
|                         <th>Enabled</th> | ||||
|                         <th>Actions</th> | ||||
|                     </tr> | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|                     <tr v-for="(server, index) in servers"> | ||||
|                         <td>{{index + 1}}</td> | ||||
|                         <td>{{server.name}}</td> | ||||
|                         <td>{{server.address}}</td> | ||||
|                         <td>{{server.port}}</td> | ||||
|                         <td> | ||||
|                             <span v-if="server.enable" class="badge bg-success">Yes</span> | ||||
|                             <span v-else class="badge bg-danger">No</span> | ||||
|                         </td> | ||||
|                         <td> | ||||
|                             <button class="btn btn-info btn-sm" @click="showEditModal(server)">Edit</button> | ||||
|                             <button class="btn btn-danger btn-sm" @click="deleteServer(server.id)">Delete</button> | ||||
|                         </td> | ||||
|                     </tr> | ||||
|                     </tbody> | ||||
|                 </table> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- Add/Edit Modal --> | ||||
|     <div class="modal fade" id="serverModal" tabindex="-1" role="dialog"> | ||||
|         <div class="modal-dialog" role="document"> | ||||
|             <div class="modal-content"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h5 class="modal-title">{{modal.title}}</h5> | ||||
|                     <button type="button" class="close" data-dismiss="modal" aria-label="Close"> | ||||
|                         <span aria-hidden="true">×</span> | ||||
|                     </button> | ||||
|                 </div> | ||||
|                 <div class="modal-body"> | ||||
|                     <form> | ||||
|                         <div class="form-group"> | ||||
|                             <label>Name</label> | ||||
|                             <input type="text" class="form-control" v-model="modal.server.name"> | ||||
|                         </div> | ||||
|                         <div class="form-group"> | ||||
|                             <label>Address (IP or Domain)</label> | ||||
|                             <input type="text" class="form-control" v-model="modal.server.address"> | ||||
|                         </div> | ||||
|                         <div class="form-group"> | ||||
|                             <label>Port</label> | ||||
|                             <input type="number" class="form-control" v-model.number="modal.server.port"> | ||||
|                         </div> | ||||
|                         <div class="form-group"> | ||||
|                             <label>API Key</label> | ||||
|                             <input type="text" class="form-control" v-model="modal.server.apiKey"> | ||||
|                         </div> | ||||
|                         <div class="form-check"> | ||||
|                             <input type="checkbox" class="form-check-input" v-model="modal.server.enable"> | ||||
|                             <label class="form-check-label">Enabled</label> | ||||
|                         </div> | ||||
|                     </form> | ||||
|                 </div> | ||||
|                 <div class="modal-footer"> | ||||
|                     <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> | ||||
|                     <button type="button" class="btn btn-primary" @click="saveServer">Save</button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <script> | ||||
|     const app = new Vue({ | ||||
|         el: '#app', | ||||
|         data: { | ||||
|             servers: [], | ||||
|             modal: { | ||||
|                 title: '', | ||||
|                 server: { | ||||
|                     name: '', | ||||
|                     address: '', | ||||
|                     port: 0, | ||||
|                     apiKey: '', | ||||
|                     enable: true | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         methods: { | ||||
|             loadServers() { | ||||
|                 axios.get('{{.base_path}}server/list') | ||||
|                     .then(response => { | ||||
|                         this.servers = response.data.obj; | ||||
|                     }) | ||||
|                     .catch(error => { | ||||
|                         alert(error.response.data.msg); | ||||
|                     }); | ||||
|             }, | ||||
|             showAddModal() { | ||||
|                 this.modal.title = 'Add Server'; | ||||
|                 this.modal.server = { | ||||
|                     name: '', | ||||
|                     address: '', | ||||
|                     port: 0, | ||||
|                     apiKey: '', | ||||
|                     enable: true | ||||
|                 }; | ||||
|                 $('#serverModal').modal('show'); | ||||
|             }, | ||||
|             showEditModal(server) { | ||||
|                 this.modal.title = 'Edit Server'; | ||||
|                 this.modal.server = Object.assign({}, server); | ||||
|                 $('#serverModal').modal('show'); | ||||
|             }, | ||||
|             saveServer() { | ||||
|                 let url = '{{.base_path}}server/add'; | ||||
|                 if (this.modal.server.id) { | ||||
|                     url = `{{.base_path}}server/update/${this.modal.server.id}`; | ||||
|                 } | ||||
|                 axios.post(url, this.modal.server) | ||||
|                     .then(response => { | ||||
|                         alert(response.data.msg); | ||||
|                         $('#serverModal').modal('hide'); | ||||
|                         this.loadServers(); | ||||
|                     }) | ||||
|                     .catch(error => { | ||||
|                         alert(error.response.data.msg); | ||||
|                     }); | ||||
|             }, | ||||
|             deleteServer(id) { | ||||
|                 if (!confirm('Are you sure you want to delete this server?')) { | ||||
|                     return; | ||||
|                 } | ||||
|                 axios.post(`{{.base_path}}server/del/${id}`) | ||||
|                     .then(response => { | ||||
|                         alert(response.data.msg); | ||||
|                         this.loadServers(); | ||||
|                     }) | ||||
|                     .catch(error => { | ||||
|                         alert(error.response.data.msg); | ||||
|                     }); | ||||
|             } | ||||
|         }, | ||||
|         mounted() { | ||||
|             this.loadServers(); | ||||
|         } | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| {{template "footer" .}} | ||||
|  | @ -39,7 +39,7 @@ | |||
|             <template #title>{{ i18n "pages.settings.panelPort"}}</template> | ||||
|             <template #description>{{ i18n "pages.settings.panelPortDesc"}}</template> | ||||
|             <template #control> | ||||
|                 <a-input-number :min="1" :min="65531" v-model="allSetting.webPort" :style="{ width: '100%' }"></a-input> | ||||
|                 <a-input-number :min="1" :min="65535" v-model="allSetting.webPort" :style="{ width: '100%' }"></a-input> | ||||
|             </template> | ||||
|         </a-setting-list-item> | ||||
|         <a-setting-list-item paddings="small"> | ||||
|  | @ -137,7 +137,8 @@ | |||
|             <template #title>{{ i18n "pages.settings.datepicker"}}</template> | ||||
|             <template #description>{{ i18n "pages.settings.datepickerDescription"}}</template> | ||||
|             <template #control> | ||||
|                 <a-select :style="{ width: '100%' }" :dropdown-class-name="themeSwitcher.currentTheme" v-model="datepicker"> | ||||
|                 <a-select :style="{ width: '100%' }" :dropdown-class-name="themeSwitcher.currentTheme" | ||||
|                     v-model="datepicker"> | ||||
|                     <a-select-option v-for="item in datepickerList" :value="item.value"> | ||||
|                         <span v-text="item.name"></span> | ||||
|                     </a-select-option> | ||||
|  |  | |||
|  | @ -40,7 +40,7 @@ | |||
|             <template #title>{{ i18n "pages.settings.subPort"}}</template> | ||||
|             <template #description>{{ i18n "pages.settings.subPortDesc"}}</template> | ||||
|             <template #control> | ||||
|                 <a-input-number v-model="allSetting.subPort" :min="1" :min="65531" | ||||
|                 <a-input-number v-model="allSetting.subPort" :min="1" :min="65535" | ||||
|                     :style="{ width: '100%' }"></a-input-number> | ||||
|             </template> | ||||
|         </a-setting-list-item> | ||||
|  | @ -48,13 +48,10 @@ | |||
|             <template #title>{{ i18n "pages.settings.subPath"}}</template> | ||||
|             <template #description>{{ i18n "pages.settings.subPathDesc"}}</template> | ||||
|             <template #control> | ||||
|                 <a-input | ||||
|                     type="text" | ||||
|                     v-model="allSetting.subPath" | ||||
|                 <a-input type="text" v-model="allSetting.subPath" | ||||
|                     @input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')" | ||||
|                     @blur="allSetting.subPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subPath)" | ||||
|                     placeholder="/sub/" | ||||
|                 ></a-input> | ||||
|                     placeholder="/sub/"></a-input> | ||||
|             </template> | ||||
|         </a-setting-list-item> | ||||
|         <a-setting-list-item paddings="small"> | ||||
|  | @ -108,4 +105,4 @@ | |||
|         </a-setting-list-item> | ||||
|     </a-collapse-panel> | ||||
| </a-collapse> | ||||
| {{end}} | ||||
| {{end}} | ||||
|  | @ -218,6 +218,8 @@ | |||
|                                             <a-menu-item key="android-npvtunnel" | ||||
|                                                 @click="copy(app.subUrl)">NPV | ||||
|                                                 Tunnel</a-menu-item> | ||||
| 											<a-menu-item key="android-happ" | ||||
|                                                 @click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>	 | ||||
|                                         </a-menu> | ||||
|                                     </a-dropdown> | ||||
|                                 </a-col> | ||||
|  | @ -244,6 +246,8 @@ | |||
|                                                 @click="copy(npvtunUrl)">NPV | ||||
|                                                 Tunnel | ||||
|                                             </a-menu-item> | ||||
| 											<a-menu-item key="ios-happ" | ||||
|                                                 @click="open(happUrl)">Happ</a-menu-item> | ||||
|                                         </a-menu> | ||||
|                                     </a-dropdown> | ||||
|                                 </a-col> | ||||
|  |  | |||
							
								
								
									
										34
									
								
								web/middleware/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								web/middleware/auth.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| package middleware | ||||
| 
 | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"x-ui/web/service" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
| 
 | ||||
| func ApiAuth() gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		apiKey := c.GetHeader("Api-Key") | ||||
| 		if apiKey == "" { | ||||
| 			c.JSON(http.StatusUnauthorized, gin.H{"error": "API key is required"}) | ||||
| 			c.Abort() | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		settingService := service.SettingService{} | ||||
| 		panelAPIKey, err := settingService.GetAPIKey() | ||||
| 		if err != nil || panelAPIKey == "" { | ||||
| 			c.JSON(http.StatusInternalServerError, gin.H{"error": "API key not configured on the panel"}) | ||||
| 			c.Abort() | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if apiKey != panelAPIKey { | ||||
| 			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) | ||||
| 			c.Abort() | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		c.Next() | ||||
| 	} | ||||
| } | ||||
|  | @ -3,8 +3,11 @@ | |||
| package service | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"sort" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | @ -35,6 +38,25 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) { | |||
| 	if err != nil && err != gorm.ErrRecordNotFound { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	// Enrich client stats with UUID/SubId from inbound settings
 | ||||
| 	for _, inbound := range inbounds { | ||||
| 		clients, _ := s.GetClients(inbound) | ||||
| 		if len(clients) == 0 || len(inbound.ClientStats) == 0 { | ||||
| 			continue | ||||
| 		} | ||||
| 		// Build a map email -> client
 | ||||
| 		cMap := make(map[string]model.Client, len(clients)) | ||||
| 		for _, c := range clients { | ||||
| 			cMap[strings.ToLower(c.Email)] = c | ||||
| 		} | ||||
| 		for i := range inbound.ClientStats { | ||||
| 			email := strings.ToLower(inbound.ClientStats[i].Email) | ||||
| 			if c, ok := cMap[email]; ok { | ||||
| 				inbound.ClientStats[i].UUID = c.ID | ||||
| 				inbound.ClientStats[i].SubId = c.SubID | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return inbounds, nil | ||||
| } | ||||
| 
 | ||||
|  | @ -47,6 +69,24 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) { | |||
| 	if err != nil && err != gorm.ErrRecordNotFound { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	// Enrich client stats with UUID/SubId from inbound settings
 | ||||
| 	for _, inbound := range inbounds { | ||||
| 		clients, _ := s.GetClients(inbound) | ||||
| 		if len(clients) == 0 || len(inbound.ClientStats) == 0 { | ||||
| 			continue | ||||
| 		} | ||||
| 		cMap := make(map[string]model.Client, len(clients)) | ||||
| 		for _, c := range clients { | ||||
| 			cMap[strings.ToLower(c.Email)] = c | ||||
| 		} | ||||
| 		for i := range inbound.ClientStats { | ||||
| 			email := strings.ToLower(inbound.ClientStats[i].Email) | ||||
| 			if c, ok := cMap[email]; ok { | ||||
| 				inbound.ClientStats[i].UUID = c.ID | ||||
| 				inbound.ClientStats[i].SubId = c.SubID | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return inbounds, nil | ||||
| } | ||||
| 
 | ||||
|  | @ -636,6 +676,11 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) { | |||
| 	} | ||||
| 	s.xrayApi.Close() | ||||
| 
 | ||||
| 	if err == nil { | ||||
| 		body, _ := json.Marshal(data) | ||||
| 		s.syncWithSlaves("POST", "/panel/inbound/api/addClient", bytes.NewReader(body)) | ||||
| 	} | ||||
| 
 | ||||
| 	return needRestart, tx.Save(oldInbound).Error | ||||
| } | ||||
| 
 | ||||
|  | @ -724,6 +769,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, | |||
| 			s.xrayApi.Close() | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err == nil { | ||||
| 		s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/%d/delClient/%s", inboundId, clientId), nil) | ||||
| 	} | ||||
| 
 | ||||
| 	return needRestart, db.Save(oldInbound).Error | ||||
| } | ||||
| 
 | ||||
|  | @ -899,6 +949,12 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin | |||
| 		logger.Debug("Client old email not found") | ||||
| 		needRestart = true | ||||
| 	} | ||||
| 
 | ||||
| 	if err == nil { | ||||
| 		body, _ := json.Marshal(data) | ||||
| 		s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/updateClient/%s", clientId), bytes.NewReader(body)) | ||||
| 	} | ||||
| 
 | ||||
| 	return needRestart, tx.Save(oldInbound).Error | ||||
| } | ||||
| 
 | ||||
|  | @ -2326,6 +2382,44 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [ | |||
| 
 | ||||
| 	return validEmails, extraEmails, nil | ||||
| } | ||||
| 
 | ||||
| func (s *InboundService) syncWithSlaves(method string, path string, body io.Reader) { | ||||
| 	serverService := MultiServerService{} | ||||
| 	servers, err := serverService.GetServers() | ||||
| 	if err != nil { | ||||
| 		logger.Warning("Failed to get servers for syncing:", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	for _, server := range servers { | ||||
| 		if !server.Enable { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		url := fmt.Sprintf("http://%s:%d%s", server.Address, server.Port, path) | ||||
| 		req, err := http.NewRequest(method, url, body) | ||||
| 		if err != nil { | ||||
| 			logger.Warningf("Failed to create request for server %s: %v", server.Name, err) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		req.Header.Set("Api-Key", server.APIKey) | ||||
| 
 | ||||
| 		client := &http.Client{} | ||||
| 		resp, err := client.Do(req) | ||||
| 		if err != nil { | ||||
| 			logger.Warningf("Failed to send request to server %s: %v", server.Name, err) | ||||
| 			continue | ||||
| 		} | ||||
| 		defer resp.Body.Close() | ||||
| 
 | ||||
| 		if resp.StatusCode != http.StatusOK { | ||||
| 			bodyBytes, _ := io.ReadAll(resp.Body) | ||||
| 			logger.Warningf("Failed to sync with server %s. Status: %s, Body: %s", server.Name, resp.Status, string(bodyBytes)) | ||||
| 		} | ||||
| 	} | ||||
|    | ||||
| func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) { | ||||
| 	oldInbound, err := s.GetInbound(inboundId) | ||||
| 	if err != nil { | ||||
|  | @ -2417,4 +2511,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b | |||
| 	} | ||||
| 
 | ||||
| 	return needRestart, db.Save(oldInbound).Error | ||||
| 
 | ||||
| } | ||||
|  |  | |||
							
								
								
									
										72
									
								
								web/service/inbound_service_sync_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								web/service/inbound_service_sync_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,72 @@ | |||
| package service | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"testing" | ||||
| 	"x-ui/database" | ||||
| 	"x-ui/database/model" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| 
 | ||||
| func TestInboundServiceSync(t *testing.T) { | ||||
| 	setup() | ||||
| 	defer teardown() | ||||
| 
 | ||||
| 	// Mock server to simulate a slave
 | ||||
| 	var receivedApiKey string | ||||
| 	var receivedBody []byte | ||||
| 	mockSlave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		receivedApiKey = r.Header.Get("Api-Key") | ||||
| 		receivedBody, _ = io.ReadAll(r.Body) | ||||
| 		w.WriteHeader(http.StatusOK) | ||||
| 	})) | ||||
| 	defer mockSlave.Close() | ||||
| 
 | ||||
| 	// Add the mock slave to the database
 | ||||
| 	multiServerService := MultiServerService{} | ||||
| 	mockSlaveURL, _ := url.Parse(mockSlave.URL) | ||||
| 	mockSlavePort, _ := strconv.Atoi(mockSlaveURL.Port()) | ||||
| 	slaveServer := &model.Server{ | ||||
| 		Name:    "mock-slave", | ||||
| 		Address: mockSlaveURL.Hostname(), | ||||
| 		Port:    mockSlavePort, | ||||
| 		APIKey:  "slave-api-key", | ||||
| 		Enable:  true, | ||||
| 	} | ||||
| 	multiServerService.AddServer(slaveServer) | ||||
| 
 | ||||
| 	// Create a test inbound and client
 | ||||
| 	inboundService := InboundService{} | ||||
| 	db := database.GetDB() | ||||
| 	testInbound := &model.Inbound{ | ||||
| 		UserId:   1, | ||||
| 		Remark:   "test-inbound", | ||||
| 		Enable:   true, | ||||
| 		Settings: `{"clients":[]}`, | ||||
| 	} | ||||
| 	db.Create(testInbound) | ||||
| 
 | ||||
| 	clientData := model.Client{ | ||||
| 		Email: "test@example.com", | ||||
| 		ID:    "test-id", | ||||
| 	} | ||||
| 	clientBytes, _ := json.Marshal([]model.Client{clientData}) | ||||
| 	inboundData := &model.Inbound{ | ||||
| 		Id:       testInbound.Id, | ||||
| 		Settings: string(clientBytes), | ||||
| 	} | ||||
| 
 | ||||
| 	// Test AddInboundClient sync
 | ||||
| 	inboundService.AddInboundClient(inboundData) | ||||
| 
 | ||||
| 	assert.Equal(t, "slave-api-key", receivedApiKey) | ||||
| 	var receivedInbound model.Inbound | ||||
| 	json.Unmarshal(receivedBody, &receivedInbound) | ||||
| 	assert.Equal(t, 1, receivedInbound.Id) | ||||
| } | ||||
							
								
								
									
										37
									
								
								web/service/multi_server_service.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								web/service/multi_server_service.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| package service | ||||
| 
 | ||||
| import ( | ||||
| 	"x-ui/database" | ||||
| 	"x-ui/database/model" | ||||
| ) | ||||
| 
 | ||||
| type MultiServerService struct{} | ||||
| 
 | ||||
| func (s *MultiServerService) GetServers() ([]*model.Server, error) { | ||||
| 	db := database.GetDB() | ||||
| 	var servers []*model.Server | ||||
| 	err := db.Find(&servers).Error | ||||
| 	return servers, err | ||||
| } | ||||
| 
 | ||||
| func (s *MultiServerService) GetServer(id int) (*model.Server, error) { | ||||
| 	db := database.GetDB() | ||||
| 	var server model.Server | ||||
| 	err := db.First(&server, id).Error | ||||
| 	return &server, err | ||||
| } | ||||
| 
 | ||||
| func (s *MultiServerService) AddServer(server *model.Server) error { | ||||
| 	db := database.GetDB() | ||||
| 	return db.Create(server).Error | ||||
| } | ||||
| 
 | ||||
| func (s *MultiServerService) UpdateServer(server *model.Server) error { | ||||
| 	db := database.GetDB() | ||||
| 	return db.Save(server).Error | ||||
| } | ||||
| 
 | ||||
| func (s *MultiServerService) DeleteServer(id int) error { | ||||
| 	db := database.GetDB() | ||||
| 	return db.Delete(&model.Server{}, id).Error | ||||
| } | ||||
							
								
								
									
										63
									
								
								web/service/multi_server_service_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								web/service/multi_server_service_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,63 @@ | |||
| package service | ||||
| 
 | ||||
| import ( | ||||
| 	"os" | ||||
| 	"testing" | ||||
| 	"x-ui/database" | ||||
| 	"x-ui/database/model" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| 
 | ||||
| func setup() { | ||||
| 	dbPath := "test.db" | ||||
| 	os.Remove(dbPath) | ||||
| 	database.InitDB(dbPath) | ||||
| } | ||||
| 
 | ||||
| func teardown() { | ||||
| 	db, _ := database.GetDB().DB() | ||||
| 	db.Close() | ||||
| 	os.Remove("test.db") | ||||
| } | ||||
| 
 | ||||
| func TestMultiServerService(t *testing.T) { | ||||
| 	setup() | ||||
| 	defer teardown() | ||||
| 
 | ||||
| 	service := MultiServerService{} | ||||
| 
 | ||||
| 	// Test AddServer
 | ||||
| 	server := &model.Server{ | ||||
| 		Name:    "test-server", | ||||
| 		Address: "127.0.0.1", | ||||
| 		Port:    54321, | ||||
| 		APIKey:  "test-key", | ||||
| 		Enable:  true, | ||||
| 	} | ||||
| 	err := service.AddServer(server) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	// Test GetServer
 | ||||
| 	retrievedServer, err := service.GetServer(server.Id) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Equal(t, server.Name, retrievedServer.Name) | ||||
| 
 | ||||
| 	// Test GetServers
 | ||||
| 	servers, err := service.GetServers() | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Len(t, servers, 1) | ||||
| 
 | ||||
| 	// Test UpdateServer
 | ||||
| 	retrievedServer.Name = "updated-server" | ||||
| 	err = service.UpdateServer(retrievedServer) | ||||
| 	assert.NoError(t, err) | ||||
| 	updatedServer, _ := service.GetServer(server.Id) | ||||
| 	assert.Equal(t, "updated-server", updatedServer.Name) | ||||
| 
 | ||||
| 	// Test DeleteServer
 | ||||
| 	err = service.DeleteServer(server.Id) | ||||
| 	assert.NoError(t, err) | ||||
| 	_, err = service.GetServer(server.Id) | ||||
| 	assert.Error(t, err) | ||||
| } | ||||
|  | @ -183,6 +183,21 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) { | |||
| 	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() | ||||
|  |  | |||
|  | @ -264,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { | |||
| 	g := engine.Group(basePath) | ||||
| 
 | ||||
| 	s.index = controller.NewIndexController(g) | ||||
| 	s.server = controller.NewServerController(g) | ||||
| 	s.server = controller.NewMultiServerController(g) | ||||
| 	s.panel = controller.NewXUIController(g) | ||||
| 	s.api = controller.NewAPIController(g) | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue