mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-10-26 18:14:50 +00:00
Compare commits
16 commits
25a72e3e76
...
0d49d45f20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d49d45f20 | ||
|
|
5408a2f82c | ||
|
|
c8d71ea748 | ||
|
|
46de886b53 | ||
|
|
6d41320ed7 | ||
|
|
bf9d2e6aeb | ||
|
|
ed96fa090b | ||
|
|
3ac1d7f546 | ||
|
|
10025ffa66 | ||
|
|
5e953bae45 | ||
|
|
747af376f2 | ||
|
|
a3ccccfe52 | ||
|
|
3299d15f28 | ||
|
|
ae82373457 | ||
|
|
d65233cc2c | ||
|
|
11dc06863e |
45 changed files with 1954 additions and 105 deletions
|
|
@ -1 +1 @@
|
|||
2.7.0
|
||||
2.8.0
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -113,3 +113,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"`
|
||||
}
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -15,6 +15,7 @@ require (
|
|||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.8
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.65.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.250911.0
|
||||
|
|
@ -62,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
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -142,6 +142,8 @@ github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1
|
|||
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg=
|
||||
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
|
||||
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
|
|
|||
|
|
@ -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
15
main.go
|
|
@ -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)
|
||||
|
|
|
|||
92
sub/sub.go
92
sub/sub.go
|
|
@ -3,14 +3,19 @@ package sub
|
|||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"x-ui/config"
|
||||
"x-ui/logger"
|
||||
"x-ui/util/common"
|
||||
webpkg "x-ui/web"
|
||||
"x-ui/web/locale"
|
||||
"x-ui/web/middleware"
|
||||
"x-ui/web/network"
|
||||
"x-ui/web/service"
|
||||
|
|
@ -18,6 +23,21 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// setEmbeddedTemplates parses and sets embedded templates on the engine
|
||||
func setEmbeddedTemplates(engine *gin.Engine) error {
|
||||
t, err := template.New("").Funcs(engine.FuncMap).ParseFS(
|
||||
webpkg.EmbeddedHTML(),
|
||||
"html/common/page.html",
|
||||
"html/component/aThemeSwitch.html",
|
||||
"html/subscription.html",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
engine.SetHTMLTemplate(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
|
|
@ -38,13 +58,10 @@ func NewServer() *Server {
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Always run in release mode for the subscription server
|
||||
gin.DefaultWriter = io.Discard
|
||||
gin.DefaultErrorWriter = io.Discard
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
engine := gin.Default()
|
||||
|
||||
|
|
@ -57,6 +74,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
|
||||
}
|
||||
|
||||
// Provide base_path in context for templates
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("base_path", "/")
|
||||
})
|
||||
|
||||
LinksPath, err := s.settingService.GetSubPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -112,6 +134,36 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
SubTitle = ""
|
||||
}
|
||||
|
||||
// set per-request localizer from headers/cookies
|
||||
engine.Use(locale.LocalizerMiddleware())
|
||||
|
||||
// register i18n function similar to web server
|
||||
i18nWebFunc := func(key string, params ...string) string {
|
||||
return locale.I18n(locale.Web, key, params...)
|
||||
}
|
||||
engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc})
|
||||
|
||||
// Templates: prefer embedded; fallback to disk if necessary
|
||||
if err := setEmbeddedTemplates(engine); err != nil {
|
||||
logger.Warning("sub: failed to parse embedded templates:", err)
|
||||
if files, derr := s.getHtmlFiles(); derr == nil {
|
||||
engine.LoadHTMLFiles(files...)
|
||||
} else {
|
||||
logger.Error("sub: no templates available (embedded parse and disk load failed)", err, derr)
|
||||
}
|
||||
}
|
||||
|
||||
// Assets: use disk if present, fallback to embedded
|
||||
if _, err := os.Stat("web/assets"); err == nil {
|
||||
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
|
||||
} else {
|
||||
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
|
||||
engine.StaticFS("/assets", http.FS(subFS))
|
||||
} else {
|
||||
logger.Error("sub: failed to mount embedded assets:", err)
|
||||
}
|
||||
}
|
||||
|
||||
g := engine.Group("/")
|
||||
|
||||
s.sub = NewSUBController(
|
||||
|
|
@ -121,6 +173,30 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
return engine, nil
|
||||
}
|
||||
|
||||
// getHtmlFiles loads templates from local folder (used in debug mode)
|
||||
func (s *Server) getHtmlFiles() ([]string, error) {
|
||||
dir, _ := os.Getwd()
|
||||
files := []string{}
|
||||
// common layout
|
||||
common := filepath.Join(dir, "web", "html", "common", "page.html")
|
||||
if _, err := os.Stat(common); err == nil {
|
||||
files = append(files, common)
|
||||
}
|
||||
// components used
|
||||
theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
|
||||
if _, err := os.Stat(theme); err == nil {
|
||||
files = append(files, theme)
|
||||
}
|
||||
// page itself
|
||||
page := filepath.Join(dir, "web", "html", "subscription.html")
|
||||
if _, err := os.Stat(page); err == nil {
|
||||
files = append(files, page)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *Server) Start() (err error) {
|
||||
// This is an anonymous function, no function name
|
||||
defer func() {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package sub
|
|||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"strings"
|
||||
"x-ui/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -58,21 +58,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
|||
|
||||
func (a *SUBController) subs(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
var host string
|
||||
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host == "" {
|
||||
host = c.GetHeader("X-Real-IP")
|
||||
}
|
||||
if host == "" {
|
||||
var err error
|
||||
host, _, err = net.SplitHostPort(c.Request.Host)
|
||||
if err != nil {
|
||||
host = c.Request.Host
|
||||
}
|
||||
}
|
||||
subs, header, err := a.subService.GetSubs(subId, host)
|
||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||
subs, header, lastOnline, err := a.subService.GetSubs(subId, host)
|
||||
if err != nil || len(subs) == 0 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
|
|
@ -81,10 +68,38 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
result += sub + "\n"
|
||||
}
|
||||
|
||||
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
|
||||
accept := c.GetHeader("Accept")
|
||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||
// Build page data in service
|
||||
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
||||
page := a.subService.BuildPageData(subId, hostHeader, header, lastOnline, subs, subURL, subJsonURL)
|
||||
c.HTML(200, "subscription.html", gin.H{
|
||||
"title": "subscription.title",
|
||||
"cur_ver": config.GetVersion(),
|
||||
"host": page.Host,
|
||||
"base_path": page.BasePath,
|
||||
"sId": page.SId,
|
||||
"download": page.Download,
|
||||
"upload": page.Upload,
|
||||
"total": page.Total,
|
||||
"used": page.Used,
|
||||
"remained": page.Remained,
|
||||
"expire": page.Expire,
|
||||
"lastOnline": page.LastOnline,
|
||||
"datepicker": page.Datepicker,
|
||||
"downloadByte": page.DownloadByte,
|
||||
"uploadByte": page.UploadByte,
|
||||
"totalByte": page.TotalByte,
|
||||
"subUrl": page.SubUrl,
|
||||
"subJsonUrl": page.SubJsonUrl,
|
||||
"result": page.Result,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Add headers
|
||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||
c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
|
||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
||||
|
||||
if a.subEncrypt {
|
||||
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
||||
|
|
@ -96,41 +111,21 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
|
||||
func (a *SUBController) subJsons(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
var host string
|
||||
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host == "" {
|
||||
host = c.GetHeader("X-Real-IP")
|
||||
}
|
||||
if host == "" {
|
||||
var err error
|
||||
host, _, err = net.SplitHostPort(c.Request.Host)
|
||||
if err != nil {
|
||||
host = c.Request.Host
|
||||
}
|
||||
}
|
||||
_, host, _, _ := a.subService.ResolveRequest(c)
|
||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||
if err != nil || len(jsonSub) == 0 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
|
||||
// Add headers
|
||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||
c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
|
||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
||||
|
||||
c.String(200, jsonSub)
|
||||
}
|
||||
}
|
||||
|
||||
func getHostFromXFH(s string) (string, error) {
|
||||
if strings.Contains(s, ":") {
|
||||
realHost, _, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return realHost, nil
|
||||
}
|
||||
return s, nil
|
||||
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
|
||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@ package sub
|
|||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"x-ui/database"
|
||||
"x-ui/database/model"
|
||||
"x-ui/logger"
|
||||
|
|
@ -14,8 +19,6 @@ import (
|
|||
"x-ui/util/random"
|
||||
"x-ui/web/service"
|
||||
"x-ui/xray"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
type SubService struct {
|
||||
|
|
@ -34,19 +37,20 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *SubService) GetSubs(subId string, host string) ([]string, string, error) {
|
||||
func (s *SubService) GetSubs(subId string, host string) ([]string, string, int64, error) {
|
||||
s.address = host
|
||||
var result []string
|
||||
var header string
|
||||
var traffic xray.ClientTraffic
|
||||
var lastOnline int64
|
||||
var clientTraffics []xray.ClientTraffic
|
||||
inbounds, err := s.getInboundsBySubId(subId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", 0, err
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
return nil, "", common.NewError("No inbounds found with ", subId)
|
||||
return nil, "", 0, common.NewError("No inbounds found with ", subId)
|
||||
}
|
||||
|
||||
s.datepicker, err = s.settingService.GetDatepicker()
|
||||
|
|
@ -73,7 +77,11 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
|
|||
if client.Enable && client.SubID == subId {
|
||||
link := s.getLink(inbound, client.Email)
|
||||
result = append(result, link)
|
||||
clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email))
|
||||
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
|
||||
clientTraffics = append(clientTraffics, ct)
|
||||
if ct.LastOnline > lastOnline {
|
||||
lastOnline = ct.LastOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -101,7 +109,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
|
|||
}
|
||||
}
|
||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
return result, header, nil
|
||||
return result, header, lastOnline, nil
|
||||
}
|
||||
|
||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||
|
|
@ -154,26 +162,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
|||
}
|
||||
|
||||
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
return s.genVmessLink(inbound, email)
|
||||
case "vless":
|
||||
return s.genVlessLink(inbound, email)
|
||||
case "trojan":
|
||||
return s.genTrojanLink(inbound, email)
|
||||
case "shadowsocks":
|
||||
return s.genShadowsocksLink(inbound, email)
|
||||
serverService := service.MultiServerService{}
|
||||
servers, err := serverService.GetServers()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get servers for subscription:", err)
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
|
||||
var links []string
|
||||
for _, server := range servers {
|
||||
if !server.Enable {
|
||||
continue
|
||||
}
|
||||
var link string
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
link = s.genVmessLink(inbound, email, server)
|
||||
case "vless":
|
||||
link = s.genVlessLink(inbound, email, server)
|
||||
case "trojan":
|
||||
link = s.genTrojanLink(inbound, email, server)
|
||||
case "shadowsocks":
|
||||
link = s.genShadowsocksLink(inbound, email, server)
|
||||
}
|
||||
if link != "" {
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
|
||||
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
func (s *SubService) genVmessLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||
if inbound.Protocol != model.VMESS {
|
||||
return ""
|
||||
}
|
||||
obj := map[string]any{
|
||||
"v": "2",
|
||||
"add": s.address,
|
||||
"add": server.Address,
|
||||
"port": inbound.Port,
|
||||
"type": "none",
|
||||
}
|
||||
|
|
@ -286,7 +311,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
|||
newObj[key] = value
|
||||
}
|
||||
}
|
||||
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string))
|
||||
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
|
||||
newObj["add"] = ep["dest"].(string)
|
||||
newObj["port"] = int(ep["port"].(float64))
|
||||
|
||||
|
|
@ -302,14 +327,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
|||
return links
|
||||
}
|
||||
|
||||
obj["ps"] = s.genRemark(inbound, email, "")
|
||||
obj["ps"] = s.genRemark(inbound, email, "", server.Name)
|
||||
|
||||
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
||||
}
|
||||
|
||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||
address := server.Address
|
||||
if inbound.Protocol != model.VLESS {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -488,7 +513,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"
|
||||
|
|
@ -509,12 +534,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 ""
|
||||
}
|
||||
|
|
@ -683,7 +708,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"
|
||||
|
|
@ -705,12 +730,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 ""
|
||||
}
|
||||
|
|
@ -850,7 +875,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"
|
||||
|
|
@ -871,17 +896,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
|
||||
|
|
@ -892,6 +918,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++ {
|
||||
|
|
@ -1001,3 +1030,172 @@ func searchHost(headers any) string {
|
|||
|
||||
return ""
|
||||
}
|
||||
|
||||
// PageData is a view model for subscription.html
|
||||
type PageData struct {
|
||||
Host string
|
||||
BasePath string
|
||||
SId string
|
||||
Download string
|
||||
Upload string
|
||||
Total string
|
||||
Used string
|
||||
Remained string
|
||||
Expire int64
|
||||
LastOnline int64
|
||||
Datepicker string
|
||||
DownloadByte int64
|
||||
UploadByte int64
|
||||
TotalByte int64
|
||||
SubUrl string
|
||||
SubJsonUrl string
|
||||
Result []string
|
||||
}
|
||||
|
||||
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
||||
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
|
||||
// scheme
|
||||
scheme = "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// base host (no port)
|
||||
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
|
||||
host = h
|
||||
}
|
||||
if host == "" {
|
||||
host = c.GetHeader("X-Real-IP")
|
||||
}
|
||||
if host == "" {
|
||||
var err error
|
||||
host, _, err = net.SplitHostPort(c.Request.Host)
|
||||
if err != nil {
|
||||
host = c.Request.Host
|
||||
}
|
||||
}
|
||||
|
||||
// host:port for URLs
|
||||
hostWithPort = c.GetHeader("X-Forwarded-Host")
|
||||
if hostWithPort == "" {
|
||||
hostWithPort = c.Request.Host
|
||||
}
|
||||
if hostWithPort == "" {
|
||||
hostWithPort = host
|
||||
}
|
||||
|
||||
// header display host
|
||||
hostHeader = c.GetHeader("X-Forwarded-Host")
|
||||
if hostHeader == "" {
|
||||
hostHeader = c.GetHeader("X-Real-IP")
|
||||
}
|
||||
if hostHeader == "" {
|
||||
hostHeader = host
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// BuildURLs constructs absolute subscription and json URLs.
|
||||
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
|
||||
if strings.HasSuffix(subPath, "/") {
|
||||
subURL = scheme + "://" + hostWithPort + subPath + subId
|
||||
} else {
|
||||
subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId
|
||||
}
|
||||
if strings.HasSuffix(subJsonPath, "/") {
|
||||
subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId
|
||||
} else {
|
||||
subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// BuildPageData parses header and prepares the template view model.
|
||||
func (s *SubService) BuildPageData(subId, hostHeader, header string, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
|
||||
// Parse header values
|
||||
var uploadByte, downloadByte, totalByte, expire int64
|
||||
parts := strings.Split(header, ";")
|
||||
for _, p := range parts {
|
||||
kv := strings.Split(strings.TrimSpace(p), "=")
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(kv[0]))
|
||||
val := strings.TrimSpace(kv[1])
|
||||
switch key {
|
||||
case "upload":
|
||||
if v, err := parseInt64(val); err == nil {
|
||||
uploadByte = v
|
||||
}
|
||||
case "download":
|
||||
if v, err := parseInt64(val); err == nil {
|
||||
downloadByte = v
|
||||
}
|
||||
case "total":
|
||||
if v, err := parseInt64(val); err == nil {
|
||||
totalByte = v
|
||||
}
|
||||
case "expire":
|
||||
if v, err := parseInt64(val); err == nil {
|
||||
expire = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
download := common.FormatTraffic(downloadByte)
|
||||
upload := common.FormatTraffic(uploadByte)
|
||||
total := "∞"
|
||||
used := common.FormatTraffic(uploadByte + downloadByte)
|
||||
remained := ""
|
||||
if totalByte > 0 {
|
||||
total = common.FormatTraffic(totalByte)
|
||||
left := totalByte - (uploadByte + downloadByte)
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
remained = common.FormatTraffic(left)
|
||||
}
|
||||
|
||||
datepicker := s.datepicker
|
||||
if datepicker == "" {
|
||||
datepicker = "gregorian"
|
||||
}
|
||||
|
||||
return PageData{
|
||||
Host: hostHeader,
|
||||
BasePath: "/",
|
||||
SId: subId,
|
||||
Download: download,
|
||||
Upload: upload,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Remained: remained,
|
||||
Expire: expire,
|
||||
LastOnline: lastOnline,
|
||||
Datepicker: datepicker,
|
||||
DownloadByte: downloadByte,
|
||||
UploadByte: uploadByte,
|
||||
TotalByte: totalByte,
|
||||
SubUrl: subURL,
|
||||
SubJsonUrl: subJsonURL,
|
||||
Result: subs,
|
||||
}
|
||||
}
|
||||
|
||||
func getHostFromXFH(s string) (string, error) {
|
||||
if strings.Contains(s, ":") {
|
||||
realHost, _, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return realHost, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseInt64(s string) (int64, error) {
|
||||
// handle potential quotes
|
||||
s = strings.Trim(s, "\"'")
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
return n, err
|
||||
}
|
||||
|
|
|
|||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
125
web/assets/js/subscription.js
Normal file
125
web/assets/js/subscription.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
(function () {
|
||||
// Vue app for Subscription page
|
||||
const el = document.getElementById('subscription-data');
|
||||
if (!el) return;
|
||||
const textarea = document.getElementById('subscription-links');
|
||||
const rawLinks = (textarea?.value || '').split('\n').filter(Boolean);
|
||||
|
||||
const data = {
|
||||
sId: el.getAttribute('data-sid') || '',
|
||||
subUrl: el.getAttribute('data-sub-url') || '',
|
||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||
download: el.getAttribute('data-download') || '',
|
||||
upload: el.getAttribute('data-upload') || '',
|
||||
used: el.getAttribute('data-used') || '',
|
||||
total: el.getAttribute('data-total') || '',
|
||||
remained: el.getAttribute('data-remained') || '',
|
||||
expireMs: (parseInt(el.getAttribute('data-expire') || '0', 10) || 0) * 1000,
|
||||
lastOnlineMs: (parseInt(el.getAttribute('data-lastonline') || '0', 10) || 0),
|
||||
downloadByte: parseInt(el.getAttribute('data-downloadbyte') || '0', 10) || 0,
|
||||
uploadByte: parseInt(el.getAttribute('data-uploadbyte') || '0', 10) || 0,
|
||||
totalByte: parseInt(el.getAttribute('data-totalbyte') || '0', 10) || 0,
|
||||
datepicker: el.getAttribute('data-datepicker') || 'gregorian',
|
||||
};
|
||||
|
||||
// Normalize lastOnline to milliseconds if it looks like seconds
|
||||
if (data.lastOnlineMs && data.lastOnlineMs < 10_000_000_000) {
|
||||
data.lastOnlineMs *= 1000;
|
||||
}
|
||||
|
||||
function renderLink(item) {
|
||||
return (
|
||||
Vue.h('a-list-item', {}, [
|
||||
Vue.h('a-space', { props: { size: 'small' } }, [
|
||||
Vue.h('a-button', { props: { size: 'small' }, on: { click: () => copy(item) } }, [Vue.h('a-icon', { props: { type: 'copy' } })]),
|
||||
Vue.h('span', { class: 'break-all' }, item)
|
||||
])
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
ClipboardManager.copyText(text).then(ok => {
|
||||
const messageType = ok ? 'success' : 'error';
|
||||
Vue.prototype.$message[messageType](ok ? 'Copied' : 'Copy failed');
|
||||
});
|
||||
}
|
||||
|
||||
function open(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function drawQR(value) {
|
||||
try { new QRious({ element: document.getElementById('qrcode'), value, size: 220 }); } catch (e) { console.warn(e); }
|
||||
}
|
||||
|
||||
// Try to extract a human label (email/ps) from different link types
|
||||
function linkName(link, idx) {
|
||||
try {
|
||||
if (link.startsWith('vmess://')) {
|
||||
const json = JSON.parse(atob(link.replace('vmess://', '')));
|
||||
if (json.ps) return json.ps;
|
||||
if (json.add && json.id) return json.add; // fallback host
|
||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||
// vless://<id>@host:port?...#name
|
||||
const hashIdx = link.indexOf('#');
|
||||
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
||||
// email sometimes in query params like sni or remark
|
||||
const qIdx = link.indexOf('?');
|
||||
if (qIdx !== -1) {
|
||||
const qs = new URL('http://x/?' + link.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
|
||||
if (qs.get('remark')) return qs.get('remark');
|
||||
if (qs.get('email')) return qs.get('email');
|
||||
}
|
||||
// else take user@host
|
||||
const at = link.indexOf('@');
|
||||
const protSep = link.indexOf('://');
|
||||
if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
|
||||
} else if (link.startsWith('ss://')) {
|
||||
// shadowsocks: label often after #
|
||||
const hashIdx = link.indexOf('#');
|
||||
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
||||
}
|
||||
} catch (e) { /* ignore and fallback */ }
|
||||
return 'Link ' + (idx + 1);
|
||||
}
|
||||
|
||||
const app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#app',
|
||||
data: {
|
||||
themeSwitcher,
|
||||
app: data,
|
||||
links: rawLinks,
|
||||
lang: '',
|
||||
viewportWidth: (typeof window !== 'undefined' ? window.innerWidth : 1024),
|
||||
},
|
||||
async mounted() {
|
||||
this.lang = LanguageManager.getLanguage();
|
||||
// Discover subJsonUrl if provided via template bootstrap
|
||||
const tpl = document.getElementById('subscription-data');
|
||||
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||
if (sj) this.app.subJsonUrl = sj;
|
||||
drawQR(this.app.subUrl);
|
||||
// Draw second QR if available
|
||||
try { new QRious({ element: document.getElementById('qrcode-subjson'), value: this.app.subJsonUrl || '', size: 220 }); } catch (e) { /* ignore */ }
|
||||
// Track viewport width for responsive behavior
|
||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||
window.addEventListener('resize', this._onResize);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||
},
|
||||
computed: {
|
||||
isMobile() { return this.viewportWidth < 576; },
|
||||
isUnlimited() { return !this.app.totalByte; },
|
||||
isActive() {
|
||||
const now = Date.now();
|
||||
const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
|
||||
const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
|
||||
return expiryOk && trafficOk;
|
||||
},
|
||||
},
|
||||
methods: { renderLink, copy, open, linkName, i18nLabel(key) { return '{{ i18n "' + key + '" }}'; } },
|
||||
});
|
||||
})();
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"x-ui/database/model"
|
||||
"x-ui/web/middleware"
|
||||
"x-ui/web/service"
|
||||
"x-ui/web/session"
|
||||
|
||||
|
|
|
|||
89
web/controller/multi_server_controller.go
Normal file
89
web/controller/multi_server_controller.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</template> Source IPs <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.sourceIP"></a-input>
|
||||
<a-input v-model.trim="ruleModal.rule.sourceIP" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
|
|
@ -19,7 +19,17 @@
|
|||
</template> Source Port <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.sourcePort"></a-input>
|
||||
<a-input v-model.trim="ruleModal.rule.sourcePort" placeholder="e.g. 53,443,1000-2000"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.rules.useComma" }}</span>
|
||||
</template> VLESS Route <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.vlessRoute" placeholder="e.g. 53,443,1000-2000"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Network'>
|
||||
<a-select v-model="ruleModal.rule.network" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
|
|
@ -52,7 +62,7 @@
|
|||
</template> IP <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.ip"></a-input>
|
||||
<a-input v-model.trim="ruleModal.rule.ip" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
|
|
@ -62,7 +72,7 @@
|
|||
</template> Domain <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.domain"></a-input>
|
||||
<a-input v-model.trim="ruleModal.rule.domain" placeholder="e.g. google.com, geosite:cn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
|
|
@ -72,7 +82,7 @@
|
|||
</template> User <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.user"></a-input>
|
||||
<a-input v-model.trim="ruleModal.rule.user" placeholder="e.g. email address"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
|
|
@ -82,7 +92,7 @@
|
|||
</template> Port <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="ruleModal.rule.port"></a-input>
|
||||
<a-input v-model.trim="ruleModal.rule.port" placeholder="e.g. 53,443,1000-2000"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Inbound Tags'>
|
||||
<a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
|
|
@ -122,6 +132,7 @@
|
|||
ip: "",
|
||||
port: "",
|
||||
sourcePort: "",
|
||||
vlessRoute: "",
|
||||
network: "",
|
||||
sourceIP: "",
|
||||
user: "",
|
||||
|
|
@ -155,6 +166,7 @@
|
|||
this.rule.ip = rule.ip ? rule.ip.join(',') : [];
|
||||
this.rule.port = rule.port;
|
||||
this.rule.sourcePort = rule.sourcePort;
|
||||
this.rule.vlessRoute = rule.vlessRoute;
|
||||
this.rule.network = rule.network;
|
||||
this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : [];
|
||||
this.rule.user = rule.user ? rule.user.join(',') : [];
|
||||
|
|
@ -169,6 +181,7 @@
|
|||
ip: "",
|
||||
port: "",
|
||||
sourcePort: "",
|
||||
vlessRoute: "",
|
||||
network: "",
|
||||
sourceIP: "",
|
||||
user: "",
|
||||
|
|
@ -210,6 +223,7 @@
|
|||
rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
|
||||
rule.port = value.port;
|
||||
rule.sourcePort = value.sourcePort;
|
||||
rule.vlessRoute = value.vlessRoute;
|
||||
rule.network = value.network;
|
||||
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
|
||||
rule.user = value.user.length > 0 ? value.user.split(',') : [];
|
||||
|
|
|
|||
165
web/html/servers.html
Normal file
165
web/html/servers.html
Normal 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">×</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" .}}
|
||||
|
|
@ -67,18 +67,22 @@
|
|||
</template>
|
||||
<template slot="info" slot-scope="text, rule, index">
|
||||
<a-popover placement="bottomRight"
|
||||
v-if="(rule.source+rule.sourcePort+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
||||
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
||||
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
||||
<template slot="content">
|
||||
<table cellpadding="2" :style="{ maxWidth: '300px' }">
|
||||
<tr v-if="rule.source">
|
||||
<td>Source</td>
|
||||
<td><a-tag color="blue" v-for="r in rule.source.split(',')">[[ r ]]</a-tag></td>
|
||||
<tr v-if="rule.sourceIP">
|
||||
<td>Source IP</td>
|
||||
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.sourcePort">
|
||||
<td>Source Port</td>
|
||||
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.vlessRoute">
|
||||
<td>VLESS Route</td>
|
||||
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.network">
|
||||
<td>Network</td>
|
||||
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
|
||||
|
|
|
|||
275
web/html/subscription.html
Normal file
275
web/html/subscription.html
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
{{ template "page/head_start" .}}
|
||||
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/util/date-util.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||
{{ template "page/head_end" .}}
|
||||
|
||||
{{ template "page/body_start" .}}
|
||||
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
|
||||
<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">
|
||||
<a-card hoverable class="subscription-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>{{ i18n "subscription.title" }}</span>
|
||||
<a-tag>{{ .sId }}</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-popover
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
title='{{ i18n "menu.settings" }}'
|
||||
placement="bottomRight" trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10">
|
||||
<a-theme-switch-login></a-theme-switch-login>
|
||||
<span>{{ i18n "pages.settings.language"
|
||||
}}</span>
|
||||
<a-select ref="selectLang" class="w-100"
|
||||
v-model="lang"
|
||||
@change="LanguageManager.setLanguage(lang)"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="l.value"
|
||||
label="English"
|
||||
v-for="l in LanguageManager.supportedLanguages"
|
||||
:key="l.value">
|
||||
<span role="img"
|
||||
:aria-label="l.name"
|
||||
v-text="l.icon"></span>
|
||||
<span
|
||||
v-text="l.name"></span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-button shape="circle" icon="setting"></a-button>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item>
|
||||
<a-space direction="vertical" align="center">
|
||||
<a-row type="flex" :gutter="[8,8]"
|
||||
justify="center" style="width:100%">
|
||||
<a-col :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple"
|
||||
class="qr-tag">
|
||||
<span>{{ i18n
|
||||
"pages.settings.subSettings"}}</span>
|
||||
</a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner
|
||||
class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode"
|
||||
class="qr-cv"
|
||||
title='{{ i18n "copy" }}'
|
||||
@click="copy(app.subUrl)"></canvas>
|
||||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple"
|
||||
class="qr-tag">
|
||||
<span>{{ i18n
|
||||
"pages.settings.subSettings"}}
|
||||
Json</span>
|
||||
</a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner
|
||||
class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode-subjson"
|
||||
class="qr-cv"
|
||||
title='{{ i18n "copy" }}'
|
||||
@click="copy(app.subJsonUrl)"></canvas>
|
||||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-descriptions bordered :column="1" size="small">
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.subId" }}'>[[
|
||||
app.sId
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.status" }}'>
|
||||
<template v-if="isUnlimited">
|
||||
<a-tag color="purple">{{ i18n
|
||||
"subscription.unlimited" }}</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag
|
||||
:color="isActive ? 'green' : 'red'">[[
|
||||
isActive ? '{{ i18n
|
||||
"subscription.active" }}' : '{{ i18n
|
||||
"subscription.inactive" }}'
|
||||
]]</a-tag>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.downloaded" }}'>[[
|
||||
app.download
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.uploaded" }}'>[[
|
||||
app.upload
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "usage" }}'>[[ app.used
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.totalQuota" }}'>[[
|
||||
app.total
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item v-if="app.totalByte > 0"
|
||||
label='{{ i18n "remained" }}'>[[
|
||||
app.remained ]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "lastOnline" }}'>
|
||||
<template v-if="app.lastOnlineMs > 0">
|
||||
<template
|
||||
v-if="app.datepicker === 'gregorian'">
|
||||
[[
|
||||
DateUtil.formatMillis(app.lastOnlineMs)
|
||||
]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[
|
||||
DateUtil.convertToJalalian(moment(app.lastOnlineMs))
|
||||
]]
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>-</span>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.expiry" }}'>
|
||||
<template v-if="app.expireMs === 0">
|
||||
{{ i18n "subscription.noExpiry" }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-if="app.datepicker === 'gregorian'">
|
||||
[[
|
||||
DateUtil.formatMillis(app.expireMs)
|
||||
]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[
|
||||
DateUtil.convertToJalalian(moment(app.expireMs))
|
||||
]]
|
||||
</template>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<br />
|
||||
<a-list bordered>
|
||||
<a-list-item v-for="(link, idx) in links" :key="link">
|
||||
<div style="width:100%; text-align:center;">
|
||||
<a-button type="primary" :block="isMobile"
|
||||
@click="copy(link)">[[ linkName(link, idx)
|
||||
]]</a-button>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
<br />
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item>
|
||||
<a-row type="flex" justify="center" :gutter="[8,8]"
|
||||
style="width:100%">
|
||||
<a-col :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<!-- Android dropdown -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button :block="isMobile"
|
||||
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
||||
size="large" type="primary">
|
||||
Android <a-icon type="down" />
|
||||
</a-button>
|
||||
<a-menu slot="overlay"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-menu-item key="android-v2box"
|
||||
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
|
||||
<a-menu-item key="android-v2rayng"
|
||||
@click="open('v2rayng://install-config?url=' + encodeURIComponent(app.subUrl))">V2RayNG</a-menu-item>
|
||||
<a-menu-item key="android-singbox"
|
||||
@click="copy(app.subUrl)">Sing-box</a-menu-item>
|
||||
<a-menu-item key="android-v2raytun"
|
||||
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
|
||||
<a-menu-item key="android-npvtunnel"
|
||||
@click="copy(app.subUrl)">NPV
|
||||
Tunnel</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<!-- iOS dropdown -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button :block="isMobile"
|
||||
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
||||
size="large" type="primary">
|
||||
iOS <a-icon type="down" />
|
||||
</a-button>
|
||||
<a-menu slot="overlay"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-menu-item key="ios-shadowrocket"
|
||||
@click="open('shadowrocket://add/subscription?url=' + encodeURIComponent(app.subUrl) + '&remark=' + encodeURIComponent(app.sId))">Shadowrocket</a-menu-item>
|
||||
<a-menu-item key="ios-v2box"
|
||||
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
|
||||
<a-menu-item key="ios-streisand"
|
||||
@click="open('streisand://import/' + encodeURIComponent(app.subUrl))">Streisand</a-menu-item>
|
||||
<a-menu-item key="ios-v2raytun"
|
||||
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
|
||||
<a-menu-item key="ios-npvtunnel"
|
||||
@click="copy(app.subUrl)">NPV
|
||||
Tunnel</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<!-- Bootstrap data for external JS -->
|
||||
<template id="subscription-data" data-sid="{{ .sId }}"
|
||||
data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
||||
data-download="{{ .download }}"
|
||||
data-upload="{{ .upload }}" data-used="{{ .used }}"
|
||||
data-total="{{ .total }}" data-remained="{{ .remained }}"
|
||||
data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||
data-downloadbyte="{{ .downloadByte }}"
|
||||
data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||
data-datepicker="{{ .datepicker }}"></template>
|
||||
<textarea id="subscription-links"
|
||||
style="display:none">{{ range .result }}{{ . }}
|
||||
{{ end }}</textarea>
|
||||
|
||||
{{template "component/aThemeSwitch" .}}
|
||||
<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
|
||||
|
||||
{{ template "page/body_end" .}}
|
||||
|
|
@ -146,8 +146,9 @@
|
|||
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
||||
{
|
||||
title: '{{ i18n "pages.xray.rules.source"}}', children: [
|
||||
{ title: 'IP', dataIndex: "source", align: 'center', width: 20, ellipsis: true },
|
||||
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true }]
|
||||
{ title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
|
||||
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true },
|
||||
{ title: 'VLESS Route', dataIndex: 'vlessRoute', align: 'center', width: 15, ellipsis: true }]
|
||||
},
|
||||
{
|
||||
title: '{{ i18n "pages.inbounds.network"}}', children: [
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package locale
|
|||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"x-ui/logger"
|
||||
|
|
@ -78,6 +79,11 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
|||
|
||||
templateData := createTemplateData(params)
|
||||
|
||||
if localizer == nil {
|
||||
// Fallback to key if localizer not ready; prevents nil panic on pages like sub
|
||||
return key
|
||||
}
|
||||
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateData,
|
||||
|
|
@ -102,6 +108,15 @@ func initTGBotLocalizer(settingService SettingService) error {
|
|||
|
||||
func LocalizerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Ensure bundle is initialized so creating a Localizer won't panic
|
||||
if i18nBundle == nil {
|
||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
// Try lazy-load from disk when running sub server without InitLocalizer
|
||||
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
|
||||
logger.Warning("i18n lazy load failed:", err)
|
||||
}
|
||||
}
|
||||
var lang string
|
||||
|
||||
if cookie, err := c.Request.Cookie("lang"); err == nil {
|
||||
|
|
@ -118,6 +133,25 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// loadTranslationsFromDisk attempts to load translation files from "web/translation" using the local filesystem.
|
||||
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
|
||||
root := os.DirFS("web")
|
||||
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := fs.ReadFile(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = bundle.ParseMessageFileBytes(data, path)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
||||
err := fs.WalkDir(i18nFS, "translation",
|
||||
func(path string, d fs.DirEntry, err error) error {
|
||||
|
|
|
|||
34
web/middleware/auth.go
Normal file
34
web/middleware/auth.go
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -606,6 +609,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,6 +702,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
|||
s.xrayApi.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/%d/delClient/%s", inboundId, clientId), nil)
|
||||
}
|
||||
|
||||
return needRestart, db.Save(oldInbound).Error
|
||||
}
|
||||
|
||||
|
|
@ -868,6 +881,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
|
||||
}
|
||||
|
||||
|
|
@ -2247,6 +2266,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 +2395,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
|
|||
}
|
||||
|
||||
return needRestart, db.Save(oldInbound).Error
|
||||
|
||||
}
|
||||
|
|
|
|||
72
web/service/inbound_service_sync_test.go
Normal file
72
web/service/inbound_service_sync_test.go
Normal 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)
|
||||
}
|
||||
37
web/service/multi_server_service.go
Normal file
37
web/service/multi_server_service.go
Normal 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
|
||||
}
|
||||
63
web/service/multi_server_service_test.go
Normal file
63
web/service/multi_server_service_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import (
|
|||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
|
|
@ -29,6 +31,7 @@ import (
|
|||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
)
|
||||
|
|
@ -1355,6 +1358,73 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||
case "client_commands":
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands"))
|
||||
case "client_sub_links":
|
||||
// show user's own clients to choose one for sub links
|
||||
tgUserID := callbackQuery.From.ID
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||
if err != nil {
|
||||
// fallback to message
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
||||
return
|
||||
}
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, tr := range traffics {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_sub_links "+tr.Email)))
|
||||
}
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard)
|
||||
case "client_individual_links":
|
||||
// show user's clients to choose for individual links
|
||||
tgUserID := callbackQuery.From.ID
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
||||
return
|
||||
}
|
||||
var buttons2 []telego.InlineKeyboardButton
|
||||
for _, tr := range traffics {
|
||||
buttons2 = append(buttons2, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_individual_links "+tr.Email)))
|
||||
}
|
||||
cols2 := 1
|
||||
if len(buttons2) >= 6 {
|
||||
cols2 = 2
|
||||
}
|
||||
keyboard2 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols2, buttons2...))
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard2)
|
||||
case "client_qr_links":
|
||||
// show user's clients to choose for QR codes
|
||||
tgUserID := callbackQuery.From.ID
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOccurred")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
||||
return
|
||||
}
|
||||
var buttons3 []telego.InlineKeyboardButton
|
||||
for _, tr := range traffics {
|
||||
buttons3 = append(buttons3, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_qr_links "+tr.Email)))
|
||||
}
|
||||
cols3 := 1
|
||||
if len(buttons3) >= 6 {
|
||||
cols3 = 2
|
||||
}
|
||||
keyboard3 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols3, buttons3...))
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard3)
|
||||
case "onlines":
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines"))
|
||||
t.onlineClients(chatId)
|
||||
|
|
@ -1439,6 +1509,23 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||
)
|
||||
prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
|
||||
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
|
||||
default:
|
||||
// dynamic callbacks
|
||||
if strings.HasPrefix(callbackQuery.Data, "client_sub_links ") {
|
||||
email := strings.TrimPrefix(callbackQuery.Data, "client_sub_links ")
|
||||
t.sendClientSubLinks(chatId, email)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(callbackQuery.Data, "client_individual_links ") {
|
||||
email := strings.TrimPrefix(callbackQuery.Data, "client_individual_links ")
|
||||
t.sendClientIndividualLinks(chatId, email)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(callbackQuery.Data, "client_qr_links ") {
|
||||
email := strings.TrimPrefix(callbackQuery.Data, "client_qr_links ")
|
||||
t.sendClientQRLinks(chatId, email)
|
||||
return
|
||||
}
|
||||
case "add_client_ch_default_traffic":
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
|
|
@ -1847,6 +1934,13 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
|||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
|
||||
),
|
||||
)
|
||||
|
||||
var ReplyMarkup telego.ReplyMarkup
|
||||
|
|
@ -1908,6 +2002,255 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
|||
}
|
||||
}
|
||||
|
||||
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
|
||||
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
||||
// Resolve subId from client email
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
_ = traffic
|
||||
if err != nil || client == nil {
|
||||
return "", "", errors.New("client not found")
|
||||
}
|
||||
|
||||
// Gather settings to construct absolute URLs
|
||||
subDomain, _ := t.settingService.GetSubDomain()
|
||||
subPort, _ := t.settingService.GetSubPort()
|
||||
subPath, _ := t.settingService.GetSubPath()
|
||||
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
||||
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
||||
subCertFile, _ := t.settingService.GetSubCertFile()
|
||||
|
||||
tls := (subKeyFile != "" && subCertFile != "")
|
||||
scheme := "http"
|
||||
if tls {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
if subDomain == "" {
|
||||
// try panel domain, otherwise OS hostname
|
||||
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
|
||||
subDomain = d
|
||||
} else if hostname != "" {
|
||||
subDomain = hostname
|
||||
} else {
|
||||
subDomain = "localhost"
|
||||
}
|
||||
}
|
||||
|
||||
host := subDomain
|
||||
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
|
||||
// standard ports: no port in host
|
||||
} else {
|
||||
host = fmt.Sprintf("%s:%d", subDomain, subPort)
|
||||
}
|
||||
|
||||
// Ensure paths
|
||||
if !strings.HasPrefix(subPath, "/") {
|
||||
subPath = "/" + subPath
|
||||
}
|
||||
if !strings.HasSuffix(subPath, "/") {
|
||||
subPath = subPath + "/"
|
||||
}
|
||||
if !strings.HasPrefix(subJsonPath, "/") {
|
||||
subJsonPath = "/" + subJsonPath
|
||||
}
|
||||
if !strings.HasSuffix(subJsonPath, "/") {
|
||||
subJsonPath = subJsonPath + "/"
|
||||
}
|
||||
|
||||
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||
return subURL, subJsonURL, nil
|
||||
}
|
||||
|
||||
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>\r\n\r\n" +
|
||||
"JSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)),
|
||||
),
|
||||
)
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
|
||||
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
|
||||
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
||||
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch raw subscription links. Prefer plain text response.
|
||||
req, err := http.NewRequest("GET", subURL, nil)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
// Force plain text to avoid HTML page; controller respects Accept header
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
|
||||
// Use default client with reasonable timeout via context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If service is configured to encode (Base64), decode it
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
|
||||
if err != nil {
|
||||
// fallback to raw text
|
||||
content = string(bodyBytes)
|
||||
} else {
|
||||
content = string(decoded)
|
||||
}
|
||||
} else {
|
||||
content = string(bodyBytes)
|
||||
}
|
||||
|
||||
// Normalize line endings and trim
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
|
||||
// Send in chunks to respect message length; use monospace formatting
|
||||
const maxPerMessage = 50
|
||||
for i := 0; i < len(cleaned); i += maxPerMessage {
|
||||
j := i + maxPerMessage
|
||||
if j > len(cleaned) {
|
||||
j = len(cleaned)
|
||||
}
|
||||
chunk := cleaned[i:j]
|
||||
msg := t.I18nBot("subscription.individualLinks") + ":\r\n"
|
||||
for _, link := range chunk {
|
||||
// wrap each link in <code>
|
||||
msg += "<code>" + link + "</code>\r\n"
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
|
||||
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to create QR PNG bytes from content
|
||||
createQR := func(content string, size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
size = 256
|
||||
}
|
||||
return qrcode.Encode(content, qrcode.Medium, size)
|
||||
}
|
||||
|
||||
// Inform user
|
||||
t.SendMsgToTgbot(chatId, "QRCode"+":")
|
||||
|
||||
// Send sub URL QR (filename: sub.png)
|
||||
if png, err := createQR(subURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "sub.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
|
||||
// Send JSON URL QR (filename: subjson.png)
|
||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
|
||||
// Also generate a few individual links' QRs (first up to 5)
|
||||
subPageURL := subURL
|
||||
req, err := http.NewRequest("GET", subPageURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
if resp, err := http.DefaultClient.Do(req); err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||
content = string(dec)
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
max := min(len(cleaned), 5)
|
||||
for i := range max {
|
||||
if png, err := createQR(cleaned[i], 320); err == nil {
|
||||
// Use the email as filename for individual link QR
|
||||
filename := email + ".png"
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||
if len(replyMarkup) > 0 {
|
||||
for _, adminId := range adminIds {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
|
||||
"somethingWentWrong" = "حدث خطأ ما"
|
||||
|
||||
[subscription]
|
||||
"title" = "معلومات الاشتراك"
|
||||
"subId" = "معرّف الاشتراك"
|
||||
"status" = "الحالة"
|
||||
"downloaded" = "التنزيل"
|
||||
"uploaded" = "الرفع"
|
||||
"expiry" = "تاريخ الانتهاء"
|
||||
"totalQuota" = "الحصة الإجمالية"
|
||||
"individualLinks" = "روابط فردية"
|
||||
"active" = "نشط"
|
||||
"inactive" = "غير نشط"
|
||||
"unlimited" = "غير محدود"
|
||||
"noExpiry" = "بدون انتهاء"
|
||||
|
||||
[menu]
|
||||
"theme" = "الثيم"
|
||||
"dark" = "داكن"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "No added reverse proxies."
|
||||
"somethingWentWrong" = "Something went wrong"
|
||||
|
||||
[subscription]
|
||||
"title" = "Subscription info"
|
||||
"subId" = "Subscription ID"
|
||||
"status" = "Status"
|
||||
"downloaded" = "Downloaded"
|
||||
"uploaded" = "Uploaded"
|
||||
"expiry" = "Expiry"
|
||||
"totalQuota" = "Total quota"
|
||||
"individualLinks" = "Individual links"
|
||||
"active" = "Active"
|
||||
"inactive" = "Inactive"
|
||||
"unlimited" = "Unlimited"
|
||||
"noExpiry" = "No expiry"
|
||||
|
||||
[menu]
|
||||
"theme" = "Theme"
|
||||
"dark" = "Dark"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "No hay proxies inversos añadidos."
|
||||
"somethingWentWrong" = "Algo salió mal"
|
||||
|
||||
[subscription]
|
||||
"title" = "Información de suscripción"
|
||||
"subId" = "ID de suscripción"
|
||||
"status" = "Estado"
|
||||
"downloaded" = "Descargado"
|
||||
"uploaded" = "Subido"
|
||||
"expiry" = "Caducidad"
|
||||
"totalQuota" = "Cuota total"
|
||||
"individualLinks" = "Enlaces individuales"
|
||||
"active" = "Activo"
|
||||
"inactive" = "Inactivo"
|
||||
"unlimited" = "Ilimitado"
|
||||
"noExpiry" = "Sin caducidad"
|
||||
|
||||
[menu]
|
||||
"theme" = "Tema"
|
||||
"dark" = "Oscuro"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
|
||||
"somethingWentWrong" = "مشکلی پیش آمد"
|
||||
|
||||
[subscription]
|
||||
"title" = "اطلاعات سابسکریپشن"
|
||||
"subId" = "شناسه اشتراک"
|
||||
"status" = "وضعیت"
|
||||
"downloaded" = "دانلود"
|
||||
"uploaded" = "آپلود"
|
||||
"expiry" = "تاریخ پایان"
|
||||
"totalQuota" = "حجم کلی"
|
||||
"individualLinks" = "لینکهای تکی"
|
||||
"active" = "فعال"
|
||||
"inactive" = "غیرفعال"
|
||||
"unlimited" = "نامحدود"
|
||||
"noExpiry" = "بدون انقضا"
|
||||
|
||||
[menu]
|
||||
"theme" = "تم"
|
||||
"dark" = "تیره"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
|
||||
"somethingWentWrong" = "Terjadi kesalahan"
|
||||
|
||||
[subscription]
|
||||
"title" = "Info langganan"
|
||||
"subId" = "ID langganan"
|
||||
"status" = "Status"
|
||||
"downloaded" = "Diunduh"
|
||||
"uploaded" = "Diunggah"
|
||||
"expiry" = "Kedaluwarsa"
|
||||
"totalQuota" = "Kuota total"
|
||||
"individualLinks" = "Tautan individual"
|
||||
"active" = "Aktif"
|
||||
"inactive" = "Nonaktif"
|
||||
"unlimited" = "Tanpa batas"
|
||||
"noExpiry" = "Tanpa kedaluwarsa"
|
||||
|
||||
[menu]
|
||||
"theme" = "Tema"
|
||||
"dark" = "Gelap"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "追加されたリバースプロキシはありません。"
|
||||
"somethingWentWrong" = "エラーが発生しました"
|
||||
|
||||
[subscription]
|
||||
"title" = "サブスクリプション情報"
|
||||
"subId" = "サブスクリプションID"
|
||||
"status" = "ステータス"
|
||||
"downloaded" = "ダウンロード"
|
||||
"uploaded" = "アップロード"
|
||||
"expiry" = "有効期限"
|
||||
"totalQuota" = "合計クォータ"
|
||||
"individualLinks" = "個別リンク"
|
||||
"active" = "有効"
|
||||
"inactive" = "無効"
|
||||
"unlimited" = "無制限"
|
||||
"noExpiry" = "期限なし"
|
||||
|
||||
[menu]
|
||||
"theme" = "テーマ"
|
||||
"dark" = "ダーク"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "Nenhum proxy reverso adicionado."
|
||||
"somethingWentWrong" = "Algo deu errado"
|
||||
|
||||
[subscription]
|
||||
"title" = "Informações da assinatura"
|
||||
"subId" = "ID da assinatura"
|
||||
"status" = "Status"
|
||||
"downloaded" = "Baixado"
|
||||
"uploaded" = "Enviado"
|
||||
"expiry" = "Validade"
|
||||
"totalQuota" = "Cota total"
|
||||
"individualLinks" = "Links individuais"
|
||||
"active" = "Ativo"
|
||||
"inactive" = "Inativo"
|
||||
"unlimited" = "Ilimitado"
|
||||
"noExpiry" = "Sem validade"
|
||||
|
||||
[menu]
|
||||
"theme" = "Tema"
|
||||
"dark" = "Escuro"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "Нет добавленных реверс-прокси."
|
||||
"somethingWentWrong" = "Что-то пошло не так"
|
||||
|
||||
[subscription]
|
||||
"title" = "Информация о подписке"
|
||||
"subId" = "ID подписки"
|
||||
"status" = "Статус"
|
||||
"downloaded" = "Загружено"
|
||||
"uploaded" = "Отправлено"
|
||||
"expiry" = "Срок действия"
|
||||
"totalQuota" = "Общий лимит"
|
||||
"individualLinks" = "Индивидуальные ссылки"
|
||||
"active" = "Активна"
|
||||
"inactive" = "Неактивна"
|
||||
"unlimited" = "Безлимит"
|
||||
"noExpiry" = "Без срока"
|
||||
|
||||
[menu]
|
||||
"theme" = "Тема"
|
||||
"dark" = "Темная"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "Eklenmiş ters proxy yok."
|
||||
"somethingWentWrong" = "Bir şeyler yanlış gitti"
|
||||
|
||||
[subscription]
|
||||
"title" = "Abonelik Bilgisi"
|
||||
"subId" = "Abonelik Kimliği"
|
||||
"status" = "Durum"
|
||||
"downloaded" = "İndirilen"
|
||||
"uploaded" = "Yüklenen"
|
||||
"expiry" = "Son Kullanma"
|
||||
"totalQuota" = "Toplam Kota"
|
||||
"individualLinks" = "Bireysel Bağlantılar"
|
||||
"active" = "Aktif"
|
||||
"inactive" = "Pasif"
|
||||
"unlimited" = "Sınırsız"
|
||||
"noExpiry" = "Süresiz"
|
||||
|
||||
[menu]
|
||||
"theme" = "Tema"
|
||||
"dark" = "Koyu"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "Немає доданих зворотних проксі."
|
||||
"somethingWentWrong" = "Щось пішло не так"
|
||||
|
||||
[subscription]
|
||||
"title" = "Інформація про підписку"
|
||||
"subId" = "ID підписки"
|
||||
"status" = "Статус"
|
||||
"downloaded" = "Завантажено"
|
||||
"uploaded" = "Відвантажено"
|
||||
"expiry" = "Термін дії"
|
||||
"totalQuota" = "Загальна квота"
|
||||
"individualLinks" = "Окремі посилання"
|
||||
"active" = "Активна"
|
||||
"inactive" = "Неактивна"
|
||||
"unlimited" = "Безліміт"
|
||||
"noExpiry" = "Без строку"
|
||||
|
||||
[menu]
|
||||
"theme" = "Тема"
|
||||
"dark" = "Темна"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "Không có proxy ngược nào được thêm."
|
||||
"somethingWentWrong" = "Đã xảy ra lỗi"
|
||||
|
||||
[subscription]
|
||||
"title" = "Thông tin đăng ký"
|
||||
"subId" = "ID đăng ký"
|
||||
"status" = "Trạng thái"
|
||||
"downloaded" = "Đã tải xuống"
|
||||
"uploaded" = "Đã tải lên"
|
||||
"expiry" = "Hết hạn"
|
||||
"totalQuota" = "Tổng hạn mức"
|
||||
"individualLinks" = "Liên kết riêng lẻ"
|
||||
"active" = "Hoạt động"
|
||||
"inactive" = "Không hoạt động"
|
||||
"unlimited" = "Không giới hạn"
|
||||
"noExpiry" = "Không hết hạn"
|
||||
|
||||
[menu]
|
||||
"theme" = "Chủ đề"
|
||||
"dark" = "Tối"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "未添加反向代理。"
|
||||
"somethingWentWrong" = "出了点问题"
|
||||
|
||||
[subscription]
|
||||
"title" = "订阅信息"
|
||||
"subId" = "订阅 ID"
|
||||
"status" = "状态"
|
||||
"downloaded" = "已下载"
|
||||
"uploaded" = "已上传"
|
||||
"expiry" = "到期"
|
||||
"totalQuota" = "总配额"
|
||||
"individualLinks" = "单独链接"
|
||||
"active" = "启用"
|
||||
"inactive" = "停用"
|
||||
"unlimited" = "无限制"
|
||||
"noExpiry" = "无到期"
|
||||
|
||||
[menu]
|
||||
"theme" = "主题"
|
||||
"dark" = "暗色"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@
|
|||
"emptyReverseDesc" = "未添加反向代理。"
|
||||
"somethingWentWrong" = "發生錯誤"
|
||||
|
||||
[subscription]
|
||||
"title" = "訂閱資訊"
|
||||
"subId" = "訂閱 ID"
|
||||
"status" = "狀態"
|
||||
"downloaded" = "已下載"
|
||||
"uploaded" = "已上傳"
|
||||
"expiry" = "到期"
|
||||
"totalQuota" = "總配額"
|
||||
"individualLinks" = "個別連結"
|
||||
"active" = "啟用"
|
||||
"inactive" = "停用"
|
||||
"unlimited" = "無限制"
|
||||
"noExpiry" = "無到期"
|
||||
|
||||
[menu]
|
||||
"theme" = "主題"
|
||||
"dark" = "深色"
|
||||
|
|
|
|||
11
web/web.go
11
web/web.go
|
|
@ -78,6 +78,15 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time {
|
|||
return startTime
|
||||
}
|
||||
|
||||
// Expose embedded resources for reuse by other servers (e.g., sub server)
|
||||
func EmbeddedHTML() embed.FS {
|
||||
return htmlFS
|
||||
}
|
||||
|
||||
func EmbeddedAssets() embed.FS {
|
||||
return assetsFS
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
|
|
@ -243,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package xray
|
|||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"x-ui/logger"
|
||||
|
|
@ -20,6 +21,12 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
|
|||
|
||||
// Convert the data to a string
|
||||
message := strings.TrimSpace(string(m))
|
||||
msgLowerAll := strings.ToLower(message)
|
||||
|
||||
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
|
||||
if runtime.GOOS == "windows" && strings.Contains(msgLowerAll, "exit status 1") {
|
||||
return len(m), nil
|
||||
}
|
||||
|
||||
// Check if the message contains a crash
|
||||
if crashRegex.MatchString(message) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -224,6 +225,15 @@ func (p *process) Start() (err error) {
|
|||
go func() {
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// On Windows, killing the process results in "exit status 1" which isn't an error for us
|
||||
if runtime.GOOS == "windows" {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "exit status 1") {
|
||||
// Suppress noisy log on graceful stop
|
||||
p.exitErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.Error("Failure in running xray-core:", err)
|
||||
p.exitErr = err
|
||||
}
|
||||
|
|
@ -239,7 +249,7 @@ func (p *process) Stop() error {
|
|||
if !p.IsRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return p.cmd.Process.Kill()
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue