From 11dc06863e95108e54416dfd5ccc87753bb2f4c6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 05:06:14 +0000 Subject: [PATCH] feat: Add multi-server support for Sanai panel 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. --- database/db.go | 1 + database/model/model.go | 9 ++ go.mod | 3 + install.sh | 7 + main.go | 15 +- sub/subService.go | 75 ++++++---- web/controller/inbound.go | 18 ++- web/controller/multi_server_controller.go | 89 ++++++++++++ web/controller/xui.go | 5 + web/html/component/aSidebar.html | 5 + web/html/servers.html | 165 ++++++++++++++++++++++ web/middleware/auth.go | 34 +++++ web/service/inbound.go | 57 ++++++++ web/service/inbound_service_sync_test.go | 72 ++++++++++ web/service/multi_server_service.go | 37 +++++ web/service/multi_server_service_test.go | 63 +++++++++ web/service/setting.go | 15 ++ web/web.go | 2 +- 18 files changed, 639 insertions(+), 33 deletions(-) create mode 100644 web/controller/multi_server_controller.go create mode 100644 web/html/servers.html create mode 100644 web/middleware/auth.go create mode 100644 web/service/inbound_service_sync_test.go create mode 100644 web/service/multi_server_service.go create mode 100644 web/service/multi_server_service_test.go diff --git a/database/db.go b/database/db.go index c72d28cf..484f1758 100644 --- a/database/db.go +++ b/database/db.go @@ -35,6 +35,7 @@ func initModels() error { &model.InboundClientIps{}, &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, + &model.Server{}, } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 2e7095d3..68d56593 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -105,3 +105,12 @@ type Client struct { Comment string `json:"comment" form:"comment"` Reset int `json:"reset" form:"reset"` } + +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"` +} diff --git a/go.mod b/go.mod index 01bbd3ff..60ca3db0 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.25.6 + github.com/stretchr/testify v1.10.0 github.com/valyala/fasthttp v1.63.0 github.com/xlzd/gotp v0.1.0 github.com/xtls/xray-core v1.250726.0 @@ -32,6 +33,7 @@ require ( github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/fasthttp/router v1.5.4 // indirect @@ -62,6 +64,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 diff --git a/install.sh b/install.sh index a1398712..62a64746 100644 --- a/install.sh +++ b/install.sh @@ -143,6 +143,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() { diff --git a/main.go b/main.go index 9986ede1..83739ebf 100644 --- a/main.go +++ b/main.go @@ -232,7 +232,7 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri } } -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) @@ -242,6 +242,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 { @@ -388,9 +397,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") @@ -440,7 +451,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) diff --git a/sub/subService.go b/sub/subService.go index 9f26c0e0..a0936026 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -154,26 +154,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", } @@ -286,7 +303,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)) @@ -302,14 +319,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 "" } @@ -477,7 +494,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" @@ -498,12 +515,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 "" } @@ -667,7 +684,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" @@ -689,12 +706,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 "" } @@ -834,7 +851,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" @@ -855,17 +872,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 @@ -876,6 +894,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++ { diff --git a/web/controller/inbound.go b/web/controller/inbound.go index a89f224f..333c8681 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -6,6 +6,7 @@ import ( "strconv" "x-ui/database/model" + "x-ui/web/middleware" "x-ui/web/service" "x-ui/web/session" @@ -32,15 +33,26 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/update/:id", a.updateInbound) g.POST("/clientIps/:email", a.getClientIps) g.POST("/clearClientIps/:email", a.clearClientIps) - g.POST("/addClient", a.addInboundClient) - g.POST("/:id/delClient/:clientId", a.delInboundClient) - g.POST("/updateClient/:clientId", a.updateInboundClient) g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic) g.POST("/resetAllTraffics", a.resetAllTraffics) g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics) g.POST("/delDepletedClients/:id", a.delDepletedClients) g.POST("/import", a.importInbound) g.POST("/onlines", a.onlines) + + // Routes for UI + g.POST("/addClient", a.addInboundClient) + g.POST("/:id/delClient/:clientId", a.delInboundClient) + g.POST("/updateClient/:clientId", a.updateInboundClient) + + // Routes for API (for slave servers) + apiGroup := g.Group("/api") + apiGroup.Use(middleware.ApiAuth()) + { + apiGroup.POST("/addClient", a.addInboundClient) + apiGroup.POST("/:id/delClient/:clientId", a.delInboundClient) + apiGroup.POST("/updateClient/:clientId", a.updateInboundClient) + } } func (a *InboundController) getInbounds(c *gin.Context) { diff --git a/web/controller/multi_server_controller.go b/web/controller/multi_server_controller.go new file mode 100644 index 00000000..65605f4a --- /dev/null +++ b/web/controller/multi_server_controller.go @@ -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) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index 5b4c0a18..42c02a15 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -24,6 +24,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) @@ -47,3 +48,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) +} diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index b69c8f3f..7f0a49a7 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -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', diff --git a/web/html/servers.html b/web/html/servers.html new file mode 100644 index 00000000..30354731 --- /dev/null +++ b/web/html/servers.html @@ -0,0 +1,165 @@ +{{template "header" .}} + +
# | +Name | +Address | +Port | +Enabled | +Actions | +
---|---|---|---|---|---|
{{index + 1}} | +{{server.name}} | +{{server.address}} | +{{server.port}} | ++ Yes + No + | ++ + + | +