Compare commits

...

10 commits

Author SHA1 Message Date
javadtgh
7a57cdbc98
Merge 5e953bae45 into 76afff2a6f 2025-09-16 12:11:12 +03:30
Tara Rostami
76afff2a6f
UI Improvements and Fixes (#3470)
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-16 09:25:21 +02:00
Vadim Iskuchekov
9623e87511
feat: Simple periodic traffic reset (for Inbounds) – daily | weekly | monthly (#3407)
* Add periodic traffic reset feature model and ui with localization support

* Remove periodic traffic reset fields from client

* fix: add periodicTrafficReset field to inbound data structure

* feat: implement periodic traffic reset job and integrate with cron scheduler

* feat: enhance periodic traffic reset functionality with scheduling and inbound filtering

* refactor: rename periodicTrafficReset to trafficReset and add lastTrafficResetTime field

* feat: add periodic client traffic reset job and schedule tasks

* Update web/job/periodic_traffic_reset_job.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update web/job/periodic_client_traffic_reset_job.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update web/service/inbound.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: rename periodicTrafficReset to trafficReset and add lastTrafficResetTime

* feat: add last traffic reset time display and update logic in inbound service

* fix: correct log message for completed periodic traffic reset

* refactor: update traffic reset fields in Inbound model and remove unused client traffic reset job

* refactor: remove unused traffic reset logic and clean up client model fields

* cleanup comments

* fix
2025-09-16 09:24:32 +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
39 changed files with 903 additions and 61 deletions

View file

@ -35,6 +35,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

@ -27,16 +27,18 @@ type User struct {
} }
type Inbound struct { type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"` UserId int `json:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"` Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"` Total int64 `json:"total" form:"total"`
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"`
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
// config part // config part
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`
@ -113,3 +115,12 @@ type VLESSSettings struct {
Encryption string `json:"encryption"` Encryption string `json:"encryption"`
Fallbacks []any `json:"fallbacks"` Fallbacks []any `json:"fallbacks"`
} }
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

@ -63,6 +63,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

View file

@ -137,6 +137,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() {

15
main.go
View file

@ -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()) err := database.InitDB(config.GetDBPath())
if err != nil { if err != nil {
fmt.Println("Database initialization failed:", err) fmt.Println("Database initialization failed:", err)
@ -242,6 +242,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 {
@ -388,9 +397,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")
@ -440,7 +451,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

@ -160,26 +160,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 {
switch inbound.Protocol { serverService := service.MultiServerService{}
case "vmess": servers, err := serverService.GetServers()
return s.genVmessLink(inbound, email) if err != nil {
case "vless": logger.Warning("Failed to get servers for subscription:", err)
return s.genVlessLink(inbound, email) return ""
case "trojan":
return s.genTrojanLink(inbound, email)
case "shadowsocks":
return s.genShadowsocksLink(inbound, email)
} }
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 { 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",
} }
@ -292,7 +309,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))
@ -308,14 +325,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 ""
} }
@ -494,7 +511,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"
@ -515,12 +532,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 ""
} }
@ -689,7 +706,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"
@ -711,12 +728,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 ""
} }
@ -856,7 +873,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"
@ -877,17 +894,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
@ -898,6 +916,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++ {

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,8 @@ class DBInbound {
this.remark = ""; this.remark = "";
this.enable = true; this.enable = true;
this.expiryTime = 0; this.expiryTime = 0;
this.trafficReset = "never";
this.lastTrafficResetTime = 0;
this.listen = ""; this.listen = "";
this.port = 0; this.port = 0;

View file

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"x-ui/database/model" "x-ui/database/model"
"x-ui/web/middleware"
"x-ui/web/service" "x-ui/web/service"
"x-ui/web/session" "x-ui/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

@ -25,6 +25,7 @@ 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)
@ -49,3 +50,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

@ -44,6 +44,30 @@
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number> <a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc" }}</span>
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
<span v-if="datepicker == 'gregorian'">[[ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span>
<span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span>
</span>
</template>
{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-select v-model="dbInbound.trafficReset" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="never">{{ i18n "pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
<a-select-option value="daily">{{ i18n "pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
<a-select-option value="weekly">{{ i18n "pages.inbounds.periodicTrafficReset.weekly" }}</a-select-option>
<a-select-option value="monthly">{{ i18n "pages.inbounds.periodicTrafficReset.monthly" }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
<a-tooltip> <a-tooltip>

View file

@ -503,6 +503,12 @@
</a-tag> </a-tag>
</td> </td>
</tr> </tr>
<tr>
<td>{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}</td>
<td>
<a-tag color="blue">[[ i18n("pages.inbounds.periodicTrafficReset." + dbInbound.trafficReset) ]]</a-tag>
</td>
</tr>
</table> </table>
</template> </template>
<a-badge> <a-badge>
@ -951,6 +957,8 @@
remark: dbInbound.remark + " - Cloned", remark: dbInbound.remark + " - Cloned",
enable: dbInbound.enable, enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime, expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: '', listen: '',
port: RandomUtil.randomInteger(10000, 60000), port: RandomUtil.randomInteger(10000, 60000),
@ -995,6 +1003,8 @@
remark: dbInbound.remark, remark: dbInbound.remark,
enable: dbInbound.enable, enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime, expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen, listen: inbound.listen,
port: inbound.port, port: inbound.port,
@ -1018,6 +1028,8 @@
remark: dbInbound.remark, remark: dbInbound.remark,
enable: dbInbound.enable, enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime, expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen, listen: inbound.listen,
port: inbound.port, port: inbound.port,

View file

@ -2,7 +2,7 @@
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
<a-sidebar></a-sidebar> <a-sidebar></a-sidebar>
<a-layout id="content-layout"> <a-layout id="content-layout">
<a-layout-content> <a-layout-content>
@ -88,7 +88,7 @@
</template> </template>
<template #extra> <template #extra>
<template v-if="status.xray.state != 'error'"> <template v-if="status.xray.state != 'error'">
<a-badge status="processing" class="running-animation" :text="status.xray.stateMsg" :color="status.xray.color"/> <a-badge status="processing" :class="({ green: 'xray-running-animation', orange: 'xray-stop-animation' }[status.xray.color]) || 'xray-processing-animation'" :text="status.xray.stateMsg" :color="status.xray.color"/>
</template> </template>
<template v-else> <template v-else>
<a-popover :overlay-class-name="themeSwitcher.currentTheme"> <a-popover :overlay-class-name="themeSwitcher.currentTheme">
@ -105,7 +105,7 @@
<template slot="content"> <template slot="content">
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span> <span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
</template> </template>
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"/> <a-badge :text="status.xray.stateMsg" :color="status.xray.color" :class="status.xray.color === 'red' ? 'xray-error-animation' : ''"/>
</a-popover> </a-popover>
</template> </template>
</template> </template>

View file

@ -102,14 +102,15 @@
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag> <a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag>
<br /> <br />
<td>Authentication</td> <td>Authentication</td>
<a-tag :color="inbound.settings.selectedAuth ? 'green' : 'red'">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag> <a-tag v-if="inbound.settings.selectedAuth" color="green">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag>
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
<br /> <br />
{{ i18n "encryption" }} {{ i18n "encryption" }}
<a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag> <a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
<br /> <br />
<template v-if="inbound.stream.security != 'none'"> <template v-if="inbound.stream.security != 'none'">
{{ i18n "domainName" }} {{ i18n "domainName" }}
<a-tag v-if="inbound.serverName" :color="inbound.serverName ? 'green' : 'orange'">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag> <a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag>
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag> <a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
</template> </template>
</template> </template>

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" .}}

View file

@ -9,7 +9,7 @@
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' subscription-page'">
<a-layout-content class="p-2"> <a-layout-content class="p-2">
<a-row type="flex" justify="center" class="mt-2"> <a-row type="flex" justify="center" class="mt-2">
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12"> <a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
@ -200,7 +200,7 @@
style="text-align:center;"> style="text-align:center;">
<!-- Android dropdown --> <!-- Android dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button :block="isMobile" <a-button icon="android" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" :style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary"> size="large" type="primary">
Android <a-icon type="down" /> Android <a-icon type="down" />
@ -225,7 +225,7 @@
style="text-align:center;"> style="text-align:center;">
<!-- iOS dropdown --> <!-- iOS dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button :block="isMobile" <a-button icon="apple" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" :style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary"> size="large" type="primary">
iOS <a-icon type="down" /> iOS <a-icon type="down" />

View file

@ -0,0 +1,44 @@
package job
import (
"x-ui/logger"
"x-ui/web/service"
)
type Period string
type PeriodicTrafficResetJob struct {
inboundService service.InboundService
period Period
}
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
return &PeriodicTrafficResetJob{
period: period,
}
}
func (j *PeriodicTrafficResetJob) Run() {
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
logger.Infof("Running periodic traffic reset job for period: %s", j.period)
if err != nil {
logger.Warning("Failed to get inbounds for traffic reset:", err)
return
}
resetCount := 0
for _, inbound := range inbounds {
if err := j.inboundService.ResetAllClientTraffics(inbound.Id); err != nil {
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", err)
continue
}
resetCount++
logger.Infof("Reset traffic for inbound %d (%s)", inbound.Id, inbound.Remark)
}
if resetCount > 0 {
logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
}
}

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

@ -1,8 +1,11 @@
package service package service
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -41,6 +44,16 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
return inbounds, nil return inbounds, nil
} }
func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Where("traffic_reset = ?", period).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return inbounds, nil
}
func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) { func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) {
db := database.GetDB() db := database.GetDB()
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" { if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
@ -409,6 +422,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Remark = inbound.Remark oldInbound.Remark = inbound.Remark
oldInbound.Enable = inbound.Enable oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol oldInbound.Protocol = inbound.Protocol
@ -606,6 +620,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
} }
@ -694,10 +713,16 @@ 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
} }
func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) { func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) {
// TODO: check if TrafficReset field is updating
clients, err := s.GetClients(data) clients, err := s.GetClients(data)
if err != nil { if err != nil {
return false, err return false, err
@ -868,6 +893,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
} }
@ -1684,6 +1715,7 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
db := database.GetDB() db := database.GetDB()
// Reset traffic stats in ClientTraffic table
result := db.Model(xray.ClientTraffic{}). result := db.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail). Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Updates(map[string]any{"enable": true, "up": 0, "down": 0})
@ -1692,6 +1724,7 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
@ -1759,20 +1792,39 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
func (s *InboundService) ResetAllClientTraffics(id int) error { func (s *InboundService) ResetAllClientTraffics(id int) error {
db := database.GetDB() db := database.GetDB()
now := time.Now().Unix() * 1000
whereText := "inbound_id " return db.Transaction(func(tx *gorm.DB) error {
if id == -1 { whereText := "inbound_id "
whereText += " > ?" if id == -1 {
} else { whereText += " > ?"
whereText += " = ?" } else {
} whereText += " = ?"
}
result := db.Model(xray.ClientTraffic{}). // Reset client traffics
Where(whereText, id). result := tx.Model(xray.ClientTraffic{}).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
err := result.Error if result.Error != nil {
return err return result.Error
}
// Update lastTrafficResetTime for the inbound(s)
inboundWhereText := "id "
if id == -1 {
inboundWhereText += " > ?"
} else {
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
return result.Error
})
} }
func (s *InboundService) ResetAllTraffics() error { func (s *InboundService) ResetAllTraffics() error {
@ -2247,6 +2299,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 {
@ -2338,4 +2428,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

@ -180,6 +180,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

@ -244,6 +244,9 @@
"exportInbound" = "تصدير الإدخال" "exportInbound" = "تصدير الإدخال"
"import" = "استيراد" "import" = "استيراد"
"importInbound" = "استيراد إدخال" "importInbound" = "استيراد إدخال"
"periodicTrafficResetTitle" = "إعادة تعيين حركة المرور"
"periodicTrafficResetDesc" = "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة"
"lastReset" = "آخر إعادة تعيين"
[pages.client] [pages.client]
"add" = "أضف عميل" "add" = "أضف عميل"
@ -263,6 +266,12 @@
"renew" = "تجديد تلقائي" "renew" = "تجديد تلقائي"
"renewDesc" = "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)" "renewDesc" = "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)"
[pages.inbounds.periodicTrafficReset]
"never" = "أبداً"
"daily" = "يومياً"
"weekly" = "أسبوعياً"
"monthly" = "شهرياً"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "تم الحصول عليه" "obtain" = "تم الحصول عليه"
"updateSuccess" = "تم التحديث بنجاح" "updateSuccess" = "تم التحديث بنجاح"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Export Inbound" "exportInbound" = "Export Inbound"
"import" = "Import" "import" = "Import"
"importInbound" = "Import an Inbound" "importInbound" = "Import an Inbound"
"periodicTrafficResetTitle" = "Traffic Reset"
"periodicTrafficResetDesc" = "Automatically reset traffic counter at specified intervals"
"lastReset" = "Last Reset"
[pages.client] [pages.client]
"add" = "Add Client" "add" = "Add Client"
@ -263,6 +266,12 @@
"renew" = "Auto Renew" "renew" = "Auto Renew"
"renewDesc" = "Auto-renewal after expiration. (0 = disable)(unit: day)" "renewDesc" = "Auto-renewal after expiration. (0 = disable)(unit: day)"
[pages.inbounds.periodicTrafficReset]
"never" = "Never"
"daily" = "Daily"
"weekly" = "Weekly"
"monthly" = "Monthly"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Obtain" "obtain" = "Obtain"
"updateSuccess" = "The update was successful." "updateSuccess" = "The update was successful."

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportación entrante" "exportInbound" = "Exportación entrante"
"import" = "Importar" "import" = "Importar"
"importInbound" = "Importar un entrante" "importInbound" = "Importar un entrante"
"periodicTrafficResetTitle" = "Reset de Tráfico"
"periodicTrafficResetDesc" = "Reiniciar automáticamente el contador de tráfico en intervalos especificados"
"lastReset" = "Último reinicio"
[pages.client] [pages.client]
"add" = "Agregar Cliente" "add" = "Agregar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovación automática" "renew" = "Renovación automática"
"renewDesc" = "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)" "renewDesc" = "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)"
[pages.inbounds.periodicTrafficReset]
"never" = "Nunca"
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensualmente"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Recibir" "obtain" = "Recibir"
"updateSuccess" = "La actualización fue exitosa" "updateSuccess" = "La actualización fue exitosa"

View file

@ -244,6 +244,9 @@
"exportInbound" = "استخراج ورودی" "exportInbound" = "استخراج ورودی"
"import" = "افزودن" "import" = "افزودن"
"importInbound" = "افزودن یک ورودی" "importInbound" = "افزودن یک ورودی"
"periodicTrafficResetTitle" = "بازنشانی ترافیک"
"periodicTrafficResetDesc" = "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص"
"lastReset" = "آخرین بازنشانی"
[pages.client] [pages.client]
"add" = "کاربر جدید" "add" = "کاربر جدید"
@ -261,7 +264,13 @@
"expireDays" = "مدت زمان" "expireDays" = "مدت زمان"
"days" = "(روز)" "days" = "(روز)"
"renew" = "تمدید خودکار" "renew" = "تمدید خودکار"
"renewDesc" = "(تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز" "renewDesc" = "تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز)"
[pages.inbounds.periodicTrafficReset]
"never" = "هرگز"
"daily" = "روزانه"
"weekly" = "هفتگی"
"monthly" = "ماهانه"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "فراهم‌سازی" "obtain" = "فراهم‌سازی"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Ekspor Masuk" "exportInbound" = "Ekspor Masuk"
"import" = "Impor" "import" = "Impor"
"importInbound" = "Impor Masuk" "importInbound" = "Impor Masuk"
"periodicTrafficResetTitle" = "Reset Trafik Berkala"
"periodicTrafficResetDesc" = "Reset otomatis penghitung trafik pada interval tertentu"
"lastReset" = "Reset Terakhir"
[pages.client] [pages.client]
"add" = "Tambah Klien" "add" = "Tambah Klien"
@ -263,6 +266,12 @@
"renew" = "Perpanjang Otomatis" "renew" = "Perpanjang Otomatis"
"renewDesc" = "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)" "renewDesc" = "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)"
[pages.inbounds.periodicTrafficReset]
"never" = "Tidak Pernah"
"daily" = "Harian"
"weekly" = "Mingguan"
"monthly" = "Bulanan"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Dapatkan" "obtain" = "Dapatkan"
"updateSuccess" = "Pembaruan berhasil" "updateSuccess" = "Pembaruan berhasil"

View file

@ -244,6 +244,9 @@
"exportInbound" = "インバウンドルールをエクスポート" "exportInbound" = "インバウンドルールをエクスポート"
"import" = "インポート" "import" = "インポート"
"importInbound" = "インバウンドルールをインポート" "importInbound" = "インバウンドルールをインポート"
"periodicTrafficResetTitle" = "トラフィックリセット"
"periodicTrafficResetDesc" = "指定された間隔でトラフィックカウンタを自動的にリセット"
"lastReset" = "最後のリセット"
[pages.client] [pages.client]
"add" = "クライアント追加" "add" = "クライアント追加"
@ -263,6 +266,12 @@
"renew" = "自動更新" "renew" = "自動更新"
"renewDesc" = "期限が切れた後に自動更新。0 = 無効)(単位:日)" "renewDesc" = "期限が切れた後に自動更新。0 = 無効)(単位:日)"
[pages.inbounds.periodicTrafficReset]
"never" = "なし"
"daily" = "毎日"
"weekly" = "毎週"
"monthly" = "毎月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "取得" "obtain" = "取得"
"updateSuccess" = "更新が成功しました" "updateSuccess" = "更新が成功しました"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportar Inbound" "exportInbound" = "Exportar Inbound"
"import" = "Importar" "import" = "Importar"
"importInbound" = "Importar um Inbound" "importInbound" = "Importar um Inbound"
"periodicTrafficResetTitle" = "Reset de Tráfego"
"periodicTrafficResetDesc" = "Reinicia automaticamente o contador de tráfego em intervalos especificados"
"lastReset" = "Último Reset"
[pages.client] [pages.client]
"add" = "Adicionar Cliente" "add" = "Adicionar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovação Automática" "renew" = "Renovação Automática"
"renewDesc" = "Renovação automática após expiração. (0 = desativado)(unidade: dia)" "renewDesc" = "Renovação automática após expiração. (0 = desativado)(unidade: dia)"
[pages.inbounds.periodicTrafficReset]
"never" = "Nunca"
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensalmente"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Obter" "obtain" = "Obter"
"updateSuccess" = "A atualização foi bem-sucedida" "updateSuccess" = "A atualização foi bem-sucedida"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Экспорт инбаундов" "exportInbound" = "Экспорт инбаундов"
"import" = "Импортировать" "import" = "Импортировать"
"importInbound" = "Импорт инбаундов" "importInbound" = "Импорт инбаундов"
"periodicTrafficResetTitle" = "Сброс трафика"
"periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы"
"lastReset" = "Последний сброс"
[pages.client] [pages.client]
"add" = "Создать клиента" "add" = "Создать клиента"
@ -263,6 +266,12 @@
"renew" = "Автопродление" "renew" = "Автопродление"
"renewDesc" = "Автопродление после истечения срока действия. (0 = отключить)(единица: день)" "renewDesc" = "Автопродление после истечения срока действия. (0 = отключить)(единица: день)"
[pages.inbounds.periodicTrafficReset]
"never" = "Никогда"
"daily" = "Ежедневно"
"weekly" = "Еженедельно"
"monthly" = "Ежемесячно"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Получить" "obtain" = "Получить"
"updateSuccess" = "Обновление прошло успешно" "updateSuccess" = "Обновление прошло успешно"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Geleni Dışa Aktar" "exportInbound" = "Geleni Dışa Aktar"
"import" = "İçe Aktar" "import" = "İçe Aktar"
"importInbound" = "Bir Gelen İçe Aktar" "importInbound" = "Bir Gelen İçe Aktar"
"periodicTrafficResetTitle" = "Trafik Sıfırlama"
"periodicTrafficResetDesc" = "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla"
"lastReset" = "Son Sıfırlama"
[pages.client] [pages.client]
"add" = "Müşteri Ekle" "add" = "Müşteri Ekle"
@ -263,6 +266,12 @@
"renew" = "Otomatik Yenile" "renew" = "Otomatik Yenile"
"renewDesc" = "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)" "renewDesc" = "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)"
[pages.inbounds.periodicTrafficReset]
"never" = "Asla"
"daily" = "Günlük"
"weekly" = "Haftalık"
"monthly" = "Aylık"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Elde Et" "obtain" = "Elde Et"
"updateSuccess" = "Güncelleme başarılı oldu" "updateSuccess" = "Güncelleme başarılı oldu"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Експортувати вхідні" "exportInbound" = "Експортувати вхідні"
"import" = "Імпорт" "import" = "Імпорт"
"importInbound" = "Імпортувати вхідний" "importInbound" = "Імпортувати вхідний"
"periodicTrafficResetTitle" = "Скидання трафіку"
"periodicTrafficResetDesc" = "Автоматично скидати лічильник трафіку через певні проміжки часу"
"lastReset" = "Останнє скидання"
[pages.client] [pages.client]
"add" = "Додати клієнта" "add" = "Додати клієнта"
@ -263,6 +266,12 @@
"renew" = "Автоматичне оновлення" "renew" = "Автоматичне оновлення"
"renewDesc" = "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)" "renewDesc" = "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)"
[pages.inbounds.periodicTrafficReset]
"never" = "Ніколи"
"daily" = "Щодня"
"weekly" = "Щотижня"
"monthly" = "Щомісяця"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Отримати" "obtain" = "Отримати"
"updateSuccess" = "Оновлення пройшло успішно" "updateSuccess" = "Оновлення пройшло успішно"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Xuất nhập khẩu" "exportInbound" = "Xuất nhập khẩu"
"import" = "Nhập" "import" = "Nhập"
"importInbound" = "Nhập inbound" "importInbound" = "Nhập inbound"
"periodicTrafficResetTitle" = "Đặt lại lưu lượng"
"periodicTrafficResetDesc" = "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định"
"lastReset" = "Đặt lại lần cuối"
[pages.client] [pages.client]
"add" = "Thêm người dùng" "add" = "Thêm người dùng"
@ -263,6 +266,12 @@
"renew" = "Tự động gia hạn" "renew" = "Tự động gia hạn"
"renewDesc" = "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)" "renewDesc" = "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)"
[pages.inbounds.periodicTrafficReset]
"never" = "Không bao giờ"
"daily" = "Hàng ngày"
"weekly" = "Hàng tuần"
"monthly" = "Hàng tháng"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Nhận" "obtain" = "Nhận"
"updateSuccess" = "Cập nhật thành công" "updateSuccess" = "Cập nhật thành công"

View file

@ -244,6 +244,9 @@
"exportInbound" = "导出入站规则" "exportInbound" = "导出入站规则"
"import"="导入" "import"="导入"
"importInbound" = "导入入站规则" "importInbound" = "导入入站规则"
"periodicTrafficResetTitle" = "流量重置"
"periodicTrafficResetDesc" = "按指定间隔自动重置流量计数器"
"lastReset" = "上次重置"
[pages.client] [pages.client]
"add" = "添加客户端" "add" = "添加客户端"
@ -263,6 +266,12 @@
"renew" = "自动续订" "renew" = "自动续订"
"renewDesc" = "到期后自动续订。(0 = 禁用)(单位: 天)" "renewDesc" = "到期后自动续订。(0 = 禁用)(单位: 天)"
[pages.inbounds.periodicTrafficReset]
"never" = "从不"
"daily" = "每日"
"weekly" = "每周"
"monthly" = "每月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "获取" "obtain" = "获取"
"updateSuccess" = "更新成功" "updateSuccess" = "更新成功"

View file

@ -244,6 +244,9 @@
"exportInbound" = "匯出入站規則" "exportInbound" = "匯出入站規則"
"import"="匯入" "import"="匯入"
"importInbound" = "匯入入站規則" "importInbound" = "匯入入站規則"
"periodicTrafficResetTitle" = "流量重置"
"periodicTrafficResetDesc" = "按指定間隔自動重置流量計數器"
"lastReset" = "上次重置"
[pages.client] [pages.client]
"add" = "新增客戶端" "add" = "新增客戶端"
@ -263,6 +266,12 @@
"renew" = "自動續訂" "renew" = "自動續訂"
"renewDesc" = "到期後自動續訂。(0 = 禁用)(單位: 天)" "renewDesc" = "到期後自動續訂。(0 = 禁用)(單位: 天)"
[pages.inbounds.periodicTrafficReset]
"never" = "從不"
"daily" = "每日"
"weekly" = "每週"
"monthly" = "每月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "獲取" "obtain" = "獲取"
"updateSuccess" = "更新成功" "updateSuccess" = "更新成功"

View file

@ -252,7 +252,7 @@ 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.server = controller.NewMultiServerController(g)
s.panel = controller.NewXUIController(g) s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g) s.api = controller.NewAPIController(g)
@ -289,6 +289,19 @@ func (s *Server) startTask() {
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
// Periodic traffic resets
logger.Info("Scheduling periodic traffic reset jobs")
{
// Inbound traffic reset jobs
// Run once a day, midnight
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
// Run once a week, midnight between Sat/Sun
s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
// Run once a month, midnight, first of month
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
}
// Make a traffic condition every day, 8:30 // Make a traffic condition every day, 8:30
var entry cron.EntryID var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled() isTgbotenabled, err := s.settingService.GetTgbotEnabled()