{{ i18n "transmission" }}:
host:
{{ i18n "host" }}:
path:
{{ i18n "path" }}:
quic {{ i18n "encryption" }}:
quic {{ i18n "password" }}:
quic {{ i18n "camouflage" }}:
kcp {{ i18n "encryption" }}:
kcp {{ i18n "password" }}:
grpc serviceName:
tls:
xtls:
tls:
+ tls {{ i18n "domainName" }}:
+ xtls {{ i18n "domainName" }}:
{{ i18n "protocol"}}:
{{ i18n "pages.inbounds.address"}}:
{{ i18n "pages.inbounds.port"}}:
uuid:
alterId:
uuid:
flow:
{{ i18n "password"}}:
{{ i18n "encryption"}}:
{{ i18n "password"}}:
{{ i18n "username"}}:
{{ i18n "password"}}:
{{ i18n "username"}}:
{{ i18n "password"}}:
[[ line ]]
+ +/usage uuid | id
\n example : /usage fc3239ed-8f3b-4151-ff51-b183d5182142
"
+ msg.ParseMode = "HTML"
+ }
+ if _, err := bot.Send(msg); err != nil {
+ logger.Warning(err)
+ }
+ }
+
+ continue
+ }
+
+ if !update.Message.IsCommand() { // ignore any non-command Messages
+ continue
+ }
+
+ // Create a new MessageConfig. We don't have text yet,
+ // so we leave it empty.
+ msg := tgbotapi.NewMessage(update.Message.Chat.ID, "")
+
+ // Extract the command from the Message.
+ switch update.Message.Command() {
+ case "help":
+ msg.Text = "What you need?"
+ msg.ReplyMarkup = numericKeyboard
+ case "start":
+ msg.Text = "Hi :) \n What you need?"
+ msg.ReplyMarkup = numericKeyboard
+
+ case "status":
+ msg.Text = "bot is ok."
+
+ case "usage":
+ msg.Text = j.getClientUsage(update.Message.CommandArguments())
+ default:
+ msg.Text = "I don't know that command, /help"
+ msg.ReplyMarkup = numericKeyboard
+
+ }
+
+ if _, err := bot.Send(msg); err != nil {
+ logger.Warning(err)
+ }
+ }
+ return j
+
+}
+func (j *StatsNotifyJob) getClientUsage(id string) string {
+ traffic , err := j.inboundService.GetClientTrafficById(id)
+ if err != nil {
+ logger.Warning(err)
+ return "something wrong!"
+ }
+ expiryTime := ""
+ if traffic.ExpiryTime == 0 {
+ expiryTime = fmt.Sprintf("unlimited")
+ } else {
+ expiryTime = fmt.Sprintf("%s", time.Unix((traffic.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
+ }
+ total := ""
+ if traffic.Total == 0 {
+ total = fmt.Sprintf("unlimited")
+ } else {
+ total = fmt.Sprintf("%s", common.FormatTraffic((traffic.Total)))
+ }
+ output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n",
+ traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)),
+ total, expiryTime)
+
+ return output
+}
diff --git a/web/job/xray_traffic_job.go b/web/job/xray_traffic_job.go
new file mode 100644
index 0000000..97f85c2
--- /dev/null
+++ b/web/job/xray_traffic_job.go
@@ -0,0 +1,38 @@
+package job
+
+import (
+ "x-ui/logger"
+ "x-ui/web/service"
+)
+
+type XrayTrafficJob struct {
+ xrayService service.XrayService
+ inboundService service.InboundService
+}
+
+func NewXrayTrafficJob() *XrayTrafficJob {
+ return new(XrayTrafficJob)
+}
+
+func (j *XrayTrafficJob) Run() {
+ if !j.xrayService.IsXrayRunning() {
+ return
+ }
+
+ traffics, clientTraffics, err := j.xrayService.GetXrayTraffic()
+ if err != nil {
+ logger.Warning("get xray traffic failed:", err)
+ return
+ }
+ err = j.inboundService.AddTraffic(traffics)
+ if err != nil {
+ logger.Warning("add traffic failed:", err)
+ }
+
+ err = j.inboundService.AddClientTraffic(clientTraffics)
+ if err != nil {
+ logger.Warning("add client traffic failed:", err)
+ }
+
+
+}
diff --git a/web/network/auto_https_listener.go b/web/network/auto_https_listener.go
new file mode 100644
index 0000000..2661469
--- /dev/null
+++ b/web/network/auto_https_listener.go
@@ -0,0 +1,21 @@
+package network
+
+import "net"
+
+type AutoHttpsListener struct {
+ net.Listener
+}
+
+func NewAutoHttpsListener(listener net.Listener) net.Listener {
+ return &AutoHttpsListener{
+ Listener: listener,
+ }
+}
+
+func (l *AutoHttpsListener) Accept() (net.Conn, error) {
+ conn, err := l.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+ return NewAutoHttpsConn(conn), nil
+}
diff --git a/web/network/autp_https_conn.go b/web/network/autp_https_conn.go
new file mode 100644
index 0000000..d1a9d52
--- /dev/null
+++ b/web/network/autp_https_conn.go
@@ -0,0 +1,67 @@
+package network
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "net"
+ "net/http"
+ "sync"
+)
+
+type AutoHttpsConn struct {
+ net.Conn
+
+ firstBuf []byte
+ bufStart int
+
+ readRequestOnce sync.Once
+}
+
+func NewAutoHttpsConn(conn net.Conn) net.Conn {
+ return &AutoHttpsConn{
+ Conn: conn,
+ }
+}
+
+func (c *AutoHttpsConn) readRequest() bool {
+ c.firstBuf = make([]byte, 2048)
+ n, err := c.Conn.Read(c.firstBuf)
+ c.firstBuf = c.firstBuf[:n]
+ if err != nil {
+ return false
+ }
+ reader := bytes.NewReader(c.firstBuf)
+ bufReader := bufio.NewReader(reader)
+ request, err := http.ReadRequest(bufReader)
+ if err != nil {
+ return false
+ }
+ resp := http.Response{
+ Header: http.Header{},
+ }
+ resp.StatusCode = http.StatusTemporaryRedirect
+ location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI)
+ resp.Header.Set("Location", location)
+ resp.Write(c.Conn)
+ c.Close()
+ c.firstBuf = nil
+ return true
+}
+
+func (c *AutoHttpsConn) Read(buf []byte) (int, error) {
+ c.readRequestOnce.Do(func() {
+ c.readRequest()
+ })
+
+ if c.firstBuf != nil {
+ n := copy(buf, c.firstBuf[c.bufStart:])
+ c.bufStart += n
+ if c.bufStart >= len(c.firstBuf) {
+ c.firstBuf = nil
+ }
+ return n, nil
+ }
+
+ return c.Conn.Read(buf)
+}
diff --git a/web/service/config.json b/web/service/config.json
new file mode 100644
index 0000000..5370fcf
--- /dev/null
+++ b/web/service/config.json
@@ -0,0 +1,75 @@
+{
+ "log": {
+ "loglevel": "warning",
+ "access": "./access.log"
+ },
+
+ "api": {
+ "services": [
+ "HandlerService",
+ "LoggerService",
+ "StatsService"
+ ],
+ "tag": "api"
+ },
+ "inbounds": [
+ {
+ "listen": "127.0.0.1",
+ "port": 62789,
+ "protocol": "dokodemo-door",
+ "settings": {
+ "address": "127.0.0.1"
+ },
+ "tag": "api"
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "settings": {}
+ },
+ {
+ "protocol": "blackhole",
+ "settings": {},
+ "tag": "blocked"
+ }
+ ],
+ "policy": {
+ "levels": {
+ "0": {
+ "statsUserUplink": true,
+ "statsUserDownlink": true
+ }
+ },
+ "system": {
+ "statsInboundDownlink": true,
+ "statsInboundUplink": true
+ }
+ },
+ "routing": {
+ "rules": [
+ {
+ "inboundTag": [
+ "api"
+ ],
+ "outboundTag": "api",
+ "type": "field"
+ },
+ {
+ "ip": [
+ "geoip:private"
+ ],
+ "outboundTag": "blocked",
+ "type": "field"
+ },
+ {
+ "outboundTag": "blocked",
+ "protocol": [
+ "bittorrent"
+ ],
+ "type": "field"
+ }
+ ]
+ },
+ "stats": {}
+}
diff --git a/web/service/inbound.go b/web/service/inbound.go
new file mode 100644
index 0000000..de922d5
--- /dev/null
+++ b/web/service/inbound.go
@@ -0,0 +1,440 @@
+package service
+
+import (
+ "fmt"
+ "time"
+ "x-ui/database"
+ "encoding/json"
+ "x-ui/database/model"
+ "x-ui/util/common"
+ "x-ui/xray"
+ "x-ui/logger"
+
+ "gorm.io/gorm"
+)
+
+type InboundService struct {
+}
+
+func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
+ db := database.GetDB()
+ var inbounds []*model.Inbound
+ err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Find(&inbounds).Error
+ if err != nil && err != gorm.ErrRecordNotFound {
+ return nil, err
+ }
+ return inbounds, nil
+}
+
+func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
+ db := database.GetDB()
+ var inbounds []*model.Inbound
+ err := db.Model(model.Inbound{}).Preload("ClientStats").Find(&inbounds).Error
+ if err != nil && err != gorm.ErrRecordNotFound {
+ return nil, err
+ }
+ return inbounds, nil
+}
+
+func (s *InboundService) checkPortExist(port int, ignoreId int) (bool, error) {
+ db := database.GetDB()
+ db = db.Model(model.Inbound{}).Where("port = ?", port)
+ if ignoreId > 0 {
+ db = db.Where("id != ?", ignoreId)
+ }
+ var count int64
+ err := db.Count(&count).Error
+ if err != nil {
+ return false, err
+ }
+ return count > 0, nil
+}
+
+func (s *InboundService) getClients(inbound *model.Inbound) ([]model.Client, error) {
+ settings := map[string][]model.Client{}
+ json.Unmarshal([]byte(inbound.Settings), &settings)
+ if settings == nil {
+ return nil, fmt.Errorf("Setting is null")
+ }
+
+ clients := settings["clients"]
+ if clients == nil {
+ return nil, nil
+ }
+ return clients, nil
+}
+
+func (s *InboundService) checkEmailsExist(emails map[string] bool, ignoreId int) (string, error) {
+ db := database.GetDB()
+ var inbounds []*model.Inbound
+ db = db.Model(model.Inbound{}).Where("Protocol in ?", []model.Protocol{model.VMess, model.VLESS})
+ if (ignoreId > 0) {
+ db = db.Where("id != ?", ignoreId)
+ }
+ db = db.Find(&inbounds)
+ if db.Error != nil {
+ return "", db.Error
+ }
+
+ for _, inbound := range inbounds {
+ clients, err := s.getClients(inbound)
+ if err != nil {
+ return "", err
+ }
+
+ for _, client := range clients {
+ if emails[client.Email] {
+ return client.Email, nil
+ }
+ }
+ }
+ return "", nil
+}
+
+func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (string, error) {
+ clients, err := s.getClients(inbound)
+ if err != nil {
+ return "", err
+ }
+ emails := make(map[string] bool)
+ for _, client := range clients {
+ if (client.Email != "") {
+ if emails[client.Email] {
+ return client.Email, nil
+ }
+ emails[client.Email] = true;
+ }
+ }
+ return s.checkEmailsExist(emails, inbound.Id)
+}
+
+func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound,error) {
+ exist, err := s.checkPortExist(inbound.Port, 0)
+ if err != nil {
+ return inbound, err
+ }
+ if exist {
+ return inbound, common.NewError("端口已存在:", inbound.Port)
+ }
+
+ existEmail, err := s.checkEmailExistForInbound(inbound)
+ if err != nil {
+ return inbound, err
+ }
+ if existEmail != "" {
+ return inbound, common.NewError("Duplicate email:", existEmail)
+ }
+
+ db := database.GetDB()
+
+ err = db.Save(inbound).Error
+ if err == nil {
+ s.UpdateClientStat(inbound.Id,inbound.Settings)
+ }
+ return inbound, err
+}
+
+func (s *InboundService) AddInbounds(inbounds []*model.Inbound) error {
+ for _, inbound := range inbounds {
+ exist, err := s.checkPortExist(inbound.Port, 0)
+ if err != nil {
+ return err
+ }
+ if exist {
+ return common.NewError("端口已存在:", inbound.Port)
+ }
+ }
+
+ db := database.GetDB()
+ tx := db.Begin()
+ var err error
+ defer func() {
+ if err == nil {
+ tx.Commit()
+ } else {
+ tx.Rollback()
+ }
+ }()
+
+ for _, inbound := range inbounds {
+ err = tx.Save(inbound).Error
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *InboundService) DelInbound(id int) error {
+ db := database.GetDB()
+ return db.Delete(model.Inbound{}, id).Error
+}
+
+func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
+ db := database.GetDB()
+ inbound := &model.Inbound{}
+ err := db.Model(model.Inbound{}).First(inbound, id).Error
+ if err != nil {
+ return nil, err
+ }
+ return inbound, nil
+}
+
+func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, error) {
+ exist, err := s.checkPortExist(inbound.Port, inbound.Id)
+ if err != nil {
+ return inbound, err
+ }
+ if exist {
+ return inbound, common.NewError("端口已存在:", inbound.Port)
+ }
+
+ existEmail, err := s.checkEmailExistForInbound(inbound)
+ if err != nil {
+ return inbound, err
+ }
+ if existEmail != "" {
+ return inbound, common.NewError("Duplicate email:", existEmail)
+ }
+
+ oldInbound, err := s.GetInbound(inbound.Id)
+ if err != nil {
+ return inbound, err
+ }
+ oldInbound.Up = inbound.Up
+ oldInbound.Down = inbound.Down
+ oldInbound.Total = inbound.Total
+ oldInbound.Remark = inbound.Remark
+ oldInbound.Enable = inbound.Enable
+ oldInbound.ExpiryTime = inbound.ExpiryTime
+ oldInbound.Listen = inbound.Listen
+ oldInbound.Port = inbound.Port
+ oldInbound.Protocol = inbound.Protocol
+ oldInbound.Settings = inbound.Settings
+ oldInbound.StreamSettings = inbound.StreamSettings
+ oldInbound.Sniffing = inbound.Sniffing
+ oldInbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
+
+ s.UpdateClientStat(inbound.Id,inbound.Settings)
+ db := database.GetDB()
+ return inbound, db.Save(oldInbound).Error
+}
+
+func (s *InboundService) AddTraffic(traffics []*xray.Traffic) (err error) {
+ if len(traffics) == 0 {
+ return nil
+ }
+ db := database.GetDB()
+ db = db.Model(model.Inbound{})
+ tx := db.Begin()
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ } else {
+ tx.Commit()
+ }
+ }()
+ for _, traffic := range traffics {
+ if traffic.IsInbound {
+ err = tx.Where("tag = ?", traffic.Tag).
+ UpdateColumn("up", gorm.Expr("up + ?", traffic.Up)).
+ UpdateColumn("down", gorm.Expr("down + ?", traffic.Down)).
+ Error
+ if err != nil {
+ return
+ }
+ }
+ }
+ return
+}
+func (s *InboundService) AddClientTraffic(traffics []*xray.ClientTraffic) (err error) {
+ if len(traffics) == 0 {
+ return nil
+ }
+ db := database.GetDB()
+ dbInbound := db.Model(model.Inbound{})
+
+ db = db.Model(xray.ClientTraffic{})
+ tx := db.Begin()
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ } else {
+ tx.Commit()
+ }
+ }()
+ txInbound := dbInbound.Begin()
+ defer func() {
+ if err != nil {
+ txInbound.Rollback()
+ } else {
+ txInbound.Commit()
+ }
+ }()
+
+ for _, traffic := range traffics {
+ inbound := &model.Inbound{}
+
+ err := txInbound.Where("settings like ?", "%" + traffic.Email + "%").First(inbound).Error
+ traffic.InboundId = inbound.Id
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ // delete removed client record
+ clientErr := s.DelClientStat(tx, traffic.Email)
+ logger.Warning(err, traffic.Email,clientErr)
+
+ }
+ continue
+ }
+ // get settings clients
+ settings := map[string][]model.Client{}
+ json.Unmarshal([]byte(inbound.Settings), &settings)
+ clients := settings["clients"]
+ for _, client := range clients {
+ if traffic.Email == client.Email {
+ traffic.ExpiryTime = client.ExpiryTime
+ traffic.Total = client.TotalGB
+ }
+ }
+ if tx.Where("inbound_id = ?", inbound.Id).Where("email = ?", traffic.Email).
+ UpdateColumn("enable", true).
+ UpdateColumn("expiry_time", traffic.ExpiryTime).
+ UpdateColumn("total",traffic.Total).
+ UpdateColumn("up", gorm.Expr("up + ?", traffic.Up)).
+ UpdateColumn("down", gorm.Expr("down + ?", traffic.Down)).RowsAffected == 0 {
+ err = tx.Create(traffic).Error
+ }
+
+ if err != nil {
+ logger.Warning("AddClientTraffic update data ", err)
+ continue
+ }
+
+ }
+ return
+}
+
+func (s *InboundService) DisableInvalidInbounds() (int64, error) {
+ db := database.GetDB()
+ now := time.Now().Unix() * 1000
+ result := db.Model(model.Inbound{}).
+ Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
+ Update("enable", false)
+ err := result.Error
+ count := result.RowsAffected
+ return count, err
+}
+func (s *InboundService) DisableInvalidClients() (int64, error) {
+ db := database.GetDB()
+ now := time.Now().Unix() * 1000
+ result := db.Model(xray.ClientTraffic{}).
+ Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
+ Update("enable", false)
+ err := result.Error
+ count := result.RowsAffected
+ return count, err
+}
+func (s *InboundService) UpdateClientStat(inboundId int, inboundSettings string) (error) {
+ db := database.GetDB()
+
+ // get settings clients
+ settings := map[string][]model.Client{}
+ json.Unmarshal([]byte(inboundSettings), &settings)
+ clients := settings["clients"]
+ for _, client := range clients {
+ result := db.Model(xray.ClientTraffic{}).
+ Where("inbound_id = ? and email = ?", inboundId, client.Email).
+ Updates(map[string]interface{}{"enable": true, "total": client.TotalGB, "expiry_time": client.ExpiryTime})
+ if result.RowsAffected == 0 {
+ clientTraffic := xray.ClientTraffic{}
+ clientTraffic.InboundId = inboundId
+ clientTraffic.Email = client.Email
+ clientTraffic.Total = client.TotalGB
+ clientTraffic.ExpiryTime = client.ExpiryTime
+ clientTraffic.Enable = true
+ clientTraffic.Up = 0
+ clientTraffic.Down = 0
+ db.Create(&clientTraffic)
+ }
+ err := result.Error
+ if err != nil {
+ return err
+ }
+
+ }
+ return nil
+}
+func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
+ return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error
+}
+
+func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error) {
+ db := database.GetDB()
+ InboundClientIps := &model.InboundClientIps{}
+ err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
+ if err != nil {
+ return "", err
+ }
+ return InboundClientIps.Ips, nil
+}
+func (s *InboundService) ClearClientIps(clientEmail string) (error) {
+ db := database.GetDB()
+
+ result := db.Model(model.InboundClientIps{}).
+ Where("client_email = ?", clientEmail).
+ Update("ips", "")
+ err := result.Error
+
+
+ if err != nil {
+ return err
+ }
+ return nil
+}
+func (s *InboundService) ResetClientTraffic(clientEmail string) (error) {
+ db := database.GetDB()
+
+ result := db.Model(xray.ClientTraffic{}).
+ Where("email = ?", clientEmail).
+ Update("up", 0).
+ Update("down", 0)
+
+ err := result.Error
+
+
+ if err != nil {
+ return err
+ }
+ return nil
+}
+func (s *InboundService) GetClientTrafficById(uuid string) (traffic *xray.ClientTraffic, err error) {
+ db := database.GetDB()
+ inbound := &model.Inbound{}
+ traffic = &xray.ClientTraffic{}
+
+ err = db.Model(model.Inbound{}).Where("settings like ?", "%" + uuid + "%").First(inbound).Error
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ logger.Warning(err)
+ return nil, err
+ }
+ }
+ traffic.InboundId = inbound.Id
+
+ // get settings clients
+ settings := map[string][]model.Client{}
+ json.Unmarshal([]byte(inbound.Settings), &settings)
+ clients := settings["clients"]
+ for _, client := range clients {
+ if uuid == client.ID {
+ traffic.Email = client.Email
+ }
+ }
+ err = db.Model(xray.ClientTraffic{}).Where("email = ?", traffic.Email).First(traffic).Error
+ if err != nil {
+ logger.Warning(err)
+ return nil, err
+ }
+ return traffic, err
+}
diff --git a/web/service/panel.go b/web/service/panel.go
new file mode 100644
index 0000000..f90d3e6
--- /dev/null
+++ b/web/service/panel.go
@@ -0,0 +1,26 @@
+package service
+
+import (
+ "os"
+ "syscall"
+ "time"
+ "x-ui/logger"
+)
+
+type PanelService struct {
+}
+
+func (s *PanelService) RestartPanel(delay time.Duration) error {
+ p, err := os.FindProcess(syscall.Getpid())
+ if err != nil {
+ return err
+ }
+ go func() {
+ time.Sleep(delay)
+ err := p.Signal(syscall.SIGHUP)
+ if err != nil {
+ logger.Error("send signal SIGHUP failed:", err)
+ }
+ }()
+ return nil
+}
diff --git a/web/service/server.go b/web/service/server.go
new file mode 100644
index 0000000..f46d247
--- /dev/null
+++ b/web/service/server.go
@@ -0,0 +1,301 @@
+package service
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "github.com/shirou/gopsutil/cpu"
+ "github.com/shirou/gopsutil/disk"
+ "github.com/shirou/gopsutil/host"
+ "github.com/shirou/gopsutil/load"
+ "github.com/shirou/gopsutil/mem"
+ "github.com/shirou/gopsutil/net"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "runtime"
+ "time"
+ "x-ui/logger"
+ "x-ui/util/sys"
+ "x-ui/xray"
+)
+
+type ProcessState string
+
+const (
+ Running ProcessState = "running"
+ Stop ProcessState = "stop"
+ Error ProcessState = "error"
+)
+
+type Status struct {
+ T time.Time `json:"-"`
+ Cpu float64 `json:"cpu"`
+ Mem struct {
+ Current uint64 `json:"current"`
+ Total uint64 `json:"total"`
+ } `json:"mem"`
+ Swap struct {
+ Current uint64 `json:"current"`
+ Total uint64 `json:"total"`
+ } `json:"swap"`
+ Disk struct {
+ Current uint64 `json:"current"`
+ Total uint64 `json:"total"`
+ } `json:"disk"`
+ Xray struct {
+ State ProcessState `json:"state"`
+ ErrorMsg string `json:"errorMsg"`
+ Version string `json:"version"`
+ } `json:"xray"`
+ Uptime uint64 `json:"uptime"`
+ Loads []float64 `json:"loads"`
+ TcpCount int `json:"tcpCount"`
+ UdpCount int `json:"udpCount"`
+ NetIO struct {
+ Up uint64 `json:"up"`
+ Down uint64 `json:"down"`
+ } `json:"netIO"`
+ NetTraffic struct {
+ Sent uint64 `json:"sent"`
+ Recv uint64 `json:"recv"`
+ } `json:"netTraffic"`
+}
+
+type Release struct {
+ TagName string `json:"tag_name"`
+}
+
+type ServerService struct {
+ xrayService XrayService
+}
+
+func (s *ServerService) GetStatus(lastStatus *Status) *Status {
+ now := time.Now()
+ status := &Status{
+ T: now,
+ }
+
+ percents, err := cpu.Percent(0, false)
+ if err != nil {
+ logger.Warning("get cpu percent failed:", err)
+ } else {
+ status.Cpu = percents[0]
+ }
+
+ upTime, err := host.Uptime()
+ if err != nil {
+ logger.Warning("get uptime failed:", err)
+ } else {
+ status.Uptime = upTime
+ }
+
+ memInfo, err := mem.VirtualMemory()
+ if err != nil {
+ logger.Warning("get virtual memory failed:", err)
+ } else {
+ status.Mem.Current = memInfo.Used
+ status.Mem.Total = memInfo.Total
+ }
+
+ swapInfo, err := mem.SwapMemory()
+ if err != nil {
+ logger.Warning("get swap memory failed:", err)
+ } else {
+ status.Swap.Current = swapInfo.Used
+ status.Swap.Total = swapInfo.Total
+ }
+
+ distInfo, err := disk.Usage("/")
+ if err != nil {
+ logger.Warning("get dist usage failed:", err)
+ } else {
+ status.Disk.Current = distInfo.Used
+ status.Disk.Total = distInfo.Total
+ }
+
+ avgState, err := load.Avg()
+ if err != nil {
+ logger.Warning("get load avg failed:", err)
+ } else {
+ status.Loads = []float64{avgState.Load1, avgState.Load5, avgState.Load15}
+ }
+
+ ioStats, err := net.IOCounters(false)
+ if err != nil {
+ logger.Warning("get io counters failed:", err)
+ } else if len(ioStats) > 0 {
+ ioStat := ioStats[0]
+ status.NetTraffic.Sent = ioStat.BytesSent
+ status.NetTraffic.Recv = ioStat.BytesRecv
+
+ if lastStatus != nil {
+ duration := now.Sub(lastStatus.T)
+ seconds := float64(duration) / float64(time.Second)
+ up := uint64(float64(status.NetTraffic.Sent-lastStatus.NetTraffic.Sent) / seconds)
+ down := uint64(float64(status.NetTraffic.Recv-lastStatus.NetTraffic.Recv) / seconds)
+ status.NetIO.Up = up
+ status.NetIO.Down = down
+ }
+ } else {
+ logger.Warning("can not find io counters")
+ }
+
+ status.TcpCount, err = sys.GetTCPCount()
+ if err != nil {
+ logger.Warning("get tcp connections failed:", err)
+ }
+
+ status.UdpCount, err = sys.GetUDPCount()
+ if err != nil {
+ logger.Warning("get udp connections failed:", err)
+ }
+
+ if s.xrayService.IsXrayRunning() {
+ status.Xray.State = Running
+ status.Xray.ErrorMsg = ""
+ } else {
+ err := s.xrayService.GetXrayErr()
+ if err != nil {
+ status.Xray.State = Error
+ } else {
+ status.Xray.State = Stop
+ }
+ status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
+ }
+ status.Xray.Version = s.xrayService.GetXrayVersion()
+
+ return status
+}
+
+func (s *ServerService) GetXrayVersions() ([]string, error) {
+ url := "https://api.github.com/repos/hossinasaadi/Xray-core/releases"
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+
+ defer resp.Body.Close()
+ buffer := bytes.NewBuffer(make([]byte, 8192))
+ buffer.Reset()
+ _, err = buffer.ReadFrom(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ releases := make([]Release, 0)
+ err = json.Unmarshal(buffer.Bytes(), &releases)
+ if err != nil {
+ return nil, err
+ }
+ versions := make([]string, 0, len(releases))
+ for _, release := range releases {
+ versions = append(versions, release.TagName)
+ }
+ return versions, nil
+}
+
+func (s *ServerService) downloadXRay(version string) (string, error) {
+ osName := runtime.GOOS
+ arch := runtime.GOARCH
+
+ switch osName {
+ case "darwin":
+ osName = "macos"
+ }
+
+ switch arch {
+ case "amd64":
+ arch = "64"
+ case "arm64":
+ arch = "arm64-v8a"
+ }
+
+ fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
+ url := fmt.Sprintf("https://github.com/hossinasaadi/Xray-core/releases/download/%s/%s", version, fileName)
+ resp, err := http.Get(url)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ os.Remove(fileName)
+ file, err := os.Create(fileName)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+
+ _, err = io.Copy(file, resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ return fileName, nil
+}
+
+func (s *ServerService) UpdateXray(version string) error {
+ zipFileName, err := s.downloadXRay(version)
+ if err != nil {
+ return err
+ }
+
+ zipFile, err := os.Open(zipFileName)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ zipFile.Close()
+ os.Remove(zipFileName)
+ }()
+
+ stat, err := zipFile.Stat()
+ if err != nil {
+ return err
+ }
+ reader, err := zip.NewReader(zipFile, stat.Size())
+ if err != nil {
+ return err
+ }
+
+ s.xrayService.StopXray()
+ defer func() {
+ err := s.xrayService.RestartXray(true)
+ if err != nil {
+ logger.Error("start xray failed:", err)
+ }
+ }()
+
+ copyZipFile := func(zipName string, fileName string) error {
+ zipFile, err := reader.Open(zipName)
+ if err != nil {
+ return err
+ }
+ os.Remove(fileName)
+ file, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, fs.ModePerm)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ _, err = io.Copy(file, zipFile)
+ return err
+ }
+
+ err = copyZipFile("xray", xray.GetBinaryPath())
+ if err != nil {
+ return err
+ }
+ err = copyZipFile("geosite.dat", xray.GetGeositePath())
+ if err != nil {
+ return err
+ }
+ err = copyZipFile("geoip.dat", xray.GetGeoipPath())
+ if err != nil {
+ return err
+ }
+
+ return nil
+
+}
diff --git a/web/service/setting.go b/web/service/setting.go
new file mode 100644
index 0000000..bedc833
--- /dev/null
+++ b/web/service/setting.go
@@ -0,0 +1,303 @@
+package service
+
+import (
+ _ "embed"
+ "errors"
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+ "x-ui/database"
+ "x-ui/database/model"
+ "x-ui/logger"
+ "x-ui/util/common"
+ "x-ui/util/random"
+ "x-ui/util/reflect_util"
+ "x-ui/web/entity"
+)
+
+//go:embed config.json
+var xrayTemplateConfig string
+
+var defaultValueMap = map[string]string{
+ "xrayTemplateConfig": xrayTemplateConfig,
+ "webListen": "",
+ "webPort": "54321",
+ "webCertFile": "",
+ "webKeyFile": "",
+ "secret": random.Seq(32),
+ "webBasePath": "/",
+ "timeLocation": "Asia/Shanghai",
+ "tgBotEnable": "false",
+ "tgBotToken": "",
+ "tgBotChatId": "0",
+ "tgRunTime": "",
+}
+
+type SettingService struct {
+}
+
+func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
+ db := database.GetDB()
+ settings := make([]*model.Setting, 0)
+ err := db.Model(model.Setting{}).Find(&settings).Error
+ if err != nil {
+ return nil, err
+ }
+ allSetting := &entity.AllSetting{}
+ t := reflect.TypeOf(allSetting).Elem()
+ v := reflect.ValueOf(allSetting).Elem()
+ fields := reflect_util.GetFields(t)
+
+ setSetting := func(key, value string) (err error) {
+ defer func() {
+ panicErr := recover()
+ if panicErr != nil {
+ err = errors.New(fmt.Sprint(panicErr))
+ }
+ }()
+
+ var found bool
+ var field reflect.StructField
+ for _, f := range fields {
+ if f.Tag.Get("json") == key {
+ field = f
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ // 有些设置自动生成,不需要返回到前端给用户修改
+ return nil
+ }
+
+ fieldV := v.FieldByName(field.Name)
+ switch t := fieldV.Interface().(type) {
+ case int:
+ n, err := strconv.ParseInt(value, 10, 64)
+ if err != nil {
+ return err
+ }
+ fieldV.SetInt(n)
+ case string:
+ fieldV.SetString(value)
+ case bool:
+ fieldV.SetBool(value == "true")
+ default:
+ return common.NewErrorf("unknown field %v type %v", key, t)
+ }
+ return
+ }
+
+ keyMap := map[string]bool{}
+ for _, setting := range settings {
+ err := setSetting(setting.Key, setting.Value)
+ if err != nil {
+ return nil, err
+ }
+ keyMap[setting.Key] = true
+ }
+
+ for key, value := range defaultValueMap {
+ if keyMap[key] {
+ continue
+ }
+ err := setSetting(key, value)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return allSetting, nil
+}
+
+func (s *SettingService) ResetSettings() error {
+ db := database.GetDB()
+ return db.Where("1 = 1").Delete(model.Setting{}).Error
+}
+
+func (s *SettingService) getSetting(key string) (*model.Setting, error) {
+ db := database.GetDB()
+ setting := &model.Setting{}
+ err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
+ if err != nil {
+ return nil, err
+ }
+ return setting, nil
+}
+
+func (s *SettingService) saveSetting(key string, value string) error {
+ setting, err := s.getSetting(key)
+ db := database.GetDB()
+ if database.IsNotFound(err) {
+ return db.Create(&model.Setting{
+ Key: key,
+ Value: value,
+ }).Error
+ } else if err != nil {
+ return err
+ }
+ setting.Key = key
+ setting.Value = value
+ return db.Save(setting).Error
+}
+
+func (s *SettingService) getString(key string) (string, error) {
+ setting, err := s.getSetting(key)
+ if database.IsNotFound(err) {
+ value, ok := defaultValueMap[key]
+ if !ok {
+ return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
+ }
+ return value, nil
+ } else if err != nil {
+ return "", err
+ }
+ return setting.Value, nil
+}
+
+func (s *SettingService) setString(key string, value string) error {
+ return s.saveSetting(key, value)
+}
+
+func (s *SettingService) getBool(key string) (bool, error) {
+ str, err := s.getString(key)
+ if err != nil {
+ return false, err
+ }
+ return strconv.ParseBool(str)
+}
+
+func (s *SettingService) setBool(key string, value bool) error {
+ return s.setString(key, strconv.FormatBool(value))
+}
+
+func (s *SettingService) getInt(key string) (int, error) {
+ str, err := s.getString(key)
+ if err != nil {
+ return 0, err
+ }
+ return strconv.Atoi(str)
+}
+
+func (s *SettingService) setInt(key string, value int) error {
+ return s.setString(key, strconv.Itoa(value))
+}
+
+func (s *SettingService) GetXrayConfigTemplate() (string, error) {
+ return s.getString("xrayTemplateConfig")
+}
+
+func (s *SettingService) GetListen() (string, error) {
+ return s.getString("webListen")
+}
+
+func (s *SettingService) GetTgBotToken() (string, error) {
+ return s.getString("tgBotToken")
+}
+
+func (s *SettingService) SetTgBotToken(token string) error {
+ return s.setString("tgBotToken", token)
+}
+
+func (s *SettingService) GetTgBotChatId() (int, error) {
+ return s.getInt("tgBotChatId")
+}
+
+func (s *SettingService) SetTgBotChatId(chatId int) error {
+ return s.setInt("tgBotChatId", chatId)
+}
+
+func (s *SettingService) SetTgbotenabled(value bool) error {
+ return s.setBool("tgBotEnable", value)
+}
+
+func (s *SettingService) GetTgbotenabled() (bool, error) {
+ return s.getBool("tgBotEnable")
+}
+
+func (s *SettingService) SetTgbotRuntime(time string) error {
+ return s.setString("tgRunTime", time)
+}
+
+func (s *SettingService) GetTgbotRuntime() (string, error) {
+ return s.getString("tgRunTime")
+}
+
+func (s *SettingService) GetPort() (int, error) {
+ return s.getInt("webPort")
+}
+
+func (s *SettingService) SetPort(port int) error {
+ return s.setInt("webPort", port)
+}
+
+func (s *SettingService) GetCertFile() (string, error) {
+ return s.getString("webCertFile")
+}
+
+func (s *SettingService) GetKeyFile() (string, error) {
+ return s.getString("webKeyFile")
+}
+
+func (s *SettingService) GetSecret() ([]byte, error) {
+ secret, err := s.getString("secret")
+ if secret == defaultValueMap["secret"] {
+ err := s.saveSetting("secret", secret)
+ if err != nil {
+ logger.Warning("save secret failed:", err)
+ }
+ }
+ return []byte(secret), err
+}
+
+func (s *SettingService) GetBasePath() (string, error) {
+ basePath, err := s.getString("webBasePath")
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(basePath, "/") {
+ basePath = "/" + basePath
+ }
+ if !strings.HasSuffix(basePath, "/") {
+ basePath += "/"
+ }
+ return basePath, nil
+}
+
+func (s *SettingService) GetTimeLocation() (*time.Location, error) {
+ l, err := s.getString("timeLocation")
+ if err != nil {
+ return nil, err
+ }
+ location, err := time.LoadLocation(l)
+ if err != nil {
+ defaultLocation := defaultValueMap["timeLocation"]
+ logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
+ return time.LoadLocation(defaultLocation)
+ }
+ return location, nil
+}
+
+func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
+ if err := allSetting.CheckValid(); err != nil {
+ return err
+ }
+
+ v := reflect.ValueOf(allSetting).Elem()
+ t := reflect.TypeOf(allSetting).Elem()
+ fields := reflect_util.GetFields(t)
+ errs := make([]error, 0)
+ for _, field := range fields {
+ key := field.Tag.Get("json")
+ fieldV := v.FieldByName(field.Name)
+ value := fmt.Sprint(fieldV.Interface())
+ err := s.saveSetting(key, value)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return common.Combine(errs...)
+}
diff --git a/web/service/user.go b/web/service/user.go
new file mode 100644
index 0000000..e4e7572
--- /dev/null
+++ b/web/service/user.go
@@ -0,0 +1,73 @@
+package service
+
+import (
+ "errors"
+ "x-ui/database"
+ "x-ui/database/model"
+ "x-ui/logger"
+
+ "gorm.io/gorm"
+)
+
+type UserService struct {
+}
+
+func (s *UserService) GetFirstUser() (*model.User, error) {
+ db := database.GetDB()
+
+ user := &model.User{}
+ err := db.Model(model.User{}).
+ First(user).
+ Error
+ if err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+func (s *UserService) CheckUser(username string, password string) *model.User {
+ db := database.GetDB()
+
+ user := &model.User{}
+ err := db.Model(model.User{}).
+ Where("username = ? and password = ?", username, password).
+ First(user).
+ Error
+ if err == gorm.ErrRecordNotFound {
+ return nil
+ } else if err != nil {
+ logger.Warning("check user err:", err)
+ return nil
+ }
+ return user
+}
+
+func (s *UserService) UpdateUser(id int, username string, password string) error {
+ db := database.GetDB()
+ return db.Model(model.User{}).
+ Where("id = ?", id).
+ Update("username", username).
+ Update("password", password).
+ Error
+}
+
+func (s *UserService) UpdateFirstUser(username string, password string) error {
+ if username == "" {
+ return errors.New("username can not be empty")
+ } else if password == "" {
+ return errors.New("password can not be empty")
+ }
+ db := database.GetDB()
+ user := &model.User{}
+ err := db.Model(model.User{}).First(user).Error
+ if database.IsNotFound(err) {
+ user.Username = username
+ user.Password = password
+ return db.Model(model.User{}).Create(user).Error
+ } else if err != nil {
+ return err
+ }
+ user.Username = username
+ user.Password = password
+ return db.Save(user).Error
+}
diff --git a/web/service/xray.go b/web/service/xray.go
new file mode 100644
index 0000000..37fd3b0
--- /dev/null
+++ b/web/service/xray.go
@@ -0,0 +1,163 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "sync"
+ "x-ui/logger"
+ "x-ui/xray"
+ "go.uber.org/atomic"
+)
+
+var p *xray.Process
+var lock sync.Mutex
+var isNeedXrayRestart atomic.Bool
+var result string
+
+type XrayService struct {
+ inboundService InboundService
+ settingService SettingService
+}
+
+func (s *XrayService) IsXrayRunning() bool {
+ return p != nil && p.IsRunning()
+}
+
+func (s *XrayService) GetXrayErr() error {
+ if p == nil {
+ return nil
+ }
+ return p.GetErr()
+}
+
+func (s *XrayService) GetXrayResult() string {
+ if result != "" {
+ return result
+ }
+ if s.IsXrayRunning() {
+ return ""
+ }
+ if p == nil {
+ return ""
+ }
+ result = p.GetResult()
+ return result
+}
+
+func (s *XrayService) GetXrayVersion() string {
+ if p == nil {
+ return "Unknown"
+ }
+ return p.GetVersion()
+}
+func RemoveIndex(s []interface{}, index int) []interface{} {
+ return append(s[:index], s[index+1:]...)
+}
+
+func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
+ templateConfig, err := s.settingService.GetXrayConfigTemplate()
+ if err != nil {
+ return nil, err
+ }
+
+ xrayConfig := &xray.Config{}
+ err = json.Unmarshal([]byte(templateConfig), xrayConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ s.inboundService.DisableInvalidClients()
+
+ inbounds, err := s.inboundService.GetAllInbounds()
+ if err != nil {
+ return nil, err
+ }
+ for _, inbound := range inbounds {
+ if !inbound.Enable {
+ continue
+ }
+ // get settings clients
+ settings := map[string]interface{}{}
+ json.Unmarshal([]byte(inbound.Settings), &settings)
+ clients, ok := settings["clients"].([]interface{})
+ if ok {
+ // check users active or not
+
+ clientStats := inbound.ClientStats
+ for _, clientTraffic := range clientStats {
+
+ for index, client := range clients {
+ c := client.(map[string]interface{})
+ if c["email"] == clientTraffic.Email {
+ if ! clientTraffic.Enable {
+ clients = RemoveIndex(clients,index)
+ logger.Info("Remove Inbound User",c["email"] ,"due the expire or traffic limit")
+
+ }
+
+ }
+ }
+
+
+ }
+ settings["clients"] = clients
+ modifiedSettings, err := json.Marshal(settings)
+ if err != nil {
+ return nil, err
+ }
+
+ inbound.Settings = string(modifiedSettings)
+ }
+ inboundConfig := inbound.GenXrayInboundConfig()
+ xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
+ }
+ return xrayConfig, nil
+}
+
+func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
+ if !s.IsXrayRunning() {
+ return nil, nil, errors.New("xray is not running")
+ }
+ return p.GetTraffic(true)
+}
+
+func (s *XrayService) RestartXray(isForce bool) error {
+ lock.Lock()
+ defer lock.Unlock()
+ logger.Debug("restart xray, force:", isForce)
+
+ xrayConfig, err := s.GetXrayConfig()
+ if err != nil {
+ return err
+ }
+
+ if p != nil && p.IsRunning() {
+ if !isForce && p.GetConfig().Equals(xrayConfig) {
+ logger.Debug("not need to restart xray")
+ return nil
+ }
+ p.Stop()
+ }
+
+ p = xray.NewProcess(xrayConfig)
+ result = ""
+ return p.Start()
+}
+
+func (s *XrayService) StopXray() error {
+ lock.Lock()
+ defer lock.Unlock()
+ logger.Debug("stop xray")
+ if s.IsXrayRunning() {
+ return p.Stop()
+ }
+ return errors.New("xray is not running")
+}
+
+func (s *XrayService) SetToNeedRestart() {
+ isNeedXrayRestart.Store(true)
+}
+
+func (s *XrayService) IsNeedRestartAndSetFalse() bool {
+ return isNeedXrayRestart.CAS(true, false)
+}
diff --git a/web/session/session.go b/web/session/session.go
new file mode 100644
index 0000000..2dfe94b
--- /dev/null
+++ b/web/session/session.go
@@ -0,0 +1,46 @@
+package session
+
+import (
+ "encoding/gob"
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "x-ui/database/model"
+)
+
+const (
+ loginUser = "LOGIN_USER"
+)
+
+func init() {
+ gob.Register(model.User{})
+}
+
+func SetLoginUser(c *gin.Context, user *model.User) error {
+ s := sessions.Default(c)
+ s.Set(loginUser, user)
+ return s.Save()
+}
+
+func GetLoginUser(c *gin.Context) *model.User {
+ s := sessions.Default(c)
+ obj := s.Get(loginUser)
+ if obj == nil {
+ return nil
+ }
+ user := obj.(model.User)
+ return &user
+}
+
+func IsLogin(c *gin.Context) bool {
+ return GetLoginUser(c) != nil
+}
+
+func ClearSession(c *gin.Context) {
+ s := sessions.Default(c)
+ s.Clear()
+ s.Options(sessions.Options{
+ Path: "/",
+ MaxAge: -1,
+ })
+ s.Save()
+}
diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml
new file mode 100644
index 0000000..7a31f40
--- /dev/null
+++ b/web/translation/translate.en_US.toml
@@ -0,0 +1,189 @@
+"username" = "username"
+"password" = "password"
+"login" = "login"
+"confirm" = "confirm"
+"cancel" = "cancel"
+"close" = "close"
+"copy" = "copy"
+"copied" = "copied"
+"download" = "download"
+"remark" = "remark"
+"enable" = "enable"
+"protocol" = "protocol"
+
+"loading" = "Loading"
+"second" = "second"
+"minute" = "minute"
+"hour" = "hour"
+"day" = "day"
+"check" = "check"
+"indefinitely" = "indefinitely"
+"unlimited" = "unlimited"
+"none" = "none"
+"qrCode" = "QR Code"
+"edit" = "edit"
+"delete" = "delete"
+"reset" = "reset"
+"copySuccess" = "Copy successfully"
+"sure" = "Sure"
+"encryption" = "encryption"
+"transmission" = "transmission"
+"host" = "host"
+"path" = "path"
+"camouflage" = "camouflage"
+"turnOn" = "turn on"
+"closure" = "closure"
+"domainName" = "domain name"
+"additional" = "alter"
+"monitor" = "Listen IP"
+"certificate" = "certificat"
+"fail" = "fail"
+"success" = " success"
+"getVersion" = "get version"
+"install" = "install"
+
+[menu]
+"dashboard" = "System Status"
+"inbounds" = "Inbounds"
+"setting" = "Panel Setting"
+"logout" = "LogOut"
+"link" = "Other"
+
+[pages.login]
+"title" = "Login"
+"loginAgain" = "The login time limit has expired, please log in again"
+
+[pages.login.toasts]
+"invalidFormData" = "Input Data Format Is Invalid"
+"emptyUsername" = "please Enter Username"
+"emptyPassword" = "please Enter Password"
+"wrongUsernameOrPassword" = "invalid username or password"
+"successLogin" = "Login"
+
+
+[pages.index]
+"title" = "system status"
+"memory" = "memory"
+"hard" = "hard disk"
+"xrayStatus" = "xray Status"
+"xraySwitch" = "Switch Version"
+"xraySwitchClick" = "Click on the version you want to switch"
+"xraySwitchClickDesk" = "Please choose carefully, older versions may have incompatible configurations"
+"operationHours" = "Operation Hours"
+"operationHoursDesc" = "The running time of the system since it was started"
+"systemLoad" = "System Load"
+"connectionCount" = "Connection Count"
+"connectionCountDesc" = "The total number of connections for all network cards"
+"upSpeed" = "Total upload speed for all network cards"
+"downSpeed" = "Total download speed for all network cards"
+"totalSent" = "Total upload traffic of all network cards since system startup"
+"totalReceive" = "Total download traffic of all network cards since system startup"
+"xraySwitchVersionDialog" = "switch xray version"
+"xraySwitchVersionDialogDesc" = "whether to switch the xray version to"
+"dontRefreshh" = "Installation is in progress, please do not refresh this page"
+
+
+[pages.inbounds]
+"title" = "Inbounds"
+"totalDownUp" = "Total uploads/downloads"
+"totalUsage" = "Total usage"
+"inboundCount" = "Number of inbound"
+"operate" = "operate"
+"enable" = "enable"
+"remark" = "remark"
+"protocol" = "protocol"
+"port" = "port"
+"traffic" = "traffic"
+"details" = "details"
+"transportConfig" = "transport config"
+"expireDate" = "expire date"
+"resetTraffic" = "reset traffic"
+"addInbound" = "addInbound"
+"addTo" = "Add To"
+"revise" = "Revise"
+"modifyInbound" = "Modify InBound"
+"deleteInbound" = "Delete Inbound"
+"deleteInboundContent" = "Are you sure you want to delete inbound?"
+"resetTrafficContent" = "Are you sure you want to reset traffic?"
+"copyLink" = "Copy Link"
+"address" = "address"
+"network" = "network"
+"destinationPort" = "destination port"
+"targetAddress" = "target address"
+"disableInsecureEncryption" = "Disable insecure encryption"
+"monitorDesc" = "Leave blank by default"
+"meansNoLimit" = "means no limit"
+"totalFlow" = "total flow"
+"leaveBlankToNeverExpire" = "Leave blank to never expire"
+"noRecommendKeepDefault" = "There are no special requirements to keep the default"
+"certificatePath" = "certificate file path"
+"certificateContent" = "certificate file content"
+"publicKeyPath" = "public key file path"
+"publicKeyContent" = "public key content"
+"keyPath" = "key file path"
+"keyContent" = "key content"
+
+[pages.inbounds.toasts]
+"obtain" = "Obtain"
+
+[pages.inbounds.stream.general]
+"requestHeader" = "request header"
+"name" = "name"
+"value" = "value"
+
+[pages.inbounds.stream.tcp]
+"requestVersion" = "request version"
+"requestMethod" = "request method"
+"requestPath" = "request path"
+"responseVersion" = "response version"
+"responseStatus" = "response status"
+"responseStatusDescription" = "response status description"
+"responseHeader" = "response header"
+
+[pages.inbounds.stream.quic]
+"encryption" = "encryption"
+
+
+[pages.setting]
+"title" = "Setting"
+"save" = "Save"
+"restartPanel" = "Restart Panel"
+"restartPanelDesc" = "Are you sure you want to restart the panel? Click OK to restart after 3 seconds. If you cannot access the panel after restarting, please go to the server to view the panel log information"
+"panelConfig" = "Panel Configuration"
+"userSetting" = "User Setting"
+"xrayConfiguration" = "xray Configuration"
+"TGReminder" = "TG Reminder Related Settings"
+"otherSetting" = "Other Setting"
+"panelListeningIP" = "Panel listening IP"
+"panelListeningIPDesc" = "Leave blank by default to monitor all IPs, restart the panel to take effect"
+"panelPort" = "Panel Port"
+"panelPortDesc" = "Restart the panel to take effect"
+"publicKeyPath" = "Panel certificate public key file path"
+"publicKeyPathDesc" = "Fill in an absolute path starting with '/', restart the panel to take effect"
+"privateKeyPath" = "Panel certificate key file path"
+"privateKeyPathDesc" = "Fill in an absolute path starting with '/', restart the panel to take effect"
+"panelUrlPath" = "panel url root path"
+"panelUrlPathDesc" = "Must start with '/' and end with '/', restart the panel to take effect"
+"oldUsername" = "Current Username"
+"currentPassword" = "Current Password"
+"newUsername" = "New Username"
+"newPassword" = "New Password"
+"xrayConfigTemplate" = "xray Configuration Template"
+"xrayConfigTemplateDesc" = "Generate the final xray configuration file based on this template, restart the panel to take effect"
+"telegramBotEnable" = "Enable telegram bot"
+"telegramBotEnableDesc" = "Restart the panel to take effect"
+"telegramToken" = "Telegram Token"
+"telegramTokenDesc" = "Restart the panel to take effect"
+"telegramChatId" = "Telegram ChatId"
+"telegramChatIdDesc" = "Restart the panel to take effect"
+"telegramNotifyTime" = "Telegram bot notification time"
+"telegramNotifyTimeDesc" = "Using Crontab timing format, restart the panel to take effect"
+"timeZonee" = "Time Zone"
+"timeZoneDesc" = "The scheduled task runs according to the time in the time zone, and restarts the panel to take effect"
+
+[pages.setting.toasts]
+"modifySetting" = "modify setting"
+"getSetting" = "get setting"
+"modifyUser" = "modify user"
+"originalUserPassIncorrect" = "The original user name or original password is incorrect"
+"userPassMustBeNotEmpty" = "New username and new password cannot be empty"
\ No newline at end of file
diff --git a/web/translation/translate.zh_Hans.toml b/web/translation/translate.zh_Hans.toml
new file mode 100644
index 0000000..3174688
--- /dev/null
+++ b/web/translation/translate.zh_Hans.toml
@@ -0,0 +1,189 @@
+"username" = "用户名"
+"password" = "密码"
+"login" = "登录"
+"confirm" = "确定"
+"cancel" = "取消"
+"close" = "关闭"
+"copy" = "复制"
+"copied" = "已复制"
+"download" = "下载"
+"remark" = "备注"
+"enable" = "启用"
+"protocol" = "协议"
+
+"loading" = "加载中"
+"second" = "秒"
+"minute" = "分钟"
+"hour" = "小时"
+"day" = "天"
+"check" = "查看"
+"indefinitely" = "无限期"
+"unlimited" = "无限制"
+"none" = "无"
+"qrCode" = "二维码"
+"edit" = "编辑"
+"delete" = "删除"
+"reset" = "重置"
+"copySuccess" = "复制成功"
+"sure" = "确定"
+"encryption" = "加密"
+"transmission" = "传输"
+"host" = "主持人"
+"path" = "小路"
+"camouflage" = "伪装"
+"turnOn" = "开启"
+"closure" = "关闭"
+"domainName" = "域名"
+"additional" = "额外"
+"monitor" = "监听"
+"certificate" = "证书"
+"fail" = "失败"
+"success" = "成功"
+"getVersion" = "获取版本"
+"install" = "安装"
+
+[menu]
+"dashboard" = "系统状态"
+"inbounds" = "入站列表"
+"setting" = "面板设置"
+"logout" = "退出登录"
+"link" = "其他"
+
+[pages.login]
+"title" = "登录"
+"loginAgain" = "登录时效已过,请重新登录"
+
+[pages.login.toasts]
+"invalidFormData" = "数据格式错误"
+"emptyUsername" = "请输入用户名"
+"emptyPassword" = "请输入密码"
+"wrongUsernameOrPassword" = "用户名或密码错误"
+"successLogin" = "登录"
+
+[pages.index]
+"title" = "系统状态"
+"memory" = "内存"
+"hard" = "硬盘"
+"xrayStatus" = "xray 状态"
+"xraySwitch" = "切换版本"
+"xraySwitchClick" = "点击你想切换的版本"
+"xraySwitchClickDesk" = "请谨慎选择,旧版本可能配置不兼容"
+"operationHours" = "运行时间"
+"operationHoursDesc" = "系统自启动以来的运行时间"
+"systemLoad" = "系统负载"
+"connectionCount" = "连接数"
+"connectionCountDesc" = "所有网卡的总连接数"
+"upSpeed" = "所有网卡的总上传速度"
+"downSpeed" = "所有网卡的总下载速度"
+"totalSent" = "系统启动以来所有网卡的总上传流量"
+"totalReceive" = "系统启动以来所有网卡的总下载流量"
+"xraySwitchVersionDialog" = "切换 xray 版本"
+"xraySwitchVersionDialogDesc" = "是否切换 xray 版本至"
+"dontRefreshh" = "安装中,请不要刷新此页面"
+
+
+[pages.inbounds]
+"title" = "入站列表"
+"totalDownUp" = "总上传 / 下载"
+"totalUsage" = "总用量"
+"inboundCount" = "入站数量"
+"operate" = "操作"
+"enable" = "启用"
+"remark" = "备注"
+"protocol" = "协议"
+"port" = "端口"
+"traffic" = "流量"
+"details" = "详细信息"
+"transportConfig" = "传输配置"
+"expireDate" = "到期时间"
+"resetTraffic" = "重置流量"
+"addInbound" = "添加入"
+"addTo" = "添加"
+"revise" = "修改"
+"modifyInbound" = "修改入站"
+"deleteInbound" = "删除入站"
+"deleteInboundContent" = "确定要删除入站吗?"
+"resetTrafficContent" = "确定要重置流量吗?"
+"copyLink" = "复制链接"
+"address" = "地址"
+"network" = "网络"
+"destinationPort" = "目标端口"
+"targetAddress" = "目标地址"
+"disableInsecureEncryption" = "禁用不安全加密"
+"monitorDesc" = "默认留空即可"
+"meansNoLimit" = "表示不限制"
+"totalFlow" = "总流量"
+"leaveBlankToNeverExpire" = "留空则永不到期"
+"noRecommendKeepDefault" = "没有特殊需求保持默认即可"
+"certificatePath" = "证书文件路径"
+"certificateContent" = "证书文件内容"
+"publicKeyPath" = "公钥文件路径"
+"publicKeyContent" = "公钥内容"
+"keyPath" = "密钥文件路径"
+"keyContent" = "密钥内容"
+
+[pages.inbounds.toasts]
+"obtain" = "获取"
+
+[pages.inbounds.stream.general]
+"requestHeader" = "请求头"
+"name" = "名称"
+"value" = "值"
+
+[pages.inbounds.stream.tcp]
+"requestVersion" = "请求版本"
+"requestMethod" = "请求方法"
+"requestPath" = "请求路径"
+"responseVersion" = "响应版本"
+"responseStatus" = "响应状态"
+"responseStatusDescription" = "响应状态说明"
+"responseHeader" = "响应头"
+
+[pages.inbounds.stream.quic]
+"encryption" = "加密"
+
+
+[pages.setting]
+"title" = "设置"
+"save" = "保存配置"
+"restartPanel" = "重启面板"
+"restartPanelDesc" = "确定要重启面板吗?点击确定将于 3 秒后重启,若重启后无法访问面板,请前往服务器查看面板日志信息"
+"panelConfig" = "面板配置"
+"userSetting" = "用户设置"
+"xrayConfiguration" = "xray 相关设置"
+"TGReminder" = "TG提醒相关设置"
+"otherSetting" = "其他设置"
+"panelListeningIP" = "面板监听 IP"
+"panelListeningIPDesc" = "默认留空监听所有 IP,重启面板生效"
+"panelPort" = "面板监听端口"
+"panelPortDesc" = "重启面板生效"
+"publicKeyPath" = "面板证书公钥文件路径"
+"publicKeyPathDesc" = "填写一个 '/' 开头的绝对路径,重启面板生效"
+"privateKeyPath" = "面板证书密钥文件路径"
+"privateKeyPathDesc" = "填写一个 '/' 开头的绝对路径,重启面板生效"
+"panelUrlPath" = "面板 url 根路径"
+"panelUrlPathDesc" = "必须以 '/' 开头,以 '/' 结尾,重启面板生效"
+"oldUsername" = "原用户名"
+"currentPassword" = "原密码"
+"newUsername" = "新用户名"
+"newPassword" = "新密码"
+"xrayConfigTemplate" = "xray 配置模版"
+"xrayConfigTemplateDesc" = "以该模版为基础生成最终的 xray 配置文件,重启面板生效"
+"telegramBotEnable" = "启用电报机器人"
+"telegramBotEnableDesc" = "重启面板生效"
+"telegramToken" = "电报机器人TOKEN"
+"telegramTokenDesc" = "重启面板生效"
+"telegramChatId" = "电报机器人ChatId"
+"telegramChatIdDesc" = "重启面板生效"
+"telegramNotifyTime" = "电报机器人通知时间"
+"telegramNotifyTimeDesc" = "采用Crontab定时格式,重启面板生效"
+"timeZonee" = "时区"
+"timeZoneDesc" = "定时任务按照该时区的时间运行,重启面板生效"
+
+[pages.setting.toasts]
+"modifySetting" = "修改设置"
+"getSetting" = "获取设置"
+"modifyUser" = "修改用户"
+"originalUserPassIncorrect" = "原用户名或原密码错误"
+"userPassMustBeNotEmpty" = "新用户名和新密码不能为空"
+
diff --git a/web/translation/translate.zh_Hant.toml b/web/translation/translate.zh_Hant.toml
new file mode 100644
index 0000000..2700f4d
--- /dev/null
+++ b/web/translation/translate.zh_Hant.toml
@@ -0,0 +1,55 @@
+"username" = "用戶名"
+"password" = "密碼"
+"login" = "登錄"
+"confirm" = "確定"
+"cancel" = "取消"
+"close" = "關閉"
+"copy" = "複製"
+"copied" = "已複製"
+"download" = "下載"
+"remark" = "備註"
+"enable" = "啟用"
+"protocol" = "協議"
+
+[menu]
+"dashboard" = "系统状态"
+"inbounds" = "入站列表"
+"setting" = "面板设置"
+"logout" = "退出登录"
+"link" = "其他"
+
+[pages.login]
+"title" = "登錄"
+
+[pages.login.toasts]
+"invalidFormData" = "数据格式错误"
+"emptyUsername" = "请输入用户名"
+"emptyPassword" = "请输入密码"
+"wrongUsernameOrPassword" = "用户名或密码错误"
+"successLogin" = "登录"
+
+[pages.index]
+"title" = "系统状态"
+
+[pages.inbounds]
+"title" = "入站列表"
+
+[pages.inbounds.stream.general]
+"requestHeader" = "request header"
+"name" = "name"
+"value" = "value"
+
+[pages.inbounds.stream.tcp]
+"requestVersion" = "request version"
+"requestMethod" = "request method"
+"requestPath" = "request path"
+"responseVersion" = "response version"
+"responseStatus" = "response status"
+"responseStatusDescription" = "response status description"
+"responseHeader" = "response header"
+
+[pages.inbounds.stream.quic]
+"encryption" = "encryption"
+
+[pages.setting]
+"title" = "设置"
\ No newline at end of file
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..c9db8d0
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,435 @@
+package web
+
+import (
+ "context"
+ "crypto/tls"
+ "embed"
+ "html/template"
+ "io"
+ "io/fs"
+ "net"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+ "x-ui/config"
+ "x-ui/logger"
+ "x-ui/util/common"
+ "x-ui/web/controller"
+ "x-ui/web/job"
+ "x-ui/web/network"
+ "x-ui/web/service"
+
+ "github.com/BurntSushi/toml"
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-contrib/sessions/cookie"
+ "github.com/gin-gonic/gin"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+ "github.com/robfig/cron/v3"
+ "golang.org/x/text/language"
+)
+
+//go:embed assets/*
+var assetsFS embed.FS
+
+//go:embed html/*
+var htmlFS embed.FS
+
+//go:embed translation/*
+var i18nFS embed.FS
+
+var startTime = time.Now()
+
+type wrapAssetsFS struct {
+ embed.FS
+}
+
+func (f *wrapAssetsFS) Open(name string) (fs.File, error) {
+ file, err := f.FS.Open("assets/" + name)
+ if err != nil {
+ return nil, err
+ }
+ return &wrapAssetsFile{
+ File: file,
+ }, nil
+}
+
+type wrapAssetsFile struct {
+ fs.File
+}
+
+func (f *wrapAssetsFile) Stat() (fs.FileInfo, error) {
+ info, err := f.File.Stat()
+ if err != nil {
+ return nil, err
+ }
+ return &wrapAssetsFileInfo{
+ FileInfo: info,
+ }, nil
+}
+
+type wrapAssetsFileInfo struct {
+ fs.FileInfo
+}
+
+func (f *wrapAssetsFileInfo) ModTime() time.Time {
+ return startTime
+}
+
+type Server struct {
+ httpServer *http.Server
+ listener net.Listener
+
+ index *controller.IndexController
+ server *controller.ServerController
+ xui *controller.XUIController
+ api *controller.APIController
+
+ xrayService service.XrayService
+ settingService service.SettingService
+ inboundService service.InboundService
+
+ cron *cron.Cron
+
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func NewServer() *Server {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Server{
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+func (s *Server) getHtmlFiles() ([]string, error) {
+ files := make([]string, 0)
+ dir, _ := os.Getwd()
+ err := fs.WalkDir(os.DirFS(dir), "web/html", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ files = append(files, path)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return files, nil
+}
+
+func (s *Server) getHtmlTemplate(funcMap template.FuncMap) (*template.Template, error) {
+ t := template.New("").Funcs(funcMap)
+ err := fs.WalkDir(htmlFS, "html", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if d.IsDir() {
+ newT, err := t.ParseFS(htmlFS, path+"/*.html")
+ if err != nil {
+ // ignore
+ return nil
+ }
+ t = newT
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return t, nil
+}
+
+func (s *Server) initRouter() (*gin.Engine, error) {
+ if config.IsDebug() {
+ gin.SetMode(gin.DebugMode)
+ } else {
+ gin.DefaultWriter = io.Discard
+ gin.DefaultErrorWriter = io.Discard
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ engine := gin.Default()
+
+ secret, err := s.settingService.GetSecret()
+ if err != nil {
+ return nil, err
+ }
+
+ basePath, err := s.settingService.GetBasePath()
+ if err != nil {
+ return nil, err
+ }
+ assetsBasePath := basePath + "assets/"
+
+ store := cookie.NewStore(secret)
+ engine.Use(sessions.Sessions("session", store))
+ engine.Use(func(c *gin.Context) {
+ c.Set("base_path", basePath)
+ })
+ engine.Use(func(c *gin.Context) {
+ uri := c.Request.RequestURI
+ if strings.HasPrefix(uri, assetsBasePath) {
+ c.Header("Cache-Control", "max-age=31536000")
+ }
+ })
+ err = s.initI18n(engine)
+ if err != nil {
+ return nil, err
+ }
+
+ if config.IsDebug() {
+ // for develop
+ files, err := s.getHtmlFiles()
+ if err != nil {
+ return nil, err
+ }
+ engine.LoadHTMLFiles(files...)
+ engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets")))
+ } else {
+ // for prod
+ t, err := s.getHtmlTemplate(engine.FuncMap)
+ if err != nil {
+ return nil, err
+ }
+ engine.SetHTMLTemplate(t)
+ engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: assetsFS}))
+ }
+
+ g := engine.Group(basePath)
+
+ s.index = controller.NewIndexController(g)
+ s.server = controller.NewServerController(g)
+ s.xui = controller.NewXUIController(g)
+ s.api = controller.NewAPIController(g)
+
+ return engine, nil
+}
+
+func (s *Server) initI18n(engine *gin.Engine) error {
+ bundle := i18n.NewBundle(language.SimplifiedChinese)
+ bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
+ err := fs.WalkDir(i18nFS, "translation", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ data, err := i18nFS.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ _, err = bundle.ParseMessageFileBytes(data, path)
+ return err
+ })
+ if err != nil {
+ return err
+ }
+
+ findI18nParamNames := func(key string) []string {
+ names := make([]string, 0)
+ keyLen := len(key)
+ for i := 0; i < keyLen-1; i++ {
+ if key[i:i+2] == "{{" { // 判断开头 "{{"
+ j := i + 2
+ isFind := false
+ for ; j < keyLen-1; j++ {
+ if key[j:j+2] == "}}" { // 结尾 "}}"
+ isFind = true
+ break
+ }
+ }
+ if isFind {
+ names = append(names, key[i+3:j])
+ }
+ }
+ }
+ return names
+ }
+
+ var localizer *i18n.Localizer
+
+ I18n := func(key string, params ...string) (string, error) {
+ names := findI18nParamNames(key)
+ if len(names) != len(params) {
+ return "", common.NewError("find names:", names, "---------- params:", params, "---------- num not equal")
+ }
+ templateData := map[string]interface{}{}
+ for i := range names {
+ templateData[names[i]] = params[i]
+ }
+ return localizer.Localize(&i18n.LocalizeConfig{
+ MessageID: key,
+ TemplateData: templateData,
+ })
+ }
+
+ engine.FuncMap["i18n"] = I18n;
+
+ engine.Use(func(c *gin.Context) {
+ //accept := c.GetHeader("Accept-Language")
+
+ var lang string
+
+ if cookie, err := c.Request.Cookie("lang"); err == nil {
+ lang = cookie.Value
+ } else {
+ lang = c.GetHeader("Accept-Language")
+ }
+
+ localizer = i18n.NewLocalizer(bundle, lang)
+ c.Set("localizer", localizer)
+ c.Set("I18n" , I18n)
+ c.Next()
+ })
+
+ return nil
+}
+
+func (s *Server) startTask() {
+ err := s.xrayService.RestartXray(true)
+ if err != nil {
+ logger.Warning("start xray failed:", err)
+ }
+ // 每 30 秒检查一次 xray 是否在运行
+ s.cron.AddJob("@every 30s", job.NewCheckXrayRunningJob())
+
+ go func() {
+ time.Sleep(time.Second * 5)
+ // 每 10 秒统计一次流量,首次启动延迟 5 秒,与重启 xray 的时间错开
+ s.cron.AddJob("@every 10s", job.NewXrayTrafficJob())
+ }()
+
+ // 每 30 秒检查一次 inbound 流量超出和到期的情况
+ s.cron.AddJob("@every 30s", job.NewCheckInboundJob())
+
+ // check client ips from log file every 10 sec
+ s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
+
+ // 每一天提示一次流量情况,上海时间8点30
+ var entry cron.EntryID
+ isTgbotenabled, err := s.settingService.GetTgbotenabled()
+ if (err == nil) && (isTgbotenabled) {
+ runtime, err := s.settingService.GetTgbotRuntime()
+ if err != nil || runtime == "" {
+ logger.Errorf("Add NewStatsNotifyJob error[%s],Runtime[%s] invalid,wil run default", err, runtime)
+ runtime = "@daily"
+ }
+ logger.Infof("Tg notify enabled,run at %s", runtime)
+ entry, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob())
+ if err != nil {
+ logger.Warning("Add NewStatsNotifyJob error", err)
+ return
+ }
+ // listen for TG bot income messages
+ go job.NewStatsNotifyJob().OnReceive()
+ } else {
+ s.cron.Remove(entry)
+ }
+}
+
+func (s *Server) Start() (err error) {
+ //这是一个匿名函数,没没有函数名
+ defer func() {
+ if err != nil {
+ s.Stop()
+ }
+ }()
+
+ loc, err := s.settingService.GetTimeLocation()
+ if err != nil {
+ return err
+ }
+ s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
+ s.cron.Start()
+
+ engine, err := s.initRouter()
+ if err != nil {
+ return err
+ }
+
+ certFile, err := s.settingService.GetCertFile()
+ if err != nil {
+ return err
+ }
+ keyFile, err := s.settingService.GetKeyFile()
+ if err != nil {
+ return err
+ }
+ listen, err := s.settingService.GetListen()
+ if err != nil {
+ return err
+ }
+ port, err := s.settingService.GetPort()
+ if err != nil {
+ return err
+ }
+ listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
+ listener, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return err
+ }
+ if certFile != "" || keyFile != "" {
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ listener.Close()
+ return err
+ }
+ c := &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ }
+ listener = network.NewAutoHttpsListener(listener)
+ listener = tls.NewListener(listener, c)
+ }
+
+ if certFile != "" || keyFile != "" {
+ logger.Info("web server run https on", listener.Addr())
+ } else {
+ logger.Info("web server run http on", listener.Addr())
+ }
+ s.listener = listener
+
+ s.startTask()
+
+ s.httpServer = &http.Server{
+ Handler: engine,
+ }
+
+ go func() {
+ s.httpServer.Serve(listener)
+ }()
+
+ return nil
+}
+
+func (s *Server) Stop() error {
+ s.cancel()
+ s.xrayService.StopXray()
+ if s.cron != nil {
+ s.cron.Stop()
+ }
+ var err1 error
+ var err2 error
+ if s.httpServer != nil {
+ err1 = s.httpServer.Shutdown(s.ctx)
+ }
+ if s.listener != nil {
+ err2 = s.listener.Close()
+ }
+ return common.Combine(err1, err2)
+}
+
+func (s *Server) GetCtx() context.Context {
+ return s.ctx
+}
+
+func (s *Server) GetCron() *cron.Cron {
+ return s.cron
+}
diff --git a/x-ui.service b/x-ui.service
new file mode 100644
index 0000000..24754a8
--- /dev/null
+++ b/x-ui.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=x-ui Service
+After=network.target
+Wants=network.target
+
+[Service]
+Environment="XRAY_VMESS_AEAD_FORCED=false"
+Type=simple
+WorkingDirectory=/usr/local/x-ui/
+ExecStart=/usr/local/x-ui/x-ui
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/x-ui.sh b/x-ui.sh
new file mode 100644
index 0000000..3427cd7
--- /dev/null
+++ b/x-ui.sh
@@ -0,0 +1,630 @@
+#!/bin/bash
+
+red='\033[0;31m'
+green='\033[0;32m'
+yellow='\033[0;33m'
+plain='\033[0m'
+
+#Add some basic function here
+function LOGD() {
+ echo -e "${yellow}[DEG] $* ${plain}"
+}
+
+function LOGE() {
+ echo -e "${red}[ERR] $* ${plain}"
+}
+
+function LOGI() {
+ echo -e "${green}[INF] $* ${plain}"
+}
+# check root
+[[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1
+
+# check os
+if [[ -f /etc/redhat-release ]]; then
+ release="centos"
+elif cat /etc/issue | grep -Eqi "debian"; then
+ release="debian"
+elif cat /etc/issue | grep -Eqi "ubuntu"; then
+ release="ubuntu"
+elif cat /etc/issue | grep -Eqi "centos|red hat|redhat"; then
+ release="centos"
+elif cat /proc/version | grep -Eqi "debian"; then
+ release="debian"
+elif cat /proc/version | grep -Eqi "ubuntu"; then
+ release="ubuntu"
+elif cat /proc/version | grep -Eqi "centos|red hat|redhat"; then
+ release="centos"
+else
+ LOGE "check system OS failed,please contact with author! \n" && exit 1
+fi
+
+os_version=""
+
+# os version
+if [[ -f /etc/os-release ]]; then
+ os_version=$(awk -F'[= ."]' '/VERSION_ID/{print $3}' /etc/os-release)
+fi
+if [[ -z "$os_version" && -f /etc/lsb-release ]]; then
+ os_version=$(awk -F'[= ."]+' '/DISTRIB_RELEASE/{print $2}' /etc/lsb-release)
+fi
+
+if [[ x"${release}" == x"centos" ]]; then
+ if [[ ${os_version} -le 6 ]]; then
+ LOGE "please use CentOS 7 or higher version! \n" && exit 1
+ fi
+elif [[ x"${release}" == x"ubuntu" ]]; then
+ if [[ ${os_version} -lt 16 ]]; then
+ LOGE "please use Ubuntu 16 or higher version!\n" && exit 1
+ fi
+elif [[ x"${release}" == x"debian" ]]; then
+ if [[ ${os_version} -lt 8 ]]; then
+ LOGE "please use Debian 8 or higher version!\n" && exit 1
+ fi
+fi
+
+confirm() {
+ if [[ $# > 1 ]]; then
+ echo && read -p "$1 [Default$2]: " temp
+ if [[ x"${temp}" == x"" ]]; then
+ temp=$2
+ fi
+ else
+ read -p "$1 [y/n]: " temp
+ fi
+ if [[ x"${temp}" == x"y" || x"${temp}" == x"Y" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+confirm_restart() {
+ confirm "Restart the panel, Attention: Restarting the panel will also restart xray" "y"
+ if [[ $? == 0 ]]; then
+ restart
+ else
+ show_menu
+ fi
+}
+
+before_show_menu() {
+ echo && echo -n -e "${yellow}Press enter to return to the main menu: ${plain}" && read temp
+ show_menu
+}
+
+install() {
+ bash <(curl -Ls https://raw.githubusercontent.com/4xmen/x-ui/main/install.sh)
+ if [[ $? == 0 ]]; then
+ if [[ $# == 0 ]]; then
+ start
+ else
+ start 0
+ fi
+ fi
+}
+
+update() {
+ confirm "This function will forcefully reinstall the latest version, and the data will not be lost. Do you want to continue?" "n"
+ if [[ $? != 0 ]]; then
+ LOGE "Cancelled"
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ return 0
+ fi
+ bash <(curl -Ls https://raw.githubusercontent.com/4xmen/x-ui/main/install.sh)
+ if [[ $? == 0 ]]; then
+ LOGI "Update is complete, Panel has automatically restarted "
+ exit 0
+ fi
+}
+
+uninstall() {
+ confirm "Are you sure you want to uninstall the panel? xray will also uninstalled!" "n"
+ if [[ $? != 0 ]]; then
+ if [[ $# == 0 ]]; then
+ show_menu
+ fi
+ return 0
+ fi
+ systemctl stop x-ui
+ systemctl disable x-ui
+ rm /etc/systemd/system/x-ui.service -f
+ systemctl daemon-reload
+ systemctl reset-failed
+ rm /etc/x-ui/ -rf
+ rm /usr/local/x-ui/ -rf
+
+ echo ""
+ echo -e "Uninstalled Successfully,If you want to remove this script,then after exiting the script run ${green}rm /usr/bin/x-ui -f${plain} to delete it."
+ echo ""
+
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+reset_user() {
+ confirm "Reset your username and password to admin?" "n"
+ if [[ $? != 0 ]]; then
+ if [[ $# == 0 ]]; then
+ show_menu
+ fi
+ return 0
+ fi
+ /usr/local/x-ui/x-ui setting -username admin -password admin
+ echo -e "Username and password have been reset to ${green}admin${plain},Please restart the panel now."
+ confirm_restart
+}
+
+reset_config() {
+ confirm "Are you sure you want to reset all panel settings,Account data will not be lost,Username and password will not change" "n"
+ if [[ $? != 0 ]]; then
+ if [[ $# == 0 ]]; then
+ show_menu
+ fi
+ return 0
+ fi
+ /usr/local/x-ui/x-ui setting -reset
+ echo -e "All panel settings have been reset to default,Please restart the panel now,and use the default ${green}54321${plain} Port to Access the web Panel"
+ confirm_restart
+}
+
+check_config() {
+ info=$(/usr/local/x-ui/x-ui setting -show true)
+ if [[ $? != 0 ]]; then
+ LOGE "get current settings error,please check logs"
+ show_menu
+ fi
+ LOGI "${info}"
+}
+
+set_port() {
+ echo && echo -n -e "Enter port number[1-65535]: " && read port
+ if [[ -z "${port}" ]]; then
+ LOGD "Cancelled"
+ before_show_menu
+ else
+ /usr/local/x-ui/x-ui setting -port ${port}
+ echo -e "The port is set,Please restart the panel now,and use the new port ${green}${port}${plain} to access web panel"
+ confirm_restart
+ fi
+}
+
+start() {
+ check_status
+ if [[ $? == 0 ]]; then
+ echo ""
+ LOGI "Panel is running,No need to start again,If you need to restart, please select restart"
+ else
+ systemctl start x-ui
+ sleep 2
+ check_status
+ if [[ $? == 0 ]]; then
+ LOGI "x-ui Started Successfully"
+ else
+ LOGE "panel Failed to start,Probably because it takes longer than two seconds to start,Please check the log information later"
+ fi
+ fi
+
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+stop() {
+ check_status
+ if [[ $? == 1 ]]; then
+ echo ""
+ LOGI "Panel stopped,No need to stop again!"
+ else
+ systemctl stop x-ui
+ sleep 2
+ check_status
+ if [[ $? == 1 ]]; then
+ LOGI "x-ui and xray stopped successfully"
+ else
+ LOGE "Panel stop failed,Probably because the stop time exceeds two seconds,Please check the log information later"
+ fi
+ fi
+
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+restart() {
+ systemctl restart x-ui
+ sleep 2
+ check_status
+ if [[ $? == 0 ]]; then
+ LOGI "x-ui and xray Restarted successfully"
+ else
+ LOGE "Panel restart failed,Probably because it takes longer than two seconds to start,Please check the log information later"
+ fi
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+status() {
+ systemctl status x-ui -l
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+enable() {
+ systemctl enable x-ui
+ if [[ $? == 0 ]]; then
+ LOGI "x-ui Set to boot automatically on startup successfully"
+ else
+ LOGE "x-ui Failed to set Autostart"
+ fi
+
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+disable() {
+ systemctl disable x-ui
+ if [[ $? == 0 ]]; then
+ LOGI "x-ui Autostart Cancelled successfully"
+ else
+ LOGE "x-ui Failed to cancel autostart"
+ fi
+
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+show_log() {
+ journalctl -u x-ui.service -e --no-pager -f
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+}
+
+migrate_v2_ui() {
+ /usr/local/x-ui/x-ui v2-ui
+
+ before_show_menu
+}
+
+install_bbr() {
+ # temporary workaround for installing bbr
+ bash <(curl -L -s https://raw.githubusercontent.com/teddysun/across/master/bbr.sh)
+ echo ""
+ before_show_menu
+}
+
+update_shell() {
+ wget -O /usr/bin/x-ui -N --no-check-certificate https://github.com/4xmen/x-ui/raw/main/x-ui.sh
+ if [[ $? != 0 ]]; then
+ echo ""
+ LOGE "Failed to download script,Please check whether the machine can connect Github"
+ before_show_menu
+ else
+ chmod +x /usr/bin/x-ui
+ LOGI "Upgrade script succeeded,Please rerun the script" && exit 0
+ fi
+}
+
+# 0: running, 1: not running, 2: not installed
+check_status() {
+ if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
+ return 2
+ fi
+ temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
+ if [[ x"${temp}" == x"running" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+check_enabled() {
+ temp=$(systemctl is-enabled x-ui)
+ if [[ x"${temp}" == x"enabled" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+check_uninstall() {
+ check_status
+ if [[ $? != 2 ]]; then
+ echo ""
+ LOGE "Panel installed,Please do not reinstall"
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ return 1
+ else
+ return 0
+ fi
+}
+
+check_install() {
+ check_status
+ if [[ $? == 2 ]]; then
+ echo ""
+ LOGE "Please install the panel first"
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ return 1
+ else
+ return 0
+ fi
+}
+
+show_status() {
+ check_status
+ case $? in
+ 0)
+ echo -e "Panel state: ${green}Runing${plain}"
+ show_enable_status
+ ;;
+ 1)
+ echo -e "Panel state: ${yellow}Not Running${plain}"
+ show_enable_status
+ ;;
+ 2)
+ echo -e "Panel state: ${red}Not Installed${plain}"
+ ;;
+ esac
+ show_xray_status
+}
+
+show_enable_status() {
+ check_enabled
+ if [[ $? == 0 ]]; then
+ echo -e "Start automatically: ${green}Yes${plain}"
+ else
+ echo -e "Start automatically: ${red}No${plain}"
+ fi
+}
+
+check_xray_status() {
+ count=$(ps -ef | grep "xray-linux" | grep -v "grep" | wc -l)
+ if [[ count -ne 0 ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+show_xray_status() {
+ check_xray_status
+ if [[ $? == 0 ]]; then
+ echo -e "xray state: ${green}Runing${plain}"
+ else
+ echo -e "xray state: ${red}Not Running${plain}"
+ fi
+}
+
+ssl_cert_issue() {
+ echo -E ""
+ LOGD "******Instructions for use******"
+ LOGI "This Acme script requires the following data:"
+ LOGI "1.Cloudflare Registered e-mail"
+ LOGI "2.Cloudflare Global API Key"
+ LOGI "3.The domain name that has been resolved dns to the current server by Cloudflare"
+ LOGI "4.The script applies for a certificate. The default installation path is /root/cert "
+ confirm "Confirmed?[y/n]" "y"
+ if [ $? -eq 0 ]; then
+ cd ~
+ LOGI "Install Acme-Script"
+ curl https://get.acme.sh | sh
+ if [ $? -ne 0 ]; then
+ LOGE "Failed to install acme script"
+ exit 1
+ fi
+ CF_Domain=""
+ CF_GlobalKey=""
+ CF_AccountEmail=""
+ certPath=/root/cert
+ if [ ! -d "$certPath" ]; then
+ mkdir $certPath
+ else
+ rm -rf $certPath
+ mkdir $certPath
+ fi
+ LOGD "Please set a domain name:"
+ read -p "Input your domain here:" CF_Domain
+ LOGD "Your domain name is set to:${CF_Domain}"
+ LOGD "Please set the API key:"
+ read -p "Input your key here:" CF_GlobalKey
+ LOGD "Your API key is:${CF_GlobalKey}"
+ LOGD "Please set up registered email:"
+ read -p "Input your email here:" CF_AccountEmail
+ LOGD "Your registered email address is:${CF_AccountEmail}"
+ ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
+ if [ $? -ne 0 ]; then
+ LOGE "Default CA, Lets'Encrypt fail, script exiting..."
+ exit 1
+ fi
+ export CF_Key="${CF_GlobalKey}"
+ export CF_Email=${CF_AccountEmail}
+ ~/.acme.sh/acme.sh --issue --dns dns_cf -d ${CF_Domain} -d *.${CF_Domain} --log
+ if [ $? -ne 0 ]; then
+ LOGE "Certificate issuance failed, script exiting..."
+ exit 1
+ else
+ LOGI "Certificate issued Successfully, Installing..."
+ fi
+ ~/.acme.sh/acme.sh --installcert -d ${CF_Domain} -d *.${CF_Domain} --ca-file /root/cert/ca.cer \
+ --cert-file /root/cert/${CF_Domain}.cer --key-file /root/cert/${CF_Domain}.key \
+ --fullchain-file /root/cert/fullchain.cer
+ if [ $? -ne 0 ]; then
+ LOGE "Certificate installation failed, script exiting..."
+ exit 1
+ else
+ LOGI "Certificate installed Successfully,Turning on automatic updates..."
+ fi
+ ~/.acme.sh/acme.sh --upgrade --auto-upgrade
+ if [ $? -ne 0 ]; then
+ LOGE "Auto update setup Failed, script exiting..."
+ ls -lah cert
+ chmod 755 $certPath
+ exit 1
+ else
+ LOGI "The certificate is installed and auto-renewal is turned on, Specific information is as follows"
+ ls -lah cert
+ chmod 755 $certPath
+ fi
+ else
+ show_menu
+ fi
+}
+
+show_usage() {
+ echo "x-ui control menu usages: "
+ echo "------------------------------------------"
+ echo "x-ui - Enter Admin menu"
+ echo "x-ui start - Start x-ui"
+ echo "x-ui stop - Stop x-ui"
+ echo "x-ui restart - Restart x-ui"
+ echo "x-ui status - Show x-ui status"
+ echo "x-ui enable - Enable x-ui on system startup"
+ echo "x-ui disable - Disable x-ui on system startup"
+ echo "x-ui log - Check x-ui logs"
+ echo "x-ui v2-ui - Migrate v2-ui Account data to x-ui"
+ echo "x-ui update - Update x-ui"
+ echo "x-ui install - Install x-ui"
+ echo "x-ui uninstall - Uninstall x-ui"
+ echo "------------------------------------------"
+}
+
+show_menu() {
+ echo -e "
+ ${green}x-ui Panel Management Script${plain}
+ ${green}0.${plain} exit script
+————————————————
+ ${green}1.${plain} Install x-ui
+ ${green}2.${plain} Update x-ui
+ ${green}3.${plain} Uninstall x-ui
+————————————————
+ ${green}4.${plain} Reset username and password
+ ${green}5.${plain} Reset panel settings
+ ${green}6.${plain} Set panel port
+ ${green}7.${plain} View current panel settings
+————————————————
+ ${green}8.${plain} Start x-ui
+ ${green}9.${plain} stop x-ui
+ ${green}10.${plain} Reboot x-ui
+ ${green}11.${plain} Check x-ui state
+ ${green}12.${plain} Check x-ui logs
+————————————————
+ ${green}13.${plain} set x-ui Autostart
+ ${green}14.${plain} Cancel x-ui Autostart
+————————————————
+ ${green}15.${plain} 一A key installation bbr (latest kernel)
+ ${green}16.${plain} 一Apply for an SSL certificate with one click(acme script)
+ "
+ show_status
+ echo && read -p "Please enter your selection [0-16]: " num
+
+ case "${num}" in
+ 0)
+ exit 0
+ ;;
+ 1)
+ check_uninstall && install
+ ;;
+ 2)
+ check_install && update
+ ;;
+ 3)
+ check_install && uninstall
+ ;;
+ 4)
+ check_install && reset_user
+ ;;
+ 5)
+ check_install && reset_config
+ ;;
+ 6)
+ check_install && set_port
+ ;;
+ 7)
+ check_install && check_config
+ ;;
+ 8)
+ check_install && start
+ ;;
+ 9)
+ check_install && stop
+ ;;
+ 10)
+ check_install && restart
+ ;;
+ 11)
+ check_install && status
+ ;;
+ 12)
+ check_install && show_log
+ ;;
+ 13)
+ check_install && enable
+ ;;
+ 14)
+ check_install && disable
+ ;;
+ 15)
+ install_bbr
+ ;;
+ 16)
+ ssl_cert_issue
+ ;;
+ *)
+ LOGE "Please enter the correct number [0-16]"
+ ;;
+ esac
+}
+
+if [[ $# > 0 ]]; then
+ case $1 in
+ "start")
+ check_install 0 && start 0
+ ;;
+ "stop")
+ check_install 0 && stop 0
+ ;;
+ "restart")
+ check_install 0 && restart 0
+ ;;
+ "status")
+ check_install 0 && status 0
+ ;;
+ "enable")
+ check_install 0 && enable 0
+ ;;
+ "disable")
+ check_install 0 && disable 0
+ ;;
+ "log")
+ check_install 0 && show_log 0
+ ;;
+ "v2-ui")
+ check_install 0 && migrate_v2_ui 0
+ ;;
+ "update")
+ check_install 0 && update 0
+ ;;
+ "install")
+ check_uninstall 0 && install 0
+ ;;
+ "uninstall")
+ check_install 0 && uninstall 0
+ ;;
+ *) show_usage ;;
+ esac
+else
+ show_menu
+fi
diff --git a/xray/client_traffic.go b/xray/client_traffic.go
new file mode 100644
index 0000000..4df6a50
--- /dev/null
+++ b/xray/client_traffic.go
@@ -0,0 +1,12 @@
+package xray
+
+type ClientTraffic struct {
+ Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
+ InboundId int `json:"inboundId" form:"inboundId"`
+ Enable bool `json:"enable" form:"enable"`
+ Email string `json:"email" form:"email" gorm:"unique"`
+ Up int64 `json:"up" form:"up"`
+ Down int64 `json:"down" form:"down"`
+ ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
+ Total int64 `json:"total" form:"total"`
+}
diff --git a/xray/config.go b/xray/config.go
new file mode 100644
index 0000000..cc63ca4
--- /dev/null
+++ b/xray/config.go
@@ -0,0 +1,62 @@
+package xray
+
+import (
+ "bytes"
+ "x-ui/util/json_util"
+)
+
+type Config struct {
+ LogConfig json_util.RawMessage `json:"log"`
+ RouterConfig json_util.RawMessage `json:"routing"`
+ DNSConfig json_util.RawMessage `json:"dns"`
+ InboundConfigs []InboundConfig `json:"inbounds"`
+ OutboundConfigs json_util.RawMessage `json:"outbounds"`
+ Transport json_util.RawMessage `json:"transport"`
+ Policy json_util.RawMessage `json:"policy"`
+ API json_util.RawMessage `json:"api"`
+ Stats json_util.RawMessage `json:"stats"`
+ Reverse json_util.RawMessage `json:"reverse"`
+ FakeDNS json_util.RawMessage `json:"fakeDns"`
+}
+
+func (c *Config) Equals(other *Config) bool {
+ if len(c.InboundConfigs) != len(other.InboundConfigs) {
+ return false
+ }
+ for i, inbound := range c.InboundConfigs {
+ if !inbound.Equals(&other.InboundConfigs[i]) {
+ return false
+ }
+ }
+ if !bytes.Equal(c.LogConfig, other.LogConfig) {
+ return false
+ }
+ if !bytes.Equal(c.RouterConfig, other.RouterConfig) {
+ return false
+ }
+ if !bytes.Equal(c.DNSConfig, other.DNSConfig) {
+ return false
+ }
+ if !bytes.Equal(c.OutboundConfigs, other.OutboundConfigs) {
+ return false
+ }
+ if !bytes.Equal(c.Transport, other.Transport) {
+ return false
+ }
+ if !bytes.Equal(c.Policy, other.Policy) {
+ return false
+ }
+ if !bytes.Equal(c.API, other.API) {
+ return false
+ }
+ if !bytes.Equal(c.Stats, other.Stats) {
+ return false
+ }
+ if !bytes.Equal(c.Reverse, other.Reverse) {
+ return false
+ }
+ if !bytes.Equal(c.FakeDNS, other.FakeDNS) {
+ return false
+ }
+ return true
+}
diff --git a/xray/inbound.go b/xray/inbound.go
new file mode 100644
index 0000000..461c2ee
--- /dev/null
+++ b/xray/inbound.go
@@ -0,0 +1,41 @@
+package xray
+
+import (
+ "bytes"
+ "x-ui/util/json_util"
+)
+
+type InboundConfig struct {
+ Listen json_util.RawMessage `json:"listen"` // listen 不能为空字符串
+ Port int `json:"port"`
+ Protocol string `json:"protocol"`
+ Settings json_util.RawMessage `json:"settings"`
+ StreamSettings json_util.RawMessage `json:"streamSettings"`
+ Tag string `json:"tag"`
+ Sniffing json_util.RawMessage `json:"sniffing"`
+}
+
+func (c *InboundConfig) Equals(other *InboundConfig) bool {
+ if !bytes.Equal(c.Listen, other.Listen) {
+ return false
+ }
+ if c.Port != other.Port {
+ return false
+ }
+ if c.Protocol != other.Protocol {
+ return false
+ }
+ if !bytes.Equal(c.Settings, other.Settings) {
+ return false
+ }
+ if !bytes.Equal(c.StreamSettings, other.StreamSettings) {
+ return false
+ }
+ if c.Tag != other.Tag {
+ return false
+ }
+ if !bytes.Equal(c.Sniffing, other.Sniffing) {
+ return false
+ }
+ return true
+}
diff --git a/xray/process.go b/xray/process.go
new file mode 100644
index 0000000..aa0040d
--- /dev/null
+++ b/xray/process.go
@@ -0,0 +1,313 @@
+package xray
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+ "x-ui/util/common"
+
+ "github.com/Workiva/go-datastructures/queue"
+ statsservice "github.com/xtls/xray-core/app/stats/command"
+ "google.golang.org/grpc"
+)
+
+var trafficRegex = regexp.MustCompile("(inbound|outbound)>>>([^>]+)>>>traffic>>>(downlink|uplink)")
+var ClientTrafficRegex = regexp.MustCompile("(user)>>>([^>]+)>>>traffic>>>(downlink|uplink)")
+
+func GetBinaryName() string {
+ return fmt.Sprintf("xray-%s-%s", runtime.GOOS, runtime.GOARCH)
+}
+
+func GetBinaryPath() string {
+ return "bin/" + GetBinaryName()
+}
+
+func GetConfigPath() string {
+ return "bin/config.json"
+}
+
+func GetGeositePath() string {
+ return "bin/geosite.dat"
+}
+
+func GetGeoipPath() string {
+ return "bin/geoip.dat"
+}
+
+func stopProcess(p *Process) {
+ p.Stop()
+}
+
+type Process struct {
+ *process
+}
+
+func NewProcess(xrayConfig *Config) *Process {
+ p := &Process{newProcess(xrayConfig)}
+ runtime.SetFinalizer(p, stopProcess)
+ return p
+}
+
+type process struct {
+ cmd *exec.Cmd
+
+ version string
+ apiPort int
+
+ config *Config
+ lines *queue.Queue
+ exitErr error
+}
+
+func newProcess(config *Config) *process {
+ return &process{
+ version: "Unknown",
+ config: config,
+ lines: queue.New(100),
+ }
+}
+
+func (p *process) IsRunning() bool {
+ if p.cmd == nil || p.cmd.Process == nil {
+ return false
+ }
+ if p.cmd.ProcessState == nil {
+ return true
+ }
+ return false
+}
+
+func (p *process) GetErr() error {
+ return p.exitErr
+}
+
+func (p *process) GetResult() string {
+ if p.lines.Empty() && p.exitErr != nil {
+ return p.exitErr.Error()
+ }
+ items, _ := p.lines.TakeUntil(func(item interface{}) bool {
+ return true
+ })
+ lines := make([]string, 0, len(items))
+ for _, item := range items {
+ lines = append(lines, item.(string))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (p *process) GetVersion() string {
+ return p.version
+}
+
+func (p *Process) GetAPIPort() int {
+ return p.apiPort
+}
+
+func (p *Process) GetConfig() *Config {
+ return p.config
+}
+
+func (p *process) refreshAPIPort() {
+ for _, inbound := range p.config.InboundConfigs {
+ if inbound.Tag == "api" {
+ p.apiPort = inbound.Port
+ break
+ }
+ }
+}
+
+func (p *process) refreshVersion() {
+ cmd := exec.Command(GetBinaryPath(), "-version")
+ data, err := cmd.Output()
+ if err != nil {
+ p.version = "Unknown"
+ } else {
+ datas := bytes.Split(data, []byte(" "))
+ if len(datas) <= 1 {
+ p.version = "Unknown"
+ } else {
+ p.version = string(datas[1])
+ }
+ }
+}
+
+func (p *process) Start() (err error) {
+ if p.IsRunning() {
+ return errors.New("xray is already running")
+ }
+
+ defer func() {
+ if err != nil {
+ p.exitErr = err
+ }
+ }()
+
+ data, err := json.MarshalIndent(p.config, "", " ")
+ if err != nil {
+ return common.NewErrorf("生成 xray 配置文件失败: %v", err)
+ }
+ configPath := GetConfigPath()
+ err = os.WriteFile(configPath, data, fs.ModePerm)
+ if err != nil {
+ return common.NewErrorf("写入配置文件失败: %v", err)
+ }
+
+ cmd := exec.Command(GetBinaryPath(), "-c", configPath, "-restrictedIPsPath", "./bin/blockedIPs")
+ p.cmd = cmd
+
+ stdReader, err := cmd.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ errReader, err := cmd.StderrPipe()
+ if err != nil {
+ return err
+ }
+
+ go func() {
+ defer func() {
+ common.Recover("")
+ stdReader.Close()
+ }()
+ reader := bufio.NewReaderSize(stdReader, 8192)
+ for {
+ line, _, err := reader.ReadLine()
+ if err != nil {
+ return
+ }
+ if p.lines.Len() >= 100 {
+ p.lines.Get(1)
+ }
+ p.lines.Put(string(line))
+ }
+ }()
+
+ go func() {
+ defer func() {
+ common.Recover("")
+ errReader.Close()
+ }()
+ reader := bufio.NewReaderSize(errReader, 8192)
+ for {
+ line, _, err := reader.ReadLine()
+ if err != nil {
+ return
+ }
+ if p.lines.Len() >= 100 {
+ p.lines.Get(1)
+ }
+ p.lines.Put(string(line))
+ }
+ }()
+
+ go func() {
+ err := cmd.Run()
+ if err != nil {
+ p.exitErr = err
+ }
+ }()
+
+ p.refreshVersion()
+ p.refreshAPIPort()
+
+ return nil
+}
+
+func (p *process) Stop() error {
+ if !p.IsRunning() {
+ return errors.New("xray is not running")
+ }
+ return p.cmd.Process.Kill()
+}
+
+func (p *process) GetTraffic(reset bool) ([]*Traffic, []*ClientTraffic, error) {
+ if p.apiPort == 0 {
+ return nil, nil, common.NewError("xray api port wrong:", p.apiPort)
+ }
+ conn, err := grpc.Dial(fmt.Sprintf("127.0.0.1:%v", p.apiPort), grpc.WithInsecure())
+ if err != nil {
+ return nil, nil, err
+ }
+ defer conn.Close()
+
+ client := statsservice.NewStatsServiceClient(conn)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+ defer cancel()
+ request := &statsservice.QueryStatsRequest{
+ Reset_: reset,
+ }
+ resp, err := client.QueryStats(ctx, request)
+ if err != nil {
+ return nil, nil, err
+ }
+ tagTrafficMap := map[string]*Traffic{}
+ emailTrafficMap := map[string]*ClientTraffic{}
+
+ clientTraffics := make([]*ClientTraffic, 0)
+ traffics := make([]*Traffic, 0)
+ for _, stat := range resp.GetStat() {
+ matchs := trafficRegex.FindStringSubmatch(stat.Name)
+ if len(matchs) < 3 {
+
+ matchs := ClientTrafficRegex.FindStringSubmatch(stat.Name)
+ if len(matchs) < 3 {
+ continue
+ }else {
+
+ isUser := matchs[1] == "user"
+ email := matchs[2]
+ isDown := matchs[3] == "downlink"
+ if ! isUser {
+ continue
+ }
+ traffic, ok := emailTrafficMap[email]
+ if !ok {
+ traffic = &ClientTraffic{
+ Email: email,
+ }
+ emailTrafficMap[email] = traffic
+ clientTraffics = append(clientTraffics, traffic)
+ }
+ if isDown {
+ traffic.Down = stat.Value
+ } else {
+ traffic.Up = stat.Value
+ }
+
+ }
+ continue
+ }
+ isInbound := matchs[1] == "inbound"
+ tag := matchs[2]
+ isDown := matchs[3] == "downlink"
+ if tag == "api" {
+ continue
+ }
+ traffic, ok := tagTrafficMap[tag]
+ if !ok {
+ traffic = &Traffic{
+ IsInbound: isInbound,
+ Tag: tag,
+ }
+ tagTrafficMap[tag] = traffic
+ traffics = append(traffics, traffic)
+ }
+ if isDown {
+ traffic.Down = stat.Value
+ } else {
+ traffic.Up = stat.Value
+ }
+ }
+
+ return traffics, clientTraffics, nil
+}
diff --git a/xray/traffic.go b/xray/traffic.go
new file mode 100644
index 0000000..a1ef518
--- /dev/null
+++ b/xray/traffic.go
@@ -0,0 +1,8 @@
+package xray
+
+type Traffic struct {
+ IsInbound bool
+ Tag string
+ Up int64
+ Down int64
+}