Separate outbounds

This commit is contained in:
Alireza Ahmadi 2026-05-30 19:05:10 +02:00
parent dc546308e9
commit c0bbfc49a5
31 changed files with 1562 additions and 262 deletions

View file

@ -39,18 +39,6 @@ func initUser() error {
return nil
}
func initInbound() error {
return db.AutoMigrate(&model.Inbound{})
}
func initSetting() error {
return db.AutoMigrate(&model.Setting{})
}
func initClientTraffic() error {
return db.AutoMigrate(&xray.ClientTraffic{})
}
func InitDB(dbPath string) error {
dir := path.Dir(dbPath)
err := os.MkdirAll(dir, fs.ModeDir)
@ -78,16 +66,12 @@ func InitDB(dbPath string) error {
if err != nil {
return err
}
err = initInbound()
if err != nil {
return err
}
err = initSetting()
if err != nil {
return err
}
err = initClientTraffic()
err = db.AutoMigrate(
&model.Inbound{},
&model.Outbound{},
&model.Setting{},
&xray.ClientTraffic{},
)
if err != nil {
return err
}

View file

@ -46,6 +46,47 @@ type Inbound struct {
Sniffing string `json:"sniffing" form:"sniffing"`
}
type Outbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
Sort int `json:"sort" form:"sort"`
SendThrough string `json:"sendThrough" form:"sendThrough"`
Protocol string `json:"protocol" form:"protocol"`
Settings string `json:"settings" form:"settings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
ProxySettings string `json:"proxySettings" form:"proxySettings"`
Mux string `json:"mux" form:"mux"`
TargetStrategy string `json:"targetStrategy" form:"targetStrategy"`
}
func (o *Outbound) GenXrayOutboundConfig() *xray.OutboundConfig {
cfg := &xray.OutboundConfig{
Protocol: o.Protocol,
Tag: o.Tag,
TargetStrategy: o.TargetStrategy,
}
if o.SendThrough != "" {
cfg.SendThrough = json_util.RawMessage(fmt.Sprintf("\"%s\"", o.SendThrough))
}
if len(o.Settings) > 0 {
cfg.Settings = json_util.RawMessage(o.Settings)
} else {
cfg.Settings = json_util.RawMessage("{}")
}
if len(o.StreamSettings) > 0 {
cfg.StreamSettings = json_util.RawMessage(o.StreamSettings)
}
if len(o.ProxySettings) > 0 {
cfg.ProxySettings = json_util.RawMessage(o.ProxySettings)
}
if len(o.Mux) > 0 {
cfg.Mux = json_util.RawMessage(o.Mux)
}
return cfg
}
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen
if listen != "" {

40
go.mod
View file

@ -1,6 +1,6 @@
module github.com/alireza0/x-ui
go 1.26.1
go 1.26.3
require (
github.com/gin-contrib/gzip v1.2.6
@ -10,13 +10,13 @@ require (
github.com/goccy/go-json v0.10.6
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pelletier/go-toml/v2 v2.3.1
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.3
github.com/shirou/gopsutil/v4 v4.26.4
github.com/xtls/xray-core v1.260327.0
go.uber.org/atomic v1.11.0
golang.org/x/text v0.35.0
google.golang.org/grpc v1.80.0
golang.org/x/text v0.37.0
google.golang.org/grpc v1.81.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@ -25,10 +25,10 @@ require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
@ -50,38 +50,38 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.40 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/miekg/dns v1.1.72 // indirect
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.11.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.8.4 // indirect
github.com/sagernet/sing v0.8.10 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect

53
go.sum
View file

@ -8,6 +8,8 @@ github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -16,6 +18,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -97,8 +101,12 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94=
github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -114,6 +122,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -124,6 +134,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@ -132,10 +144,14 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.8.4 h1:Fj+jlY3F8vhcRfz/G/P3Dwcs5wqnmyNPT7u1RVVmjFI=
github.com/sagernet/sing v0.8.4/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
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=
@ -149,8 +165,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@ -169,18 +189,25 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@ -191,14 +218,28 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -209,12 +250,22 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
@ -225,6 +276,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -47,6 +47,9 @@ func runWebServer() {
log.Fatal(err)
}
outboundService := service.OutboundService{}
outboundService.MigrateDB()
var server *web.Server
server = web.NewServer()
@ -364,6 +367,7 @@ func getPanelURI() {
func migrateDb() {
inboundService := service.InboundService{}
outboundService := service.OutboundService{}
err := database.InitDB(config.GetDBPath())
if err != nil {
@ -371,6 +375,7 @@ func migrateDb() {
}
fmt.Println("Start migrating database...")
inboundService.MigrateDB()
outboundService.MigrateDB()
fmt.Println("Migration done!")
}

View file

@ -0,0 +1,64 @@
class DBOutbound {
constructor(data) {
this.id = 0;
this.up = 0;
this.down = 0;
this.sort = 0;
this.sendThrough = "";
this.protocol = "";
this.settings = "";
this.tag = "";
this.streamSettings = "";
this.proxySettings = "";
this.mux = "";
this.targetStrategy = "";
if (data == null) {
return;
}
ObjectUtil.cloneProps(this, data);
}
toOutbound() {
const config = {
protocol: this.protocol,
tag: this.tag,
};
if (!ObjectUtil.isEmpty(this.sendThrough)) {
config.sendThrough = this.sendThrough;
}
if (!ObjectUtil.isEmpty(this.settings)) {
config.settings = JSON.parse(this.settings);
}
if (!ObjectUtil.isEmpty(this.streamSettings)) {
config.streamSettings = JSON.parse(this.streamSettings);
}
if (!ObjectUtil.isEmpty(this.proxySettings)) {
config.proxySettings = JSON.parse(this.proxySettings);
}
if (!ObjectUtil.isEmpty(this.mux)) {
config.mux = JSON.parse(this.mux);
}
if (!ObjectUtil.isEmpty(this.targetStrategy)) {
config.targetStrategy = this.targetStrategy;
}
return Outbound.fromJson(config);
}
static payloadFromOutbound(outbound, db) {
const json = outbound.toJson();
const data = {
up: db.up,
down: db.down,
sendThrough: json.sendThrough || "",
protocol: json.protocol,
tag: json.tag || "",
targetStrategy: json.targetStrategy || "",
settings: JSON.stringify(json.settings ?? {}, null, 2),
streamSettings: json.streamSettings ? JSON.stringify(json.streamSettings, null, 2) : "",
proxySettings: json.proxySettings ? JSON.stringify(json.proxySettings, null, 2) : "",
mux: json.mux ? JSON.stringify(json.mux, null, 2) : "",
};
return data;
}
}

View file

@ -1237,7 +1237,7 @@ class Outbound extends CommonClass {
}
static fromLink(link) {
data = link.split('://');
var data = link.split('://');
if (data.length != 2) return null;
switch (data[0].toLowerCase()) {
case Protocols.VMess:

118
web/controller/outbound.go Normal file
View file

@ -0,0 +1,118 @@
package controller
import (
"strconv"
"github.com/alireza0/x-ui/database/model"
"github.com/alireza0/x-ui/web/service"
"github.com/gin-gonic/gin"
)
type OutboundController struct {
outboundService service.OutboundService
xrayService service.XrayService
}
func NewOutboundController(g *gin.RouterGroup) *OutboundController {
a := &OutboundController{}
a.initRouter(g)
return a
}
func (a *OutboundController) initRouter(g *gin.RouterGroup) {
g = g.Group("/outbound")
g.POST("/list", a.getOutbounds)
g.POST("/add", a.addOutbound)
g.POST("/del/:id", a.delOutbound)
g.POST("/update/:id", a.updateOutbound)
g.POST("/setFirst/:id", a.setFirstOutbound)
g.POST("/:id/resetTraffic", a.resetTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/onlines", a.onlines)
}
func (a *OutboundController) getOutbounds(c *gin.Context) {
outbounds, err := a.outboundService.GetAllOutbounds()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.toasts.obtain"), err)
return
}
jsonObj(c, outbounds, nil)
}
func (a *OutboundController) addOutbound(c *gin.Context) {
outbound := &model.Outbound{}
err := c.ShouldBind(outbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.create"), err)
return
}
outbound, needRestart, err := a.outboundService.AddOutbound(outbound)
jsonMsgObj(c, I18nWeb(c, "pages.outbounds.create"), outbound, err)
if err == nil && needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *OutboundController) delOutbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.delete"), err)
return
}
needRestart, err := a.outboundService.DelOutbound(id)
jsonMsg(c, I18nWeb(c, "pages.outbounds.delete"), err)
if err == nil && needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *OutboundController) updateOutbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.update"), err)
return
}
outbound := &model.Outbound{Id: id}
err = c.ShouldBind(outbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.update"), err)
return
}
outbound, needRestart, err := a.outboundService.UpdateOutbound(outbound)
jsonMsgObj(c, I18nWeb(c, "pages.outbounds.update"), outbound, err)
if err == nil && needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *OutboundController) setFirstOutbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.update"), err)
return
}
err = a.outboundService.SetFirstOutbound(id)
if err == nil {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.outbounds.update"), err)
}
func (a *OutboundController) resetTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.resetTraffic"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.outbounds.resetTraffic"), a.outboundService.ResetTraffic(id))
}
func (a *OutboundController) resetAllTraffics(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.outbounds.resetAllTraffic"), a.outboundService.ResetAllTraffics())
}
func (a *OutboundController) onlines(c *gin.Context) {
jsonObj(c, a.outboundService.GetOnlineOutbounds(), nil)
}

View file

@ -12,6 +12,7 @@ type XraySettingController struct {
XraySettingService service.XraySettingService
SettingService service.SettingService
InboundService service.InboundService
OutboundService service.OutboundService
XrayService service.XrayService
WarpService service.WarpService
}
@ -47,10 +48,26 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
if err != nil {
clientReverseTags = "[]"
}
outboundTags, err := a.OutboundService.GetOutboundTags()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
outboundReverseTags, err := a.OutboundService.GetOutboundReverseTags()
if err != nil {
outboundReverseTags = "[]"
}
outboundSummaries, err := a.OutboundService.GetOutboundSummariesJSON()
if err != nil {
outboundSummaries = "[]"
}
xrayResponse := map[string]any{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"clientReverseTags": json.RawMessage(clientReverseTags),
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTags": json.RawMessage(outboundTags),
"outboundReverseTags": json.RawMessage(outboundReverseTags),
"outbounds": json.RawMessage(outboundSummaries),
}
result, err := json.Marshal(xrayResponse)
if err != nil {

View file

@ -8,6 +8,7 @@ type XUIController struct {
BaseController
inboundController *InboundController
outboundController *OutboundController
settingController *SettingController
xraySettingController *XraySettingController
}
@ -24,10 +25,12 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/inbounds", a.inbounds)
g.GET("/outbounds", a.outbounds)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
a.inboundController = NewInboundController(g)
a.outboundController = NewOutboundController(g)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
}
@ -40,6 +43,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
html(c, "inbounds.html", "pages.inbounds.title", nil)
}
func (a *XUIController) outbounds(c *gin.Context) {
html(c, "outbounds.html", "pages.outbounds.title", nil)
}
func (a *XUIController) settings(c *gin.Context) {
html(c, "settings.html", "pages.settings.title", nil)
}

View file

@ -4,9 +4,13 @@
<span><strong>{{ i18n "menu.dashboard"}}</strong></span>
</a-menu-item>
<a-menu-item key="{{ .base_path }}xui/inbounds">
<a-icon type="user"></a-icon>
<a-icon type="cloud-download"></a-icon>
<span><strong>{{ i18n "menu.inbounds"}}</strong></span>
</a-menu-item>
<a-menu-item key="{{ .base_path }}xui/outbounds">
<a-icon type="cloud-upload"></a-icon>
<span><strong>{{ i18n "menu.outbounds"}}</strong></span>
</a-menu-item>
<a-menu-item key="{{ .base_path }}xui/settings">
<a-icon type="setting"></a-icon>
<span><strong>{{ i18n "menu.settings"}}</strong></span>

View file

@ -1,9 +1,7 @@
{{define "form/outbound"}}
<!-- base -->
<a-tabs :active-key="outModal.activeKey" style="padding: 0; background-color: transparent;" @change="(activeKey) => {outModal.toggleJson(activeKey == '2'); }">
<a-tab-pane key="1" tab="Form">
<a-tabs :animated="false" class="outbound-detail-inner-tabs" style="padding: 1rem;">
<a-tab-pane key="ob-basic" tab="{{ i18n "pages.xray.outbound.settings" }}">
<a-tabs :animated="false" class="outbound-detail-tabs" :active-key="outModal.activeKey" style="padding: 1rem;"
@change="(activeKey) => outModal.onTabChange(activeKey)">
<a-tab-pane key="ob-settings" tab="{{ i18n "pages.xray.outbound.settings" }}">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "protocol" }}'>
<a-select v-model="outbound.protocol" :dropdown-class-name="themeSwitcher.currentTheme">
@ -1080,14 +1078,20 @@
</template>
</a-form>
</a-tab-pane>
</a-tabs>
</a-tab-pane>
<a-tab-pane key="2" tab="JSON" force-render="true">
<a-tab-pane key="ob-json" tab='{{ i18n "pages.xray.outbound.advanced" }}' force-render="true">
<a-divider>{{ i18n "pages.xray.outbound.linkConverter" }}</a-divider>
<a-form-item style="margin: 10px 0">
Link: <a-input v-model.trim="outModal.link" style="width: 300px; margin-right: 5px;" placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"></a-input>
<a-button @click="convertLink" type="primary"><a-icon type="form"></a-icon></a-button>
<a-input-search
v-model.trim="outModal.link"
style="margin-right: 5px;"
@search="convertLink"
@pressEnter="convertLink"
placeholder="vmess:// | vless:// | trojan:// | ss:// | hysteria2://">
<a-button slot="enterButton" type="primary"><a-icon type="sync"></a-icon></a-button>
</a-input-search>
</a-form-item>
<textarea style="position:absolute; left: -800px;" id="outboundJson"></textarea>
<a-divider>{{ i18n "pages.xray.outbound.jsonFreeEditor" }}</a-divider>
<textarea style="position:absolute; left: -800px;" id="outboundJson"></textarea>
</a-tab-pane>
</a-tabs>
{{end}}

View file

@ -1,11 +1,20 @@
{{define "outModal"}}
{{define "outboundModal"}}
<a-modal id="out-modal" v-model="outModal.visible" :title="outModal.title" @ok="outModal.ok"
:confirm-loading="outModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-button-props="{ props: { disabled: !outModal.isValid } }" style="overflow: hidden;" width="800px" body-style="padding: 0;"
:ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
{{template "form/outbound"}}
{{template "form/outbound"}}
</a-modal>
<script>
const outboundCmOptions = {
lineNumbers: true,
mode: "application/json",
lint: true,
theme: "xq",
lineWrapping: true,
indentUnit: 2,
tabSize: 2,
};
const outModal = {
title: '',
@ -20,23 +29,32 @@
cm: null,
duplicateTag: false,
isValid: true,
activeKey: '1',
activeKey: 'ob-settings',
tags: [],
ok() {
ObjectUtil.execute(outModal.confirm, outModal.outbound.toJson());
},
show({ title='', okText='{{ i18n "confirm" }}', outbound, confirm=(outbound)=>{}, isEdit=false, tags=[] }) {
onTabChange(activeKey) {
this.activeKey = activeKey;
if (activeKey === 'ob-json') {
setTimeout(() => this.initJsonEditor(), 0);
} else {
this.destroyJsonEditor();
}
},
show({ title='', okText='{{ i18n "confirm" }}', outbound, confirm=(outbound)=>{}, isEdit=false, tags=[] }) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
this.jsonMode = false;
this.link = '';
this.activeKey = '1';
this.destroyJsonEditor();
this.activeKey = 'ob-settings';
this.visible = true;
this.outbound = isEdit ? Outbound.fromJson(outbound) : new Outbound();
this.isEdit = isEdit;
this.tags = tags;
this.check()
this.check();
},
close() {
outModal.visible = false;
@ -45,8 +63,8 @@
loading(loading=true) {
outModal.confirmLoading = loading;
},
check(){
if(outModal.outbound.tag == '' || outModal.tags.includes(outModal.outbound.tag)){
check() {
if (outModal.outbound.tag == '' || outModal.tags.includes(outModal.outbound.tag)) {
this.duplicateTag = true;
this.isValid = false;
} else {
@ -54,29 +72,32 @@
this.isValid = true;
}
},
initJsonEditor() {
const textAreaObj = document.getElementById('outboundJson');
if (!textAreaObj) return;
this.destroyJsonEditor();
textAreaObj.value = JSON.stringify(this.outbound.toJson(), null, 2);
this.cm = CodeMirror.fromTextArea(textAreaObj, outboundCmOptions);
this.cm.on('change', editor => {
const value = editor.getValue();
if (this.isJsonString(value)) {
this.outbound = Outbound.fromJson(JSON.parse(value));
this.check();
}
});
},
destroyJsonEditor() {
if (this.cm != null) {
this.cm.toTextArea();
this.cm = null;
}
},
toggleJson(jsonTab) {
textAreaObj = document.getElementById('outboundJson');
if(jsonTab){
if(this.cm != null) {
this.cm.toTextArea();
this.cm=null;
}
textAreaObj.value = JSON.stringify(this.outbound.toJson(), null, 2);
this.cm = CodeMirror.fromTextArea(textAreaObj, app.cmOptions);
this.cm.on('change',editor => {
value = editor.getValue();
if(this.isJsonString(value)){
this.outbound = Outbound.fromJson(JSON.parse(value));
this.check();
}
});
this.activeKey = '2';
if (jsonTab) {
this.activeKey = 'ob-json';
setTimeout(() => this.initJsonEditor(), 0);
} else {
if(this.cm != null) {
this.cm.toTextArea();
this.cm=null;
}
this.activeKey = '1';
this.destroyJsonEditor();
}
},
isJsonString(str) {
@ -107,13 +128,13 @@
canEnableTls() {
return this.outModal.outbound.canEnableTls();
},
convertLink(){
newOutbound = Outbound.fromLink(outModal.link);
if(newOutbound){
convertLink() {
const newOutbound = Outbound.fromLink(outModal.link);
if (newOutbound) {
this.outModal.outbound = newOutbound;
this.outModal.toggleJson(true);
this.outModal.check();
this.$message.success('Link imported successfully...');
this.$message.success('Link imported successfully...');
outModal.link = '';
} else {
this.$message.error('Wrong Link!');
@ -122,6 +143,5 @@
},
},
});
</script>
{{end}}

498
web/html/xui/outbounds.html Normal file
View file

@ -0,0 +1,498 @@
<!DOCTYPE html>
<html lang="en">
{{template "head" .}}
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
<script src="{{ .base_path }}assets/base64/base64.min.js"></script>
<script src="{{ .base_path }}assets/codemirror/codemirror.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
<script src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
<style>
@media (min-width: 769px) {
.ant-layout-content {
margin: 24px 16px;
}
}
@media (max-width: 768px) {
.ant-card-body {
padding: .5rem;
}
}
.ant-col-sm-24 {
margin: 0.5rem -2rem 0.5rem 2rem;
}
.online-animation .ant-badge-status-dot {
animation: 1.2s ease infinite normal none running onlineAnimation;
}
@keyframes onlineAnimation {
0%, 50%, 100% { transform: scale(1); opacity: 1; }
10% { transform: scale(1.5); opacity: .2; }
}
.info-large-tag {
max-width: 200px;
overflow: hidden;
}
</style>
<body>
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
{{ template "commonSider" . }}
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="spinning" :delay="500" tip='{{ i18n "loading"}}'>
<transition name="list" appear>
<a-alert type="error" v-if="showAlert" style="margin-bottom: 10px"
message='{{ i18n "secAlertTitle" }}'
description='{{ i18n "secAlertSsl" }}'
show-icon closable>
</a-alert>
</transition>
<transition name="list" appear>
<a-card hoverable>
<a-row>
<a-col :xs="24" :sm="24" :lg="12">
<strong>{{ i18n "pages.outbounds.totalDownUp" }}:</strong>
<a-tag color="blue">[[ sizeFormat(total.up) ]] / [[ sizeFormat(total.down) ]]</a-tag>
</a-col>
<a-col :xs="24" :sm="24" :lg="12">
<strong>{{ i18n "pages.outbounds.totalUsage" }}:</strong>
<a-tag color="blue">[[ sizeFormat(total.up + total.down) ]]</a-tag>
</a-col>
<a-col :xs="24" :sm="24" :lg="12">
<strong>{{ i18n "pages.outbounds.outboundCount" }}:</strong>
<a-tag color="blue">[[ dbOutbounds.length ]]</a-tag>
</a-col>
<a-col :xs="24" :sm="24" :lg="12">
<a-back-top :target="() => document.getElementById('content-layout')" visibility-height="200"></a-back-top>
<strong>{{ i18n "online" }}:</strong>
<a-tag color="blue">[[ onlineOutbounds.length ]]</a-tag>
<a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-for="tag in onlineOutbounds" style="margin: 0;">[[ tag ]]</p>
</template>
<a-tag style="margin:0; padding: 0 2px;" color="green" v-if="onlineOutbounds.length">[[ onlineOutbounds.length ]]</a-tag>
</a-popover>
</a-col>
</a-row>
</a-card>
</transition>
<transition name="list" appear>
<a-card hoverable>
<div slot="title">
<a-row>
<a-col :xs="12" :sm="12" :lg="12">
<a-button type="primary" icon="plus" @click="addOutbound()">
<template v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary" icon="menu">
<template v-if="!isMobile">{{ i18n "pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="resetTraffics">
<a-icon type="reload"></a-icon>
{{ i18n "pages.outbounds.resetAllTraffic" }}
</a-menu-item>
<a-menu-item key="warp">
<a-icon type="cloud"></a-icon>
WARP
</a-menu-item>
</a-menu>
</a-dropdown>
</a-col>
<a-col :xs="12" :sm="12" :lg="12" style="text-align: right;">
<a-select v-model="refreshInterval"
v-if="isRefreshEnabled"
style="width: 70px;"
@change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select>
<a-icon type="sync" :spin="refreshing" @click="manualRefresh" style="margin: 0 5px;"></a-icon>
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh"></a-switch>
</a-col>
</a-row>
</div>
<div :style="isMobile ? '' : 'display: flex; align-items: center; justify-content: flex-start;'">
<a-tooltip :title='`{{ i18n "filter" }}`'>
<a-switch v-model="enableFilter"
:style="isMobile ? 'margin-bottom: .5rem; display: flex;' : 'margin-right: .5rem;'"
@change="toggleFilter">
<a-icon slot="checkedChildren" type="search"></a-icon>
<a-icon slot="unCheckedChildren" type="filter"></a-icon>
</a-switch>
</a-tooltip>
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus style="max-width: 300px" :size="isMobile ? 'small' : ''"></a-input>
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterOutbounds" button-style="solid" :size="isMobile ? 'small' : ''">
<a-radio-button value="">{{ i18n "none" }}</a-radio-button>
<a-radio-button value="online">{{ i18n "online" }}</a-radio-button>
</a-radio-group>
</div>
<a-back-top></a-back-top>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="r => r.id"
:data-source="searchedOutbounds"
:scroll="isMobile ? {} : { x: 800 }"
:pagination="pagination(searchedOutbounds)"
style="margin-top: 10px;">
<template slot="actions" slot-scope="text, row, index">
<a-tooltip v-if="index > 0">
<template slot="title">{{ i18n "pages.xray.rules.first" }}</template>
<a-icon style="font-size: 24px; cursor: pointer;" class="normal-icon" type="vertical-align-top" @click="setFirstOutbound(row.id)"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "pages.outbounds.edit" }}</template>
<a-icon style="font-size: 24px; cursor: pointer;" class="normal-icon" type="edit" @click="editOutbound(row)"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "pages.outbounds.resetTraffic" }}</template>
<a-popconfirm @confirm="resetTraffic(row.id, false)"
title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
:overlay-class-name="themeSwitcher.currentTheme"
ok-text='{{ i18n "reset"}}'
cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" :style="themeSwitcher.isDarkTheme ? 'color: #3c89e8' : 'color: blue'"></a-icon>
<a-icon style="font-size: 24px; cursor: pointer;" class="normal-icon" type="retweet"></a-icon>
</a-popconfirm>
</a-tooltip>
<a-tooltip>
<template slot="title"><span style="color: #FF4D4F">{{ i18n "delete" }}</span></template>
<a-popconfirm @confirm="deleteOutbound(row.id, false)"
title='{{ i18n "pages.outbounds.deleteConfirm" }}'
:overlay-class-name="themeSwitcher.currentTheme"
ok-text='{{ i18n "delete"}}'
ok-type="danger"
cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" style="color: #e04141"></a-icon>
<a-icon style="font-size: 24px; cursor: pointer;" class="delete-icon" type="delete"></a-icon>
</a-popconfirm>
</a-tooltip>
</template>
<template slot="online" slot-scope="text, row">
<a-tag v-if="isOutboundOnline(row.tag)" color="green">{{ i18n "online" }}</a-tag>
<a-tag v-else>{{ i18n "offline" }}</a-tag>
</template>
<template slot="tag" slot-scope="text, row">
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">
<template v-if="isOutboundOnline(row.tag)">{{ i18n "online" }}</template>
<template v-else>{{ i18n "offline" }}</template>
</template>
<a-badge
:class="isOutboundOnline(row.tag) ? 'online-animation' : ''"
:color="isOutboundOnline(row.tag) ? 'green' : (themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc')">
</a-badge>
</a-tooltip>
[[ row.tag ]]
</template>
<template slot="protocol" slot-scope="text, row">
<a-tag color="purple">[[ row.protocol ]]</a-tag>
<template v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(row.protocol)">
<a-tag color="blue">[[ row.toOutbound().stream.network ]]</a-tag>
<a-tag v-if="row.toOutbound().stream.isTls" color="green">tls</a-tag>
<a-tag v-if="row.toOutbound().stream.isReality" color="green">reality</a-tag>
</template>
</template>
<template slot="address" slot-scope="text, row">
<p style="margin: 0 5px;" v-for="addr in findOutboundAddress(row.toOutbound().toJson())">[[ addr ]]</p>
</template>
<template slot="traffic" slot-scope="text, row">
<a-tag color="blue">↑[[ sizeFormat(row.up) ]] / ↓[[ sizeFormat(row.down) ]]</a-tag>
</template>
</a-table>
</a-card>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "js" .}}
{{template "component/themeSwitcher" .}}
<script src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/dboutbound.js?{{ .cur_ver }}"></script>
<script>
const columns = [
{ title: "ID", align: 'center', width: 30, dataIndex: 'id' },
{ title: '{{ i18n "pages.inbounds.operate" }}', align: 'right', width: 70, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "online" }}', align: 'center', width: 60, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 100, scopedSlots: { customRender: 'tag' } },
{ title: '{{ i18n "protocol"}}', align: 'center', width: 120, scopedSlots: { customRender: 'protocol' } },
{ title: '{{ i18n "pages.xray.outbound.address"}}', width: 150, align: 'center', scopedSlots: { customRender: 'address' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 140, scopedSlots: { customRender: 'traffic' } },
];
const mobileColumns = [
{ title: "ID", align: 'center', width: 30, dataIndex: 'id' },
{ title: '{{ i18n "pages.inbounds.operate" }}', align: 'center', width: 110, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'center', scopedSlots: { customRender: 'tag' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', scopedSlots: { customRender: 'traffic' } },
];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
data: {
siderDrawer,
themeSwitcher,
spinning: false,
isMobile: window.innerWidth <= 768,
dbOutbounds: [],
searchedOutbounds: [],
onlineOutbounds: [],
searchKey: '',
enableFilter: false,
filterBy: '',
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true",
refreshing: false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
showAlert: false,
pageSize: 0,
},
methods: {
loading(spinning = true) { this.spinning = spinning; },
async getDBOutbounds() {
this.refreshing = true;
const msg = await HttpUtil.post('/xui/outbound/list');
if (!msg.success) {
this.refreshing = false;
return;
}
await this.getOnlineOutbounds();
this.setOutbounds(msg.obj);
setTimeout(() => { this.refreshing = false; }, 500);
},
async getOnlineOutbounds() {
const msg = await HttpUtil.post('/xui/outbound/onlines');
if (msg.success) {
this.onlineOutbounds = msg.obj != null ? msg.obj : [];
}
},
setOutbounds(list) {
this.dbOutbounds = [];
this.searchedOutbounds = [];
for (const item of list) {
const dbOutbound = new DBOutbound(item);
this.dbOutbounds.push(dbOutbound);
this.searchedOutbounds.push(dbOutbound);
}
this.filterOutbounds();
},
isOutboundOnline(tag) {
return this.onlineOutbounds.includes(tag);
},
applyOutboundFilter() {
let list = this.dbOutbounds.slice();
if (this.enableFilter && this.filterBy === 'online') {
list = list.filter(o => this.isOutboundOnline(o.tag));
}
if (this.searchKey) {
const key = this.searchKey.toLowerCase();
list = list.filter(o =>
o.tag.toLowerCase().includes(key) ||
o.protocol.toLowerCase().includes(key)
);
}
this.searchedOutbounds = list;
},
filterOutbounds() {
this.applyOutboundFilter();
},
toggleFilter() {
if (!this.enableFilter) {
this.filterBy = '';
}
this.filterOutbounds();
},
findOutboundAddress(o) {
let serverObj = null;
switch (o.protocol) {
case Protocols.VMess:
case Protocols.VLESS:
if (o.settings?.address && o.settings?.port) {
return [o.settings.address + ':' + o.settings.port];
}
break;
case Protocols.HTTP:
case Protocols.Socks:
case Protocols.Shadowsocks:
case Protocols.Trojan:
serverObj = o.settings?.servers;
break;
case Protocols.DNS:
return [o.settings?.address + ':' + o.settings?.port];
case Protocols.Wireguard:
return o.settings?.peers?.map(peer => peer.endpoint) ?? [];
}
return serverObj ? serverObj.map(obj => obj.address + ':' + obj.port) : [];
},
allTags(excludeId) {
return this.dbOutbounds.filter(o => o.id !== excludeId).map(o => o.tag);
},
addOutbound() {
outModal.show({
title: '{{ i18n "pages.xray.outbound.addOutbound"}}',
okText: '{{ i18n "pages.xray.outbound.addOutbound" }}',
confirm: async (outbound) => {
outModal.loading();
const db = new DBOutbound();
const data = DBOutbound.payloadFromOutbound(Outbound.fromJson(outbound), db);
await this.submit('/xui/outbound/add', data, outModal);
},
isEdit: false,
tags: this.allTags(-1),
});
},
editOutbound(dbOutbound) {
outModal.show({
title: '{{ i18n "pages.xray.outbound.editOutbound"}} ' + dbOutbound.tag,
outbound: dbOutbound.toOutbound().toJson(),
confirm: async (outbound) => {
outModal.loading();
const data = DBOutbound.payloadFromOutbound(Outbound.fromJson(outbound), dbOutbound);
await this.submit(`/xui/outbound/update/${dbOutbound.id}`, data, outModal);
},
isEdit: true,
tags: this.allTags(dbOutbound.id),
});
},
deleteOutbound(id, confirmation = true) {
if (confirmation) {
this.$confirm({
title: '{{ i18n "pages.outbounds.delete" }}',
content: '{{ i18n "pages.outbounds.deleteConfirm" }}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit(`/xui/outbound/del/${id}`),
});
} else {
this.submit(`/xui/outbound/del/${id}`);
}
},
setFirstOutbound(id) {
this.submit(`/xui/outbound/setFirst/${id}`);
},
resetTraffic(id, confirmation = true) {
if (confirmation) {
this.$confirm({
title: '{{ i18n "pages.outbounds.resetTraffic" }}',
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
class: themeSwitcher.currentTheme,
onOk: () => this.submit(`/xui/outbound/${id}/resetTraffic`),
});
} else {
this.submit(`/xui/outbound/${id}/resetTraffic`);
}
},
resetAllTraffics() {
this.$confirm({
title: '{{ i18n "pages.outbounds.resetAllTraffic" }}',
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
class: themeSwitcher.currentTheme,
onOk: () => this.submit('/xui/outbound/resetAllTraffics'),
});
},
generalActions({ key }) {
switch (key) {
case 'resetTraffics':
this.resetAllTraffics();
break;
case 'warp':
warpModal.show();
break;
}
},
async submit(url, data, modal) {
const msg = await HttpUtil.postWithModal(url, data, modal);
if (msg.success) {
await this.getDBOutbounds();
}
},
async startDataRefreshLoop() {
while (this.isRefreshEnabled) {
try {
await this.getDBOutbounds();
} catch (e) {
console.error(e);
}
await PromiseUtil.sleep(this.refreshInterval);
}
},
toggleRefresh() {
localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
},
changeRefreshInterval() {
localStorage.setItem("refreshInterval", this.refreshInterval);
},
async manualRefresh() {
if (!this.refreshing) {
this.loading(true);
await this.getDBOutbounds();
this.loading(false);
}
},
pagination(obj) {
if (this.pageSize > 0 && obj.length > this.pageSize) {
const sizeOptions = [];
for (let i = this.pageSize; i <= obj.length; i += this.pageSize) {
sizeOptions.push(i.toString());
}
sizeOptions.push(obj.length.toString());
return {
showSizeChanger: true,
size: 'small',
position: 'bottom',
pageSize: this.pageSize,
pageSizeOptions: sizeOptions,
};
}
return false;
},
onResize() {
this.isMobile = window.innerWidth <= 768;
},
},
watch: {
searchKey: debounce(function () {
this.applyOutboundFilter();
}, 500),
},
computed: {
total() {
let up = 0, down = 0;
this.dbOutbounds.forEach(o => { up += o.up; down += o.down; });
return { up, down };
},
},
mounted() {
if (window.location.protocol !== "https:") {
this.showAlert = true;
}
window.addEventListener('resize', this.onResize);
this.onResize();
this.loading();
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
} else {
this.getDBOutbounds();
}
this.loading(false);
},
});
</script>
{{template "outboundModal"}}
{{template "warpModal"}}
</body>
</html>

View file

@ -213,20 +213,36 @@ new Vue({
this.delOutbound();
}
},
addOutbound(){
app.templateSettings.outbounds.push(warpModal.warpOutbound.toJson());
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
warpModal.close();
async addOutbound(){
warpModal.loading(true);
const db = new DBOutbound();
const data = DBOutbound.payloadFromOutbound(warpModal.warpOutbound, db);
const msg = await HttpUtil.post('/xui/outbound/add', data);
warpModal.loading(false);
if (msg.success) {
await app.getDBOutbounds();
warpModal.close();
}
},
resetOutbound(){
app.templateSettings.outbounds[this.warpOutboundIndex] = warpModal.warpOutbound.toJson();
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
warpModal.close();
async resetOutbound(){
const row = app.dbOutbounds[this.warpOutboundIndex];
if (!row) return;
warpModal.loading(true);
const data = DBOutbound.payloadFromOutbound(warpModal.warpOutbound, row);
const msg = await HttpUtil.post(`/xui/outbound/update/${row.id}`, data);
warpModal.loading(false);
if (msg.success) {
await app.getDBOutbounds();
warpModal.close();
}
},
delOutbound(){
if (this.warpOutboundIndex != -1){
app.templateSettings.outbounds.splice(this.warpOutboundIndex,1);
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
async delOutbound(){
const row = app.dbOutbounds[this.warpOutboundIndex];
if (row) {
warpModal.loading(true);
await HttpUtil.post(`/xui/outbound/del/${row.id}`);
warpModal.loading(false);
await app.getDBOutbounds();
}
warpModal.close();
}
@ -234,7 +250,7 @@ new Vue({
computed: {
warpOutboundIndex: {
get: function() {
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag == 'warp') : -1;
return app.dbOutbounds ? app.dbOutbounds.findIndex((o) => o.tag == 'warp') : -1;
}
}
}

View file

@ -513,50 +513,6 @@
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="tpl-outbound" tab='{{ i18n "pages.xray.Outbounds"}}' style="padding-top: 20px;" force-render="true">
<a-button type="primary" icon="plus" @click="addOutbound()" style="margin-bottom: 10px;">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
<a-button type="primary" icon="cloud" @click="showWarp()" style="margin-bottom: 10px;">WARP</a-button>
<a-table :columns="outboundColumns" bordered
:row-key="r => r.key"
:data-source="outboundData"
:scroll="isMobile ? {} : { x: 200 }"
:pagination="false"
:indent-size="0"
:style="isMobile ? 'padding: 5px 5px' : 'margin-right: 1px;'">
<template slot="action" slot-scope="text, outbound, index">
[[ index+1 ]]
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more" style="font-size: 16px; text-decoration: bold;"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon>
{{ i18n "pages.xray.rules.first"}}
</a-menu-item>
<a-menu-item @click="editOutbound(index)">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item @click="deleteOutbound(index)">
<span style="color: #FF4D4F">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="address" slot-scope="text, outbound, index">
<p style="margin: 0 5px;" v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
</template>
<template slot="protocol" slot-scope="text, outbound, index">
<a-tag style="margin:0;" color="purple">[[ outbound.protocol ]]</a-tag>
<template v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<a-tag style="margin:0;" color="blue">[[ outbound.streamSettings.network ]]</a-tag>
<a-tag style="margin:0;" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag>
<a-tag style="margin:0;" v-if="outbound.streamSettings.security=='reality'" color="green">reality</a-tag>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="tpl-balancer" tab='{{ i18n "pages.xray.Balancers"}}' style="padding-top: 20px;" force-render="true">
<a-button type="primary" icon="plus" @click="addBalancer()" style="margin-bottom: 10px;">{{ i18n "pages.xray.balancer.addBalancer"}}</a-button>
<a-table :columns="balancerColumns" bordered v-if="balancersData.length>0"
@ -743,7 +699,6 @@
<a-radio-group v-model="advSettings" @change="changeCode" button-style="solid" style="margin: 10px 0;" :size="isMobile ? 'small' : ''">
<a-radio-button value="xraySetting">{{ i18n "pages.xray.completeTemplate"}}</a-radio-button>
<a-radio-button value="inboundSettings">{{ i18n "pages.xray.Inbounds" }}</a-radio-button>
<a-radio-button value="outboundSettings">{{ i18n "pages.xray.Outbounds" }}</a-radio-button>
<a-radio-button value="routingRuleSettings">{{ i18n "pages.xray.Routings" }}</a-radio-button>
</a-radio-group>
<textarea style="position:absolute; left: -800px;" id="xraySetting"></textarea>
@ -758,7 +713,6 @@
{{template "component/themeSwitcher" .}}
{{template "component/setting"}}
{{template "ruleModal"}}
{{template "outModal"}}
{{template "balancerModal"}}
{{template "dnsModal"}}
{{template "fakednsModal"}}
@ -837,6 +791,9 @@
xraySetting: '',
inboundTags: [],
clientReverseTags: [],
outboundTags: [],
outboundReverseTags: [],
loadedOutbounds: [],
saveBtnDisable: true,
restartResult: '',
showAlert: false,
@ -955,6 +912,9 @@
this.xraySetting = xs;
this.inboundTags = result.inboundTags;
this.clientReverseTags = result.clientReverseTags;
this.outboundTags = result.outboundTags || [];
this.outboundReverseTags = result.outboundReverseTags || [];
this.loadedOutbounds = result.outbounds || [];
this.saveBtnDisable = true;
}
},
@ -995,18 +955,6 @@
if(pageKey == 'tpl-advanced') this.changeCode();
if(pageKey == 'tpl-balancer') this.changeObsCode();
},
syncRulesWithOutbound(tag, setting) {
const newTemplateSettings = this.templateSettings;
const haveRules = newTemplateSettings.routing.rules.some((r) => r?.outboundTag === tag);
const outboundIndex = newTemplateSettings.outbounds.findIndex((o) => o.tag === tag);
if (!haveRules && outboundIndex > 0) {
newTemplateSettings.outbounds.splice(outboundIndex);
}
if (haveRules && outboundIndex < 0) {
newTemplateSettings.outbounds.push(setting);
}
this.templateSettings = newTemplateSettings;
},
templateRuleGetter(routeSettings) {
const { property, outboundTag } = routeSettings;
let result = [];
@ -1127,46 +1075,6 @@
}
return serverObj ? serverObj.map(obj => obj.address + ':' + obj.port) : null;
},
addOutbound(){
outModal.show({
title: '{{ i18n "pages.xray.outbound.addOutbound"}}',
okText: '{{ i18n "pages.xray.outbound.addOutbound" }}',
confirm: (outbound) => {
outModal.loading();
if(outbound.tag.length > 0){
this.templateSettings.outbounds.push(outbound);
this.outboundSettings = JSON.stringify(this.templateSettings.outbounds);
}
outModal.close();
},
isEdit: false,
tags: this.templateSettings.outbounds.map(obj => obj.tag)
});
},
editOutbound(index){
outModal.show({
title: '{{ i18n "pages.xray.outbound.editOutbound"}} ' + (index+1),
outbound: app.templateSettings.outbounds[index],
confirm: (outbound) => {
outModal.loading();
this.templateSettings.outbounds[index] = outbound;
this.outboundSettings = JSON.stringify(this.templateSettings.outbounds);
outModal.close();
},
isEdit: true,
tags: this.outboundData.filter((o) => o.key != index ).map(obj => obj.tag)
});
},
deleteOutbound(index){
outbounds = this.templateSettings.outbounds;
outbounds.splice(index,1);
this.outboundSettings = JSON.stringify(outbounds);
},
setFirstOutbound(index){
outbounds = this.templateSettings.outbounds;
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
this.outboundSettings = JSON.stringify(outbounds);
},
addBalancer() {
balancerModal.show({
title: '{{ i18n "pages.xray.balancer.addBalancer"}}',
@ -1438,9 +1346,6 @@
rules.splice(index,1);
this.routingRuleSettings = JSON.stringify(rules);
},
showWarp(){
warpModal.show();
}
},
async mounted() {
if (window.location.protocol !== "https:") {
@ -1529,25 +1434,6 @@
this.templateSettings = newTemplateSettings;
},
},
outboundSettings: {
get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.outbounds, null, 2) : null; },
set: function (newValue) {
newTemplateSettings = this.templateSettings;
newTemplateSettings.outbounds = JSON.parse(newValue);
this.templateSettings = newTemplateSettings;
},
},
outboundData: {
get: function () {
data = []
if (this.templateSettings != null) {
this.templateSettings.outbounds.forEach((o, index) => {
data.push({'key': index, ...o});
});
}
return data;
},
},
routingRuleSettings: {
get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null; },
set: function (newValue) {
@ -1618,23 +1504,21 @@
burstObservatoryEnable: function () { return this.templateSettings != null && this.templateSettings.burstObservatory != undefined },
freedomStrategy: {
get: function () {
if (!this.templateSettings) return "AsIs";
freedomOutbound = this.templateSettings.outbounds.find((o) => o.protocol === "freedom" && o.tag == "direct");
if (!freedomOutbound) return "AsIs";
if (!freedomOutbound.settings || !freedomOutbound.settings.domainStrategy) return "AsIs";
const freedomOutbound = this.loadedOutbounds.find((o) => o.protocol === "freedom" && o.tag == "direct");
if (!freedomOutbound || !freedomOutbound.settings || !freedomOutbound.settings.domainStrategy) return "AsIs";
return freedomOutbound.settings.domainStrategy;
},
set: function (newValue) {
newTemplateSettings = this.templateSettings;
freedomOutboundIndex = newTemplateSettings.outbounds.findIndex((o) => o.protocol === "freedom" && o.tag == "direct");
if(freedomOutboundIndex == -1){
newTemplateSettings.outbounds.push({protocol: "freedom", tag: "direct", settings: { "domainStrategy": newValue }});
} else if (!newTemplateSettings.outbounds[freedomOutboundIndex].settings) {
newTemplateSettings.outbounds[freedomOutboundIndex].settings = { "domainStrategy": newValue };
} else {
newTemplateSettings.outbounds[freedomOutboundIndex].settings.domainStrategy = newValue;
}
this.templateSettings = newTemplateSettings;
const freedomOutbound = this.loadedOutbounds.find((o) => o.protocol === "freedom" && o.tag == "direct");
if (!freedomOutbound) return;
const settings = freedomOutbound.settings || {};
settings.domainStrategy = newValue;
const data = {
protocol: freedomOutbound.protocol,
tag: freedomOutbound.tag,
settings: JSON.stringify(settings, null, 2),
};
HttpUtil.post(`/xui/outbound/update/${freedomOutbound.id}`, data).then(() => this.getXraySetting());
}
},
routingStrategy: {
@ -1678,7 +1562,6 @@
},
set: function (newValue) {
this.templateRuleSetter({ outboundTag: "direct", property: "ip", data: newValue });
this.syncRulesWithOutbound("direct", this.directSettings);
}
},
directDomains: {
@ -1687,7 +1570,6 @@
},
set: function (newValue) {
this.templateRuleSetter({ outboundTag: "direct", property: "domain", data: newValue });
this.syncRulesWithOutbound("direct", this.directSettings);
}
},
ipv4Domains: {
@ -1696,7 +1578,6 @@
},
set: function (newValue) {
this.templateRuleSetter({ outboundTag: "IPv4", property: "domain", data: newValue });
this.syncRulesWithOutbound("IPv4", this.ipv4Settings);
}
},
warpDomains: {
@ -1736,7 +1617,7 @@
},
WarpExist: {
get: function() {
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp")>=0 : false;
return this.outboundTags ? this.outboundTags.includes("warp") : false;
},
},
enableDNS: {
@ -1850,8 +1731,7 @@
}
},
geodataOutboundTags: function () {
if (!this.templateSettings) return [];
return this.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map((o) => o.tag);
return this.outboundTags || [];
},
geodataAssets: {
get: function () {

View file

@ -82,8 +82,8 @@
};
}
this.balancerTags = balancerTags.filter((tag) => tag != balancer.tag);
this.outboundTags = app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag);
this.outboundTags.push(...app.clientReverseTags);
this.outboundTags = [...(app.outboundTags || [])];
if (app.clientReverseTags) this.outboundTags.push(...app.clientReverseTags);
this.isEdit = isEdit;
this.check();
this.checkSelector();

View file

@ -195,11 +195,13 @@
this.isEdit = isEdit;
this.inboundTags = app.templateSettings.inbounds.filter((i) => !ObjectUtil.isEmpty(i.tag)).map(obj => obj.tag);
this.inboundTags.push(...app.inboundTags);
app.templateSettings.outbounds.filter(o => o.protocol === Protocols.VLESS && o.settings.reverse?.tag).forEach(o => {
this.inboundTags.push(o.settings.reverse.tag);
});
if (app.outboundReverseTags) {
app.outboundReverseTags.forEach(tag => {
if (tag && !this.inboundTags.includes(tag)) this.inboundTags.push(tag);
});
}
if (app.enableDNS && !ObjectUtil.isEmpty(app.dnsTag)) this.inboundTags.push(app.dnsTag)
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
this.outboundTags = ["", ...(app.outboundTags || [])];
if (app.clientReverseTags) {
app.clientReverseTags.forEach(tag => {
if (tag && !this.outboundTags.includes(tag)) {

View file

@ -6,8 +6,9 @@ import (
)
type XrayTrafficJob struct {
xrayService service.XrayService
inboundService service.InboundService
xrayService service.XrayService
inboundService service.InboundService
outboundService service.OutboundService
}
func NewXrayTrafficJob() *XrayTrafficJob {
@ -26,7 +27,10 @@ func (j *XrayTrafficJob) Run() {
}
err, needRestart := j.inboundService.AddTraffic(traffics, clientTraffics)
if err != nil {
logger.Warning("add traffic failed:", err)
logger.Warning("add inbound traffic failed:", err)
}
if err := j.outboundService.AddTraffic(traffics); err != nil {
logger.Warning("add outbound traffic failed:", err)
}
if needRestart {
j.xrayService.SetToNeedRestart()

381
web/service/outbound.go Normal file
View file

@ -0,0 +1,381 @@
package service
import (
"encoding/json"
"github.com/alireza0/x-ui/database"
"github.com/alireza0/x-ui/database/model"
"github.com/alireza0/x-ui/logger"
"github.com/alireza0/x-ui/util/common"
"github.com/alireza0/x-ui/xray"
"gorm.io/gorm"
)
type OutboundService struct {
xrayApi xray.XrayAPI
settingService SettingService
}
func (s *OutboundService) GetAllOutbounds() ([]*model.Outbound, error) {
db := database.GetDB()
var outbounds []*model.Outbound
err := db.Model(model.Outbound{}).Order("sort asc, id asc").Find(&outbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return outbounds, nil
}
func (s *OutboundService) GetOutbound(id int) (*model.Outbound, error) {
db := database.GetDB()
outbound := &model.Outbound{}
err := db.Model(model.Outbound{}).First(outbound, id).Error
if err != nil {
return nil, err
}
return outbound, nil
}
func (s *OutboundService) checkTagExist(tag string, ignoreId int) (bool, error) {
db := database.GetDB().Model(model.Outbound{}).Where("tag = ?", tag)
if ignoreId > 0 {
db = db.Where("id != ?", ignoreId)
}
var count int64
err := db.Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *OutboundService) AddOutbound(outbound *model.Outbound) (*model.Outbound, bool, error) {
exist, err := s.checkTagExist(outbound.Tag, 0)
if err != nil {
return outbound, false, err
}
if exist {
return outbound, false, common.NewError("Tag already exists:", outbound.Tag)
}
db := database.GetDB()
var maxSort int
db.Model(model.Outbound{}).Select("COALESCE(MAX(sort), -1)").Scan(&maxSort)
outbound.Sort = maxSort + 1
err = db.Save(outbound).Error
if err != nil {
return outbound, false, err
}
needRestart := false
if p != nil && p.IsRunning() {
s.xrayApi.Init(p.GetAPIPort())
outboundJson, err1 := json.MarshalIndent(outbound.GenXrayOutboundConfig(), "", " ")
if err1 != nil {
logger.Debug("Unable to marshal outbound config:", err1)
} else {
err1 = s.xrayApi.AddOutbound(outboundJson)
if err1 == nil {
logger.Debug("New outbound added by api:", outbound.Tag)
} else {
logger.Debug("Unable to add outbound by api:", err1)
needRestart = true
}
}
s.xrayApi.Close()
}
return outbound, needRestart, nil
}
func (s *OutboundService) DelOutbound(id int) (bool, error) {
db := database.GetDB()
outbound, err := s.GetOutbound(id)
if err != nil {
return false, err
}
needRestart := false
if p != nil && p.IsRunning() {
s.xrayApi.Init(p.GetAPIPort())
err1 := s.xrayApi.DelOutbound(outbound.Tag)
if err1 == nil {
logger.Debug("Outbound deleted by api:", outbound.Tag)
} else {
logger.Debug("Unable to delete outbound by api:", err1)
needRestart = true
}
s.xrayApi.Close()
}
return needRestart, db.Delete(model.Outbound{}, id).Error
}
func (s *OutboundService) UpdateOutbound(outbound *model.Outbound) (*model.Outbound, bool, error) {
exist, err := s.checkTagExist(outbound.Tag, outbound.Id)
if err != nil {
return outbound, false, err
}
if exist {
return outbound, false, common.NewError("Tag already exists:", outbound.Tag)
}
oldOutbound, err := s.GetOutbound(outbound.Id)
if err != nil {
return outbound, false, err
}
oldTag := oldOutbound.Tag
oldOutbound.SendThrough = outbound.SendThrough
oldOutbound.Protocol = outbound.Protocol
oldOutbound.Settings = outbound.Settings
oldOutbound.Tag = outbound.Tag
oldOutbound.StreamSettings = outbound.StreamSettings
oldOutbound.ProxySettings = outbound.ProxySettings
oldOutbound.Mux = outbound.Mux
oldOutbound.TargetStrategy = outbound.TargetStrategy
needRestart := false
if p != nil && p.IsRunning() {
s.xrayApi.Init(p.GetAPIPort())
if s.xrayApi.DelOutbound(oldTag) == nil {
logger.Debug("Old outbound deleted by api:", oldTag)
}
outboundJson, err2 := json.MarshalIndent(oldOutbound.GenXrayOutboundConfig(), "", " ")
if err2 != nil {
logger.Debug("Unable to marshal updated outbound config:", err2)
needRestart = true
} else {
err2 = s.xrayApi.AddOutbound(outboundJson)
if err2 == nil {
logger.Debug("Updated outbound added by api:", oldOutbound.Tag)
} else {
logger.Debug("Unable to update outbound by api:", err2)
needRestart = true
}
}
s.xrayApi.Close()
}
db := database.GetDB()
return outbound, needRestart, db.Save(oldOutbound).Error
}
func (s *OutboundService) SetFirstOutbound(id int) error {
db := database.GetDB()
outbound, err := s.GetOutbound(id)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(model.Outbound{}).Where("sort < ?", outbound.Sort).
Update("sort", gorm.Expr("sort + 1")).Error; err != nil {
return err
}
return tx.Model(model.Outbound{}).Where("id = ?", id).Update("sort", 0).Error
})
}
func (s *OutboundService) ResetTraffic(id int) error {
db := database.GetDB()
return db.Model(model.Outbound{}).Where("id = ?", id).
Updates(map[string]interface{}{"up": 0, "down": 0}).Error
}
func (s *OutboundService) ResetAllTraffics() error {
db := database.GetDB()
return db.Model(model.Outbound{}).Where("1 = 1").
Updates(map[string]interface{}{"up": 0, "down": 0}).Error
}
func (s *OutboundService) GetOutboundSummariesJSON() (string, error) {
outbounds, err := s.GetAllOutbounds()
if err != nil {
return "", err
}
summaries := make([]map[string]interface{}, 0, len(outbounds))
for _, o := range outbounds {
var settings interface{}
if len(o.Settings) > 0 {
json.Unmarshal([]byte(o.Settings), &settings)
}
summaries = append(summaries, map[string]interface{}{
"id": o.Id,
"tag": o.Tag,
"protocol": o.Protocol,
"settings": settings,
})
}
result, _ := json.Marshal(summaries)
return string(result), nil
}
func (s *OutboundService) GetOutboundReverseTags() (string, error) {
outbounds, err := s.GetAllOutbounds()
if err != nil {
return "", err
}
var tags []string
for _, o := range outbounds {
if o.Protocol != "vless" {
continue
}
var settings map[string]interface{}
if json.Unmarshal([]byte(o.Settings), &settings) != nil {
continue
}
reverse, ok := settings["reverse"].(map[string]interface{})
if !ok {
continue
}
if tag, ok := reverse["tag"].(string); ok && tag != "" {
tags = append(tags, tag)
}
}
result, _ := json.Marshal(tags)
return string(result), nil
}
func (s *OutboundService) GetOutboundTags() (string, error) {
db := database.GetDB()
var tags []string
err := db.Model(model.Outbound{}).Select("tag").Order("sort asc, id asc").Find(&tags).Error
if err != nil && err != gorm.ErrRecordNotFound {
return "", err
}
result, _ := json.Marshal(tags)
return string(result), nil
}
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic) error {
hasOutboundTraffic := false
for _, traffic := range traffics {
if !traffic.IsInbound {
hasOutboundTraffic = true
break
}
}
if !hasOutboundTraffic {
if p != nil {
p.SetOnlineOutbounds(nil)
}
return nil
}
var onlineOutbounds []string
db := database.GetDB()
for _, traffic := range traffics {
if traffic.IsInbound {
continue
}
err := db.Model(&model.Outbound{}).Where("tag = ?", traffic.Tag).
Updates(map[string]interface{}{
"up": gorm.Expr("up + ?", traffic.Up),
"down": gorm.Expr("down + ?", traffic.Down),
}).Error
if err != nil {
return err
}
if traffic.Up+traffic.Down > 0 {
onlineOutbounds = append(onlineOutbounds, traffic.Tag)
}
}
if p != nil {
p.SetOnlineOutbounds(onlineOutbounds)
}
return nil
}
func (s *OutboundService) GetOnlineOutbounds() []string {
if p == nil {
return nil
}
return p.GetOnlineOutbounds()
}
func outboundFromMap(raw map[string]interface{}, sort int) *model.Outbound {
o := &model.Outbound{Sort: sort}
if v, ok := raw["sendThrough"].(string); ok {
o.SendThrough = v
}
if v, ok := raw["protocol"].(string); ok {
o.Protocol = v
}
if v, ok := raw["tag"].(string); ok {
o.Tag = v
}
if v, ok := raw["targetStrategy"].(string); ok {
o.TargetStrategy = v
}
if v, ok := raw["settings"]; ok && v != nil {
b, _ := json.Marshal(v)
o.Settings = string(b)
}
if v, ok := raw["streamSettings"]; ok && v != nil {
b, _ := json.Marshal(v)
o.StreamSettings = string(b)
}
if v, ok := raw["proxySettings"]; ok && v != nil {
b, _ := json.Marshal(v)
o.ProxySettings = string(b)
}
if v, ok := raw["mux"]; ok && v != nil {
b, _ := json.Marshal(v)
o.Mux = string(b)
}
return o
}
func (s *OutboundService) MigrateDB() {
db := database.GetDB()
var count int64
db.Model(&model.Outbound{}).Count(&count)
if count > 0 {
return
}
templateConfig, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
logger.Warning("outbound migration: get xray template failed:", err)
return
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(templateConfig), &cfg); err != nil {
logger.Warning("outbound migration: parse template failed:", err)
return
}
rawOutbounds, ok := cfg["outbounds"].([]interface{})
if !ok || len(rawOutbounds) == 0 {
return
}
for i, item := range rawOutbounds {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
outbound := outboundFromMap(raw, i)
if outbound.Tag == "" || outbound.Protocol == "" {
continue
}
if err := db.Create(outbound).Error; err != nil {
logger.Warning("outbound migration: create failed:", err)
}
}
cfg["outbounds"] = []interface{}{}
newTemplate, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
logger.Warning("outbound migration: marshal template failed:", err)
return
}
if err := s.settingService.saveSetting("xrayTemplateConfig", string(newTemplate)); err != nil {
logger.Warning("outbound migration: save template failed:", err)
return
}
logger.Info("Migrated", len(rawOutbounds), "outbound(s) from xray settings to database")
}

View file

@ -90,8 +90,9 @@ type Release struct {
}
type ServerService struct {
xrayService XrayService
inboundService InboundService
xrayService XrayService
inboundService InboundService
outboundService OutboundService
}
func (s *ServerService) GetStatus(lastStatus *Status) *Status {
@ -580,6 +581,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
return common.NewErrorf("Error migrating db: %v", err)
}
s.inboundService.MigrateDB()
s.outboundService.MigrateDB()
// Start Xray
err = s.RestartXrayService()

View file

@ -20,9 +20,10 @@ var (
)
type XrayService struct {
inboundService InboundService
settingService SettingService
xrayAPI xray.XrayAPI
inboundService InboundService
outboundService OutboundService
settingService SettingService
xrayAPI xray.XrayAPI
}
func (s *XrayService) IsXrayRunning() bool {
@ -182,6 +183,17 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
inboundConfig := inbound.GenXrayInboundConfig()
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
}
xrayConfig.OutboundConfigs = []xray.OutboundConfig{}
outbounds, err := s.outboundService.GetAllOutbounds()
if err != nil {
return nil, err
}
for _, outbound := range outbounds {
outboundConfig := outbound.GenXrayOutboundConfig()
xrayConfig.OutboundConfigs = append(xrayConfig.OutboundConfigs, *outboundConfig)
}
return xrayConfig, nil
}

View file

@ -56,6 +56,7 @@
[menu]
"dashboard" = "Overview"
"inbounds" = "Inbounds"
"outbounds" = "Outbounds"
"settings" = "Panel Settings"
"xray" = "Xray Configs"
"logout" = "Log Out"
@ -179,6 +180,22 @@
"import" = "Import"
"importInbound" = "Import an Inbound"
[pages.outbounds]
"title" = "Outbounds"
"totalDownUp" = "Total Sent/Received"
"totalUsage" = "Total Usage"
"outboundCount" = "Total Outbounds"
"create" = "Create"
"edit" = "Edit Outbound"
"update" = "Update"
"delete" = "Delete Outbound"
"deleteConfirm" = "Are you sure you want to delete this outbound?"
"resetTraffic" = "Reset Traffic"
"resetAllTraffic" = "Reset All Outbound Traffic"
[pages.outbounds.toasts]
"obtain" = "Failed to load outbounds"
[pages.client]
"add" = "Add Client"
"edit" = "Edit Client"
@ -616,6 +633,9 @@
"reverseTagDesc" = "The tag of the reverse proxy inbound that this VLESS client uses."
"reverseTagPlaceholder" = "reverse-inbound-tag"
"sendThrough" = "Send Through"
"advanced" = "Advanced"
"linkConverter" = "Link converter"
"jsonFreeEditor" = "JSON free editor"
[pages.xray.balancer]
"addBalancer" = "Add Balancer"

View file

@ -56,6 +56,7 @@
[menu]
"dashboard" = "نمای کلی"
"inbounds" = "ورودی‌ها"
"outbounds" = "خروجی‌ها"
"settings" = "تنظیمات پنل"
"xray" = "پیکربندی ایکس‌ری"
"logout" = "خروج"
@ -179,6 +180,22 @@
"import" = "افزودن"
"importInbound" = "افزودن یک ورودی"
[pages.outbounds]
"title" = "خروجی‌ها"
"totalDownUp" = "مجموع ارسال/دریافت"
"totalUsage" = "مجموع مصرف"
"outboundCount" = "تعداد خروجی‌ها"
"create" = "ایجاد"
"edit" = "ویرایش خروجی"
"update" = "به‌روزرسانی"
"delete" = "حذف خروجی"
"deleteConfirm" = "آیا از حذف این خروجی مطمئن هستید؟"
"resetTraffic" = "ریست ترافیک"
"resetAllTraffic" = "ریست ترافیک همه خروجی‌ها"
[pages.outbounds.toasts]
"obtain" = "دریافت خروجی‌ها ناموفق بود"
[pages.client]
"add" = "کاربر جدید"
"edit" = "ویرایش کاربر"
@ -616,6 +633,9 @@
"reverseTagDesc" = "برچسب inbound پروکسی معکوسی که این کلاینت VLESS به آن متصل می‌شود."
"reverseTagPlaceholder" = "reverse-inbound-tag"
"sendThrough" = "ارسال با"
"advanced" = "پیشرفته"
"linkConverter" = "تبدیل لینک"
"jsonFreeEditor" = "ویرایشگر آزاد JSON"
[pages.xray.balancer]
"addBalancer" = "افزودن بالانسر"

View file

@ -56,6 +56,7 @@
[menu]
"dashboard" = "Обзор"
"inbounds" = "Подключения"
"outbounds" = "Исходящие"
"settings" = "Настройки"
"xray" = "Xray"
"logout" = "Выйти"
@ -179,6 +180,22 @@
"import" = "Импортировать"
"importInbound" = "Импортировать входящее сообщение"
[pages.outbounds]
"title" = "Исходящие"
"totalDownUp" = "Всего отправлено/получено"
"totalUsage" = "Общий трафик"
"outboundCount" = "Всего исходящих"
"create" = "Создать"
"edit" = "Редактировать исходящий"
"update" = "Обновить"
"delete" = "Удалить исходящий"
"deleteConfirm" = "Удалить этот исходящий?"
"resetTraffic" = "Сбросить трафик"
"resetAllTraffic" = "Сбросить весь трафик исходящих"
[pages.outbounds.toasts]
"obtain" = "Не удалось загрузить исходящие"
[pages.client]
"add" = "Добавить клиента"
"edit" = "Редактировать клиента"
@ -616,6 +633,9 @@
"reverseTagDesc" = "Тег inbound обратного прокси, который использует этот VLESS-клиент."
"reverseTagPlaceholder" = "reverse-inbound-tag"
"sendThrough" = "Отправить через"
"advanced" = "Дополнительно"
"linkConverter" = "Конвертер ссылок"
"jsonFreeEditor" = "Свободный редактор JSON"
[pages.xray.balancer]
"addBalancer" = "Добавить балансир"

View file

@ -56,6 +56,7 @@
[menu]
"dashboard" = "Tổng quan"
"inbounds" = "Đầu Vào khách hàng"
"outbounds" = "Đầu Ra"
"settings" = "Cài đặt X-UI"
"xray" = "Cài đặt Xray"
"logout" = "Đăng xuất"
@ -179,6 +180,22 @@
"import" = "Nhập"
"importInbound" = "Nhập hàng gửi về"
[pages.outbounds]
"title" = "Outbounds"
"totalDownUp" = "Tổng Gửi/Nhận"
"totalUsage" = "Tổng sử dụng"
"outboundCount" = "Tổng Outbound"
"create" = "Tạo"
"edit" = "Sửa Outbound"
"update" = "Cập nhật"
"delete" = "Xóa Outbound"
"deleteConfirm" = "Bạn có chắc muốn xóa outbound này?"
"resetTraffic" = "Đặt lại lưu lượng"
"resetAllTraffic" = "Đặt lại tất cả lưu lượng outbound"
[pages.outbounds.toasts]
"obtain" = "Không thể tải outbounds"
[pages.client]
"add" = "Thêm máy khách"
"edit" = "Chỉnh sửa Máy khách"
@ -616,6 +633,9 @@
"reverseTagDesc" = "Thẻ inbound reverse proxy mà client VLESS này sử dụng."
"reverseTagPlaceholder" = "reverse-inbound-tag"
"sendThrough" = "Gửi qua"
"advanced" = "Nâng cao"
"linkConverter" = "Chuyển đổi liên kết"
"jsonFreeEditor" = "Trình soạn JSON tự do"
[pages.xray.balancer]
"addBalancer" = "Thêm cân bằng"

View file

@ -56,6 +56,7 @@
[menu]
"dashboard" = "概述"
"inbounds" = "入站列表"
"outbounds" = "出站列表"
"settings" = "面板设置"
"xray" = "Xray"
"logout" = "退出登录"
@ -179,6 +180,22 @@
"import"="导入"
"importInbound" = "导入入站数据"
[pages.outbounds]
"title" = "出站"
"totalDownUp" = "总发送/接收"
"totalUsage" = "总用量"
"outboundCount" = "出站总数"
"create" = "创建"
"edit" = "编辑出站"
"update" = "更新"
"delete" = "删除出站"
"deleteConfirm" = "确定要删除此出站吗?"
"resetTraffic" = "重置流量"
"resetAllTraffic" = "重置所有出站流量"
[pages.outbounds.toasts]
"obtain" = "获取出站失败"
[pages.client]
"add" = "添加客户端"
"edit" = "编辑客户"
@ -616,6 +633,9 @@
"reverseTagDesc" = "此 VLESS 客户端使用的反向代理 inbound 标签。"
"reverseTagPlaceholder" = "reverse-inbound-tag"
"sendThrough" = "发送通过"
"advanced" = "高级"
"linkConverter" = "链接转换"
"jsonFreeEditor" = "JSON 自由编辑"
[pages.xray.balancer]
"addBalancer" = "添加平衡器"

View file

@ -87,6 +87,34 @@ func (x *XrayAPI) DelInbound(tag string) error {
return err
}
func (x *XrayAPI) AddOutbound(outbound []byte) error {
client := *x.HandlerServiceClient
conf := new(conf.OutboundDetourConfig)
err := json.Unmarshal(outbound, conf)
if err != nil {
logger.Debug("Failed to unmarshal outbound:", err)
return err
}
config, err := conf.Build()
if err != nil {
logger.Debug("Failed to build outbound:", err)
return err
}
outboundConfig := command.AddOutboundRequest{Outbound: config}
_, err = client.AddOutbound(context.Background(), &outboundConfig)
return err
}
func (x *XrayAPI) DelOutbound(tag string) error {
client := *x.HandlerServiceClient
_, err := client.RemoveOutbound(context.Background(), &command.RemoveOutboundRequest{
Tag: tag,
})
return err
}
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]interface{}) error {
var account *serial.TypedMessage
switch Protocol {

View file

@ -11,7 +11,7 @@ type Config struct {
RouterConfig json_util.RawMessage `json:"routing"`
DNSConfig json_util.RawMessage `json:"dns"`
InboundConfigs []InboundConfig `json:"inbounds"`
OutboundConfigs json_util.RawMessage `json:"outbounds"`
OutboundConfigs []OutboundConfig `json:"outbounds"`
Transport json_util.RawMessage `json:"transport"`
Policy json_util.RawMessage `json:"policy"`
API json_util.RawMessage `json:"api"`
@ -41,9 +41,14 @@ func (c *Config) Equals(other *Config) bool {
if !bytes.Equal(c.DNSConfig, other.DNSConfig) {
return false
}
if !bytes.Equal(c.OutboundConfigs, other.OutboundConfigs) {
if len(c.OutboundConfigs) != len(other.OutboundConfigs) {
return false
}
for i, outbound := range c.OutboundConfigs {
if !outbound.Equals(&other.OutboundConfigs[i]) {
return false
}
}
if !bytes.Equal(c.Transport, other.Transport) {
return false
}

46
xray/outbound.go Normal file
View file

@ -0,0 +1,46 @@
package xray
import (
"bytes"
"github.com/alireza0/x-ui/util/json_util"
)
type OutboundConfig struct {
SendThrough json_util.RawMessage `json:"sendThrough,omitempty"`
Protocol string `json:"protocol"`
Settings json_util.RawMessage `json:"settings"`
Tag string `json:"tag"`
StreamSettings json_util.RawMessage `json:"streamSettings,omitempty"`
ProxySettings json_util.RawMessage `json:"proxySettings,omitempty"`
Mux json_util.RawMessage `json:"mux,omitempty"`
TargetStrategy string `json:"targetStrategy,omitempty"`
}
func (c *OutboundConfig) Equals(other *OutboundConfig) bool {
if !bytes.Equal(c.SendThrough, other.SendThrough) {
return false
}
if c.Protocol != other.Protocol {
return false
}
if !bytes.Equal(c.Settings, other.Settings) {
return false
}
if c.Tag != other.Tag {
return false
}
if !bytes.Equal(c.StreamSettings, other.StreamSettings) {
return false
}
if !bytes.Equal(c.ProxySettings, other.ProxySettings) {
return false
}
if !bytes.Equal(c.Mux, other.Mux) {
return false
}
if c.TargetStrategy != other.TargetStrategy {
return false
}
return true
}

View file

@ -57,7 +57,8 @@ type process struct {
version string
apiPort int
onlineClients []string
onlineClients []string
onlineOutbounds []string
config *Config
logWriter *LogWriter
@ -115,6 +116,14 @@ func (p *Process) SetOnlineClients(users []string) {
p.onlineClients = users
}
func (p *Process) GetOnlineOutbounds() []string {
return p.onlineOutbounds
}
func (p *Process) SetOnlineOutbounds(tags []string) {
p.onlineOutbounds = tags
}
func (p *Process) GetUptime() uint64 {
return uint64(time.Since(p.startTime).Seconds())
}