Compare commits

...

19 commits

Author SHA1 Message Date
javadtgh
1fdc6c80ef
Merge 3b262cf180 into 89def9aee6 2025-09-24 23:10:56 +03:00
mhsanaei
89def9aee6
fix
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
2025-09-24 21:30:58 +02:00
Sanaei
3b262cf180
Merge branch 'main' into feature/multi-server-support 2025-09-24 21:27:55 +02:00
mhsanaei
b2b0024648
login: autocomplete password
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
2025-09-24 20:41:32 +02:00
mhsanaei
5822758b7c
tiny changes 2025-09-24 19:51:01 +02:00
mhsanaei
49430b3991
Update docker.yml 2025-09-24 15:42:01 +02:00
mhsanaei
104526aab2
v2.8.4
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
2025-09-24 11:47:43 +02:00
mhsanaei
a0c07241c0
minor changes 2025-09-24 11:47:14 +02:00
mhsanaei
adf3242602
bug fix 2025-09-24 11:44:02 +02:00
mhsanaei
3f62592e4b
API improve security: returns 404 for unauthenticated API requests 2025-09-24 11:29:55 +02:00
Sanaei
4c7249c451
Merge branch 'main' into feature/multi-server-support 2025-09-21 23:55:26 +02:00
Sanaei
edd8b12988
Merge branch 'main' into feature/multi-server-support 2025-09-19 13:24:09 +02:00
Sanaei
5e953bae45
Merge branch 'main' into feature/multi-server-support 2025-09-12 12:17:12 +02:00
Sanaei
747af376f2
Merge branch 'main' into feature/multi-server-support 2025-09-09 20:53:50 +02:00
Sanaei
a3ccccfe52
Merge branch 'main' into feature/multi-server-support 2025-09-08 14:45:59 +02:00
Sanaei
3299d15f28
Merge branch 'main' into feature/multi-server-support 2025-08-14 18:06:16 +02:00
Sanaei
ae82373457
Merge branch 'main' into feature/multi-server-support 2025-08-04 11:22:53 +02:00
Sanaei
d65233cc2c
Merge branch 'main' into feature/multi-server-support 2025-08-04 10:33:41 +02:00
google-labs-jules[bot]
11dc06863e 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.
2025-07-27 17:25:58 +02:00
26 changed files with 758 additions and 100 deletions

View file

@ -1,7 +1,9 @@
name: Release 3X-UI for Docker name: Release 3X-UI for Docker
permissions: permissions:
contents: read contents: read
packages: write packages: write
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
@ -27,7 +29,7 @@ jobs:
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=tag type=ref,event=tag
type=pep440,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -47,7 +49,7 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
@ -55,6 +57,6 @@ jobs:
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386 platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View file

@ -1 +1 @@
2.8.3 2.8.4

View file

@ -37,6 +37,7 @@ func initModels() error {
&model.InboundClientIps{}, &model.InboundClientIps{},
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.Server{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {

View file

@ -119,3 +119,12 @@ type Client struct {
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update 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"`
}

3
go.mod
View file

@ -65,6 +65,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pires/go-proxyproto v0.8.1 // 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
@ -95,7 +96,7 @@ require (
golang.org/x/tools v0.37.0 // indirect golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect

2
go.sum
View file

@ -236,6 +236,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=

View file

@ -140,6 +140,13 @@ config_after_install() {
fi fi
/usr/local/x-ui/x-ui migrate /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() { install_x-ui() {

16
main.go
View file

@ -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. // 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()) err := database.InitDB(config.GetDBPath())
if err != nil { if err != nil {
fmt.Println("Database initialization failed:", err) fmt.Println("Database initialization failed:", err)
@ -250,6 +251,15 @@ func updateSetting(port int, username string, password string, webBasePath strin
settingService := service.SettingService{} settingService := service.SettingService{}
userService := service.UserService{} 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 { if port > 0 {
err := settingService.SetPort(port) err := settingService.SetPort(port)
if err != nil { if err != nil {
@ -402,9 +412,11 @@ func main() {
var show bool var show bool
var getCert bool var getCert bool
var resetTwoFactor bool var resetTwoFactor bool
var apiKey string
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings") settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
settingCmd.BoolVar(&show, "show", false, "Display current settings") settingCmd.BoolVar(&show, "show", false, "Display current settings")
settingCmd.IntVar(&port, "port", 0, "Set panel port number") settingCmd.IntVar(&port, "port", 0, "Set panel port number")
settingCmd.StringVar(&apiKey, "apiKey", "", "Set API Key")
settingCmd.StringVar(&username, "username", "", "Set login username") settingCmd.StringVar(&username, "username", "", "Set login username")
settingCmd.StringVar(&password, "password", "", "Set login password") settingCmd.StringVar(&password, "password", "", "Set login password")
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel") settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
@ -454,7 +466,7 @@ func main() {
if reset { if reset {
resetSetting() resetSetting()
} else { } else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey)
} }
if show { if show {
showSetting(show) showSetting(show)

View file

@ -98,8 +98,14 @@ func (s *Server) initRouter() (*gin.Engine, error) {
} }
// Set base_path based on LinksPath for template rendering // Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
if basePath != "/" && !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
logger.Debug("sub: Setting base_path to:", basePath)
engine.Use(func(c *gin.Context) { engine.Use(func(c *gin.Context) {
c.Set("base_path", LinksPath) c.Set("base_path", basePath)
}) })
Encrypt, err := s.settingService.GetSubEncrypt() Encrypt, err := s.settingService.GetSubEncrypt()
@ -179,22 +185,48 @@ func (s *Server) initRouter() (*gin.Engine, error) {
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets" linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
} }
// Mount assets in multiple paths to handle different URL patterns
var assetsFS http.FileSystem
if _, err := os.Stat("web/assets"); err == nil { if _, err := os.Stat("web/assets"); err == nil {
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) assetsFS = http.FS(os.DirFS("web/assets"))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
}
} else { } else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
engine.StaticFS("/assets", http.FS(subFS)) assetsFS = http.FS(subFS)
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(subFS))
}
} else { } else {
logger.Error("sub: failed to mount embedded assets:", err) logger.Error("sub: failed to mount embedded assets:", err)
} }
} }
if assetsFS != nil {
engine.StaticFS("/assets", assetsFS)
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, assetsFS)
}
// Add middleware to handle dynamic asset paths with subid
if LinksPath != "/" {
engine.Use(func(c *gin.Context) {
path := c.Request.URL.Path
// Check if this is an asset request with subid pattern: /sub/path/{subid}/assets/...
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
// Extract the asset path after /assets/
assetsIndex := strings.Index(path, "/assets/")
if assetsIndex != -1 {
assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
if assetPath != "" {
// Serve the asset file
c.FileFromFS(assetPath, assetsFS)
c.Abort()
return
}
}
}
c.Next()
})
}
}
g := engine.Group("/") g := engine.Group("/")
s.sub = NewSUBController( s.sub = NewSUBController(

View file

@ -87,7 +87,20 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.jsonEnabled { if !a.jsonEnabled {
subJsonURL = "" subJsonURL = ""
} }
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL) // Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
basePath = "/"
}
// Add subId to base_path for asset URLs
basePathStr := basePath.(string)
if basePathStr == "/" {
basePathStr = "/" + subId + "/"
} else {
// Remove trailing slash if exists, add subId, then add trailing slash
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
}
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
c.HTML(200, "subpage.html", gin.H{ c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title", "title": "subscription.title",
"cur_ver": config.GetVersion(), "cur_ver": config.GetVersion(),

View file

@ -162,26 +162,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
} }
func (s *SubService) getLink(inbound *model.Inbound, email string) string { func (s *SubService) getLink(inbound *model.Inbound, email string) string {
serverService := service.MultiServerService{}
servers, err := serverService.GetServers()
if err != nil {
logger.Warning("Failed to get servers for subscription:", err)
return ""
}
var links []string
for _, server := range servers {
if !server.Enable {
continue
}
var link string
switch inbound.Protocol { switch inbound.Protocol {
case "vmess": case "vmess":
return s.genVmessLink(inbound, email) link = s.genVmessLink(inbound, email, server)
case "vless": case "vless":
return s.genVlessLink(inbound, email) link = s.genVlessLink(inbound, email, server)
case "trojan": case "trojan":
return s.genTrojanLink(inbound, email) link = s.genTrojanLink(inbound, email, server)
case "shadowsocks": case "shadowsocks":
return s.genShadowsocksLink(inbound, email) link = s.genShadowsocksLink(inbound, email, server)
} }
return "" 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 { if inbound.Protocol != model.VMESS {
return "" return ""
} }
obj := map[string]any{ obj := map[string]any{
"v": "2", "v": "2",
"add": s.address, "add": server.Address,
"port": inbound.Port, "port": inbound.Port,
"type": "none", "type": "none",
} }
@ -294,7 +311,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
newObj[key] = value 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["add"] = ep["dest"].(string)
newObj["port"] = int(ep["port"].(float64)) newObj["port"] = int(ep["port"].(float64))
@ -310,14 +327,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
return links return links
} }
obj["ps"] = s.genRemark(inbound, email, "") obj["ps"] = s.genRemark(inbound, email, "", server.Name)
jsonStr, _ := json.MarshalIndent(obj, "", " ") jsonStr, _ := json.MarshalIndent(obj, "", " ")
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
} }
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.VLESS { if inbound.Protocol != model.VLESS {
return "" return ""
} }
@ -497,7 +514,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() 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 { if index > 0 {
links += "\n" links += "\n"
@ -518,12 +535,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { func (s *SubService) genTrojanLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.Trojan { if inbound.Protocol != model.Trojan {
return "" return ""
} }
@ -692,7 +709,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() 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 { if index > 0 {
links += "\n" links += "\n"
@ -714,12 +731,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.Shadowsocks { if inbound.Protocol != model.Shadowsocks {
return "" return ""
} }
@ -859,7 +876,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() 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 { if index > 0 {
links += "\n" links += "\n"
@ -880,17 +897,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() 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]) separationChar := string(s.remarkModel[0])
orderChars := s.remarkModel[1:] orderChars := s.remarkModel[1:]
orders := map[byte]string{ orders := map[byte]string{
'i': "", 'i': "",
'e': "", 'e': "",
'o': "", 'o': "",
's': "",
} }
if len(email) > 0 { if len(email) > 0 {
orders['e'] = email orders['e'] = email
@ -901,6 +919,9 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if len(extra) > 0 { if len(extra) > 0 {
orders['o'] = extra orders['o'] = extra
} }
if len(serverName) > 0 {
orders['s'] = serverName
}
var remark []string var remark []string
for i := 0; i < len(orderChars); i++ { for i := 0; i < len(orderChars); i++ {
@ -1148,7 +1169,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model. // BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page. // BuildPageData constructs page data for rendering the subscription information page.
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string) PageData { func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
download := common.FormatTraffic(traffic.Down) download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up) upload := common.FormatTraffic(traffic.Up)
total := "∞" total := "∞"
@ -1167,7 +1188,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
return PageData{ return PageData{
Host: hostHeader, Host: hostHeader,
BasePath: "/", // kept as "/"; templates now use context base_path injected from router BasePath: basePath,
SId: subId, SId: subId,
Download: download, Download: download,
Upload: upload, Upload: upload,

View file

@ -1,7 +1,10 @@
package controller package controller
import ( import (
"net/http"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -21,11 +24,21 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
return a return a
} }
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users
func (a *APIController) checkAPIAuth(c *gin.Context) {
if !session.IsLogin(c) {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Next()
}
// initRouter sets up the API routes for inbounds, server, and other endpoints. // initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) { func (a *APIController) initRouter(g *gin.RouterGroup) {
// Main API group // Main API group
api := g.Group("/panel/api") api := g.Group("/panel/api")
api.Use(a.checkLogin) api.Use(a.checkAPIAuth)
// Inbounds API // Inbounds API
inbounds := api.Group("/inbounds") inbounds := api.Group("/inbounds")

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"

View file

@ -39,8 +39,9 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication. // initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) { func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.POST("/login", a.login)
g.GET("/logout", a.logout) g.GET("/logout", a.logout)
g.POST("/login", a.login)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable) g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
} }

View 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)
}

View file

@ -8,8 +8,6 @@ import (
type XUIController struct { type XUIController struct {
BaseController BaseController
inboundController *InboundController
serverController *ServerController
settingController *SettingController settingController *SettingController
xraySettingController *XraySettingController xraySettingController *XraySettingController
} }
@ -28,11 +26,10 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)
g.GET("/servers", a.servers)
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
a.inboundController = NewInboundController(g)
a.serverController = NewServerController(g)
a.settingController = NewSettingController(g) a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g) a.xraySettingController = NewXraySettingController(g)
} }
@ -56,3 +53,7 @@ func (a *XUIController) settings(c *gin.Context) {
func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil) html(c, "xray.html", "pages.xray.title", nil)
} }
func (a *XUIController) servers(c *gin.Context) {
html(c, "servers.html", "Servers", nil)
}

View file

@ -54,6 +54,11 @@
icon: 'user', icon: 'user',
title: '{{ i18n "menu.inbounds"}}' title: '{{ i18n "menu.inbounds"}}'
}, },
{
key: '{{ .base_path }}panel/servers',
icon: 'cloud-server',
title: 'Servers'
},
{ {
key: '{{ .base_path }}panel/settings', key: '{{ .base_path }}panel/settings',
icon: 'setting', icon: 'setting',

View file

@ -35,8 +35,8 @@
<a-space direction="vertical" :size="10"> <a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login> <a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language" }}</span> <span>{{ i18n "pages.settings.language" }}</span>
<a-select ref="selectLang" class="w-100" v-model="lang" <a-select ref="selectLang" class="w-100" v-model="lang" @change="LanguageManager.setLanguage(lang)"
@change="LanguageManager.setLanguage(lang)" :dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages"> <a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages">
<span role="img" aria-label="l.name" v-text="l.icon"></span> <span role="img" aria-label="l.name" v-text="l.icon"></span>
&nbsp;&nbsp;<span v-text="l.name"></span> &nbsp;&nbsp;<span v-text="l.name"></span>
@ -68,7 +68,7 @@
</a-input> </a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-input-password autocomplete="password" name="password" v-model.trim="user.password" <a-input-password autocomplete="current-password" name="password" v-model.trim="user.password"
placeholder='{{ i18n "password" }}' required> placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon> <a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password> </a-input-password>
@ -81,7 +81,8 @@
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-row justify="center" class="centered"> <a-row justify="center" class="centered">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem" :style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'"> <div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning" <a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit"> :icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]] [[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]

165
web/html/servers.html Normal file
View 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">&times;</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" .}}

34
web/middleware/auth.go Normal file
View 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()
}
}

View file

@ -3,8 +3,11 @@
package service package service
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -673,6 +676,11 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
} }
s.xrayApi.Close() 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 return needRestart, tx.Save(oldInbound).Error
} }
@ -761,6 +769,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
s.xrayApi.Close() 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 return needRestart, db.Save(oldInbound).Error
} }
@ -936,6 +949,12 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
logger.Debug("Client old email not found") logger.Debug("Client old email not found")
needRestart = true 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 return needRestart, tx.Save(oldInbound).Error
} }
@ -2363,6 +2382,44 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
return validEmails, extraEmails, nil 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) { func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) {
oldInbound, err := s.GetInbound(inboundId) oldInbound, err := s.GetInbound(inboundId)
if err != nil { if err != nil {
@ -2454,4 +2511,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
} }
return needRestart, db.Save(oldInbound).Error return needRestart, db.Save(oldInbound).Error
} }

View 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)
}

View 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
}

View 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)
}

View file

@ -183,6 +183,21 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
return setting, nil 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 { func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key) setting, err := s.getSetting(key)
db := database.GetDB() db := database.GetDB()

View file

@ -96,7 +96,6 @@ type Server struct {
listener net.Listener listener net.Listener
index *controller.IndexController index *controller.IndexController
server *controller.ServerController
panel *controller.XUIController panel *controller.XUIController
api *controller.APIController api *controller.APIController
@ -264,10 +263,14 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group(basePath) g := engine.Group(basePath)
s.index = controller.NewIndexController(g) s.index = controller.NewIndexController(g)
s.server = controller.NewServerController(g)
s.panel = controller.NewXUIController(g) s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g) s.api = controller.NewAPIController(g)
// Add a catch-all route to handle undefined paths and return 404
engine.NoRoute(func(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
})
return engine, nil return engine, nil
} }