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{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
&model.Server{},
}
for _, model := range models {
if err := db.AutoMigrate(model); err != nil {

View file

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

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

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())
if err != nil {
fmt.Println("Database initialization failed:", err)
@ -242,6 +242,15 @@ func updateSetting(port int, username string, password string, webBasePath strin
settingService := service.SettingService{}
userService := service.UserService{}
if apiKey != "" {
err := settingService.SetAPIKey(apiKey)
if err != nil {
fmt.Println("Failed to set API Key:", err)
} else {
fmt.Printf("API Key set successfully: %v\n", apiKey)
}
}
if port > 0 {
err := settingService.SetPort(port)
if err != nil {
@ -388,9 +397,11 @@ func main() {
var show bool
var getCert bool
var resetTwoFactor bool
var apiKey string
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
settingCmd.BoolVar(&show, "show", false, "Display current settings")
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
settingCmd.StringVar(&apiKey, "apiKey", "", "Set API Key")
settingCmd.StringVar(&username, "username", "", "Set login username")
settingCmd.StringVar(&password, "password", "", "Set login password")
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
@ -440,7 +451,7 @@ func main() {
if reset {
resetSetting()
} else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey)
}
if show {
showSetting(show)

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 {
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",
}
@ -292,7 +309,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))
@ -308,14 +325,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 ""
}
@ -494,7 +511,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"
@ -515,12 +532,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 ""
}
@ -689,7 +706,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"
@ -711,12 +728,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 ""
}
@ -856,7 +873,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"
@ -877,17 +894,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
@ -898,6 +916,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++ {

File diff suppressed because one or more lines are too long

View file

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

View file

@ -6,6 +6,7 @@ import (
"strconv"
"x-ui/database/model"
"x-ui/web/middleware"
"x-ui/web/service"
"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("/inbounds", a.inbounds)
g.GET("/servers", a.servers)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
@ -49,3 +50,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',

View file

@ -44,6 +44,30 @@
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
</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>
<template slot="label">
<a-tooltip>

View file

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

View file

@ -2,7 +2,7 @@
{{ template "page/head_end" .}}
{{ 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-layout id="content-layout">
<a-layout-content>
@ -88,7 +88,7 @@
</template>
<template #extra>
<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 v-else>
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
@ -105,7 +105,7 @@
<template slot="content">
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
</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>
</template>
</template>

View file

@ -102,14 +102,15 @@
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag>
<br />
<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 />
{{ i18n "encryption" }}
<a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
<br />
<template v-if="inbound.stream.security != 'none'">
{{ 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>
</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/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-row type="flex" justify="center" class="mt-2">
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
@ -200,7 +200,7 @@
style="text-align:center;">
<!-- Android dropdown -->
<a-dropdown :trigger="['click']">
<a-button :block="isMobile"
<a-button icon="android" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary">
Android <a-icon type="down" />
@ -225,7 +225,7 @@
style="text-align:center;">
<!-- iOS dropdown -->
<a-dropdown :trigger="['click']">
<a-button :block="isMobile"
<a-button icon="apple" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary">
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
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
@ -41,6 +44,16 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
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) {
db := database.GetDB()
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.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol
@ -606,6 +620,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
}
@ -694,10 +713,16 @@ 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
}
func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) {
// TODO: check if TrafficReset field is updating
clients, err := s.GetClients(data)
if err != nil {
return false, err
@ -868,6 +893,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
}
@ -1684,6 +1715,7 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
db := database.GetDB()
// Reset traffic stats in ClientTraffic table
result := db.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
@ -1692,6 +1724,7 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err != nil {
return err
}
return nil
}
@ -1759,20 +1792,39 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
func (s *InboundService) ResetAllClientTraffics(id int) error {
db := database.GetDB()
now := time.Now().Unix() * 1000
whereText := "inbound_id "
if id == -1 {
whereText += " > ?"
} else {
whereText += " = ?"
}
return db.Transaction(func(tx *gorm.DB) error {
whereText := "inbound_id "
if id == -1 {
whereText += " > ?"
} else {
whereText += " = ?"
}
result := db.Model(xray.ClientTraffic{}).
Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
// Reset client traffics
result := tx.Model(xray.ClientTraffic{}).
Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
err := result.Error
return err
if result.Error != nil {
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 {
@ -2247,6 +2299,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 {
@ -2338,4 +2428,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

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

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

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportación entrante"
"import" = "Importar"
"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]
"add" = "Agregar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovación automática"
"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]
"obtain" = "Recibir"
"updateSuccess" = "La actualización fue exitosa"

View file

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

View file

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

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportar Inbound"
"import" = "Importar"
"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]
"add" = "Adicionar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovação Automática"
"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]
"obtain" = "Obter"
"updateSuccess" = "A atualização foi bem-sucedida"

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Geleni Dışa Aktar"
"import" = "İç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]
"add" = "Müşteri Ekle"
@ -263,6 +266,12 @@
"renew" = "Otomatik Yenile"
"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]
"obtain" = "Elde Et"
"updateSuccess" = "Güncelleme başarılı oldu"

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Xuất nhập khẩu"
"import" = "Nhập"
"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]
"add" = "Thêm người dùng"
@ -263,6 +266,12 @@
"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)"
[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]
"obtain" = "Nhận"
"updateSuccess" = "Cập nhật thành công"

View file

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

View file

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

View file

@ -252,7 +252,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)
@ -289,6 +289,19 @@ func (s *Server) startTask() {
// check client ips from log file every day
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
var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled()