Compare commits

...

11 commits

Author SHA1 Message Date
javadtgh
e6ac973bb9
Merge 4c7249c451 into b3e96230c4 2025-09-23 00:04:09 +03:00
Evgeny Volferts
b3e96230c4
Add Alpine Linux support (#3534)
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
* Add Alpine linux support

* Fix for reading logs
2025-09-22 21:56:43 +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
20 changed files with 848 additions and 96 deletions

View file

@ -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 {

View file

@ -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
View file

@ -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

View file

@ -56,6 +56,9 @@ install_base() {
opensuse-tumbleweed)
zypper refresh && zypper -q install -y wget curl tar timezone
;;
alpine)
apk update && apk add wget curl tar tzdata
;;
*)
apt-get update && apt-get install -y -q wget curl tar tzdata
;;
@ -137,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() {
@ -177,7 +187,11 @@ install_x-ui() {
# Stop x-ui service and remove old resources
if [[ -e /usr/local/x-ui/ ]]; then
systemctl stop x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui
fi
rm /usr/local/x-ui/ -rf
fi
@ -201,10 +215,18 @@ install_x-ui() {
chmod +x /usr/bin/x-ui
config_after_install
cp -f x-ui.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable x-ui
systemctl start x-ui
if [[ $release == "alpine" ]]; then
wget -O /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
chmod +x /etc/init.d/x-ui
rc-update add x-ui
rc-service x-ui start
else
cp -f x-ui.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable x-ui
systemctl start x-ui
fi
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
echo -e ""
echo -e "┌───────────────────────────────────────────────────────┐

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.
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)

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 {
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++ {

View file

@ -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"

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

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

View file

@ -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',

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
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
@ -636,6 +639,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 +732,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 +912,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 +2345,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 +2474,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
}
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
}
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()

View file

@ -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)

13
x-ui.rc Normal file
View file

@ -0,0 +1,13 @@
#!/sbin/openrc-run
command="/usr/local/x-ui/x-ui"
command_background=true
pidfile="/run/x-ui.pid"
description="x-ui Service"
procname="x-ui"
depend() {
need net
}
start_pre(){
cd /usr/local/x-ui
}

251
x-ui.sh
View file

@ -153,11 +153,19 @@ uninstall() {
fi
return 0
fi
systemctl stop x-ui
systemctl disable x-ui
rm /etc/systemd/system/x-ui.service -f
systemctl daemon-reload
systemctl reset-failed
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
rc-update del x-ui
rm /etc/init.d/x-ui -f
else
systemctl stop x-ui
systemctl disable x-ui
rm /etc/systemd/system/x-ui.service -f
systemctl daemon-reload
systemctl reset-failed
fi
rm /etc/x-ui/ -rf
rm /usr/local/x-ui/ -rf
@ -286,7 +294,11 @@ start() {
echo ""
LOGI "Panel is running, No need to start again, If you need to restart, please select restart"
else
systemctl start x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui start
else
systemctl start x-ui
fi
sleep 2
check_status
if [[ $? == 0 ]]; then
@ -307,7 +319,11 @@ stop() {
echo ""
LOGI "Panel stopped, No need to stop again!"
else
systemctl stop x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui
fi
sleep 2
check_status
if [[ $? == 1 ]]; then
@ -323,7 +339,11 @@ stop() {
}
restart() {
systemctl restart x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui restart
else
systemctl restart x-ui
fi
sleep 2
check_status
if [[ $? == 0 ]]; then
@ -337,14 +357,22 @@ restart() {
}
status() {
systemctl status x-ui -l
if [[ $release == "alpine" ]]; then
rc-service x-ui status
else
systemctl status x-ui -l
fi
if [[ $# == 0 ]]; then
before_show_menu
fi
}
enable() {
systemctl enable x-ui
if [[ $release == "alpine" ]]; then
rc-update add x-ui
else
systemctl enable x-ui
fi
if [[ $? == 0 ]]; then
LOGI "x-ui Set to boot automatically on startup successfully"
else
@ -357,7 +385,11 @@ enable() {
}
disable() {
systemctl disable x-ui
if [[ $release == "alpine" ]]; then
rc-update del x-ui
else
systemctl disable x-ui
fi
if [[ $? == 0 ]]; then
LOGI "x-ui Autostart Cancelled successfully"
else
@ -370,32 +402,54 @@ disable() {
}
show_log() {
echo -e "${green}\t1.${plain} Debug Log"
echo -e "${green}\t2.${plain} Clear All logs"
echo -e "${green}\t0.${plain} Back to Main Menu"
read -rp "Choose an option: " choice
if [[ $release == "alpine" ]]; then
echo -e "${green}\t1.${plain} Debug Log"
echo -e "${green}\t0.${plain} Back to Main Menu"
read -rp "Choose an option: " choice
case "$choice" in
0)
show_menu
;;
1)
journalctl -u x-ui -e --no-pager -f -p debug
if [[ $# == 0 ]]; then
before_show_menu
fi
;;
2)
sudo journalctl --rotate
sudo journalctl --vacuum-time=1s
echo "All Logs cleared."
restart
;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
show_log
;;
esac
case "$choice" in
0)
show_menu
;;
1)
grep -F 'x-ui[' /var/log/messages
if [[ $# == 0 ]]; then
before_show_menu
fi
;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
show_log
;;
esac
else
echo -e "${green}\t1.${plain} Debug Log"
echo -e "${green}\t2.${plain} Clear All logs"
echo -e "${green}\t0.${plain} Back to Main Menu"
read -rp "Choose an option: " choice
case "$choice" in
0)
show_menu
;;
1)
journalctl -u x-ui -e --no-pager -f -p debug
if [[ $# == 0 ]]; then
before_show_menu
fi
;;
2)
sudo journalctl --rotate
sudo journalctl --vacuum-time=1s
echo "All Logs cleared."
restart
;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
show_log
;;
esac
fi
}
bbr_menu() {
@ -464,6 +518,9 @@ enable_bbr() {
arch | manjaro | parch)
pacman -Sy --noconfirm ca-certificates
;;
alpine)
apk add ca-certificates
;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
@ -500,23 +557,42 @@ update_shell() {
# 0: running, 1: not running, 2: not installed
check_status() {
if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
return 2
fi
temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
if [[ "${temp}" == "running" ]]; then
return 0
if [[ $release == "alpine" ]]; then
if [[ ! -f /etc/init.d/x-ui ]]; then
return 2
fi
if [[ $(rc-service x-ui status | grep -F 'status: started' -c) == 1 ]]; then
return 0
else
return 1
fi
else
return 1
if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
return 2
fi
temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
if [[ "${temp}" == "running" ]]; then
return 0
else
return 1
fi
fi
}
check_enabled() {
temp=$(systemctl is-enabled x-ui)
if [[ "${temp}" == "enabled" ]]; then
return 0
if [[ $release == "alpine" ]]; then
if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
return 0
else
return 1
fi
else
return 1
temp=$(systemctl is-enabled x-ui)
if [[ "${temp}" == "enabled" ]]; then
return 0
else
return 1
fi
fi
}
@ -798,7 +874,11 @@ update_geo() {
show_menu
;;
1)
systemctl stop x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui
fi
rm -f geoip.dat geosite.dat
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
@ -806,7 +886,11 @@ update_geo() {
restart
;;
2)
systemctl stop x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui
fi
rm -f geoip_IR.dat geosite_IR.dat
wget -O geoip_IR.dat -N https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -O geosite_IR.dat -N https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
@ -814,7 +898,11 @@ update_geo() {
restart
;;
3)
systemctl stop x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui
fi
rm -f geoip_RU.dat geosite_RU.dat
wget -O geoip_RU.dat -N https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -O geosite_RU.dat -N https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
@ -985,6 +1073,9 @@ ssl_cert_issue() {
arch | manjaro | parch)
pacman -Sy --noconfirm socat
;;
alpine)
apk add socat
;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
@ -1335,7 +1426,11 @@ iplimit_main() {
read -rp "Please enter new Ban Duration in Minutes [default 30]: " NUM
if [[ $NUM =~ ^[0-9]+$ ]]; then
create_iplimit_jails ${NUM}
systemctl restart fail2ban
if [[ $release == "alpine" ]]; then
rc-service fail2ban restart
else
systemctl restart fail2ban
fi
else
echo -e "${red}${NUM} is not a number! Please, try again.${plain}"
fi
@ -1388,7 +1483,11 @@ iplimit_main() {
iplimit_main
;;
9)
systemctl restart fail2ban
if [[ $release == "alpine" ]]; then
rc-service fail2ban restart
else
systemctl restart fail2ban
fi
iplimit_main
;;
10)
@ -1436,6 +1535,9 @@ install_iplimit() {
arch | manjaro | parch)
pacman -Syu --noconfirm fail2ban
;;
alpine)
apk add fail2ban
;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
@ -1472,12 +1574,21 @@ install_iplimit() {
create_iplimit_jails
# Launching fail2ban
if ! systemctl is-active --quiet fail2ban; then
systemctl start fail2ban
if [[ $release == "alpine" ]]; then
if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
rc-service fail2ban start
else
rc-service fail2ban restart
fi
rc-update add fail2ban
else
systemctl restart fail2ban
if ! systemctl is-active --quiet fail2ban; then
systemctl start fail2ban
else
systemctl restart fail2ban
fi
systemctl enable fail2ban
fi
systemctl enable fail2ban
echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
before_show_menu
@ -1493,13 +1604,21 @@ remove_iplimit() {
rm -f /etc/fail2ban/filter.d/3x-ipl.conf
rm -f /etc/fail2ban/action.d/3x-ipl.conf
rm -f /etc/fail2ban/jail.d/3x-ipl.conf
systemctl restart fail2ban
if [[ $release == "alpine" ]]; then
rc-service fail2ban restart
else
systemctl restart fail2ban
fi
echo -e "${green}IP Limit removed successfully!${plain}\n"
before_show_menu
;;
2)
rm -rf /etc/fail2ban
systemctl stop fail2ban
if [[ $release == "alpine" ]]; then
rc-service fail2ban stop
else
systemctl stop fail2ban
fi
case "${release}" in
ubuntu | debian | armbian)
apt-get remove -y fail2ban
@ -1517,6 +1636,9 @@ remove_iplimit() {
arch | manjaro | parch)
pacman -Rns --noconfirm fail2ban
;;
alpine)
apk del fail2ban
;;
*)
echo -e "${red}Unsupported operating system. Please uninstall Fail2ban manually.${plain}\n"
exit 1
@ -1540,9 +1662,16 @@ show_banlog() {
echo -e "${green}Checking ban logs...${plain}\n"
if ! systemctl is-active --quiet fail2ban; then
echo -e "${red}Fail2ban service is not running!${plain}\n"
return 1
if [[ $release == "alpine" ]]; then
if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
echo -e "${red}Fail2ban service is not running!${plain}\n"
return 1
fi
else
if ! systemctl is-active --quiet fail2ban; then
echo -e "${red}Fail2ban service is not running!${plain}\n"
return 1
fi
fi
if [[ -f "$system_log" ]]; then