mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-26 18:02:30 +03:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50603fd430 | ||
|
|
8bea0fde2b | ||
|
|
b2d32f588f | ||
|
|
8177f6dc66 | ||
|
|
77d94b25d0 | ||
|
|
32b7ada549 | ||
|
|
6099a07ff0 | ||
|
|
e9806832ec | ||
|
|
15ebf3df10 | ||
|
|
d44b70682c | ||
|
|
fb75e3d7c7 | ||
|
|
e9979b6774 | ||
|
|
2b83dc047b | ||
|
|
c90f8a05bf | ||
|
|
9f96ef83ec | ||
|
|
e19061d513 | ||
|
|
51e2fb6dbf | ||
|
|
f21ed92296 | ||
|
|
22de983752 | ||
|
|
0b5c239f98 | ||
|
|
03393c9f52 | ||
|
|
b56db67759 | ||
|
|
6d05702d00 | ||
|
|
9791b05a4e | ||
|
|
0aca2d3b3d | ||
|
|
8529f4f0cf | ||
|
|
abc5cf3439 | ||
|
|
a7e7788e29 | ||
|
|
8620344925 | ||
|
|
47e229e323 | ||
|
|
4521beab7c | ||
|
|
a62c637632 | ||
|
|
35609b7b13 | ||
|
|
a4b1b3d06d | ||
|
|
5f7c7c5f3d | ||
|
|
6bcaf61c44 | ||
|
|
ff25072690 | ||
|
|
530c1597b8 | ||
|
|
c8e16d8c41 | ||
|
|
17f67ef3a5 | ||
|
|
eb4791a1cd | ||
|
|
71ac920436 | ||
|
|
e6d0c33937 | ||
|
|
eef2d311f4 |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -126,7 +126,7 @@ jobs:
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
|
||||
@@ -27,7 +27,7 @@ case $1 in
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
@@ -37,4 +37,4 @@ curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/release
|
||||
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
cd ../../
|
||||
cd ../../
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.9.2
|
||||
2.9.4
|
||||
@@ -21,9 +21,21 @@ const (
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
Hysteria Protocol = "hysteria"
|
||||
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
|
||||
// settings.version to discriminate. Imports from outside the panel
|
||||
// can carry the literal "hysteria2" string, so IsHysteria below
|
||||
// accepts both.
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
||||
// with the literal v2 string would otherwise fall through (#4081).
|
||||
func IsHysteria(p Protocol) bool {
|
||||
return p == Hysteria || p == Hysteria2
|
||||
}
|
||||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
@@ -117,22 +129,27 @@ type CustomGeoResource struct {
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
22
database/model/model_test.go
Normal file
22
database/model/model_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsHysteria(t *testing.T) {
|
||||
cases := []struct {
|
||||
in Protocol
|
||||
want bool
|
||||
}{
|
||||
{Hysteria, true},
|
||||
{Hysteria2, true},
|
||||
{VLESS, false},
|
||||
{Shadowsocks, false},
|
||||
{Protocol(""), false},
|
||||
{Protocol("hysteria3"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsHysteria(c.in); got != c.want {
|
||||
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
go.mod
24
go.mod
@@ -15,9 +15,9 @@ require (
|
||||
github.com/mymmrac/telego v1.8.0
|
||||
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.70.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
@@ -26,20 +26,20 @@ require (
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/grpc v1.81.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
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
|
||||
@@ -57,12 +57,12 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/ratelimit v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
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.21 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.42 // 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
|
||||
@@ -72,7 +72,7 @@ require (
|
||||
github.com/quic-go/quic-go v0.59.0 // 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.8 // indirect
|
||||
github.com/sagernet/sing v0.8.9 // 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
|
||||
@@ -84,7 +84,7 @@ require (
|
||||
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.1 // 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.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
@@ -95,7 +95,7 @@ require (
|
||||
golang.org/x/tools v0.44.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
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
|
||||
68
go.sum
68
go.sum
@@ -1,5 +1,5 @@
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
@@ -10,16 +10,16 @@ github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEc
|
||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||
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=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
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=
|
||||
@@ -107,8 +107,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -119,10 +119,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
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.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=
|
||||
@@ -138,8 +138,8 @@ github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0C
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
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.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
||||
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -156,12 +156,12 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagernet/sing v0.8.8 h1:1dRlGJ3wm4d2nwjKI1R/dr/7GKDKgUvXyD4OAWlQyt8=
|
||||
github.com/sagernet/sing v0.8.8/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
|
||||
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||
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/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=
|
||||
@@ -203,20 +203,20 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1/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/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
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/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/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 v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
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=
|
||||
@@ -256,10 +256,10 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
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/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||
google.golang.org/grpc v1.81.0/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=
|
||||
|
||||
440
install.sh
440
install.sh
@@ -18,7 +18,7 @@ xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
source /etc/os-release
|
||||
release=$ID
|
||||
elif [[ -f /usr/lib/os-release ]]; then
|
||||
elif [[ -f /usr/lib/os-release ]]; then
|
||||
source /usr/lib/os-release
|
||||
release=$ID
|
||||
else
|
||||
@@ -59,16 +59,16 @@ is_domain() {
|
||||
# Port helpers
|
||||
is_port_in_use() {
|
||||
local port="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
|
||||
if command -v ss > /dev/null 2>&1; then
|
||||
ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
|
||||
return
|
||||
fi
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
|
||||
if command -v netstat > /dev/null 2>&1; then
|
||||
netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
|
||||
return
|
||||
fi
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
|
||||
if command -v lsof > /dev/null 2>&1; then
|
||||
lsof -nP -iTCP:${port} -sTCP:LISTEN > /dev/null 2>&1 && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
@@ -77,35 +77,35 @@ install_base() {
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
|
||||
;;
|
||||
;;
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
|
||||
;;
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
|
||||
else
|
||||
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
|
||||
;;
|
||||
;;
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
|
||||
;;
|
||||
;;
|
||||
alpine)
|
||||
apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
|
||||
;;
|
||||
;;
|
||||
*)
|
||||
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
|
||||
;;
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
gen_random_string() {
|
||||
local length="$1"
|
||||
openssl rand -base64 $(( length * 2 )) \
|
||||
openssl rand -base64 $((length * 2)) \
|
||||
| tr -dc 'a-zA-Z0-9' \
|
||||
| head -c "$length"
|
||||
}
|
||||
@@ -113,7 +113,7 @@ gen_random_string() {
|
||||
install_acme() {
|
||||
echo -e "${green}Installing acme.sh for SSL certificate management...${plain}"
|
||||
cd ~ || return 1
|
||||
curl -s https://get.acme.sh | sh >/dev/null 2>&1
|
||||
curl -s https://get.acme.sh | sh > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to install acme.sh${plain}"
|
||||
return 1
|
||||
@@ -128,60 +128,60 @@ setup_ssl_certificate() {
|
||||
local server_ip="$2"
|
||||
local existing_port="$3"
|
||||
local existing_webBasePath="$4"
|
||||
|
||||
|
||||
echo -e "${green}Setting up SSL certificate...${plain}"
|
||||
|
||||
|
||||
# Check if acme.sh is installed
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||||
install_acme
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to install acme.sh, skipping SSL setup${plain}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Create certificate directory
|
||||
local certPath="/root/cert/${domain}"
|
||||
mkdir -p "$certPath"
|
||||
|
||||
|
||||
# Issue certificate
|
||||
echo -e "${green}Issuing SSL certificate for ${domain}...${plain}"
|
||||
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
|
||||
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
|
||||
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
||||
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
|
||||
rm -rf ~/.acme.sh/${domain} 2>/dev/null
|
||||
rm -rf "$certPath" 2>/dev/null
|
||||
rm -rf ~/.acme.sh/${domain} 2> /dev/null
|
||||
rm -rf "$certPath" 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
# Install certificate
|
||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||
--key-file /root/cert/${domain}/privkey.pem \
|
||||
--fullchain-file /root/cert/${domain}/fullchain.pem \
|
||||
--reloadcmd "systemctl restart x-ui" >/dev/null 2>&1
|
||||
|
||||
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to install certificate${plain}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
# Enable auto-renew
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
|
||||
# Secure permissions: private key readable only by owner
|
||||
chmod 600 $certPath/privkey.pem 2>/dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2>/dev/null
|
||||
|
||||
chmod 600 $certPath/privkey.pem 2> /dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2> /dev/null
|
||||
|
||||
# Set certificate for panel
|
||||
local webCertFile="/root/cert/${domain}/fullchain.pem"
|
||||
local webKeyFile="/root/cert/${domain}/privkey.pem"
|
||||
|
||||
|
||||
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" > /dev/null 2>&1
|
||||
echo -e "${green}SSL certificate installed and configured successfully!${plain}"
|
||||
return 0
|
||||
else
|
||||
@@ -194,14 +194,14 @@ setup_ssl_certificate() {
|
||||
# Requires acme.sh and port 80 open for HTTP-01 challenge
|
||||
setup_ip_certificate() {
|
||||
local ipv4="$1"
|
||||
local ipv6="$2" # optional
|
||||
local ipv6="$2" # optional
|
||||
|
||||
echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}"
|
||||
echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}"
|
||||
echo -e "${yellow}Default listener is port 80. If you choose another port, ensure external port 80 forwards to it.${plain}"
|
||||
|
||||
# Check for acme.sh
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||||
install_acme
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to install acme.sh${plain}"
|
||||
@@ -273,8 +273,8 @@ setup_ip_certificate() {
|
||||
|
||||
# Issue certificate with shortlived profile
|
||||
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --issue \
|
||||
${domain_args} \
|
||||
--standalone \
|
||||
@@ -288,9 +288,9 @@ setup_ip_certificate() {
|
||||
echo -e "${red}Failed to issue IP certificate${plain}"
|
||||
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
|
||||
rm -rf ${certDir} 2>/dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -308,25 +308,25 @@ setup_ip_certificate() {
|
||||
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
|
||||
echo -e "${red}Certificate files not found after installation${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
|
||||
rm -rf ${certDir} 2>/dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${green}Certificate files installed successfully${plain}"
|
||||
|
||||
# Enable auto-upgrade for acme.sh (ensures cron job runs)
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
|
||||
|
||||
# Secure permissions: private key readable only by owner
|
||||
chmod 600 ${certDir}/privkey.pem 2>/dev/null
|
||||
chmod 644 ${certDir}/fullchain.pem 2>/dev/null
|
||||
chmod 600 ${certDir}/privkey.pem 2> /dev/null
|
||||
chmod 644 ${certDir}/fullchain.pem 2> /dev/null
|
||||
|
||||
# Configure panel to use the certificate
|
||||
echo -e "${green}Setting certificate paths for the panel...${plain}"
|
||||
${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem"
|
||||
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Warning: Could not set certificate paths automatically${plain}"
|
||||
echo -e "${yellow}Certificate files are at:${plain}"
|
||||
@@ -346,9 +346,9 @@ setup_ip_certificate() {
|
||||
ssl_cert_issue() {
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
|
||||
|
||||
# check for acme.sh first
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||||
echo "acme.sh could not be found. Installing now..."
|
||||
cd ~ || return 1
|
||||
curl -s https://get.acme.sh | sh
|
||||
@@ -364,18 +364,18 @@ ssl_cert_issue() {
|
||||
local domain=""
|
||||
while true; do
|
||||
read -rp "Please enter your domain name: " domain
|
||||
domain="${domain// /}" # Trim whitespace
|
||||
|
||||
domain="${domain// /}" # Trim whitespace
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
echo -e "${red}Domain name cannot be empty. Please try again.${plain}"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
if ! is_domain "$domain"; then
|
||||
echo -e "${red}Invalid domain format: ${domain}. Please enter a valid domain name.${plain}"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
break
|
||||
done
|
||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||
@@ -383,9 +383,9 @@ ssl_cert_issue() {
|
||||
|
||||
# detect existing certificate and reuse it if present
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
@@ -412,7 +412,7 @@ ssl_cert_issue() {
|
||||
|
||||
# Stop panel temporarily
|
||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||
systemctl stop x-ui 2> /dev/null || rc-service x-ui stop 2> /dev/null
|
||||
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
# issue the certificate
|
||||
@@ -421,7 +421,7 @@ ssl_cert_issue() {
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||
@@ -441,18 +441,18 @@ ssl_cert_issue() {
|
||||
echo -e "${green}\t0.${plain} Keep default reloadcmd"
|
||||
read -rp "Choose an option: " choice
|
||||
case "$choice" in
|
||||
1)
|
||||
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
|
||||
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
|
||||
;;
|
||||
2)
|
||||
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
|
||||
read -rp "Please enter your custom reloadcmd: " reloadCmd
|
||||
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${green}Keeping default reloadcmd${plain}"
|
||||
;;
|
||||
1)
|
||||
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
|
||||
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
|
||||
;;
|
||||
2)
|
||||
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
|
||||
read -rp "Please enter your custom reloadcmd: " reloadCmd
|
||||
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${green}Keeping default reloadcmd${plain}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -469,14 +469,14 @@ ssl_cert_issue() {
|
||||
installWroteFiles=1
|
||||
fi
|
||||
|
||||
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && (${installRc} -eq 0 || ${installWroteFiles} -eq 1) ]]; then
|
||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||
else
|
||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
fi
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -486,18 +486,18 @@ ssl_cert_issue() {
|
||||
echo -e "${yellow}Auto renew setup had issues, certificate details:${plain}"
|
||||
ls -lah /root/cert/${domain}/
|
||||
# Secure permissions: private key readable only by owner
|
||||
chmod 600 $certPath/privkey.pem 2>/dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2>/dev/null
|
||||
chmod 600 $certPath/privkey.pem 2> /dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2> /dev/null
|
||||
else
|
||||
echo -e "${green}Auto renew succeeded, certificate details:${plain}"
|
||||
ls -lah /root/cert/${domain}/
|
||||
# Secure permissions: private key readable only by owner
|
||||
chmod 600 $certPath/privkey.pem 2>/dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2>/dev/null
|
||||
chmod 600 $certPath/privkey.pem 2> /dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2> /dev/null
|
||||
fi
|
||||
|
||||
# start panel
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
|
||||
# Prompt user to set panel paths after successful certificate installation
|
||||
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
|
||||
@@ -513,14 +513,14 @@ ssl_cert_issue() {
|
||||
echo ""
|
||||
echo -e "${green}Access URL: https://${domain}:${existing_port}/${existing_webBasePath}${plain}"
|
||||
echo -e "${yellow}Panel will restart to apply SSL certificate...${plain}"
|
||||
systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null
|
||||
systemctl restart x-ui 2> /dev/null || rc-service x-ui restart 2> /dev/null
|
||||
else
|
||||
echo -e "${red}Error: Certificate or private key file not found for domain: $domain.${plain}"
|
||||
fi
|
||||
else
|
||||
echo -e "${yellow}Skipping panel path setting.${plain}"
|
||||
fi
|
||||
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -528,7 +528,7 @@ ssl_cert_issue() {
|
||||
# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
|
||||
prompt_and_setup_ssl() {
|
||||
local panel_port="$1"
|
||||
local web_base_path="$2" # expected without leading slash
|
||||
local web_base_path="$2" # expected without leading slash
|
||||
local server_ip="$3"
|
||||
|
||||
local ssl_choice=""
|
||||
@@ -539,124 +539,124 @@ prompt_and_setup_ssl() {
|
||||
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
||||
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||
|
||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||
|
||||
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
|
||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
|
||||
ssl_choice="2"
|
||||
fi
|
||||
|
||||
case "$ssl_choice" in
|
||||
1)
|
||||
# User chose Let's Encrypt domain option
|
||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||
if ssl_cert_issue; then
|
||||
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||
if [[ -z "${cert_domain}" ]]; then
|
||||
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
fi
|
||||
1)
|
||||
# User chose Let's Encrypt domain option
|
||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||
if ssl_cert_issue; then
|
||||
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||
if [[ -z "${cert_domain}" ]]; then
|
||||
cert_domain=$(~/.acme.sh/acme.sh --list 2> /dev/null | tail -1 | awk '{print $1}')
|
||||
fi
|
||||
|
||||
if [[ -n "${cert_domain}" ]]; then
|
||||
SSL_HOST="${cert_domain}"
|
||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||
if [[ -n "${cert_domain}" ]]; then
|
||||
SSL_HOST="${cert_domain}"
|
||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||
else
|
||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
else
|
||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
else
|
||||
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
# User chose Let's Encrypt IP certificate option
|
||||
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
|
||||
|
||||
# Ask for optional IPv6
|
||||
local ipv6_addr=""
|
||||
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
|
||||
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
|
||||
|
||||
# Stop panel if running (port 80 needed)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop >/dev/null 2>&1
|
||||
else
|
||||
systemctl stop x-ui >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
|
||||
if [ $? -eq 0 ]; then
|
||||
SSL_HOST="${server_ip}"
|
||||
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
|
||||
else
|
||||
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
;;
|
||||
3)
|
||||
# User chose Custom Paths (User Provided) option
|
||||
echo -e "${green}Using custom existing certificate...${plain}"
|
||||
local custom_cert=""
|
||||
local custom_key=""
|
||||
local custom_domain=""
|
||||
;;
|
||||
2)
|
||||
# User chose Let's Encrypt IP certificate option
|
||||
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
|
||||
|
||||
# 3.1 Request Domain to compose Panel URL later
|
||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||
custom_domain="${custom_domain// /}" # Remove spaces
|
||||
# Ask for optional IPv6
|
||||
local ipv6_addr=""
|
||||
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
|
||||
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
|
||||
|
||||
# 3.2 Loop for Certificate Path
|
||||
while true; do
|
||||
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
|
||||
# Strip quotes if present
|
||||
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
# Stop panel if running (port 80 needed)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop > /dev/null 2>&1
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
systemctl stop x-ui > /dev/null 2>&1
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.3 Loop for Private Key Path
|
||||
while true; do
|
||||
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
|
||||
# Strip quotes if present
|
||||
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
|
||||
if [ $? -eq 0 ]; then
|
||||
SSL_HOST="${server_ip}"
|
||||
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
3)
|
||||
# User chose Custom Paths (User Provided) option
|
||||
echo -e "${green}Using custom existing certificate...${plain}"
|
||||
local custom_cert=""
|
||||
local custom_key=""
|
||||
local custom_domain=""
|
||||
|
||||
# 3.4 Apply Settings via x-ui binary
|
||||
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
|
||||
|
||||
# Set SSL_HOST for composing Panel URL
|
||||
if [[ -n "$custom_domain" ]]; then
|
||||
SSL_HOST="$custom_domain"
|
||||
else
|
||||
# 3.1 Request Domain to compose Panel URL later
|
||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||
custom_domain="${custom_domain// /}" # Remove spaces
|
||||
|
||||
# 3.2 Loop for Certificate Path
|
||||
while true; do
|
||||
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
|
||||
# Strip quotes if present
|
||||
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.3 Loop for Private Key Path
|
||||
while true; do
|
||||
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
|
||||
# Strip quotes if present
|
||||
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.4 Apply Settings via x-ui binary
|
||||
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" > /dev/null 2>&1
|
||||
|
||||
# Set SSL_HOST for composing Panel URL
|
||||
if [[ -n "$custom_domain" ]]; then
|
||||
SSL_HOST="$custom_domain"
|
||||
else
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
|
||||
echo -e "${green}✓ Custom certificate paths applied.${plain}"
|
||||
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
|
||||
|
||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
|
||||
echo -e "${green}✓ Custom certificate paths applied.${plain}"
|
||||
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
|
||||
|
||||
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
;;
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ config_after_install() {
|
||||
)
|
||||
local server_ip=""
|
||||
for ip_address in "${URL_lists[@]}"; do
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
||||
local http_code=$(echo "$response" | tail -n1)
|
||||
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
||||
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
||||
@@ -684,13 +684,13 @@ config_after_install() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
if [[ ${#existing_webBasePath} -lt 4 ]]; then
|
||||
if [[ "$existing_hasDefaultCredential" == "true" ]]; then
|
||||
local config_webBasePath=$(gen_random_string 18)
|
||||
local config_username=$(gen_random_string 10)
|
||||
local config_password=$(gen_random_string 10)
|
||||
|
||||
|
||||
read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm
|
||||
if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then
|
||||
read -rp "Please set up the panel port: " config_port
|
||||
@@ -699,9 +699,9 @@ config_after_install() {
|
||||
local config_port=$(shuf -i 1024-62000 -n 1)
|
||||
echo -e "${yellow}Generated random port: ${config_port}${plain}"
|
||||
fi
|
||||
|
||||
|
||||
${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}"
|
||||
|
||||
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} SSL Certificate Setup (MANDATORY) ${plain}"
|
||||
@@ -711,7 +711,7 @@ config_after_install() {
|
||||
echo ""
|
||||
|
||||
prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
|
||||
|
||||
|
||||
# Display final credentials and access information
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
@@ -750,7 +750,7 @@ config_after_install() {
|
||||
if [[ "$existing_hasDefaultCredential" == "true" ]]; then
|
||||
local config_username=$(gen_random_string 10)
|
||||
local config_password=$(gen_random_string 10)
|
||||
|
||||
|
||||
echo -e "${yellow}Default credentials detected. Security update required...${plain}"
|
||||
${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}"
|
||||
echo -e "Generated new random login credentials:"
|
||||
@@ -778,13 +778,13 @@ config_after_install() {
|
||||
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
${xui_folder}/x-ui migrate
|
||||
}
|
||||
|
||||
install_x-ui() {
|
||||
cd ${xui_folder%/x-ui}/
|
||||
|
||||
|
||||
# Download resources
|
||||
if [ $# == 0 ]; then
|
||||
tag_version=$(curl -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
@@ -806,12 +806,12 @@ install_x-ui() {
|
||||
tag_version=$1
|
||||
tag_version_numeric=${tag_version#v}
|
||||
min_version="2.3.5"
|
||||
|
||||
|
||||
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
|
||||
echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
|
||||
echo -e "Beginning to install x-ui $1"
|
||||
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url}
|
||||
@@ -825,7 +825,7 @@ install_x-ui() {
|
||||
echo -e "${red}Failed to download x-ui.sh${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Stop x-ui service and remove old resources
|
||||
if [[ -e ${xui_folder}/ ]]; then
|
||||
if [[ $release == "alpine" ]]; then
|
||||
@@ -835,22 +835,22 @@ install_x-ui() {
|
||||
fi
|
||||
rm ${xui_folder}/ -rf
|
||||
fi
|
||||
|
||||
|
||||
# Extract resources and set permissions
|
||||
tar zxvf x-ui-linux-$(arch).tar.gz
|
||||
rm x-ui-linux-$(arch).tar.gz -f
|
||||
|
||||
|
||||
cd x-ui
|
||||
chmod +x x-ui
|
||||
chmod +x x-ui.sh
|
||||
|
||||
|
||||
# Check the system's architecture and rename the file accordingly
|
||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||
mv bin/xray-linux-$(arch) bin/xray-linux-arm
|
||||
chmod +x bin/xray-linux-arm
|
||||
fi
|
||||
chmod +x x-ui bin/xray-linux-$(arch)
|
||||
|
||||
|
||||
# Update x-ui cli and se set permission
|
||||
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
|
||||
chmod +x /usr/bin/x-ui
|
||||
@@ -870,7 +870,7 @@ install_x-ui() {
|
||||
echo -e "${green}Created /etc/.gitignore and added x-ui.db for etckeeper${plain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ $release == "alpine" ]]; then
|
||||
curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
|
||||
if [[ $? -ne 0 ]]; then
|
||||
@@ -883,73 +883,73 @@ install_x-ui() {
|
||||
else
|
||||
# Install systemd service file
|
||||
service_installed=false
|
||||
|
||||
|
||||
if [ -f "x-ui.service" ]; then
|
||||
echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service ${xui_service}/ >/dev/null 2>&1
|
||||
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [ "$service_installed" = false ]; then
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
if [ -f "x-ui.service.debian" ]; then
|
||||
echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service.debian ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
if [ -f "x-ui.service.arch" ]; then
|
||||
echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
*)
|
||||
if [ -f "x-ui.service.rhel" ]; then
|
||||
echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service.rhel ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
# If service file not found in tar.gz, download from GitHub
|
||||
if [ "$service_installed" = false ]; then
|
||||
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
|
||||
;;
|
||||
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
|
||||
;;
|
||||
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
|
||||
;;
|
||||
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
|
||||
exit 1
|
||||
fi
|
||||
service_installed=true
|
||||
fi
|
||||
|
||||
|
||||
if [ "$service_installed" = true ]; then
|
||||
echo -e "${green}Setting up systemd unit...${plain}"
|
||||
chown root:root ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
chmod 644 ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl start x-ui
|
||||
@@ -958,7 +958,7 @@ install_x-ui() {
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
|
||||
echo -e ""
|
||||
echo -e "┌───────────────────────────────────────────────────────┐
|
||||
|
||||
20
main.go
20
main.go
@@ -130,20 +130,22 @@ func runWebServer() {
|
||||
}
|
||||
|
||||
// resetSetting resets all panel settings to their default values.
|
||||
func resetSetting() {
|
||||
func resetSetting() error {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
if err != nil {
|
||||
fmt.Println("Failed to initialize database:", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
settingService := service.SettingService{}
|
||||
err = settingService.ResetSettings()
|
||||
if err != nil {
|
||||
fmt.Println("Failed to reset settings:", err)
|
||||
return err
|
||||
} else {
|
||||
fmt.Println("Settings successfully reset.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showSetting displays the current panel settings if show is true.
|
||||
@@ -255,11 +257,11 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
||||
}
|
||||
|
||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||
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) error {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
if err != nil {
|
||||
fmt.Println("Database initialization failed:", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
settingService := service.SettingService{}
|
||||
@@ -311,6 +313,8 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
||||
fmt.Printf("listen %v set successfully", listenIP)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCert updates the SSL certificate files for the panel.
|
||||
@@ -481,9 +485,13 @@ func main() {
|
||||
return
|
||||
}
|
||||
if reset {
|
||||
resetSetting()
|
||||
if err = resetSetting(); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
|
||||
if err = updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if show {
|
||||
showSetting(show)
|
||||
|
||||
@@ -55,7 +55,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Enable && client.SubID == subId {
|
||||
if client.SubID == subId {
|
||||
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
|
||||
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
||||
}
|
||||
@@ -159,13 +159,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
||||
}
|
||||
|
||||
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
|
||||
// Hysteria (v1 / v2) doesn't ride an xray `streamSettings.network`
|
||||
// transport and the TLS story is handled inside hysteria itself, so
|
||||
// applyTransport / applySecurity below don't model it. Build the
|
||||
// proxy directly. Without this, hysteria inbounds fell into the
|
||||
// `default: return nil` branch and silently vanished from the
|
||||
// generated Clash config.
|
||||
if inbound.Protocol == model.Hysteria {
|
||||
// Hysteria has its own transport + TLS model, applyTransport /
|
||||
// applySecurity don't fit. IsHysteria also covers the literal
|
||||
// "hysteria2" protocol string (#4081).
|
||||
if model.IsHysteria(inbound.Protocol) {
|
||||
return s.buildHysteriaProxy(inbound, client, extraRemark)
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
"host": page.Host,
|
||||
"base_path": page.BasePath,
|
||||
"sId": page.SId,
|
||||
"enabled": page.Enabled,
|
||||
"download": page.Download,
|
||||
"upload": page.Upload,
|
||||
"total": page.Total,
|
||||
|
||||
@@ -44,7 +44,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||
fragmentOrNoises := false
|
||||
if fragment != "" || noises != "" {
|
||||
fragmentOrNoises = true
|
||||
defaultOutboundsSettings := map[string]interface{}{
|
||||
defaultOutboundsSettings := map[string]any{
|
||||
"domainStrategy": "UseIP",
|
||||
"redirect": "",
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
||||
}
|
||||
|
||||
defaultDirectOutbound := map[string]interface{}{
|
||||
defaultDirectOutbound := map[string]any{
|
||||
"protocol": "freedom",
|
||||
"settings": defaultOutboundsSettings,
|
||||
"tag": "direct_out",
|
||||
@@ -116,7 +116,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
if client.Enable && client.SubID == subId {
|
||||
if client.SubID == subId {
|
||||
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
|
||||
newConfigs := s.getConfig(inbound, client, host)
|
||||
configArray = append(configArray, newConfigs...)
|
||||
@@ -209,7 +209,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
||||
case "trojan", "shadowsocks":
|
||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
||||
case "hysteria":
|
||||
case "hysteria", "hysteria2":
|
||||
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
|
||||
}
|
||||
|
||||
|
||||
1348
sub/subService.go
1348
sub/subService.go
File diff suppressed because it is too large
Load Diff
564
update.sh
564
update.sh
@@ -12,16 +12,16 @@ xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
||||
# Don't edit this config
|
||||
b_source="${BASH_SOURCE[0]}"
|
||||
while [ -h "$b_source" ]; do
|
||||
b_dir="$(cd -P "$(dirname "$b_source")" >/dev/null 2>&1 && pwd || pwd -P)"
|
||||
b_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)"
|
||||
b_source="$(readlink "$b_source")"
|
||||
[[ $b_source != /* ]] && b_source="$b_dir/$b_source"
|
||||
done
|
||||
cur_dir="$(cd -P "$(dirname "$b_source")" >/dev/null 2>&1 && pwd || pwd -P)"
|
||||
cur_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)"
|
||||
script_name=$(basename "$0")
|
||||
|
||||
# Check command exist function
|
||||
_command_exists() {
|
||||
type "$1" &>/dev/null
|
||||
type "$1" &> /dev/null
|
||||
}
|
||||
|
||||
# Fail, log and exit script function
|
||||
@@ -44,7 +44,7 @@ fi
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
source /etc/os-release
|
||||
release=$ID
|
||||
elif [[ -f /usr/lib/os-release ]]; then
|
||||
elif [[ -f /usr/lib/os-release ]]; then
|
||||
source /usr/lib/os-release
|
||||
release=$ID
|
||||
else
|
||||
@@ -61,7 +61,7 @@ arch() {
|
||||
armv6* | armv6) echo 'armv6' ;;
|
||||
armv5* | armv5) echo 'armv5' ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) echo -e "${red}Unsupported CPU architecture!${plain}" && rm -f "${cur_dir}/${script_name}" >/dev/null 2>&1 && exit 2;;
|
||||
*) echo -e "${red}Unsupported CPU architecture!${plain}" && rm -f "${cur_dir}/${script_name}" > /dev/null 2>&1 && exit 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -84,23 +84,23 @@ is_domain() {
|
||||
# Port helpers
|
||||
is_port_in_use() {
|
||||
local port="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
|
||||
if command -v ss > /dev/null 2>&1; then
|
||||
ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
|
||||
return
|
||||
fi
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
|
||||
if command -v netstat > /dev/null 2>&1; then
|
||||
netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
|
||||
return
|
||||
fi
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
|
||||
if command -v lsof > /dev/null 2>&1; then
|
||||
lsof -nP -iTCP:${port} -sTCP:LISTEN > /dev/null 2>&1 && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
gen_random_string() {
|
||||
local length="$1"
|
||||
openssl rand -base64 $(( length * 2 )) \
|
||||
openssl rand -base64 $((length * 2)) \
|
||||
| tr -dc 'a-zA-Z0-9' \
|
||||
| head -c "$length"
|
||||
}
|
||||
@@ -109,37 +109,37 @@ install_base() {
|
||||
echo -e "${green}Updating and install dependency packages...${plain}"
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update >/dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
|
||||
;;
|
||||
apt-get update > /dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
;;
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||
;;
|
||||
dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update >/dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||
yum -y update > /dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
else
|
||||
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||
dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||
;;
|
||||
pacman -Syu > /dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
;;
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh >/dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl >/dev/null 2>&1
|
||||
;;
|
||||
zypper refresh > /dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl > /dev/null 2>&1
|
||||
;;
|
||||
alpine)
|
||||
apk update >/dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl>/dev/null 2>&1
|
||||
;;
|
||||
apk update > /dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
apt-get update >/dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
|
||||
;;
|
||||
apt-get update > /dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_acme() {
|
||||
echo -e "${green}Installing acme.sh for SSL certificate management...${plain}"
|
||||
cd ~ || return 1
|
||||
curl -s https://get.acme.sh | sh >/dev/null 2>&1
|
||||
curl -s https://get.acme.sh | sh > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to install acme.sh${plain}"
|
||||
return 1
|
||||
@@ -154,59 +154,59 @@ setup_ssl_certificate() {
|
||||
local server_ip="$2"
|
||||
local existing_port="$3"
|
||||
local existing_webBasePath="$4"
|
||||
|
||||
|
||||
echo -e "${green}Setting up SSL certificate...${plain}"
|
||||
|
||||
|
||||
# Check if acme.sh is installed
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||||
install_acme
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to install acme.sh, skipping SSL setup${plain}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Create certificate directory
|
||||
local certPath="/root/cert/${domain}"
|
||||
mkdir -p "$certPath"
|
||||
|
||||
|
||||
# Issue certificate
|
||||
echo -e "${green}Issuing SSL certificate for ${domain}...${plain}"
|
||||
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
|
||||
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
|
||||
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
||||
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
|
||||
rm -rf ~/.acme.sh/${domain} 2>/dev/null
|
||||
rm -rf "$certPath" 2>/dev/null
|
||||
rm -rf ~/.acme.sh/${domain} 2> /dev/null
|
||||
rm -rf "$certPath" 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
# Install certificate
|
||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||
--key-file /root/cert/${domain}/privkey.pem \
|
||||
--fullchain-file /root/cert/${domain}/fullchain.pem \
|
||||
--reloadcmd "systemctl restart x-ui" >/dev/null 2>&1
|
||||
|
||||
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to install certificate${plain}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
# Enable auto-renew
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
|
||||
chmod 600 $certPath/privkey.pem 2>/dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2>/dev/null
|
||||
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
|
||||
chmod 600 $certPath/privkey.pem 2> /dev/null
|
||||
chmod 644 $certPath/fullchain.pem 2> /dev/null
|
||||
|
||||
# Set certificate for panel
|
||||
local webCertFile="/root/cert/${domain}/fullchain.pem"
|
||||
local webKeyFile="/root/cert/${domain}/privkey.pem"
|
||||
|
||||
|
||||
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" > /dev/null 2>&1
|
||||
echo -e "${green}SSL certificate installed and configured successfully!${plain}"
|
||||
return 0
|
||||
else
|
||||
@@ -219,14 +219,14 @@ setup_ssl_certificate() {
|
||||
# Requires acme.sh and port 80 open for HTTP-01 challenge
|
||||
setup_ip_certificate() {
|
||||
local ipv4="$1"
|
||||
local ipv6="$2" # optional
|
||||
local ipv6="$2" # optional
|
||||
|
||||
echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}"
|
||||
echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}"
|
||||
echo -e "${yellow}Default listener is port 80. If you choose another port, ensure external port 80 forwards to it.${plain}"
|
||||
|
||||
# Check for acme.sh
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||||
install_acme
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to install acme.sh${plain}"
|
||||
@@ -298,8 +298,8 @@ setup_ip_certificate() {
|
||||
|
||||
# Issue certificate with shortlived profile
|
||||
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --issue \
|
||||
${domain_args} \
|
||||
--standalone \
|
||||
@@ -313,9 +313,9 @@ setup_ip_certificate() {
|
||||
echo -e "${red}Failed to issue IP certificate${plain}"
|
||||
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
|
||||
rm -rf ${certDir} 2>/dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -333,19 +333,19 @@ setup_ip_certificate() {
|
||||
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
|
||||
echo -e "${red}Certificate files not found after installation${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
|
||||
rm -rf ${certDir} 2>/dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${green}Certificate files installed successfully${plain}"
|
||||
|
||||
# Enable auto-upgrade for acme.sh (ensures cron job runs)
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
|
||||
|
||||
chmod 600 ${certDir}/privkey.pem 2>/dev/null
|
||||
chmod 644 ${certDir}/fullchain.pem 2>/dev/null
|
||||
chmod 600 ${certDir}/privkey.pem 2> /dev/null
|
||||
chmod 644 ${certDir}/fullchain.pem 2> /dev/null
|
||||
|
||||
# Configure panel to use the certificate
|
||||
echo -e "${green}Setting certificate paths for the panel...${plain}"
|
||||
@@ -369,9 +369,9 @@ setup_ip_certificate() {
|
||||
ssl_cert_issue() {
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
|
||||
|
||||
# check for acme.sh first
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
||||
echo "acme.sh could not be found. Installing now..."
|
||||
cd ~ || return 1
|
||||
curl -s https://get.acme.sh | sh
|
||||
@@ -387,18 +387,18 @@ ssl_cert_issue() {
|
||||
local domain=""
|
||||
while true; do
|
||||
read -rp "Please enter your domain name: " domain
|
||||
domain="${domain// /}" # Trim whitespace
|
||||
|
||||
domain="${domain// /}" # Trim whitespace
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
echo -e "${red}Domain name cannot be empty. Please try again.${plain}"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
if ! is_domain "$domain"; then
|
||||
echo -e "${red}Invalid domain format: ${domain}. Please enter a valid domain name.${plain}"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
break
|
||||
done
|
||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||
@@ -406,9 +406,9 @@ ssl_cert_issue() {
|
||||
|
||||
# detect existing certificate and reuse it if present
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
@@ -435,7 +435,7 @@ ssl_cert_issue() {
|
||||
|
||||
# Stop panel temporarily
|
||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||
systemctl stop x-ui 2> /dev/null || rc-service x-ui stop 2> /dev/null
|
||||
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
# issue the certificate
|
||||
@@ -444,7 +444,7 @@ ssl_cert_issue() {
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||
@@ -464,18 +464,18 @@ ssl_cert_issue() {
|
||||
echo -e "${green}\t0.${plain} Keep default reloadcmd"
|
||||
read -rp "Choose an option: " choice
|
||||
case "$choice" in
|
||||
1)
|
||||
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
|
||||
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
|
||||
;;
|
||||
2)
|
||||
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
|
||||
read -rp "Please enter your custom reloadcmd: " reloadCmd
|
||||
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${green}Keeping default reloadcmd${plain}"
|
||||
;;
|
||||
1)
|
||||
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
|
||||
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
|
||||
;;
|
||||
2)
|
||||
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
|
||||
read -rp "Please enter your custom reloadcmd: " reloadCmd
|
||||
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${green}Keeping default reloadcmd${plain}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -492,14 +492,14 @@ ssl_cert_issue() {
|
||||
installWroteFiles=1
|
||||
fi
|
||||
|
||||
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && (${installRc} -eq 0 || ${installWroteFiles} -eq 1) ]]; then
|
||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||
else
|
||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
fi
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -518,7 +518,7 @@ ssl_cert_issue() {
|
||||
fi
|
||||
|
||||
# Restart panel
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
|
||||
# Prompt user to set panel paths after successful certificate installation
|
||||
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
|
||||
@@ -534,21 +534,21 @@ ssl_cert_issue() {
|
||||
echo ""
|
||||
echo -e "${green}Access URL: https://${domain}:${existing_port}/${existing_webBasePath}${plain}"
|
||||
echo -e "${yellow}Panel will restart to apply SSL certificate...${plain}"
|
||||
systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null
|
||||
systemctl restart x-ui 2> /dev/null || rc-service x-ui restart 2> /dev/null
|
||||
else
|
||||
echo -e "${red}Error: Certificate or private key file not found for domain: $domain.${plain}"
|
||||
fi
|
||||
else
|
||||
echo -e "${yellow}Skipping panel path setting.${plain}"
|
||||
fi
|
||||
|
||||
|
||||
return 0
|
||||
}
|
||||
# Unified interactive SSL setup (domain or IP)
|
||||
# Sets global `SSL_HOST` to the chosen domain/IP
|
||||
prompt_and_setup_ssl() {
|
||||
local panel_port="$1"
|
||||
local web_base_path="$2" # expected without leading slash
|
||||
local web_base_path="$2" # expected without leading slash
|
||||
local server_ip="$3"
|
||||
|
||||
local ssl_choice=""
|
||||
@@ -559,132 +559,132 @@ prompt_and_setup_ssl() {
|
||||
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
||||
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||
|
||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||
|
||||
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
|
||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
|
||||
ssl_choice="2"
|
||||
fi
|
||||
|
||||
case "$ssl_choice" in
|
||||
1)
|
||||
# User chose Let's Encrypt domain option
|
||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||
if ssl_cert_issue; then
|
||||
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||
if [[ -z "${cert_domain}" ]]; then
|
||||
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
fi
|
||||
1)
|
||||
# User chose Let's Encrypt domain option
|
||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||
if ssl_cert_issue; then
|
||||
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||
if [[ -z "${cert_domain}" ]]; then
|
||||
cert_domain=$(~/.acme.sh/acme.sh --list 2> /dev/null | tail -1 | awk '{print $1}')
|
||||
fi
|
||||
|
||||
if [[ -n "${cert_domain}" ]]; then
|
||||
SSL_HOST="${cert_domain}"
|
||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||
if [[ -n "${cert_domain}" ]]; then
|
||||
SSL_HOST="${cert_domain}"
|
||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||
else
|
||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
else
|
||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
else
|
||||
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
# User chose Let's Encrypt IP certificate option
|
||||
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
|
||||
|
||||
# Ask for optional IPv6
|
||||
local ipv6_addr=""
|
||||
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
|
||||
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
|
||||
|
||||
# Stop panel if running (port 80 needed)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop >/dev/null 2>&1
|
||||
else
|
||||
systemctl stop x-ui >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
|
||||
if [ $? -eq 0 ]; then
|
||||
SSL_HOST="${server_ip}"
|
||||
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
|
||||
else
|
||||
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
|
||||
# Restart panel after SSL is configured (restart applies new cert settings)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui restart >/dev/null 2>&1
|
||||
else
|
||||
systemctl restart x-ui >/dev/null 2>&1
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
# User chose Let's Encrypt IP certificate option
|
||||
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
|
||||
|
||||
;;
|
||||
3)
|
||||
# User chose Custom Paths (User Provided) option
|
||||
echo -e "${green}Using custom existing certificate...${plain}"
|
||||
local custom_cert=""
|
||||
local custom_key=""
|
||||
local custom_domain=""
|
||||
# Ask for optional IPv6
|
||||
local ipv6_addr=""
|
||||
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
|
||||
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
|
||||
|
||||
# 3.1 Request Domain to compose Panel URL later
|
||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||
custom_domain="${custom_domain// /}" # Remove spaces
|
||||
|
||||
# 3.2 Loop for Certificate Path
|
||||
while true; do
|
||||
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
|
||||
# Strip quotes if present
|
||||
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
# Stop panel if running (port 80 needed)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop > /dev/null 2>&1
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
systemctl stop x-ui > /dev/null 2>&1
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.3 Loop for Private Key Path
|
||||
while true; do
|
||||
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
|
||||
# Strip quotes if present
|
||||
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
|
||||
if [ $? -eq 0 ]; then
|
||||
SSL_HOST="${server_ip}"
|
||||
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.4 Apply Settings via x-ui binary
|
||||
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
|
||||
# Restart panel after SSL is configured (restart applies new cert settings)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui restart > /dev/null 2>&1
|
||||
else
|
||||
systemctl restart x-ui > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Set SSL_HOST for composing Panel URL
|
||||
if [[ -n "$custom_domain" ]]; then
|
||||
SSL_HOST="$custom_domain"
|
||||
else
|
||||
;;
|
||||
3)
|
||||
# User chose Custom Paths (User Provided) option
|
||||
echo -e "${green}Using custom existing certificate...${plain}"
|
||||
local custom_cert=""
|
||||
local custom_key=""
|
||||
local custom_domain=""
|
||||
|
||||
# 3.1 Request Domain to compose Panel URL later
|
||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||
custom_domain="${custom_domain// /}" # Remove spaces
|
||||
|
||||
# 3.2 Loop for Certificate Path
|
||||
while true; do
|
||||
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
|
||||
# Strip quotes if present
|
||||
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_cert" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.3 Loop for Private Key Path
|
||||
while true; do
|
||||
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
|
||||
# Strip quotes if present
|
||||
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
|
||||
|
||||
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
|
||||
break
|
||||
elif [[ ! -f "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File does not exist! Try again.${plain}"
|
||||
elif [[ ! -r "$custom_key" ]]; then
|
||||
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
|
||||
else
|
||||
echo -e "${red}Error: File is empty!${plain}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.4 Apply Settings via x-ui binary
|
||||
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" > /dev/null 2>&1
|
||||
|
||||
# Set SSL_HOST for composing Panel URL
|
||||
if [[ -n "$custom_domain" ]]; then
|
||||
SSL_HOST="$custom_domain"
|
||||
else
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
|
||||
echo -e "${green}✓ Custom certificate paths applied.${plain}"
|
||||
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
|
||||
|
||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
fi
|
||||
|
||||
echo -e "${green}✓ Custom certificate paths applied.${plain}"
|
||||
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
|
||||
|
||||
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
;;
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -692,12 +692,12 @@ config_after_update() {
|
||||
echo -e "${yellow}x-ui settings:${plain}"
|
||||
${xui_folder}/x-ui setting -show true
|
||||
${xui_folder}/x-ui migrate
|
||||
|
||||
|
||||
# Properly detect empty cert by checking if cert: line exists and has content after it
|
||||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2>/dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2> /dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
|
||||
|
||||
|
||||
# Get server IP
|
||||
local URL_lists=(
|
||||
"https://api4.ipify.org"
|
||||
@@ -709,7 +709,7 @@ config_after_update() {
|
||||
)
|
||||
local server_ip=""
|
||||
for ip_address in "${URL_lists[@]}"; do
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
||||
local http_code=$(echo "$response" | tail -n1)
|
||||
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
||||
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
||||
@@ -717,7 +717,7 @@ config_after_update() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# Handle missing/short webBasePath
|
||||
if [[ ${#existing_webBasePath} -lt 4 ]]; then
|
||||
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
||||
@@ -726,7 +726,7 @@ config_after_update() {
|
||||
existing_webBasePath="${config_webBasePath}"
|
||||
echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}"
|
||||
fi
|
||||
|
||||
|
||||
# Check and prompt for SSL if missing
|
||||
if [[ -z "$existing_cert" ]]; then
|
||||
echo ""
|
||||
@@ -736,16 +736,16 @@ config_after_update() {
|
||||
echo -e "${yellow}For security, SSL certificate is MANDATORY for all panels.${plain}"
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
|
||||
|
||||
if [[ -z "${server_ip}" ]]; then
|
||||
echo -e "${red}Failed to detect server IP${plain}"
|
||||
echo -e "${yellow}Please configure SSL manually using: x-ui${plain}"
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
# Prompt and setup SSL (domain or IP)
|
||||
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
||||
|
||||
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} Panel Access Information ${plain}"
|
||||
@@ -768,17 +768,17 @@ config_after_update() {
|
||||
|
||||
update_x-ui() {
|
||||
cd ${xui_folder%/x-ui}/
|
||||
|
||||
|
||||
if [ -f "${xui_folder}/x-ui" ]; then
|
||||
current_xui_version=$(${xui_folder}/x-ui -v)
|
||||
echo -e "${green}Current x-ui version: ${current_xui_version}${plain}"
|
||||
else
|
||||
_fail "ERROR: Current x-ui version: unknown"
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${green}Downloading new x-ui version...${plain}"
|
||||
|
||||
tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
|
||||
tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [[ ! -n "$tag_version" ]]; then
|
||||
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
|
||||
tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
@@ -787,110 +787,110 @@ update_x-ui() {
|
||||
fi
|
||||
fi
|
||||
echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
|
||||
${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null
|
||||
${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
|
||||
${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null
|
||||
${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
|
||||
if [[ $? -ne 0 ]]; then
|
||||
_fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -e ${xui_folder}/ ]]; then
|
||||
echo -e "${green}Stopping x-ui...${plain}"
|
||||
if [[ $release == "alpine" ]]; then
|
||||
if [ -f "/etc/init.d/x-ui" ]; then
|
||||
rc-service x-ui stop >/dev/null 2>&1
|
||||
rc-update del x-ui >/dev/null 2>&1
|
||||
rc-service x-ui stop > /dev/null 2>&1
|
||||
rc-update del x-ui > /dev/null 2>&1
|
||||
echo -e "${green}Removing old service unit version...${plain}"
|
||||
rm -f /etc/init.d/x-ui >/dev/null 2>&1
|
||||
rm -f /etc/init.d/x-ui > /dev/null 2>&1
|
||||
else
|
||||
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
||||
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
|
||||
_fail "ERROR: x-ui service unit not installed."
|
||||
fi
|
||||
else
|
||||
if [ -f "${xui_service}/x-ui.service" ]; then
|
||||
systemctl stop x-ui >/dev/null 2>&1
|
||||
systemctl disable x-ui >/dev/null 2>&1
|
||||
systemctl stop x-ui > /dev/null 2>&1
|
||||
systemctl disable x-ui > /dev/null 2>&1
|
||||
echo -e "${green}Removing old systemd unit version...${plain}"
|
||||
rm ${xui_service}/x-ui.service -f >/dev/null 2>&1
|
||||
systemctl daemon-reload >/dev/null 2>&1
|
||||
rm ${xui_service}/x-ui.service -f > /dev/null 2>&1
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
else
|
||||
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
||||
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
|
||||
_fail "ERROR: x-ui systemd unit not installed."
|
||||
fi
|
||||
fi
|
||||
echo -e "${green}Removing old x-ui version...${plain}"
|
||||
rm ${xui_folder} -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service.debian -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service.arch -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service.rhel -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.sh -f >/dev/null 2>&1
|
||||
rm ${xui_folder} -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service.debian -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service.arch -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.service.rhel -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/x-ui.sh -f > /dev/null 2>&1
|
||||
echo -e "${green}Removing old xray version...${plain}"
|
||||
rm ${xui_folder}/bin/xray-linux-amd64 -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/bin/xray-linux-amd64 -f > /dev/null 2>&1
|
||||
echo -e "${green}Removing old README and LICENSE file...${plain}"
|
||||
rm ${xui_folder}/bin/README.md -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/bin/LICENSE -f >/dev/null 2>&1
|
||||
rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1
|
||||
rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1
|
||||
else
|
||||
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
||||
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
|
||||
_fail "ERROR: x-ui not installed."
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${green}Installing new x-ui version...${plain}"
|
||||
tar zxvf x-ui-linux-$(arch).tar.gz >/dev/null 2>&1
|
||||
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
|
||||
cd x-ui >/dev/null 2>&1
|
||||
chmod +x x-ui >/dev/null 2>&1
|
||||
|
||||
tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1
|
||||
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
|
||||
cd x-ui > /dev/null 2>&1
|
||||
chmod +x x-ui > /dev/null 2>&1
|
||||
|
||||
# Check the system's architecture and rename the file accordingly
|
||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||
mv bin/xray-linux-$(arch) bin/xray-linux-arm >/dev/null 2>&1
|
||||
chmod +x bin/xray-linux-arm >/dev/null 2>&1
|
||||
mv bin/xray-linux-$(arch) bin/xray-linux-arm > /dev/null 2>&1
|
||||
chmod +x bin/xray-linux-arm > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
chmod +x x-ui bin/xray-linux-$(arch) >/dev/null 2>&1
|
||||
|
||||
|
||||
chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1
|
||||
|
||||
echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
|
||||
${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh >/dev/null 2>&1
|
||||
${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${yellow}Trying to fetch x-ui with IPv4...${plain}"
|
||||
${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh >/dev/null 2>&1
|
||||
${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
_fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
|
||||
fi
|
||||
fi
|
||||
|
||||
chmod +x ${xui_folder}/x-ui.sh >/dev/null 2>&1
|
||||
chmod +x /usr/bin/x-ui >/dev/null 2>&1
|
||||
mkdir -p /var/log/x-ui >/dev/null 2>&1
|
||||
|
||||
|
||||
chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1
|
||||
chmod +x /usr/bin/x-ui > /dev/null 2>&1
|
||||
mkdir -p /var/log/x-ui > /dev/null 2>&1
|
||||
|
||||
echo -e "${green}Changing owner...${plain}"
|
||||
chown -R root:root ${xui_folder} >/dev/null 2>&1
|
||||
|
||||
chown -R root:root ${xui_folder} > /dev/null 2>&1
|
||||
|
||||
if [ -f "${xui_folder}/bin/config.json" ]; then
|
||||
echo -e "${green}Changing on config file permissions...${plain}"
|
||||
chmod 640 ${xui_folder}/bin/config.json >/dev/null 2>&1
|
||||
chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
|
||||
if [[ $release == "alpine" ]]; then
|
||||
echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
|
||||
${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc >/dev/null 2>&1
|
||||
${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc >/dev/null 2>&1
|
||||
${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
_fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
|
||||
fi
|
||||
fi
|
||||
chmod +x /etc/init.d/x-ui >/dev/null 2>&1
|
||||
chown root:root /etc/init.d/x-ui >/dev/null 2>&1
|
||||
rc-update add x-ui >/dev/null 2>&1
|
||||
rc-service x-ui start >/dev/null 2>&1
|
||||
chmod +x /etc/init.d/x-ui > /dev/null 2>&1
|
||||
chown root:root /etc/init.d/x-ui > /dev/null 2>&1
|
||||
rc-update add x-ui > /dev/null 2>&1
|
||||
rc-service x-ui start > /dev/null 2>&1
|
||||
else
|
||||
if [ -f "x-ui.service" ]; then
|
||||
echo -e "${green}Installing systemd unit...${plain}"
|
||||
cp -f x-ui.service ${xui_service}/ >/dev/null 2>&1
|
||||
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to copy x-ui.service${plain}"
|
||||
exit 1
|
||||
@@ -901,62 +901,62 @@ update_x-ui() {
|
||||
ubuntu | debian | armbian)
|
||||
if [ -f "x-ui.service.debian" ]; then
|
||||
echo -e "${green}Installing debian-like systemd unit...${plain}"
|
||||
cp -f x-ui.service.debian ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
if [ -f "x-ui.service.arch" ]; then
|
||||
echo -e "${green}Installing arch-like systemd unit...${plain}"
|
||||
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
*)
|
||||
if [ -f "x-ui.service.rhel" ]; then
|
||||
echo -e "${green}Installing rhel-like systemd unit...${plain}"
|
||||
cp -f x-ui.service.rhel ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# If service file not found in tar.gz, download from GitHub
|
||||
if [ "$service_installed" = false ]; then
|
||||
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
|
||||
;;
|
||||
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
|
||||
;;
|
||||
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
|
||||
;;
|
||||
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
chown root:root ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
chmod 644 ${xui_service}/x-ui.service >/dev/null 2>&1
|
||||
systemctl daemon-reload >/dev/null 2>&1
|
||||
systemctl enable x-ui >/dev/null 2>&1
|
||||
systemctl start x-ui >/dev/null 2>&1
|
||||
chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
systemctl enable x-ui > /dev/null 2>&1
|
||||
systemctl start x-ui > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
|
||||
config_after_update
|
||||
|
||||
|
||||
echo -e "${green}x-ui ${tag_version}${plain} updating finished, it is running now..."
|
||||
echo -e ""
|
||||
echo -e "┌───────────────────────────────────────────────────────┐
|
||||
|
||||
@@ -3,16 +3,15 @@ const Protocols = {
|
||||
VLESS: 'vless',
|
||||
TROJAN: 'trojan',
|
||||
SHADOWSOCKS: 'shadowsocks',
|
||||
TUNNEL: 'tunnel',
|
||||
WIREGUARD: 'wireguard',
|
||||
HYSTERIA: 'hysteria',
|
||||
MIXED: 'mixed',
|
||||
HTTP: 'http',
|
||||
WIREGUARD: 'wireguard',
|
||||
TUNNEL: 'tunnel',
|
||||
TUN: 'tun',
|
||||
HYSTERIA: 'hysteria',
|
||||
};
|
||||
|
||||
const SSMethods = {
|
||||
AES_256_GCM: 'aes-256-gcm',
|
||||
CHACHA20_POLY1305: 'chacha20-poly1305',
|
||||
CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
|
||||
XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
|
||||
@@ -324,7 +323,7 @@ class KcpStreamSettings extends XrayCommonClass {
|
||||
uplinkCapacity = 5,
|
||||
downlinkCapacity = 20,
|
||||
cwndMultiplier = 1,
|
||||
maxSendingWindow = 1350,
|
||||
maxSendingWindow = 2097152,
|
||||
) {
|
||||
super();
|
||||
this.mtu = mtu;
|
||||
@@ -698,7 +697,6 @@ class TlsStreamSettings extends XrayCommonClass {
|
||||
certificates = [new TlsStreamSettings.Cert()],
|
||||
alpn = [ALPN_OPTION.H2, ALPN_OPTION.HTTP1],
|
||||
echServerKeys = '',
|
||||
echForceQuery = 'none',
|
||||
settings = new TlsStreamSettings.Settings()
|
||||
) {
|
||||
super();
|
||||
@@ -712,7 +710,6 @@ class TlsStreamSettings extends XrayCommonClass {
|
||||
this.certs = certificates;
|
||||
this.alpn = alpn;
|
||||
this.echServerKeys = echServerKeys;
|
||||
this.echForceQuery = echForceQuery;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@@ -745,7 +742,6 @@ class TlsStreamSettings extends XrayCommonClass {
|
||||
certs,
|
||||
json.alpn,
|
||||
json.echServerKeys,
|
||||
json.echForceQuery,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
@@ -762,7 +758,6 @@ class TlsStreamSettings extends XrayCommonClass {
|
||||
certificates: TlsStreamSettings.toJsonArray(this.certs),
|
||||
alpn: this.alpn,
|
||||
echServerKeys: this.echServerKeys,
|
||||
echForceQuery: this.echForceQuery,
|
||||
settings: this.settings,
|
||||
};
|
||||
}
|
||||
@@ -876,7 +871,7 @@ class RealityStreamSettings extends XrayCommonClass {
|
||||
if (!target && !serverNames) {
|
||||
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
|
||||
? getRandomRealityTarget()
|
||||
: { target: 'www.apple.com:443', sni: 'www.apple.com,apple.com' };
|
||||
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
|
||||
target = randomTarget.target;
|
||||
serverNames = randomTarget.sni;
|
||||
}
|
||||
@@ -1086,11 +1081,15 @@ class UdpMask extends XrayCommonClass {
|
||||
case 'header-wireguard':
|
||||
return {};
|
||||
case 'header-custom':
|
||||
return { client: [], server: [] };
|
||||
return {
|
||||
client: Array.isArray(settings.client) ? settings.client : [],
|
||||
server: Array.isArray(settings.server) ? settings.server : [],
|
||||
};
|
||||
case 'noise':
|
||||
return { reset: 0, noise: [] };
|
||||
case 'sudoku':
|
||||
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
|
||||
return {
|
||||
reset: settings.reset ?? 0,
|
||||
noise: Array.isArray(settings.noise) ? settings.noise : [],
|
||||
};
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@@ -1104,27 +1103,221 @@ class UdpMask extends XrayCommonClass {
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const cleanItem = item => {
|
||||
const out = { ...item };
|
||||
if (out.type === 'array') {
|
||||
delete out.packet;
|
||||
} else {
|
||||
delete out.rand;
|
||||
delete out.randRange;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let settings = this.settings;
|
||||
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
|
||||
settings = { ...settings, noise: settings.noise.map(cleanItem) };
|
||||
} else if (this.type === 'header-custom' && settings) {
|
||||
settings = {
|
||||
...settings,
|
||||
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
|
||||
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: this.type,
|
||||
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
|
||||
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FinalMaskStreamSettings extends XrayCommonClass {
|
||||
constructor(udp = []) {
|
||||
class TcpMask extends XrayCommonClass {
|
||||
constructor(type = 'fragment', settings = {}) {
|
||||
super();
|
||||
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||
this.type = type;
|
||||
this.settings = this._getDefaultSettings(type, settings);
|
||||
}
|
||||
|
||||
_getDefaultSettings(type, settings = {}) {
|
||||
switch (type) {
|
||||
case 'fragment':
|
||||
return {
|
||||
packets: settings.packets ?? 'tlshello',
|
||||
length: settings.length ?? '',
|
||||
delay: settings.delay ?? '',
|
||||
maxSplit: settings.maxSplit ?? '',
|
||||
};
|
||||
case 'sudoku':
|
||||
return {
|
||||
password: settings.password ?? '',
|
||||
ascii: settings.ascii ?? '',
|
||||
customTable: settings.customTable ?? '',
|
||||
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
|
||||
paddingMin: settings.paddingMin ?? 0,
|
||||
paddingMax: settings.paddingMax ?? 0,
|
||||
};
|
||||
case 'header-custom':
|
||||
return {
|
||||
clients: Array.isArray(settings.clients) ? settings.clients : [],
|
||||
servers: Array.isArray(settings.servers) ? settings.servers : [],
|
||||
};
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMaskStreamSettings(json.udp || []);
|
||||
return new TcpMask(
|
||||
json.type || 'fragment',
|
||||
json.settings || {}
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
udp: this.udp.map(udp => udp.toJson())
|
||||
const cleanItem = item => {
|
||||
const out = { ...item };
|
||||
if (out.type === 'array') {
|
||||
delete out.packet;
|
||||
} else {
|
||||
delete out.rand;
|
||||
delete out.randRange;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let settings = this.settings;
|
||||
if (this.type === 'header-custom' && settings) {
|
||||
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
|
||||
settings = {
|
||||
...settings,
|
||||
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
|
||||
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: this.type,
|
||||
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class QuicParams extends XrayCommonClass {
|
||||
constructor(
|
||||
congestion = 'bbr',
|
||||
debug = false,
|
||||
brutalUp = 65537,
|
||||
brutalDown = 65537,
|
||||
udpHop = undefined,
|
||||
initStreamReceiveWindow = 8388608,
|
||||
maxStreamReceiveWindow = 8388608,
|
||||
initConnectionReceiveWindow = 20971520,
|
||||
maxConnectionReceiveWindow = 20971520,
|
||||
maxIdleTimeout = 30,
|
||||
keepAlivePeriod = 5,
|
||||
disablePathMTUDiscovery = false,
|
||||
maxIncomingStreams = 1024,
|
||||
) {
|
||||
super();
|
||||
this.congestion = congestion;
|
||||
this.debug = debug;
|
||||
this.brutalUp = brutalUp;
|
||||
this.brutalDown = brutalDown;
|
||||
this.udpHop = udpHop;
|
||||
this.initStreamReceiveWindow = initStreamReceiveWindow;
|
||||
this.maxStreamReceiveWindow = maxStreamReceiveWindow;
|
||||
this.initConnectionReceiveWindow = initConnectionReceiveWindow;
|
||||
this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
|
||||
this.maxIdleTimeout = maxIdleTimeout;
|
||||
this.keepAlivePeriod = keepAlivePeriod;
|
||||
this.disablePathMTUDiscovery = disablePathMTUDiscovery;
|
||||
this.maxIncomingStreams = maxIncomingStreams;
|
||||
}
|
||||
|
||||
get hasUdpHop() {
|
||||
return this.udpHop != null;
|
||||
}
|
||||
|
||||
set hasUdpHop(value) {
|
||||
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
if (!json || Object.keys(json).length === 0) return undefined;
|
||||
return new QuicParams(
|
||||
json.congestion,
|
||||
json.debug,
|
||||
json.brutalUp,
|
||||
json.brutalDown,
|
||||
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
|
||||
json.initStreamReceiveWindow,
|
||||
json.maxStreamReceiveWindow,
|
||||
json.initConnectionReceiveWindow,
|
||||
json.maxConnectionReceiveWindow,
|
||||
json.maxIdleTimeout,
|
||||
json.keepAlivePeriod,
|
||||
json.disablePathMTUDiscovery,
|
||||
json.maxIncomingStreams,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const result = { congestion: this.congestion };
|
||||
if (this.debug) result.debug = this.debug;
|
||||
if (['brutal', 'force-brutal'].includes(this.congestion)) {
|
||||
if (this.brutalUp) result.brutalUp = this.brutalUp;
|
||||
if (this.brutalDown) result.brutalDown = this.brutalDown;
|
||||
}
|
||||
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
|
||||
if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
|
||||
if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
|
||||
if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
|
||||
if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
|
||||
if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
|
||||
if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
|
||||
if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
|
||||
if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class FinalMaskStreamSettings extends XrayCommonClass {
|
||||
constructor(tcp = [], udp = [], quicParams = undefined) {
|
||||
super();
|
||||
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
|
||||
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
|
||||
}
|
||||
|
||||
get enableQuicParams() {
|
||||
return this.quicParams != null;
|
||||
}
|
||||
|
||||
set enableQuicParams(value) {
|
||||
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMaskStreamSettings(
|
||||
json.tcp || [],
|
||||
json.udp || [],
|
||||
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const result = {};
|
||||
if (this.tcp && this.tcp.length > 0) {
|
||||
result.tcp = this.tcp.map(t => t.toJson());
|
||||
}
|
||||
if (this.udp && this.udp.length > 0) {
|
||||
result.udp = this.udp.map(udp => udp.toJson());
|
||||
}
|
||||
if (this.quicParams) {
|
||||
result.quicParams = this.quicParams.toJson();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,6 +1354,16 @@ class StreamSettings extends XrayCommonClass {
|
||||
this.sockopt = sockopt;
|
||||
}
|
||||
|
||||
addTcpMask(type = 'fragment') {
|
||||
this.finalmask.tcp.push(new TcpMask(type));
|
||||
}
|
||||
|
||||
delTcpMask(index) {
|
||||
if (this.finalmask.tcp) {
|
||||
this.finalmask.tcp.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
addUdpMask(type = 'salamander') {
|
||||
this.finalmask.udp.push(new UdpMask(type));
|
||||
}
|
||||
@@ -1172,7 +1375,10 @@ class StreamSettings extends XrayCommonClass {
|
||||
}
|
||||
|
||||
get hasFinalMask() {
|
||||
return this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
|
||||
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||
const hasQuicParams = this.finalmask.quicParams != null;
|
||||
return hasTcp || hasUdp || hasQuicParams;
|
||||
}
|
||||
|
||||
get isTls() {
|
||||
@@ -1371,6 +1577,50 @@ class Inbound extends XrayCommonClass {
|
||||
}
|
||||
}
|
||||
|
||||
static hasShareableFinalMaskValue(value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(item => Inbound.hasShareableFinalMaskValue(item));
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return Object.values(value).some(item => Inbound.hasShareableFinalMaskValue(item));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static serializeFinalMask(finalmask) {
|
||||
if (!finalmask) {
|
||||
return '';
|
||||
}
|
||||
const value = typeof finalmask.toJson === 'function' ? finalmask.toJson() : finalmask;
|
||||
return Inbound.hasShareableFinalMaskValue(value) ? JSON.stringify(value) : '';
|
||||
}
|
||||
|
||||
// Export finalmask with the same compact JSON payload shape that
|
||||
// v2rayN-compatible share links use: fm=<json>.
|
||||
static applyFinalMaskToParams(finalmask, params) {
|
||||
if (!params) return;
|
||||
const payload = Inbound.serializeFinalMask(finalmask);
|
||||
if (payload.length > 0) {
|
||||
params.set("fm", payload);
|
||||
}
|
||||
}
|
||||
|
||||
// VMess links are a base64 JSON object, so keep the same fm payload
|
||||
// under a flat property instead of a URL query string.
|
||||
static applyFinalMaskToObj(finalmask, obj) {
|
||||
if (!obj) return;
|
||||
const payload = Inbound.serializeFinalMask(finalmask);
|
||||
if (payload.length > 0) {
|
||||
obj.fm = payload;
|
||||
}
|
||||
}
|
||||
|
||||
get clients() {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS: return this.settings.vmesses;
|
||||
@@ -1567,6 +1817,8 @@ class Inbound extends XrayCommonClass {
|
||||
}
|
||||
} else if (network === 'kcp') {
|
||||
const kcp = this.stream.kcp;
|
||||
obj.mtu = kcp.mtu;
|
||||
obj.tti = kcp.tti;
|
||||
} else if (network === 'ws') {
|
||||
const ws = this.stream.ws;
|
||||
obj.path = ws.path;
|
||||
@@ -1589,6 +1841,8 @@ class Inbound extends XrayCommonClass {
|
||||
Inbound.applyXhttpPaddingToObj(xhttp, obj);
|
||||
}
|
||||
|
||||
Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
|
||||
|
||||
if (tls === 'tls') {
|
||||
if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
|
||||
obj.sni = this.stream.tls.sni;
|
||||
@@ -1627,6 +1881,8 @@ class Inbound extends XrayCommonClass {
|
||||
break;
|
||||
case "kcp":
|
||||
const kcp = this.stream.kcp;
|
||||
params.set("mtu", kcp.mtu);
|
||||
params.set("tti", kcp.tti);
|
||||
break;
|
||||
case "ws":
|
||||
const ws = this.stream.ws;
|
||||
@@ -1655,6 +1911,8 @@ class Inbound extends XrayCommonClass {
|
||||
break;
|
||||
}
|
||||
|
||||
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
|
||||
|
||||
if (security === 'tls') {
|
||||
params.set("security", "tls");
|
||||
if (this.stream.isTls) {
|
||||
@@ -1728,6 +1986,8 @@ class Inbound extends XrayCommonClass {
|
||||
break;
|
||||
case "kcp":
|
||||
const kcp = this.stream.kcp;
|
||||
params.set("mtu", kcp.mtu);
|
||||
params.set("tti", kcp.tti);
|
||||
break;
|
||||
case "ws":
|
||||
const ws = this.stream.ws;
|
||||
@@ -1756,6 +2016,8 @@ class Inbound extends XrayCommonClass {
|
||||
break;
|
||||
}
|
||||
|
||||
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
|
||||
|
||||
if (security === 'tls') {
|
||||
params.set("security", "tls");
|
||||
if (this.stream.isTls) {
|
||||
@@ -1805,6 +2067,8 @@ class Inbound extends XrayCommonClass {
|
||||
break;
|
||||
case "kcp":
|
||||
const kcp = this.stream.kcp;
|
||||
params.set("mtu", kcp.mtu);
|
||||
params.set("tti", kcp.tti);
|
||||
break;
|
||||
case "ws":
|
||||
const ws = this.stream.ws;
|
||||
@@ -1833,6 +2097,8 @@ class Inbound extends XrayCommonClass {
|
||||
break;
|
||||
}
|
||||
|
||||
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
|
||||
|
||||
if (security === 'tls') {
|
||||
params.set("security", "tls");
|
||||
if (this.stream.isTls) {
|
||||
@@ -1900,6 +2166,8 @@ class Inbound extends XrayCommonClass {
|
||||
}
|
||||
}
|
||||
|
||||
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
|
||||
|
||||
const url = new URL(link);
|
||||
for (const [key, value] of params) {
|
||||
url.searchParams.set(key, value);
|
||||
@@ -1908,7 +2176,7 @@ class Inbound extends XrayCommonClass {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getWireguardLink(address, port, remark, peerId) {
|
||||
getWireguardTxt(address, port, remark, peerId) {
|
||||
let txt = `[Interface]\n`
|
||||
txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n`
|
||||
txt += `Address = ${this.settings.peers[peerId].allowedIPs[0]}\n`
|
||||
@@ -1930,6 +2198,48 @@ class Inbound extends XrayCommonClass {
|
||||
return txt;
|
||||
}
|
||||
|
||||
getWireguardLink(address, port, remark, peerId) {
|
||||
const peer = this.settings?.peers?.[peerId];
|
||||
if (!peer) return '';
|
||||
|
||||
const link = `wireguard://${address}:${port}`;
|
||||
const url = new URL(link);
|
||||
url.username = peer.privateKey || '';
|
||||
|
||||
if (this.settings?.pubKey) {
|
||||
url.searchParams.set("publickey", this.settings.pubKey);
|
||||
}
|
||||
if (Array.isArray(peer.allowedIPs) && peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
|
||||
url.searchParams.set("address", peer.allowedIPs[0]);
|
||||
}
|
||||
if (this.settings?.mtu) {
|
||||
url.searchParams.set("mtu", this.settings.mtu);
|
||||
}
|
||||
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
genWireguardLinks(remark = '', remarkModel = '-ieo') {
|
||||
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
|
||||
const separationChar = remarkModel.charAt(0);
|
||||
let links = [];
|
||||
this.settings.peers.forEach((p, index) => {
|
||||
links.push(this.getWireguardLink(addr, this.port, remark + separationChar + (index + 1), index));
|
||||
});
|
||||
return links.join('\r\n');
|
||||
}
|
||||
|
||||
genWireguardConfigs(remark = '', remarkModel = '-ieo') {
|
||||
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
|
||||
const separationChar = remarkModel.charAt(0);
|
||||
let links = [];
|
||||
this.settings.peers.forEach((p, index) => {
|
||||
links.push(this.getWireguardTxt(addr, this.port, remark + separationChar + (index + 1), index));
|
||||
});
|
||||
return links.join('\r\n');
|
||||
}
|
||||
|
||||
genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS:
|
||||
@@ -1990,11 +2300,7 @@ class Inbound extends XrayCommonClass {
|
||||
} else {
|
||||
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
|
||||
if (this.protocol == Protocols.WIREGUARD) {
|
||||
let links = [];
|
||||
this.settings.peers.forEach((p, index) => {
|
||||
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
|
||||
});
|
||||
return links.join('\r\n');
|
||||
return this.genWireguardConfigs(remark, remarkModel);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -2310,27 +2616,34 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
|
||||
constructor(
|
||||
id = RandomUtil.randomUUID(),
|
||||
flow = '',
|
||||
reverseTag = '',
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.reverseTag = reverseTag;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.VLESSSettings.VLESS(
|
||||
json.id,
|
||||
json.flow,
|
||||
json.reverse?.tag ?? '',
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
const json = {
|
||||
id: this.id,
|
||||
flow: this.flow,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
if (this.reverseTag) {
|
||||
json.reverse = { tag: this.reverseTag };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ const Protocols = {
|
||||
VLESS: "vless",
|
||||
Trojan: "trojan",
|
||||
Shadowsocks: "shadowsocks",
|
||||
Wireguard: "wireguard",
|
||||
Hysteria: "hysteria",
|
||||
Socks: "socks",
|
||||
HTTP: "http",
|
||||
Wireguard: "wireguard",
|
||||
Hysteria: "hysteria"
|
||||
};
|
||||
|
||||
const SSMethods = {
|
||||
@@ -97,6 +97,74 @@ const Address_Port_Strategy = {
|
||||
TxtPortAndAddress: "txtportandaddress"
|
||||
};
|
||||
|
||||
const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
|
||||
|
||||
function normalizeDNSRuleField(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => item.toString().trim()).filter(item => item.length > 0).join(',');
|
||||
}
|
||||
return value.toString().trim();
|
||||
}
|
||||
|
||||
function normalizeDNSRuleAction(action) {
|
||||
action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim();
|
||||
return DNSRuleActions.includes(action) ? action : 'direct';
|
||||
}
|
||||
|
||||
function parseLegacyDNSBlockTypes(blockTypes) {
|
||||
if (blockTypes === null || blockTypes === undefined || blockTypes === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(blockTypes)) {
|
||||
return blockTypes
|
||||
.map(item => Number(item))
|
||||
.filter(item => Number.isInteger(item) && item >= 0 && item <= 65535);
|
||||
}
|
||||
|
||||
if (typeof blockTypes === 'number') {
|
||||
return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : [];
|
||||
}
|
||||
|
||||
return blockTypes
|
||||
.toString()
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(item => /^\d+$/.test(item))
|
||||
.map(item => Number(item))
|
||||
.filter(item => item >= 0 && item <= 65535);
|
||||
}
|
||||
|
||||
function buildLegacyDNSRules(nonIPQuery, blockTypes) {
|
||||
const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject';
|
||||
const rules = [];
|
||||
const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes);
|
||||
|
||||
if (parsedBlockTypes.length > 0) {
|
||||
rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(',')));
|
||||
}
|
||||
|
||||
rules.push(new Outbound.DNSRule('hijack', '1,28'));
|
||||
rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode));
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function getDNSRulesFromJson(json = {}) {
|
||||
if (Array.isArray(json.rules) && json.rules.length > 0) {
|
||||
return json.rules.map(rule => Outbound.DNSRule.fromJson(rule));
|
||||
}
|
||||
|
||||
if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) {
|
||||
return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
Object.freeze(Protocols);
|
||||
Object.freeze(SSMethods);
|
||||
Object.freeze(TLS_FLOW_CONTROL);
|
||||
@@ -107,6 +175,7 @@ Object.freeze(WireguardDomainStrategy);
|
||||
Object.freeze(USERS_SECURITY);
|
||||
Object.freeze(MODE_OPTION);
|
||||
Object.freeze(Address_Port_Strategy);
|
||||
Object.freeze(DNSRuleActions);
|
||||
|
||||
class CommonClass {
|
||||
|
||||
@@ -431,7 +500,7 @@ class HysteriaStreamSettings extends CommonClass {
|
||||
initConnectionReceiveWindow = 20971520,
|
||||
maxConnectionReceiveWindow = 20971520,
|
||||
maxIdleTimeout = 30,
|
||||
keepAlivePeriod = 0,
|
||||
keepAlivePeriod = 2,
|
||||
disablePathMTUDiscovery = false
|
||||
) {
|
||||
super();
|
||||
@@ -586,11 +655,23 @@ class UdpMask extends CommonClass {
|
||||
case 'header-wireguard':
|
||||
return {}; // No settings needed
|
||||
case 'header-custom':
|
||||
return { client: [], server: [] };
|
||||
return {
|
||||
client: Array.isArray(settings.client) ? settings.client : [],
|
||||
server: Array.isArray(settings.server) ? settings.server : [],
|
||||
};
|
||||
case 'noise':
|
||||
return { reset: 0, noise: [] };
|
||||
return {
|
||||
reset: settings.reset ?? 0,
|
||||
noise: Array.isArray(settings.noise) ? settings.noise : [],
|
||||
};
|
||||
case 'sudoku':
|
||||
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
|
||||
return {
|
||||
ascii: settings.ascii || '',
|
||||
customTable: settings.customTable || '',
|
||||
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
|
||||
paddingMin: settings.paddingMin ?? 0,
|
||||
paddingMax: settings.paddingMax ?? 0
|
||||
};
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@@ -604,28 +685,221 @@ class UdpMask extends CommonClass {
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const cleanItem = item => {
|
||||
const out = { ...item };
|
||||
if (out.type === 'array') {
|
||||
delete out.packet;
|
||||
} else {
|
||||
delete out.rand;
|
||||
delete out.randRange;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let settings = this.settings;
|
||||
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
|
||||
settings = { ...settings, noise: settings.noise.map(cleanItem) };
|
||||
} else if (this.type === 'header-custom' && settings) {
|
||||
settings = {
|
||||
...settings,
|
||||
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
|
||||
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: this.type,
|
||||
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
|
||||
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FinalMaskStreamSettings extends CommonClass {
|
||||
constructor(udp = []) {
|
||||
class TcpMask extends CommonClass {
|
||||
constructor(type = 'fragment', settings = {}) {
|
||||
super();
|
||||
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||
this.type = type;
|
||||
this.settings = this._getDefaultSettings(type, settings);
|
||||
}
|
||||
|
||||
_getDefaultSettings(type, settings = {}) {
|
||||
switch (type) {
|
||||
case 'fragment':
|
||||
return {
|
||||
packets: settings.packets ?? 'tlshello',
|
||||
length: settings.length ?? '',
|
||||
delay: settings.delay ?? '',
|
||||
maxSplit: settings.maxSplit ?? '',
|
||||
};
|
||||
case 'sudoku':
|
||||
return {
|
||||
password: settings.password ?? '',
|
||||
ascii: settings.ascii ?? '',
|
||||
customTable: settings.customTable ?? '',
|
||||
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
|
||||
paddingMin: settings.paddingMin ?? 0,
|
||||
paddingMax: settings.paddingMax ?? 0,
|
||||
};
|
||||
case 'header-custom':
|
||||
return {
|
||||
clients: Array.isArray(settings.clients) ? settings.clients : [],
|
||||
servers: Array.isArray(settings.servers) ? settings.servers : [],
|
||||
};
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMaskStreamSettings(json.udp || []);
|
||||
return new TcpMask(
|
||||
json.type || 'fragment',
|
||||
json.settings || {}
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
udp: this.udp.map(udp => udp.toJson())
|
||||
const cleanItem = item => {
|
||||
const out = { ...item };
|
||||
if (out.type === 'array') {
|
||||
delete out.packet;
|
||||
} else {
|
||||
delete out.rand;
|
||||
delete out.randRange;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let settings = this.settings;
|
||||
if (this.type === 'header-custom' && settings) {
|
||||
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
|
||||
settings = {
|
||||
...settings,
|
||||
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
|
||||
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: this.type,
|
||||
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class QuicParams extends CommonClass {
|
||||
constructor(
|
||||
congestion = 'bbr',
|
||||
debug = false,
|
||||
brutalUp = 65537,
|
||||
brutalDown = 65537,
|
||||
udpHop = undefined,
|
||||
initStreamReceiveWindow = 8388608,
|
||||
maxStreamReceiveWindow = 8388608,
|
||||
initConnectionReceiveWindow = 20971520,
|
||||
maxConnectionReceiveWindow = 20971520,
|
||||
maxIdleTimeout = 30,
|
||||
keepAlivePeriod = 5,
|
||||
disablePathMTUDiscovery = false,
|
||||
maxIncomingStreams = 1024,
|
||||
) {
|
||||
super();
|
||||
this.congestion = congestion;
|
||||
this.debug = debug;
|
||||
this.brutalUp = brutalUp;
|
||||
this.brutalDown = brutalDown;
|
||||
this.udpHop = udpHop;
|
||||
this.initStreamReceiveWindow = initStreamReceiveWindow;
|
||||
this.maxStreamReceiveWindow = maxStreamReceiveWindow;
|
||||
this.initConnectionReceiveWindow = initConnectionReceiveWindow;
|
||||
this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
|
||||
this.maxIdleTimeout = maxIdleTimeout;
|
||||
this.keepAlivePeriod = keepAlivePeriod;
|
||||
this.disablePathMTUDiscovery = disablePathMTUDiscovery;
|
||||
this.maxIncomingStreams = maxIncomingStreams;
|
||||
}
|
||||
|
||||
get hasUdpHop() {
|
||||
return this.udpHop != null;
|
||||
}
|
||||
|
||||
set hasUdpHop(value) {
|
||||
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
if (!json || Object.keys(json).length === 0) return undefined;
|
||||
return new QuicParams(
|
||||
json.congestion,
|
||||
json.debug,
|
||||
json.brutalUp,
|
||||
json.brutalDown,
|
||||
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
|
||||
json.initStreamReceiveWindow,
|
||||
json.maxStreamReceiveWindow,
|
||||
json.initConnectionReceiveWindow,
|
||||
json.maxConnectionReceiveWindow,
|
||||
json.maxIdleTimeout,
|
||||
json.keepAlivePeriod,
|
||||
json.disablePathMTUDiscovery,
|
||||
json.maxIncomingStreams,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const result = { congestion: this.congestion };
|
||||
if (this.debug) result.debug = this.debug;
|
||||
if (['brutal', 'force-brutal'].includes(this.congestion)) {
|
||||
if (this.brutalUp) result.brutalUp = this.brutalUp;
|
||||
if (this.brutalDown) result.brutalDown = this.brutalDown;
|
||||
}
|
||||
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
|
||||
if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
|
||||
if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
|
||||
if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
|
||||
if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
|
||||
if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
|
||||
if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
|
||||
if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
|
||||
if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class FinalMaskStreamSettings extends CommonClass {
|
||||
constructor(tcp = [], udp = [], quicParams = undefined) {
|
||||
super();
|
||||
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
|
||||
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
|
||||
}
|
||||
|
||||
get enableQuicParams() {
|
||||
return this.quicParams != null;
|
||||
}
|
||||
|
||||
set enableQuicParams(value) {
|
||||
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMaskStreamSettings(
|
||||
json.tcp || [],
|
||||
json.udp || [],
|
||||
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const result = {};
|
||||
if (this.tcp && this.tcp.length > 0) {
|
||||
result.tcp = this.tcp.map(t => t.toJson());
|
||||
}
|
||||
if (this.udp && this.udp.length > 0) {
|
||||
result.udp = this.udp.map(udp => udp.toJson());
|
||||
}
|
||||
if (this.quicParams) {
|
||||
result.quicParams = this.quicParams.toJson();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +935,16 @@ class StreamSettings extends CommonClass {
|
||||
this.sockopt = sockopt;
|
||||
}
|
||||
|
||||
addTcpMask(type = 'fragment') {
|
||||
this.finalmask.tcp.push(new TcpMask(type));
|
||||
}
|
||||
|
||||
delTcpMask(index) {
|
||||
if (this.finalmask.tcp) {
|
||||
this.finalmask.tcp.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
addUdpMask(type = 'salamander') {
|
||||
this.finalmask.udp.push(new UdpMask(type));
|
||||
}
|
||||
@@ -672,7 +956,10 @@ class StreamSettings extends CommonClass {
|
||||
}
|
||||
|
||||
get hasFinalMask() {
|
||||
return this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
|
||||
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||
const hasQuicParams = this.finalmask.quicParams != null;
|
||||
return hasTcp || hasUdp || hasQuicParams;
|
||||
}
|
||||
|
||||
get isTls() {
|
||||
@@ -923,6 +1210,10 @@ class Outbound extends CommonClass {
|
||||
stream.kcp = new KcpStreamSettings();
|
||||
stream.type = json.type;
|
||||
stream.seed = json.path;
|
||||
const mtu = Number(json.mtu);
|
||||
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
|
||||
const tti = Number(json.tti);
|
||||
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
|
||||
} else if (network === 'ws') {
|
||||
stream.ws = new WsStreamSettings(json.path, json.host);
|
||||
} else if (network === 'grpc') {
|
||||
@@ -960,6 +1251,7 @@ class Outbound extends CommonClass {
|
||||
let headerType = url.searchParams.get('headerType') ?? undefined;
|
||||
let host = url.searchParams.get('host') ?? undefined;
|
||||
let path = url.searchParams.get('path') ?? undefined;
|
||||
let seed = url.searchParams.get('seed') ?? path ?? undefined;
|
||||
let mode = url.searchParams.get('mode') ?? undefined;
|
||||
|
||||
if (type === 'tcp' || type === 'none') {
|
||||
@@ -967,7 +1259,11 @@ class Outbound extends CommonClass {
|
||||
} else if (type === 'kcp') {
|
||||
stream.kcp = new KcpStreamSettings();
|
||||
stream.kcp.type = headerType ?? 'none';
|
||||
stream.kcp.seed = path;
|
||||
stream.kcp.seed = seed;
|
||||
const mtu = Number(url.searchParams.get('mtu'));
|
||||
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
|
||||
const tti = Number(url.searchParams.get('tti'));
|
||||
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
|
||||
} else if (type === 'ws') {
|
||||
stream.ws = new WsStreamSettings(path, host);
|
||||
} else if (type === 'grpc') {
|
||||
@@ -1163,14 +1459,16 @@ Outbound.FreedomSettings = class extends CommonClass {
|
||||
redirect = '',
|
||||
fragment = {},
|
||||
noises = [],
|
||||
ipsBlocked = [],
|
||||
finalRules = [],
|
||||
) {
|
||||
super();
|
||||
this.domainStrategy = domainStrategy;
|
||||
this.redirect = redirect;
|
||||
this.fragment = fragment || {};
|
||||
this.noises = Array.isArray(noises) ? noises : [];
|
||||
this.ipsBlocked = Array.isArray(ipsBlocked) ? ipsBlocked : [];
|
||||
this.finalRules = Array.isArray(finalRules)
|
||||
? finalRules.map(rule => rule instanceof Outbound.FreedomSettings.FinalRule ? rule : Outbound.FreedomSettings.FinalRule.fromJson(rule))
|
||||
: [];
|
||||
}
|
||||
|
||||
addNoise() {
|
||||
@@ -1181,13 +1479,30 @@ Outbound.FreedomSettings = class extends CommonClass {
|
||||
this.noises.splice(index, 1);
|
||||
}
|
||||
|
||||
addFinalRule(action = 'block') {
|
||||
this.finalRules.push(new Outbound.FreedomSettings.FinalRule(action));
|
||||
}
|
||||
|
||||
delFinalRule(index) {
|
||||
this.finalRules.splice(index, 1);
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
const finalRules = Array.isArray(json.finalRules)
|
||||
? json.finalRules.map(rule => Outbound.FreedomSettings.FinalRule.fromJson(rule))
|
||||
: [];
|
||||
|
||||
// Backward compatibility: map legacy ipsBlocked entries to blocking finalRules.
|
||||
if (finalRules.length === 0 && Array.isArray(json.ipsBlocked) && json.ipsBlocked.length > 0) {
|
||||
finalRules.push(new Outbound.FreedomSettings.FinalRule('block', '', '', json.ipsBlocked, ''));
|
||||
}
|
||||
|
||||
return new Outbound.FreedomSettings(
|
||||
json.domainStrategy,
|
||||
json.redirect,
|
||||
json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : {},
|
||||
json.noises ? json.noises.map(noise => Outbound.FreedomSettings.Noise.fromJson(noise)) : [],
|
||||
json.ipsBlocked || [],
|
||||
finalRules,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1197,7 +1512,7 @@ Outbound.FreedomSettings = class extends CommonClass {
|
||||
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
|
||||
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
|
||||
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
|
||||
ipsBlocked: this.ipsBlocked.length === 0 ? undefined : this.ipsBlocked,
|
||||
finalRules: this.finalRules.length === 0 ? undefined : Outbound.FreedomSettings.FinalRule.toJsonArray(this.finalRules),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1259,6 +1574,37 @@ Outbound.FreedomSettings.Noise = class extends CommonClass {
|
||||
}
|
||||
};
|
||||
|
||||
Outbound.FreedomSettings.FinalRule = class extends CommonClass {
|
||||
constructor(action = 'block', network = '', port = '', ip = [], blockDelay = '') {
|
||||
super();
|
||||
this.action = action;
|
||||
this.network = network;
|
||||
this.port = port;
|
||||
this.ip = Array.isArray(ip) ? ip : [];
|
||||
this.blockDelay = blockDelay;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Outbound.FreedomSettings.FinalRule(
|
||||
json.action,
|
||||
Array.isArray(json.network) ? json.network.join(',') : json.network,
|
||||
json.port,
|
||||
json.ip || [],
|
||||
json.blockDelay,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
action: ['allow', 'block'].includes(this.action) ? this.action : 'block',
|
||||
network: ObjectUtil.isEmpty(this.network) ? undefined : this.network,
|
||||
port: ObjectUtil.isEmpty(this.port) ? undefined : this.port,
|
||||
ip: this.ip.length === 0 ? undefined : this.ip,
|
||||
blockDelay: this.action === 'block' && !ObjectUtil.isEmpty(this.blockDelay) ? this.blockDelay : undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Outbound.BlackholeSettings = class extends CommonClass {
|
||||
constructor(type) {
|
||||
super();
|
||||
@@ -1277,20 +1623,69 @@ Outbound.BlackholeSettings = class extends CommonClass {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Outbound.DNSRule = class extends CommonClass {
|
||||
constructor(action = 'direct', qtype = '', domain = '') {
|
||||
super();
|
||||
this.action = action;
|
||||
this.qtype = qtype;
|
||||
this.domain = domain;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Outbound.DNSRule(
|
||||
json.action,
|
||||
normalizeDNSRuleField(json.qtype),
|
||||
normalizeDNSRuleField(json.domain),
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const rule = {
|
||||
action: normalizeDNSRuleAction(this.action),
|
||||
};
|
||||
|
||||
const qtype = normalizeDNSRuleField(this.qtype);
|
||||
if (!ObjectUtil.isEmpty(qtype)) {
|
||||
if (/^\d+$/.test(qtype)) {
|
||||
rule.qtype = Number(qtype);
|
||||
} else {
|
||||
rule.qtype = qtype;
|
||||
}
|
||||
}
|
||||
|
||||
const domains = normalizeDNSRuleField(this.domain)
|
||||
.split(',')
|
||||
.map(d => d.trim())
|
||||
.filter(d => d.length > 0);
|
||||
if (domains.length > 0) {
|
||||
rule.domain = domains;
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
};
|
||||
|
||||
Outbound.DNSSettings = class extends CommonClass {
|
||||
constructor(
|
||||
network = 'udp',
|
||||
address = '',
|
||||
port = 53,
|
||||
nonIPQuery = 'reject',
|
||||
blockTypes = []
|
||||
rules = []
|
||||
) {
|
||||
super();
|
||||
this.network = network;
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.nonIPQuery = nonIPQuery;
|
||||
this.blockTypes = blockTypes;
|
||||
this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : [];
|
||||
}
|
||||
|
||||
addRule(action = 'direct') {
|
||||
this.rules.push(new Outbound.DNSRule(action));
|
||||
}
|
||||
|
||||
delRule(index) {
|
||||
this.rules.splice(index, 1);
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
@@ -1298,10 +1693,23 @@ Outbound.DNSSettings = class extends CommonClass {
|
||||
json.network,
|
||||
json.address,
|
||||
json.port,
|
||||
json.nonIPQuery,
|
||||
json.blockTypes,
|
||||
getDNSRulesFromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const json = {
|
||||
network: this.network,
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
};
|
||||
|
||||
if (this.rules.length > 0) {
|
||||
json.rules = Outbound.DNSRule.toJsonArray(this.rules);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
};
|
||||
Outbound.VmessSettings = class extends CommonClass {
|
||||
constructor(address, port, id, security) {
|
||||
@@ -1339,13 +1747,14 @@ Outbound.VmessSettings = class extends CommonClass {
|
||||
}
|
||||
};
|
||||
Outbound.VLESSSettings = class extends CommonClass {
|
||||
constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) {
|
||||
constructor(address, port, id, flow, encryption, reverseTag = '', testpre = 0, testseed = [900, 500, 900, 256]) {
|
||||
super();
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.encryption = encryption;
|
||||
this.reverseTag = reverseTag;
|
||||
this.testpre = testpre;
|
||||
this.testseed = testseed;
|
||||
}
|
||||
@@ -1358,6 +1767,7 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||
json.id,
|
||||
json.flow,
|
||||
json.encryption,
|
||||
json.reverse?.tag || '',
|
||||
json.testpre || 0,
|
||||
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||
);
|
||||
@@ -1371,6 +1781,9 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||
flow: this.flow,
|
||||
encryption: this.encryption,
|
||||
};
|
||||
if (!ObjectUtil.isEmpty(this.reverseTag)) {
|
||||
result.reverse = { tag: this.reverseTag };
|
||||
}
|
||||
// Only include Vision settings when flow is set
|
||||
if (this.flow && this.flow !== '') {
|
||||
if (this.testpre > 0) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
const REALITY_TARGETS = [
|
||||
{ target: 'www.apple.com:443', sni: 'www.apple.com' },
|
||||
{ target: 'www.icloud.com:443', sni: 'www.icloud.com' },
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
|
||||
@@ -43,6 +43,7 @@ class AllSetting {
|
||||
this.subDomain = "";
|
||||
this.externalTrafficInformEnable = false;
|
||||
this.externalTrafficInformURI = "";
|
||||
this.restartXrayOnClientDisable = true;
|
||||
this.subCertFile = "";
|
||||
this.subKeyFile = "";
|
||||
this.subUpdates = 12;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
const data = {
|
||||
sId: el.getAttribute('data-sid') || '',
|
||||
enabled: (el.getAttribute('data-enabled') || '').toLowerCase() === 'true',
|
||||
subUrl: el.getAttribute('data-sub-url') || '',
|
||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||
subClashUrl: el.getAttribute('data-subclash-url') || '',
|
||||
@@ -128,9 +129,10 @@
|
||||
},
|
||||
isActive() {
|
||||
const now = Date.now();
|
||||
const enabledOk = this.app.enabled;
|
||||
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;
|
||||
return enabledOk && expiryOk && trafficOk;
|
||||
},
|
||||
shadowrocketUrl() {
|
||||
const rawUrl = this.app.subUrl + '?flag=shadowrocket';
|
||||
|
||||
@@ -152,6 +152,12 @@ class RandomUtil {
|
||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||
}
|
||||
|
||||
static randomBase64(length = 16) {
|
||||
const array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(array);
|
||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||
}
|
||||
|
||||
static randomBase32String(length = 16) {
|
||||
const array = new Uint8Array(length);
|
||||
|
||||
|
||||
@@ -1,150 +1,212 @@
|
||||
/**
|
||||
* WebSocket client for real-time updates
|
||||
* WebSocket client for real-time panel updates.
|
||||
*
|
||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
||||
* - connect() — open the connection (idempotent)
|
||||
* - disconnect() — close and stop reconnecting
|
||||
* - on(event, callback) — subscribe to event
|
||||
* - off(event, callback) — unsubscribe
|
||||
* - send(data) — send JSON to the server
|
||||
* - isConnected — boolean, current state
|
||||
* - reconnectAttempts — number, attempts since last success
|
||||
* - maxReconnectAttempts — number, give-up threshold
|
||||
*
|
||||
* Built-in events:
|
||||
* 'connected', 'disconnected', 'error', 'message',
|
||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
||||
*/
|
||||
class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
||||
// cadence rather than giving up forever — a panel that recovers an hour
|
||||
// later should reconnect without a manual page reload.
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.listeners = new Map();
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map(); // event → Set<callback>
|
||||
}
|
||||
|
||||
// Open the connection. Safe to call repeatedly — no-op if already
|
||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
||||
// any pending reconnect timer so an external connect() can't race a
|
||||
// delayed retry into spawning a second socket.
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Ensure basePath ends with '/' for proper URL construction
|
||||
let basePath = this.basePath || '';
|
||||
if (basePath && !basePath.endsWith('/')) {
|
||||
basePath += '/';
|
||||
}
|
||||
const wsUrl = `${protocol}//${window.location.host}${basePath}ws`;
|
||||
|
||||
console.log('WebSocket connecting to:', wsUrl, 'basePath:', this.basePath);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
// Validate message size (prevent memory issues)
|
||||
const maxMessageSize = 10 * 1024 * 1024; // 10MB
|
||||
if (event.data && event.data.length > maxMessageSize) {
|
||||
console.error('WebSocket message too large:', event.data.length, 'bytes');
|
||||
this.ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(event.data);
|
||||
if (!message || typeof message !== 'object') {
|
||||
console.error('Invalid WebSocket message format');
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.isConnected = false;
|
||||
this.emit('disconnected');
|
||||
|
||||
if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to create WebSocket connection:', e);
|
||||
this.emit('error', e);
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
const { type, payload, time } = message;
|
||||
|
||||
// Emit to specific type listeners
|
||||
this.emit(type, payload, time);
|
||||
|
||||
// Emit to all listeners
|
||||
this.emit('message', { type, payload, time });
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
const callbacks = this.listeners.get(event);
|
||||
if (!callbacks.includes(callback)) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (e) {
|
||||
console.error('Error in WebSocket event handler:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
// Unsubscribe from an event.
|
||||
off(event, callback) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
// Send JSON to the server. Drops silently if not connected — callers
|
||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── internals ─────
|
||||
|
||||
#openSocket() {
|
||||
const url = this.#buildUrl();
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.#onMessage(event));
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||
// — bad networks would flood the console otherwise.
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let basePath = this.basePath || '';
|
||||
if (basePath && !basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event) {
|
||||
const data = event.data;
|
||||
// Reject oversized payloads up front. We compare actual UTF-8 byte
|
||||
// length (via Blob.size) against the limit — string.length counts
|
||||
// UTF-16 code units, which can undercount real bytes by up to 4× for
|
||||
// payloads with non-ASCII characters and bypass the cap.
|
||||
if (typeof data === 'string') {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
this.#emit(message.type, message.payload, message.time);
|
||||
this.#emit('message', message);
|
||||
}
|
||||
|
||||
#emit(event, ...args) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
// Exponential backoff inside the active window.
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
console.warn('WebSocket is not connected');
|
||||
// Active window exhausted — keep trying once a minute. The page-level
|
||||
// polling fallback runs in parallel; this just brings WS back when the
|
||||
// network recovers.
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global WebSocket client instance
|
||||
// Safely get basePath from global scope (defined in page.html)
|
||||
// Global instance — basePath is set by page.html before this script loads.
|
||||
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');
|
||||
|
||||
@@ -27,6 +27,34 @@ func NewInboundController(g *gin.RouterGroup) *InboundController {
|
||||
return a
|
||||
}
|
||||
|
||||
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
|
||||
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
|
||||
// Mirrors the same heuristic used by the periodic traffic job.
|
||||
const broadcastInboundsUpdateClientLimit = 5000
|
||||
|
||||
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
|
||||
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
|
||||
// so we send an invalidate signal instead — frontend re-fetches via REST.
|
||||
// Skipped entirely when no WebSocket clients are connected.
|
||||
func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
}
|
||||
inbounds, err := a.inboundService.GetInbounds(userId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
totalClients := 0
|
||||
for _, ib := range inbounds {
|
||||
totalClients += len(ib.ClientStats)
|
||||
}
|
||||
if totalClients > broadcastInboundsUpdateClientLimit {
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
return
|
||||
}
|
||||
websocket.BroadcastInbounds(inbounds)
|
||||
}
|
||||
|
||||
// initRouter initializes the routes for inbound-related operations.
|
||||
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
@@ -38,9 +66,11 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/add", a.addInbound)
|
||||
g.POST("/del/:id", a.delInbound)
|
||||
g.POST("/update/:id", a.updateInbound)
|
||||
g.POST("/setEnable/:id", a.setInboundEnable)
|
||||
g.POST("/clientIps/:email", a.getClientIps)
|
||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
||||
g.POST("/addClient", a.addInboundClient)
|
||||
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||
@@ -54,6 +84,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||
}
|
||||
|
||||
type CopyInboundClientsRequest struct {
|
||||
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
|
||||
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
|
||||
Flow string `form:"flow" json:"flow"`
|
||||
}
|
||||
|
||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
@@ -127,9 +163,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// Broadcast inbounds update via WebSocket
|
||||
inbounds, _ := a.inboundService.GetInbounds(user.Id)
|
||||
websocket.BroadcastInbounds(inbounds)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
}
|
||||
|
||||
// delInbound deletes an inbound configuration by its ID.
|
||||
@@ -148,10 +182,8 @@ func (a *InboundController) delInbound(c *gin.Context) {
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// Broadcast inbounds update via WebSocket
|
||||
user := session.GetLoginUser(c)
|
||||
inbounds, _ := a.inboundService.GetInbounds(user.Id)
|
||||
websocket.BroadcastInbounds(inbounds)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
}
|
||||
|
||||
// updateInbound updates an existing inbound configuration.
|
||||
@@ -178,10 +210,43 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// Broadcast inbounds update via WebSocket
|
||||
user := session.GetLoginUser(c)
|
||||
inbounds, _ := a.inboundService.GetInbounds(user.Id)
|
||||
websocket.BroadcastInbounds(inbounds)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
}
|
||||
|
||||
// setInboundEnable flips only the enable flag of an inbound. This is a
|
||||
// dedicated endpoint because the regular update path serialises the entire
|
||||
// settings JSON (every client) — far too heavy for an interactive switch
|
||||
// on inbounds with thousands of clients. Frontend optimistically updates
|
||||
// the UI; we just persist + sync xray + nudge other open admin sessions.
|
||||
func (a *InboundController) setInboundEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
type form struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}
|
||||
var f form
|
||||
if err := c.ShouldBind(&f); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
|
||||
// instead of fetching + serialising the whole inbound list. Other open
|
||||
// sessions re-fetch via REST. The toggling admin's own UI already
|
||||
// updated optimistically.
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
}
|
||||
|
||||
// getClientIps retrieves the IP addresses associated with a client by email.
|
||||
@@ -260,6 +325,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// copyInboundClients copies clients from source inbound to target inbound.
|
||||
func (a *InboundController) copyInboundClients(c *gin.Context) {
|
||||
targetID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
req := &CopyInboundClientsRequest{}
|
||||
err = c.ShouldBind(req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if req.SourceInboundID <= 0 {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
|
||||
return
|
||||
}
|
||||
|
||||
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
||||
func (a *InboundController) delInboundClient(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -95,9 +94,8 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
|
||||
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
|
||||
|
||||
session.SetLoginUser(c, user)
|
||||
if err := sessions.Default(c).Save(); err != nil {
|
||||
logger.Warning("Unable to save session: ", err)
|
||||
if err := session.SetLoginUser(c, user); err != nil {
|
||||
logger.Warning("Unable to save session:", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,9 +109,8 @@ func (a *IndexController) logout(c *gin.Context) {
|
||||
if user != nil {
|
||||
logger.Infof("%s logged out successfully", user.Username)
|
||||
}
|
||||
session.ClearSession(c)
|
||||
if err := sessions.Default(c).Save(); err != nil {
|
||||
logger.Warning("Unable to save session after clearing:", err)
|
||||
if err := session.ClearSession(c); err != nil {
|
||||
logger.Warning("Unable to clear session on logout:", err)
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type ServerController struct {
|
||||
|
||||
serverService service.ServerService
|
||||
settingService service.SettingService
|
||||
panelService service.PanelService
|
||||
|
||||
lastStatus *service.Status
|
||||
|
||||
@@ -43,6 +44,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/status", a.status)
|
||||
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
||||
g.GET("/getXrayVersion", a.getXrayVersion)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
g.GET("/getDb", a.getDb)
|
||||
g.GET("/getNewUUID", a.getNewUUID)
|
||||
@@ -54,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/stopXrayService", a.stopXrayService)
|
||||
g.POST("/restartXrayService", a.restartXrayService)
|
||||
g.POST("/installXray/:version", a.installXray)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.POST("/updateGeofile", a.updateGeofile)
|
||||
g.POST("/updateGeofile/:fileName", a.updateGeofile)
|
||||
g.POST("/logs/:count", a.getLogs)
|
||||
@@ -131,6 +134,16 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||
jsonObj(c, versions, nil)
|
||||
}
|
||||
|
||||
// getPanelUpdateInfo retrieves the current and latest panel version.
|
||||
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
||||
info, err := a.panelService.GetUpdateInfo()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateCheckPopover"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, info, nil)
|
||||
}
|
||||
|
||||
// installXray installs or updates Xray to the specified version.
|
||||
func (a *ServerController) installXray(c *gin.Context) {
|
||||
version := c.Param("version")
|
||||
@@ -138,6 +151,12 @@ func (a *ServerController) installXray(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
|
||||
}
|
||||
|
||||
// updatePanel starts a panel self-update to the latest release.
|
||||
func (a *ServerController) updatePanel(c *gin.Context) {
|
||||
err := a.panelService.StartUpdate()
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
|
||||
}
|
||||
|
||||
// updateGeofile updates the specified geo file for Xray.
|
||||
func (a *ServerController) updateGeofile(c *gin.Context) {
|
||||
fileName := c.Param("fileName")
|
||||
|
||||
@@ -99,7 +99,9 @@ func (a *SettingController) updateUser(c *gin.Context) {
|
||||
if err == nil {
|
||||
user.Username = form.NewUsername
|
||||
user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword)
|
||||
session.SetLoginUser(c, user)
|
||||
if saveErr := session.SetLoginUser(c, user); saveErr != nil {
|
||||
err = saveErr
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
@@ -14,18 +16,58 @@ import (
|
||||
|
||||
// getRemoteIp extracts the real IP address from the request headers or remote address.
|
||||
func getRemoteIp(c *gin.Context) string {
|
||||
value := c.GetHeader("X-Real-IP")
|
||||
if value != "" {
|
||||
return value
|
||||
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
|
||||
return ip
|
||||
}
|
||||
value = c.GetHeader("X-Forwarded-For")
|
||||
if value != "" {
|
||||
ips := strings.Split(value, ",")
|
||||
return ips[0]
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip, ok := extractTrustedIP(part); ok {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
addr := c.Request.RemoteAddr
|
||||
ip, _, _ := net.SplitHostPort(addr)
|
||||
return ip
|
||||
|
||||
if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
|
||||
return ip
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func extractTrustedIP(value string) (string, bool) {
|
||||
candidate := strings.TrimSpace(value)
|
||||
if candidate == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if ip, ok := parseIPCandidate(candidate); ok {
|
||||
return ip.String(), true
|
||||
}
|
||||
|
||||
if host, _, err := net.SplitHostPort(candidate); err == nil {
|
||||
if ip, ok := parseIPCandidate(host); ok {
|
||||
return ip.String(), true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Count(candidate, ":") == 1 {
|
||||
if host, _, err := net.SplitHostPort(fmt.Sprintf("[%s]", candidate)); err == nil {
|
||||
if ip, ok := parseIPCandidate(host); ok {
|
||||
return ip.String(), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseIPCandidate(value string) (netip.Addr, bool) {
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return ip.Unmap(), true
|
||||
}
|
||||
|
||||
// jsonMsg sends a JSON response with a message and error status.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,105 +18,80 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Time allowed to write a message to the peer
|
||||
writeWait = 10 * time.Second
|
||||
|
||||
// Time allowed to read the next pong message from the peer
|
||||
pongWait = 60 * time.Second
|
||||
|
||||
// Send pings to peer with this period (must be less than pongWait)
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
|
||||
// Maximum message size allowed from peer
|
||||
maxMessageSize = 512
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
clientReadLimit = 512
|
||||
)
|
||||
|
||||
var upgrader = ws.Upgrader{
|
||||
ReadBufferSize: 32768,
|
||||
WriteBufferSize: 32768,
|
||||
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
|
||||
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Check origin for security
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Allow connections without Origin header (same-origin requests)
|
||||
return true
|
||||
}
|
||||
// Get the host from the request
|
||||
host := r.Host
|
||||
// Extract scheme and host from origin
|
||||
originURL := origin
|
||||
// Simple check: origin should match the request host
|
||||
// This prevents cross-origin WebSocket hijacking
|
||||
if strings.HasPrefix(originURL, "http://") || strings.HasPrefix(originURL, "https://") {
|
||||
// Extract host from origin
|
||||
originHost := strings.TrimPrefix(strings.TrimPrefix(originURL, "http://"), "https://")
|
||||
if idx := strings.Index(originHost, "/"); idx != -1 {
|
||||
originHost = originHost[:idx]
|
||||
}
|
||||
if idx := strings.Index(originHost, ":"); idx != -1 {
|
||||
originHost = originHost[:idx]
|
||||
}
|
||||
// Compare hosts (without port)
|
||||
requestHost := host
|
||||
if idx := strings.Index(requestHost, ":"); idx != -1 {
|
||||
requestHost = requestHost[:idx]
|
||||
}
|
||||
return originHost == requestHost || originHost == "" || requestHost == ""
|
||||
}
|
||||
return false
|
||||
},
|
||||
EnableCompression: true,
|
||||
CheckOrigin: checkSameOrigin,
|
||||
}
|
||||
|
||||
// WebSocketController handles WebSocket connections for real-time updates
|
||||
// checkSameOrigin allows requests with no Origin header (same-origin or non-browser
|
||||
// clients) and otherwise requires the Origin hostname to match the request hostname.
|
||||
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
|
||||
// (the panel often sits behind a reverse proxy on a different port).
|
||||
func checkSameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
// IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort
|
||||
// fails in that case while url.Hostname() returns the address without
|
||||
// brackets. Strip them so same-origin checks pass for bare IPv6 hosts.
|
||||
host = r.Host
|
||||
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), host)
|
||||
}
|
||||
|
||||
// WebSocketController handles WebSocket connections for real-time updates.
|
||||
type WebSocketController struct {
|
||||
BaseController
|
||||
hub *websocket.Hub
|
||||
}
|
||||
|
||||
// NewWebSocketController creates a new WebSocket controller
|
||||
// NewWebSocketController creates a new WebSocket controller.
|
||||
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
|
||||
return &WebSocketController{
|
||||
hub: hub,
|
||||
}
|
||||
return &WebSocketController{hub: hub}
|
||||
}
|
||||
|
||||
// HandleWebSocket handles WebSocket connections
|
||||
// HandleWebSocket upgrades the HTTP connection and starts the read/write pumps.
|
||||
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
||||
// Check authentication
|
||||
if !session.IsLogin(c) {
|
||||
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade connection to WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Error("Failed to upgrade WebSocket connection:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create client
|
||||
clientID := uuid.New().String()
|
||||
client := &websocket.Client{
|
||||
ID: clientID,
|
||||
Hub: w.hub,
|
||||
Send: make(chan []byte, 512), // Increased from 256 to 512 to prevent overflow
|
||||
Topics: make(map[websocket.MessageType]bool),
|
||||
}
|
||||
|
||||
// Register client
|
||||
client := websocket.NewClient(uuid.New().String())
|
||||
w.hub.Register(client)
|
||||
logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c))
|
||||
logger.Debugf("WebSocket client %s registered from %s", client.ID, getRemoteIp(c))
|
||||
|
||||
// Start goroutines for reading and writing
|
||||
go w.writePump(client, conn)
|
||||
go w.readPump(client, conn)
|
||||
}
|
||||
|
||||
// readPump pumps messages from the WebSocket connection to the hub
|
||||
// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
|
||||
// running. Clients send no commands today; frames are discarded.
|
||||
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
|
||||
defer func() {
|
||||
if r := common.Recover("WebSocket readPump panic"); r != nil {
|
||||
@@ -124,35 +101,23 @@ func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(clientReadLimit)
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
||||
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
||||
}
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
if len(message) > maxMessageSize {
|
||||
logger.Warningf("WebSocket message from client %s exceeds max size: %d bytes", client.ID, len(message))
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle incoming messages (e.g., subscription requests)
|
||||
// For now, we'll just log them
|
||||
logger.Debugf("Received WebSocket message from client %s: %s", client.ID, string(message))
|
||||
}
|
||||
}
|
||||
|
||||
// writePump pumps messages from the hub to the WebSocket connection
|
||||
// writePump pushes hub messages to the connection and emits keepalive pings.
|
||||
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
@@ -165,17 +130,13 @@ func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn)
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-client.Send:
|
||||
case msg, ok := <-client.Send:
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
// Hub closed the channel
|
||||
conn.WriteMessage(ws.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
// Send each message individually (no batching)
|
||||
// This ensures each JSON message is sent separately and can be parsed correctly
|
||||
if err := conn.WriteMessage(ws.TextMessage, message); err != nil {
|
||||
if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
|
||||
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,14 +71,19 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
clientReverseTags, err := a.InboundService.GetClientReverseTags()
|
||||
if err != nil {
|
||||
clientReverseTags = "[]"
|
||||
}
|
||||
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
if outboundTestUrl == "" {
|
||||
outboundTestUrl = "https://www.google.com/generate_204"
|
||||
}
|
||||
xrayResponse := map[string]interface{}{
|
||||
"xraySetting": json.RawMessage(xraySetting),
|
||||
"inboundTags": json.RawMessage(inboundTags),
|
||||
"outboundTestUrl": outboundTestUrl,
|
||||
xrayResponse := map[string]any{
|
||||
"xraySetting": json.RawMessage(xraySetting),
|
||||
"inboundTags": json.RawMessage(inboundTags),
|
||||
"clientReverseTags": json.RawMessage(clientReverseTags),
|
||||
"outboundTestUrl": outboundTestUrl,
|
||||
}
|
||||
result, err := json.Marshal(xrayResponse)
|
||||
if err != nil {
|
||||
|
||||
@@ -71,6 +71,7 @@ type AllSetting struct {
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates"` // Subscription update interval in minutes
|
||||
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
|
||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
|
||||
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
|
||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{{ define "page/head_start" }}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="renderer" content="webkit">
|
||||
@@ -12,6 +13,7 @@
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* vazirmatn-regular - arabic_latin_latin-ext */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
@@ -21,10 +23,11 @@
|
||||
src: url('{{ .base_path }}assets/Vazirmatn-UI-NL-Regular.woff2') format('woff2');
|
||||
unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC, U+0030-0039;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Vazirmatn', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* mobile touch scrolling for tabs */
|
||||
@media (max-width: 576px) {
|
||||
.ant-tabs-nav-container {
|
||||
@@ -34,59 +37,69 @@
|
||||
overscroll-behavior-x: contain;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
padding: 0 !important; /* Remove padding for arrows */
|
||||
padding: 0 !important;
|
||||
/* Remove padding for arrows */
|
||||
}
|
||||
|
||||
.ant-tabs-nav-wrap {
|
||||
overflow: visible !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-scroll {
|
||||
overflow: visible !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
display: flex !important;
|
||||
transform: none !important; /* Disable JS transform */
|
||||
width: auto !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
transform: none !important;
|
||||
/* Disable JS transform */
|
||||
width: auto !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab-prev,
|
||||
.ant-tabs-tab-next {
|
||||
display: none !important; /* Hide arrows */
|
||||
display: none !important;
|
||||
/* Hide arrows */
|
||||
}
|
||||
|
||||
.ant-tabs-nav-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<title>{{ .host }} – {{ i18n .title}}</title>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "page/head_end" }}
|
||||
{{ define "page/head_end" }}
|
||||
</head>
|
||||
{{ end }}
|
||||
|
||||
{{ define "page/body_start" }}
|
||||
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "page/body_scripts" }}
|
||||
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/axios/axios.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/qs/qs.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/js/axios-init.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||
<script>
|
||||
const basePath = '{{ .base_path }}';
|
||||
axios.defaults.baseURL = basePath;
|
||||
</script>
|
||||
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "page/body_end" }}
|
||||
{{ define "page/body_scripts" }}
|
||||
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/axios/axios.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/qs/qs.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/js/axios-init.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||
<script>
|
||||
const basePath = '{{ .base_path }}';
|
||||
axios.defaults.baseURL = basePath;
|
||||
</script>
|
||||
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "page/body_end" }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -2,30 +2,39 @@
|
||||
<template slot="actions" slot-scope="text, client, index">
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "qrCode" }}</template>
|
||||
<a-icon :style="{ fontSize: '22px', marginInlineStart: '14px' }" class="normal-icon" type="qrcode" v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon>
|
||||
<a-icon :style="{ fontSize: '22px', marginInlineStart: '14px' }" class="normal-icon" type="qrcode"
|
||||
v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.client.edit" }}</template>
|
||||
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="edit" @click="openEditClient(record.id,client);"></a-icon>
|
||||
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="edit"
|
||||
@click="openEditClient(record.id,client);"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "info" }}</template>
|
||||
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
|
||||
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle"
|
||||
@click="showInfo(record.id,client);"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
||||
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
|
||||
<a-popconfirm @confirm="resetClientTraffic(client,record.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="{ color: 'var(--color-primary-100)'}"></a-icon>
|
||||
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon>
|
||||
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet"
|
||||
v-if="client.email.length > 0"></a-icon>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
||||
</template>
|
||||
<a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'>
|
||||
<a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}'
|
||||
: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="{ fontSize: '22px', cursor: 'pointer' }" class="delete-icon" type="delete" v-if="isRemovable(record.id)"></a-icon>
|
||||
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="delete-icon" type="delete"
|
||||
v-if="isRemovable(record.id)"></a-icon>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
@@ -34,7 +43,7 @@
|
||||
</template>
|
||||
<template slot="online" slot-scope="text, client, index">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content" >
|
||||
<template slot="content">
|
||||
{{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]]
|
||||
</template>
|
||||
<template v-if="client.enable && isClientOnline(client.email)">
|
||||
@@ -53,7 +62,8 @@
|
||||
<template v-else-if="!client.enable">{{ i18n "disabled" }}</template>
|
||||
<template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
|
||||
</template>
|
||||
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
||||
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
|
||||
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
||||
</a-tooltip>
|
||||
<a-space direction="vertical" :size="2">
|
||||
<span class="client-email">[[ client.email ]]</span>
|
||||
@@ -83,24 +93,22 @@
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
<table>
|
||||
<tr class="tr-table-box">
|
||||
<td class="tr-table-rt"> [[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]] </td>
|
||||
<td class="tr-table-bar" v-if="!client.enable">
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
||||
</td>
|
||||
<td class="tr-table-bar" v-else-if="client.totalGB > 0">
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
</td>
|
||||
<td v-else class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :percent="100"></a-progress>
|
||||
</td>
|
||||
<td class="tr-table-lt">
|
||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||
<span v-else class="tr-infinity-ch">∞</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="tr-table-box">
|
||||
<div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
|
||||
<div class="tr-table-bar" v-if="!client.enable">
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
||||
</div>
|
||||
<div class="tr-table-bar" v-else-if="client.totalGB > 0">
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
</div>
|
||||
<div v-else class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :percent="100"></a-progress>
|
||||
</div>
|
||||
<div class="tr-table-lt">
|
||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||
<span v-else class="tr-infinity-ch">∞</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
@@ -114,15 +122,13 @@
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
|
||||
</template>
|
||||
<table>
|
||||
<tr class="tr-table-box">
|
||||
<td class="tr-table-rt"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
||||
<td class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
</td>
|
||||
<td class="tr-table-lt">[[ client.reset + "d" ]]</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="tr-table-box">
|
||||
<div class="tr-table-rt">[[ IntlUtil.formatRelativeTime(client.expiryTime) ]]</div>
|
||||
<div class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
</div>
|
||||
<div class="tr-table-lt">[[ client.reset + "d" ]]</div>
|
||||
</div>
|
||||
</a-popover>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -131,11 +137,16 @@
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
|
||||
</template>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }" :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
||||
:color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[
|
||||
IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<a-tag v-else :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"
|
||||
:style="{ border: 'none' }" class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</a-tag>
|
||||
</template>
|
||||
@@ -165,7 +176,8 @@
|
||||
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item>
|
||||
<a-switch v-model="client.enable" size="small" @change="switchEnableClient(record.id, client, $event)"></a-switch>
|
||||
<a-switch v-model="client.enable" size="small"
|
||||
@change="switchEnableClient(record.id, client, $event)"></a-switch>
|
||||
{{ i18n "enable"}}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
@@ -179,9 +191,11 @@
|
||||
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
|
||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
||||
SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
|
||||
<td width="120px" v-if="!client.enable">
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
|
||||
:percent="statsProgress(record, client.email)" />
|
||||
</td>
|
||||
<td width="120px" v-else-if="client.totalGB > 0">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
@@ -197,11 +211,14 @@
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false"
|
||||
:status="isClientDepleted(record, client.email)? 'exception' : ''"
|
||||
:percent="statsProgress(record, client.email)" />
|
||||
</a-popover>
|
||||
</td>
|
||||
<td width="120px" v-else class="infinite-bar">
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false" :percent="100"></a-progress>
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
|
||||
:percent="100"></a-progress>
|
||||
</td>
|
||||
<td width="80px">
|
||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||
@@ -216,14 +233,16 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<template v-if="client.expiryTime !=0 && client.reset >0">
|
||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
||||
IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
||||
<td width="120px" class="infinite-bar">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
|
||||
</template>
|
||||
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''"
|
||||
:percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
</a-popover>
|
||||
</td>
|
||||
<td width="60px">[[ client.reset + "d" ]]</td>
|
||||
@@ -235,11 +254,16 @@
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
|
||||
</template>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }" :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
||||
:color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[
|
||||
IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else :color="client.enable ? 'purple' : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'" class="infinite-tag">
|
||||
<a-tag v-else :color="client.enable ? 'purple' : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"
|
||||
class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</a-tag>
|
||||
</template>
|
||||
@@ -248,7 +272,8 @@
|
||||
</table>
|
||||
</template>
|
||||
<a-badge>
|
||||
<a-icon v-if="!client.enable" slot="count" type="pause-circle" theme="filled" :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
|
||||
<a-icon v-if="!client.enable" slot="count" type="pause-circle" theme="filled"
|
||||
:style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
|
||||
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
|
||||
<a-icon type="solution"></a-icon>
|
||||
</a-button>
|
||||
@@ -271,4 +296,4 @@
|
||||
-
|
||||
</template>
|
||||
</template>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,13 +1,13 @@
|
||||
{{define "component/customStatistic"}}
|
||||
<template>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix>
|
||||
<slot name="prefix"></slot>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix"></slot>
|
||||
</template>
|
||||
</a-statistic>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix>
|
||||
<slot name="prefix"></slot>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix"></slot>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</template>
|
||||
{{end}}
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
.dark .ant-statistic-content {
|
||||
color: var(--dark-color-text-primary)
|
||||
}
|
||||
|
||||
.dark .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.55)
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -36,7 +38,7 @@
|
||||
required: false
|
||||
}
|
||||
},
|
||||
template: `{{template "component/customStatistic"}}`,
|
||||
template: `{{template "component/customStatistic" .}}`,
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -34,7 +34,7 @@
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
template: `{{template "component/persianDatepickerTemplate"}}`,
|
||||
template: `{{template "component/persianDatepickerTemplate" .}}`,
|
||||
data() {
|
||||
return {
|
||||
date: '',
|
||||
@@ -42,7 +42,7 @@
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value: function (date) {
|
||||
value: function(date) {
|
||||
this.date = this.convertToJalalian(date)
|
||||
}
|
||||
},
|
||||
@@ -52,7 +52,8 @@
|
||||
},
|
||||
methods: {
|
||||
convertToGregorian(date) {
|
||||
return date ? moment(moment(date, 'jYYYY/jMM/jDD HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')) : null
|
||||
return date ? moment(moment(date, 'jYYYY/jMM/jDD HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')) :
|
||||
null
|
||||
},
|
||||
convertToJalalian(date) {
|
||||
return date && moment.isMoment(date) ? date.format('jYYYY/jMM/jDD HH:mm:ss') : null
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
type: String,
|
||||
required: false,
|
||||
defaultValue: "default",
|
||||
validator: function (value) {
|
||||
validator: function(value) {
|
||||
return ['small', 'default'].includes(value)
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,4 @@
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -43,8 +43,7 @@
|
||||
Vue.component('a-sidebar', {
|
||||
data() {
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
tabs: [{
|
||||
key: '{{ .base_path }}panel/',
|
||||
icon: 'dashboard',
|
||||
title: '{{ i18n "menu.dashboard"}}'
|
||||
@@ -79,8 +78,8 @@
|
||||
},
|
||||
methods: {
|
||||
openLink(key) {
|
||||
return key.startsWith('http') ?
|
||||
window.open(key) :
|
||||
return key.startsWith('http') ?
|
||||
window.open(key) :
|
||||
location.href = key
|
||||
},
|
||||
closeDrawer() {
|
||||
@@ -97,7 +96,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `{{template "component/sidebar/content"}}`,
|
||||
template: `{{template "component/sidebar/content" .}}`,
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,237 +1,302 @@
|
||||
{{define "component/sortableTableTrigger"}}
|
||||
<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" @mousedown="mouseDownHandler"
|
||||
@click="clickHandler" />
|
||||
<a-icon type="drag" class="sortable-icon"
|
||||
role="button" tabindex="0"
|
||||
:aria-label="ariaLabel"
|
||||
@pointerdown="onPointerDown"
|
||||
@keydown="onKeyDown" />
|
||||
{{end}}
|
||||
|
||||
{{define "component/aTableSortable"}}
|
||||
<script>
|
||||
const DRAGGABLE_ROW_CLASS = 'draggable-row';
|
||||
const findParentRowElement = (el) => {
|
||||
if (!el || !el.tagName) {
|
||||
return null;
|
||||
} else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) {
|
||||
return el;
|
||||
} else if (el.parentNode) {
|
||||
return findParentRowElement(el.parentNode);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||
*
|
||||
* Why a rewrite:
|
||||
* - Old impl set `draggable: true` on every row, which (a) broke text
|
||||
* selection inside cells, (b) let HTML5 start a drag from anywhere on
|
||||
* the row even when the state machine wasn't primed, producing
|
||||
* "phantom drags" that didn't reorder anything.
|
||||
* - HTML5 drag has no touch support on most mobile browsers and no
|
||||
* keyboard fallback at all.
|
||||
* - The drag-image hack cloned the entire table — slow on big lists.
|
||||
*
|
||||
* New design:
|
||||
* - Only the explicit drag handle initiates a drag, via Pointer Events
|
||||
* (one API for mouse + touch + pen). Rows are not draggable.
|
||||
* - During drag, `data-source` is reordered live: the source row visually
|
||||
* slides into the target slot and other rows shift around it. The live
|
||||
* reorder IS the visual feedback — no separate floating preview.
|
||||
* - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
|
||||
* and signature as before, so existing call sites stay unchanged.
|
||||
* - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
|
||||
* the row by one; Escape cancels a pointer-drag in progress.
|
||||
*/
|
||||
const ROW_CLASS = 'sortable-row';
|
||||
|
||||
Vue.component('a-table-sortable', {
|
||||
data() {
|
||||
return {
|
||||
sortingElementIndex: null,
|
||||
newElementIndex: null,
|
||||
// null when idle. While dragging:
|
||||
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||
drag: null,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
'data-source': {
|
||||
type: undefined,
|
||||
required: false,
|
||||
},
|
||||
'customRow': {
|
||||
type: undefined,
|
||||
required: false,
|
||||
}
|
||||
'data-source': { type: undefined, required: false },
|
||||
'customRow': { type: undefined, required: false },
|
||||
'row-key': { type: undefined, required: false },
|
||||
},
|
||||
inheritAttrs: false,
|
||||
provide() {
|
||||
const sortable = {}
|
||||
Object.defineProperty(sortable, "setSortableIndex", {
|
||||
const sortable = {};
|
||||
// Methods exposed to the trigger child via inject. Defined as getters
|
||||
// so `this` binds to the component instance, not the plain object.
|
||||
Object.defineProperty(sortable, 'startDrag', {
|
||||
enumerable: true,
|
||||
get: () => this.setCurrentSortableIndex,
|
||||
get: () => this.startDrag,
|
||||
});
|
||||
Object.defineProperty(sortable, "resetSortableIndex", {
|
||||
Object.defineProperty(sortable, 'moveByKeyboard', {
|
||||
enumerable: true,
|
||||
get: () => this.resetSortableIndex,
|
||||
get: () => this.moveByKeyboard,
|
||||
});
|
||||
return {
|
||||
sortable,
|
||||
}
|
||||
return { sortable };
|
||||
},
|
||||
render: function (createElement) {
|
||||
return createElement('a-table', {
|
||||
class: {
|
||||
'ant-table-is-sorting': this.isDragging(),
|
||||
},
|
||||
props: {
|
||||
...this.$attrs,
|
||||
'data-source': this.records,
|
||||
customRow: (record, index) => this.customRowRender(record, index),
|
||||
},
|
||||
on: this.$listeners,
|
||||
nativeOn: {
|
||||
drop: (e) => this.dropHandler(e),
|
||||
},
|
||||
scopedSlots: this.$scopedSlots,
|
||||
locale: {
|
||||
filterConfirm: `{{ i18n "confirm" }}`,
|
||||
filterReset: `{{ i18n "reset" }}`,
|
||||
emptyText: `{{ i18n "noData" }}`
|
||||
}
|
||||
}, this.$slots.default,)
|
||||
},
|
||||
created() {
|
||||
this.$memoSort = {};
|
||||
beforeDestroy() {
|
||||
this.detachPointerListeners();
|
||||
},
|
||||
methods: {
|
||||
isDragging() {
|
||||
const currentIndex = this.sortingElementIndex;
|
||||
return currentIndex !== null && currentIndex !== undefined;
|
||||
isDragging() { return this.drag !== null; },
|
||||
// Resolve the row key for a record. Used to identify the source row
|
||||
// even after data-source is reordered live during drag.
|
||||
keyOf(record, fallback) {
|
||||
const rk = this.rowKey;
|
||||
if (typeof rk === 'function') return rk(record);
|
||||
if (typeof rk === 'string') return record && record[rk];
|
||||
return fallback;
|
||||
},
|
||||
resetSortableIndex(e, index) {
|
||||
this.sortingElementIndex = null;
|
||||
this.newElementIndex = null;
|
||||
this.$memoSort = {};
|
||||
},
|
||||
setCurrentSortableIndex(e, index) {
|
||||
this.sortingElementIndex = index;
|
||||
},
|
||||
dragStartHandler(e, index) {
|
||||
if (!this.isDragging()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const hideDragImage = this.$el.cloneNode(true);
|
||||
hideDragImage.id = "hideDragImage-hide";
|
||||
hideDragImage.style.opacity = 0;
|
||||
e.dataTransfer.setDragImage(hideDragImage, 0, 0);
|
||||
},
|
||||
dragStopHandler(e, index) {
|
||||
const hideDragImage = document.getElementById('hideDragImage-hide');
|
||||
if (hideDragImage) hideDragImage.remove();
|
||||
this.resetSortableIndex(e, index);
|
||||
},
|
||||
dragOverHandler(e, index) {
|
||||
if (!this.isDragging()) {
|
||||
return;
|
||||
}
|
||||
startDrag(e, sourceIndex) {
|
||||
// Primary button only (mouse left / first touch).
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const currentIndex = this.sortingElementIndex;
|
||||
if (index === currentIndex) {
|
||||
this.newElementIndex = null;
|
||||
return;
|
||||
const record = this.dataSource && this.dataSource[sourceIndex];
|
||||
this.drag = {
|
||||
sourceIndex,
|
||||
targetIndex: sourceIndex,
|
||||
pointerId: e.pointerId,
|
||||
sourceKey: this.keyOf(record, sourceIndex),
|
||||
};
|
||||
// Capture the pointer so move/up keep firing even if the cursor leaves
|
||||
// the icon. Try/catch because some older browsers throw on capture.
|
||||
if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
|
||||
try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
}
|
||||
const row = findParentRowElement(e.target);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const rect = row.getBoundingClientRect();
|
||||
const offsetTop = e.pageY - rect.top;
|
||||
if (offsetTop < rect.height / 2) {
|
||||
this.newElementIndex = Math.max(index - 1, 0);
|
||||
this.attachPointerListeners();
|
||||
},
|
||||
attachPointerListeners() {
|
||||
this._onMove = (ev) => this.onPointerMove(ev);
|
||||
this._onUp = (ev) => this.onPointerUp(ev);
|
||||
this._onCancel = (ev) => this.cancelDrag(ev);
|
||||
document.addEventListener('pointermove', this._onMove, true);
|
||||
document.addEventListener('pointerup', this._onUp, true);
|
||||
document.addEventListener('pointercancel', this._onCancel, true);
|
||||
document.addEventListener('keydown', this._onCancel, true);
|
||||
},
|
||||
detachPointerListeners() {
|
||||
if (!this._onMove) return;
|
||||
document.removeEventListener('pointermove', this._onMove, true);
|
||||
document.removeEventListener('pointerup', this._onUp, true);
|
||||
document.removeEventListener('pointercancel', this._onCancel, true);
|
||||
document.removeEventListener('keydown', this._onCancel, true);
|
||||
this._onMove = this._onUp = this._onCancel = null;
|
||||
},
|
||||
onPointerMove(e) {
|
||||
if (!this.drag) return;
|
||||
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
|
||||
// Hit-test: find which row the pointer Y is inside (or closest to).
|
||||
const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
|
||||
if (!rows.length) return;
|
||||
const y = e.clientY;
|
||||
const firstRect = rows[0].getBoundingClientRect();
|
||||
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||
let target = this.drag.targetIndex;
|
||||
if (y < firstRect.top) {
|
||||
target = 0;
|
||||
} else if (y > lastRect.bottom) {
|
||||
target = rows.length - 1;
|
||||
} else {
|
||||
this.newElementIndex = index;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect();
|
||||
if (y >= rect.top && y <= rect.bottom) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target !== this.drag.targetIndex) {
|
||||
this.drag = Object.assign({}, this.drag, { targetIndex: target });
|
||||
}
|
||||
},
|
||||
dropHandler(e) {
|
||||
if (this.isDragging()) {
|
||||
this.$emit('onsort', this.sortingElementIndex, this.newElementIndex);
|
||||
onPointerUp(e) {
|
||||
if (!this.drag) return;
|
||||
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
|
||||
this.commitDrag();
|
||||
},
|
||||
commitDrag() {
|
||||
const d = this.drag;
|
||||
this.detachPointerListeners();
|
||||
this.drag = null;
|
||||
if (d && d.sourceIndex !== d.targetIndex) {
|
||||
this.$emit('onsort', d.sourceIndex, d.targetIndex);
|
||||
}
|
||||
},
|
||||
cancelDrag(e) {
|
||||
// Triggered by pointercancel and keydown handlers. For keydown, only
|
||||
// act on Escape; otherwise let the event flow to other listeners.
|
||||
if (e && e.type === 'keydown' && e.key !== 'Escape') return;
|
||||
this.detachPointerListeners();
|
||||
this.drag = null;
|
||||
},
|
||||
// Keyboard reorder: commit immediately by emitting onsort. No "preview"
|
||||
// state needed since the move is one row up or down.
|
||||
moveByKeyboard(direction, sourceIndex) {
|
||||
const target = sourceIndex + direction;
|
||||
if (target < 0 || target >= (this.dataSource || []).length) return;
|
||||
this.$emit('onsort', sourceIndex, target);
|
||||
},
|
||||
customRowRender(record, index) {
|
||||
const parentMethodResult = this.customRow?.(record, index) || {};
|
||||
const newIndex = this.newElementIndex;
|
||||
const currentIndex = this.sortingElementIndex;
|
||||
return {
|
||||
...parentMethodResult,
|
||||
attrs: {
|
||||
...(parentMethodResult?.attrs || {}),
|
||||
draggable: true,
|
||||
},
|
||||
on: {
|
||||
...(parentMethodResult?.on || {}),
|
||||
dragstart: (e) => this.dragStartHandler(e, index),
|
||||
dragend: (e) => this.dragStopHandler(e, index),
|
||||
dragover: (e) => this.dragOverHandler(e, index),
|
||||
},
|
||||
class: {
|
||||
...(parentMethodResult?.class || {}),
|
||||
[DRAGGABLE_ROW_CLASS]: true,
|
||||
['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) : false,
|
||||
},
|
||||
};
|
||||
}
|
||||
const parent = (typeof this.customRow === 'function')
|
||||
? (this.customRow(record, index) || {})
|
||||
: {};
|
||||
const d = this.drag;
|
||||
const isSource = d && this.keyOf(record, index) === d.sourceKey;
|
||||
return Object.assign({}, parent, {
|
||||
// CRITICAL: no `draggable: true`. Drag is initiated only by the
|
||||
// handle icon. Leaves text-selection on cells working normally.
|
||||
attrs: Object.assign({}, parent.attrs || {}),
|
||||
class: Object.assign({}, parent.class || {}, {
|
||||
[ROW_CLASS]: true,
|
||||
'sortable-source-row': !!isSource,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||
// When idle or when target equals source, returns the original list
|
||||
// unchanged so Ant Design's table treats this as a stable reference.
|
||||
records() {
|
||||
const newIndex = this.newElementIndex;
|
||||
const currentIndex = this.sortingElementIndex;
|
||||
if (!this.isDragging() || newIndex === null || currentIndex === newIndex) {
|
||||
return this.dataSource;
|
||||
}
|
||||
if (this.$memoSort.newIndex === newIndex) {
|
||||
return this.$memoSort.list;
|
||||
}
|
||||
let list = [...this.dataSource];
|
||||
list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]);
|
||||
this.$memoSort = {
|
||||
newIndex,
|
||||
list,
|
||||
};
|
||||
const d = this.drag;
|
||||
if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
|
||||
const list = (this.dataSource || []).slice();
|
||||
const [item] = list.splice(d.sourceIndex, 1);
|
||||
list.splice(d.targetIndex, 0, item);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
render(h) {
|
||||
return h('a-table', {
|
||||
class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
|
||||
props: Object.assign({}, this.$attrs, {
|
||||
'data-source': this.records,
|
||||
'row-key': this.rowKey,
|
||||
customRow: (record, index) => this.customRowRender(record, index),
|
||||
locale: {
|
||||
filterConfirm: `{{ i18n "confirm" }}`,
|
||||
filterReset: `{{ i18n "reset" }}`,
|
||||
emptyText: `{{ i18n "noData" }}`,
|
||||
},
|
||||
}),
|
||||
on: this.$listeners,
|
||||
scopedSlots: this.$scopedSlots,
|
||||
}, this.$slots.default);
|
||||
},
|
||||
});
|
||||
|
||||
Vue.component('a-table-sort-trigger', {
|
||||
template: `{{template "component/sortableTableTrigger"}}`,
|
||||
template: `{{template "component/sortableTableTrigger" .}}`,
|
||||
props: {
|
||||
'item-index': {
|
||||
type: undefined,
|
||||
required: false
|
||||
}
|
||||
'item-index': { type: undefined, required: false },
|
||||
},
|
||||
inject: ['sortable'],
|
||||
computed: {
|
||||
ariaLabel() {
|
||||
// Localised label is overkill for an internal a11y string; English is
|
||||
// fine here and matches screen-reader expectations across locales.
|
||||
return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
mouseDownHandler(e) {
|
||||
if (this.sortable) {
|
||||
this.sortable.setSortableIndex(e, this.itemIndex);
|
||||
onPointerDown(e) {
|
||||
if (this.sortable && this.sortable.startDrag) {
|
||||
this.sortable.startDrag(e, this.itemIndex);
|
||||
}
|
||||
},
|
||||
mouseUpHandler(e) {
|
||||
if (this.sortable) {
|
||||
this.sortable.resetSortableIndex(e, this.itemIndex);
|
||||
onKeyDown(e) {
|
||||
if (!this.sortable || !this.sortable.moveByKeyboard) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.sortable.moveByKeyboard(-1, this.itemIndex);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.sortable.moveByKeyboard(+1, this.itemIndex);
|
||||
}
|
||||
},
|
||||
clickHandler(e) {
|
||||
e.preventDefault();
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media only screen and (max-width: 767px) {
|
||||
.sortable-icon {
|
||||
display: none;
|
||||
}
|
||||
/* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
|
||||
`touch-action: none` is critical: it tells the browser not to interpret
|
||||
touch on the icon as a scroll/zoom gesture, so pointermove fires for
|
||||
drag-tracking. Without it, mobile browsers eat the pointer events. */
|
||||
.sortable-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.sortable-icon:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sortable-icon:active { cursor: grabbing; }
|
||||
.sortable-icon:focus-visible {
|
||||
outline: 2px solid #008771;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ant-table-is-sorting .draggable-row td {
|
||||
background-color: #ffffff !important;
|
||||
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
|
||||
.light .sortable-icon:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .ant-table-is-sorting .draggable-row td {
|
||||
background-color: var(--dark-color-surface-100) !important;
|
||||
/* While dragging: the source row gets a soft green wash so the user can
|
||||
track which row is being moved. Other rows transition smoothly as the
|
||||
data-source is reordered. */
|
||||
.sortable-table-dragging .sortable-source-row > td {
|
||||
background: rgba(0, 135, 113, 0.10) !important;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.ant-table-is-sorting .dragging td {
|
||||
background-color: rgb(232 244 242) !important;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.dark .ant-table-is-sorting .dragging td {
|
||||
background-color: var(--dark-color-table-hover) !important;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
.sortable-table-dragging .sortable-row > td {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.ant-table-is-sorting .dragging {
|
||||
opacity: 1;
|
||||
box-shadow: 1px -2px 2px #008771;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ant-table-is-sorting .dragging .ant-table-row-index {
|
||||
opacity: 0.3;
|
||||
/* Disable text selection across the whole table while a drag is in
|
||||
progress — selection during drag is never useful and looks broken. */
|
||||
.sortable-table-dragging,
|
||||
.sortable-table-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -24,9 +24,11 @@
|
||||
|
||||
{{define "component/themeSwitchTemplateLogin"}}
|
||||
<template>
|
||||
<a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
|
||||
<a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10"
|
||||
:style="{ width: '100%' }">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<a-switch size="small" :default-checked="themeSwitcher.isDarkTheme" @change="themeSwitcher.toggleTheme()"></a-switch>
|
||||
<a-switch size="small" :default-checked="themeSwitcher.isDarkTheme"
|
||||
@change="themeSwitcher.toggleTheme()"></a-switch>
|
||||
<span>{{ i18n "menu.dark" }}</span>
|
||||
</a-space>
|
||||
<a-space v-if="themeSwitcher.isDarkTheme" direction="horizontal" size="small">
|
||||
@@ -40,7 +42,8 @@
|
||||
{{define "component/aThemeSwitch"}}
|
||||
<script>
|
||||
function createThemeSwitcher() {
|
||||
const isDarkTheme = localStorage.getItem('dark-mode') === 'true';
|
||||
const darkModePreference = localStorage.getItem('dark-mode');
|
||||
const isDarkTheme = darkModePreference === null ? true : darkModePreference === 'true';
|
||||
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
@@ -92,7 +95,7 @@
|
||||
}
|
||||
const themeSwitcher = createThemeSwitcher();
|
||||
Vue.component('a-theme-switch', {
|
||||
template: `{{template "component/themeSwitchTemplate"}}`,
|
||||
template: `{{template "component/themeSwitchTemplate" .}}`,
|
||||
data: () => ({
|
||||
themeSwitcher
|
||||
}),
|
||||
@@ -104,7 +107,7 @@
|
||||
}
|
||||
});
|
||||
Vue.component('a-theme-switch-login', {
|
||||
template: `{{template "component/themeSwitchTemplateLogin"}}`,
|
||||
template: `{{template "component/themeSwitchTemplateLogin" .}}`,
|
||||
data: () => ({
|
||||
themeSwitcher
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
{{define "form/client"}}
|
||||
<a-form
|
||||
layout="horizontal"
|
||||
v-if="client"
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form layout="horizontal" v-if="client" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
|
||||
<a-switch v-model="client.enable"></a-switch>
|
||||
</a-form-item>
|
||||
@@ -16,33 +10,22 @@
|
||||
<span>{{ i18n "pages.inbounds.emailDesc" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.inbounds.email" }}
|
||||
<a-icon
|
||||
type="sync"
|
||||
@click="client.email = RandomUtil.randomLowerAndNum(9)"
|
||||
></a-icon>
|
||||
<a-icon type="sync" @click="client.email = RandomUtil.randomLowerAndNum(9)"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.email"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS"
|
||||
>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
{{ i18n "password" }}
|
||||
<a-icon
|
||||
v-if="inbound.protocol === Protocols.SHADOWSOCKS"
|
||||
@click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
<a-icon
|
||||
v-if="inbound.protocol === Protocols.TROJAN"
|
||||
@click="client.password = RandomUtil.randomSeq(10)"
|
||||
type="sync"
|
||||
>
|
||||
<a-icon v-if="inbound.protocol === Protocols.SHADOWSOCKS"
|
||||
@click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
|
||||
<a-icon v-if="inbound.protocol === Protocols.TROJAN" @click="client.password = RandomUtil.randomSeq(10)"
|
||||
type="sync">
|
||||
</a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
@@ -55,42 +38,26 @@
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
Auth Password
|
||||
<a-icon
|
||||
@click="client.auth = RandomUtil.randomSeq(10)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
<a-icon @click="client.auth = RandomUtil.randomSeq(10)" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.auth"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS"
|
||||
>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
ID
|
||||
<a-icon
|
||||
@click="client.id = RandomUtil.randomUUID()"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
<a-icon @click="client.id = RandomUtil.randomUUID()" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.id"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="inbound.protocol === Protocols.VMESS"
|
||||
label='{{ i18n "security" }}'
|
||||
>
|
||||
<a-select
|
||||
v-model="client.security"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in USERS_SECURITY" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label='{{ i18n "security" }}'>
|
||||
<a-select v-model="client.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email && app.subSettings?.enable">
|
||||
@@ -100,10 +67,7 @@
|
||||
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
||||
</template>
|
||||
Subscription
|
||||
<a-icon
|
||||
@click="client.subId = RandomUtil.randomLowerAndNum(16)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
<a-icon @click="client.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.subId"></a-input>
|
||||
@@ -118,11 +82,7 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number
|
||||
:style="{ width: '50%' }"
|
||||
v-model.number="client.tgId"
|
||||
min="0"
|
||||
></a-input-number>
|
||||
<a-input-number :style="{ width: '50%' }" v-model.number="client.tgId" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email" label='{{ i18n "comment" }}'>
|
||||
<a-input v-model.trim="client.comment"></a-input>
|
||||
@@ -139,9 +99,7 @@
|
||||
</template>
|
||||
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit"
|
||||
>
|
||||
<a-form-item v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
@@ -160,27 +118,29 @@
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-form layout="block">
|
||||
<a-textarea
|
||||
id="clientIPs"
|
||||
readonly
|
||||
@click="getDBClientIps(client.email)"
|
||||
placeholder="Click To Get IPs"
|
||||
:auto-size="{ minRows: 5, maxRows: 10 }"
|
||||
>
|
||||
<a-textarea id="clientIPs" readonly @click="getDBClientIps(client.email)" placeholder="Click To Get IPs"
|
||||
:auto-size="{ minRows: 5, maxRows: 10 }">
|
||||
</a-textarea>
|
||||
</a-form>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select
|
||||
v-model="client.flow"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="client.flow" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value selected>{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VLESS">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.outbound.reverseTagDesc" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.xray.outbound.reverseTag" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.reverseTag" :placeholder='`{{ i18n "pages.xray.outbound.reverseTagPlaceholder" }}`'></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
@@ -201,45 +161,28 @@
|
||||
</a-tag>
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
||||
<a-icon
|
||||
type="retweet"
|
||||
@click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
|
||||
v-if="client.email.length > 0"
|
||||
></a-icon>
|
||||
<a-icon type="retweet" @click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
|
||||
v-if="client.email.length > 0"></a-icon>
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||
<a-switch v-model="delayedStart" @click="client._expiryTime=0"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="delayedStart" label='{{ i18n "pages.client.expireDays" }}'>
|
||||
<a-input-number
|
||||
v-model.number="delayedExpireDays"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-else>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title"
|
||||
>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template
|
||||
>
|
||||
<template slot="title">{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template>
|
||||
{{ i18n "pages.inbounds.expireDate" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-date-picker
|
||||
v-if="datepicker == 'gregorian'"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="client._expiryTime"
|
||||
></a-date-picker>
|
||||
<a-persian-datepicker
|
||||
v-else
|
||||
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="client._expiryTime"
|
||||
v-model="client._expiryTime"
|
||||
></a-persian-datepicker>
|
||||
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme" v-model="client._expiryTime"></a-date-picker>
|
||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="client._expiryTime" v-model="client._expiryTime"></a-persian-datepicker>
|
||||
<a-tag color="red" v-if="isEdit && isExpiry">Expired</a-tag>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.expiryTime != 0">
|
||||
@@ -253,4 +196,4 @@
|
||||
<a-input-number v-model.number="client.reset" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -102,69 +102,69 @@
|
||||
|
||||
<!-- vmess settings -->
|
||||
<template v-if="inbound.protocol === Protocols.VMESS">
|
||||
{{template "form/vmess"}}
|
||||
{{template "form/vmess" .}}
|
||||
</template>
|
||||
|
||||
<!-- vless settings -->
|
||||
<template v-if="inbound.protocol === Protocols.VLESS">
|
||||
{{template "form/vless"}}
|
||||
{{template "form/vless" .}}
|
||||
</template>
|
||||
|
||||
<!-- trojan settings -->
|
||||
<template v-if="inbound.protocol === Protocols.TROJAN">
|
||||
{{template "form/trojan"}}
|
||||
{{template "form/trojan" .}}
|
||||
</template>
|
||||
|
||||
<!-- shadowsocks -->
|
||||
<template v-if="inbound.protocol === Protocols.SHADOWSOCKS">
|
||||
{{template "form/shadowsocks"}}
|
||||
{{template "form/shadowsocks" .}}
|
||||
</template>
|
||||
|
||||
<!-- tunnel -->
|
||||
<template v-if="inbound.protocol === Protocols.TUNNEL">
|
||||
{{template "form/tunnel"}}
|
||||
{{template "form/tunnel" .}}
|
||||
</template>
|
||||
|
||||
<!-- mixed -->
|
||||
<template v-if="inbound.protocol === Protocols.MIXED">
|
||||
{{template "form/mixed"}}
|
||||
{{template "form/mixed" .}}
|
||||
</template>
|
||||
|
||||
<!-- http -->
|
||||
<template v-if="inbound.protocol === Protocols.HTTP">
|
||||
{{template "form/http"}}
|
||||
{{template "form/http" .}}
|
||||
</template>
|
||||
|
||||
<!-- wireguard -->
|
||||
<template v-if="inbound.protocol === Protocols.WIREGUARD">
|
||||
{{template "form/wireguard"}}
|
||||
{{template "form/wireguard" .}}
|
||||
</template>
|
||||
|
||||
<!-- tun -->
|
||||
<template v-if="inbound.protocol === Protocols.TUN">
|
||||
{{template "form/tun"}}
|
||||
{{template "form/tun" .}}
|
||||
</template>
|
||||
|
||||
<!-- hysteria -->
|
||||
<template v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||
{{template "form/hysteria"}}
|
||||
{{template "form/hysteria" .}}
|
||||
</template>
|
||||
|
||||
<!-- stream settings -->
|
||||
<template v-if="inbound.canEnableStream()">
|
||||
{{template "form/streamSettings"}}
|
||||
{{template "form/externalProxy" }}
|
||||
{{template "form/streamSettings" .}}
|
||||
{{template "form/externalProxy" .}}
|
||||
</template>
|
||||
|
||||
<!-- tls settings -->
|
||||
<template v-if="inbound.canEnableTls()">
|
||||
{{template "form/tlsSettings"}}
|
||||
{{template "form/tlsSettings" .}}
|
||||
</template>
|
||||
|
||||
<!-- sniffing -->
|
||||
<a-collapse>
|
||||
<a-collapse-panel header='Sniffing'>
|
||||
{{template "form/sniffing"}}
|
||||
{{template "form/sniffing" .}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,13 +25,13 @@
|
||||
<a-select-option value="tcp">TCP</a-select-option>
|
||||
<a-select-option value="udp">UDP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form-item>
|
||||
<a-form-item label='Follow Redirect'>
|
||||
<a-switch v-model="inbound.settings.followRedirect"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<!-- sockopt -->
|
||||
<template>
|
||||
{{template "form/streamSockopt"}}
|
||||
{{template "form/streamSockopt" .}}
|
||||
</template>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -5,7 +5,8 @@
|
||||
<td width="45%">{{ i18n "username" }}</td>
|
||||
<td width="45%">{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-button icon="plus" size="small" @click="inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())"></a-button>
|
||||
<a-button icon="plus" size="small"
|
||||
@click="inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())"></a-button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -23,4 +24,4 @@
|
||||
<a-switch v-model="inbound.settings.allowTransparent" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,9 +1,7 @@
|
||||
{{define "form/hysteria"}}
|
||||
<a-collapse activeKey="0"
|
||||
v-for="(client, index) in inbound.settings.hysterias.slice(0,1)"
|
||||
v-if="!isEdit">
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.hysterias.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
{{template "form/client" .}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
@@ -22,11 +20,9 @@
|
||||
</table>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item :label="'{{ i18n "pages.inbounds.stream.tcp.version" }}'">
|
||||
<a-input-number v-model.number="inbound.settings.version" :min="2"
|
||||
:max="2" disabled></a-input-number>
|
||||
<a-input-number v-model.number="inbound.settings.version" :min="2" :max="2" disabled></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
@@ -1,12 +1,8 @@
|
||||
{{define "form/shadowsocks"}}
|
||||
<template v-if="inbound.isSSMultiUser">
|
||||
<a-collapse
|
||||
activeKey="0"
|
||||
v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)"
|
||||
v-if="!isEdit"
|
||||
>
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
{{template "form/client" .}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
@@ -16,10 +12,8 @@
|
||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||
<th>Password</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(client, index) in inbound.settings.shadowsockses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''"
|
||||
>
|
||||
<tr v-for="(client, index) in inbound.settings.shadowsockses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.password ]]</td>
|
||||
</tr>
|
||||
@@ -27,20 +21,11 @@
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
<a-form
|
||||
:colon=" false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon=" false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "encryption" }}'>
|
||||
<a-select
|
||||
v-model="inbound.settings.method"
|
||||
@change="SSMethodChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="(method,method_name) in SSMethods" :value="method"
|
||||
>[[ method_name ]]</a-select-option
|
||||
>
|
||||
<a-select v-model="inbound.settings.method" @change="SSMethodChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="(method,method_name) in SSMethods" :value="method">[[ method_name ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.isSS2022">
|
||||
@@ -50,20 +35,15 @@
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
Password
|
||||
<a-icon
|
||||
@click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
<a-icon @click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.settings.password"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.network" }}'>
|
||||
<a-select
|
||||
v-model="inbound.settings.network"
|
||||
:style="{ width: '100px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.settings.network" :style="{ width: '100px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="tcp,udp">TCP,UDP</a-select-option>
|
||||
<a-select-option value="tcp">TCP</a-select-option>
|
||||
<a-select-option value="udp">UDP</a-select-option>
|
||||
@@ -73,4 +53,4 @@
|
||||
<a-switch v-model="inbound.settings.ivCheck"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,9 +1,5 @@
|
||||
{{define "form/mixed"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.inbounds.enable" }} UDP'>
|
||||
<a-switch v-model="inbound.settings.udp"></a-switch>
|
||||
</a-form-item>
|
||||
@@ -11,10 +7,8 @@
|
||||
<a-input v-model.trim="inbound.settings.ip"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "password" }}'>
|
||||
<a-switch
|
||||
:checked="inbound.settings.auth === 'password'"
|
||||
@change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"
|
||||
></a-switch>
|
||||
<a-switch :checked="inbound.settings.auth === 'password'"
|
||||
@change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.settings.auth === 'password'">
|
||||
<table :style="{ width: '100%', textAlign: 'center', margin: '1rem 0' }">
|
||||
@@ -22,42 +16,21 @@
|
||||
<td width="45%">{{ i18n "username" }}</td>
|
||||
<td width="45%">{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"
|
||||
></a-button>
|
||||
<a-button icon="plus" size="small"
|
||||
@click="inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"></a-button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(account, index) in inbound.settings.accounts"
|
||||
:style="{ marginBottom: '10px' }"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="account.user"
|
||||
placeholder='{{ i18n "username" }}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input-group compact v-for="(account, index) in inbound.settings.accounts" :style="{ marginBottom: '10px' }">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="account.user" placeholder='{{ i18n "username" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="account.pass"
|
||||
placeholder='{{ i18n "password" }}'
|
||||
>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="account.pass" placeholder='{{ i18n "password" }}'>
|
||||
<template slot="addonAfter">
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
@click="inbound.settings.delAccount(index)"
|
||||
></a-button>
|
||||
<a-button icon="minus" size="small" @click="inbound.settings.delAccount(index)"></a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "form/trojan"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
{{template "form/client" .}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
@@ -19,35 +19,35 @@
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<template v-if=" inbound.isTcp">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- trojan fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider style="margin:5px 0;"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
<!-- trojan fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider style="margin:5px 0;"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
@@ -1,9 +1,5 @@
|
||||
{{define "form/tun"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
@@ -26,38 +22,18 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number
|
||||
v-model.number="inbound.settings.mtu[0]"
|
||||
:min="1"
|
||||
:max="9000"
|
||||
placeholder="1500"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.settings.mtu[0]" :min="1" :max="9000" placeholder="1500"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="MTU IPv6">
|
||||
<a-input-number
|
||||
v-model.number="inbound.settings.mtu[1]"
|
||||
:min="1"
|
||||
:max="9000"
|
||||
placeholder="1280"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.settings.mtu[1]" :min="1" :max="9000" placeholder="1280"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Gateway">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.settings.gateway"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="IPv4/IPv6 gateway"
|
||||
></a-select>
|
||||
<a-select mode="tags" v-model="inbound.settings.gateway" :style="{ width: '100%' }" :token-separators="[',']"
|
||||
placeholder="IPv4/IPv6 gateway"></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="DNS">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.settings.dns"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="DNS servers"
|
||||
></a-select>
|
||||
<a-select mode="tags" v-model="inbound.settings.dns" :style="{ width: '100%' }" :token-separators="[',']"
|
||||
placeholder="DNS servers"></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
@@ -69,26 +45,14 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number
|
||||
v-model.number="inbound.settings.userLevel"
|
||||
:min="0"
|
||||
placeholder="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.settings.userLevel" :min="0" placeholder="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Auto Routing Table">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.settings.autoSystemRoutingTable"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="e.g. vpn, proxy"
|
||||
></a-select>
|
||||
<a-select mode="tags" v-model="inbound.settings.autoSystemRoutingTable" :style="{ width: '100%' }"
|
||||
:token-separators="[',']" placeholder="e.g. vpn, proxy"></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Auto Outbounds">
|
||||
<a-input
|
||||
v-model.trim="inbound.settings.autoOutboundsInterface"
|
||||
placeholder="auto"
|
||||
></a-input>
|
||||
<a-input v-model.trim="inbound.settings.autoOutboundsInterface" placeholder="auto"></a-input>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "form/vless"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
{{template "form/client" .}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
@@ -12,8 +12,7 @@
|
||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
<tr v-for="(client, index) in inbound.settings.vlesses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<tr v-for="(client, index) in inbound.settings.vlesses" :class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.id ]]</td>
|
||||
</tr>
|
||||
@@ -21,104 +20,104 @@
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Authentication">
|
||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="undefined">None</a-select-option>
|
||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
||||
Post-Quantum)</a-select-option>
|
||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
||||
(Post-Quantum)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="decryption">
|
||||
<a-input v-model.trim="inbound.settings.decryption"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="encryption">
|
||||
<a-input v-model="inbound.settings.encryption"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-space>
|
||||
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
|
||||
keys</a-button>
|
||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Authentication">
|
||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="undefined">None</a-select-option>
|
||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
||||
Post-Quantum)</a-select-option>
|
||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
||||
(Post-Quantum)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="decryption">
|
||||
<a-input v-model.trim="inbound.settings.decryption"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="encryption">
|
||||
<a-input v-model="inbound.settings.encryption"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-space>
|
||||
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
|
||||
keys</a-button>
|
||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- vless fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
<template v-if="inbound.canEnableVisionSeed()">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Vision Seed">
|
||||
<a-row :gutter="8">
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
|
||||
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }"
|
||||
placeholder="900" addon-before="[0]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
|
||||
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }"
|
||||
placeholder="500" addon-before="[1]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
|
||||
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }"
|
||||
placeholder="900" addon-before="[2]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
|
||||
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }"
|
||||
placeholder="256" addon-before="[3]"></a-input-number>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space :size="8" :style="{ marginTop: '8px' }">
|
||||
<a-button type="primary" @click="setRandomTestseed">
|
||||
Rand
|
||||
</a-button>
|
||||
<a-button @click="resetTestseed">
|
||||
Reset
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
<!-- vless fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
<template v-if="inbound.canEnableVisionSeed()">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Vision Seed">
|
||||
<a-row :gutter="8">
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
|
||||
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||
addon-before="[0]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
|
||||
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500"
|
||||
addon-before="[1]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
|
||||
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||
addon-before="[2]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
|
||||
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256"
|
||||
addon-before="[3]"></a-input-number>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space :size="8" :style="{ marginTop: '8px' }">
|
||||
<a-button type="primary" @click="setRandomTestseed">
|
||||
Rand
|
||||
</a-button>
|
||||
<a-button @click="resetTestseed">
|
||||
Reset
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "form/vmess"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
{{template "form/client" .}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
@@ -12,8 +12,8 @@
|
||||
<th>ID</th>
|
||||
<th>{{ i18n "security" }}</th>
|
||||
</tr>
|
||||
<tr v-for="(client, index) in inbound.settings.vmesses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<tr v-for="(client, index) in inbound.settings.vmesses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.id ]]</td>
|
||||
<td>[[ client.security ]]</td>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{{define "form/sniffing"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item>
|
||||
<span slot="label">
|
||||
{{ i18n "enabled" }}
|
||||
@@ -19,9 +15,7 @@
|
||||
<template v-if="inbound.sniffing.enabled">
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-checkbox-group v-model="inbound.sniffing.destOverride">
|
||||
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key"
|
||||
>[[ value ]]</a-checkbox
|
||||
>
|
||||
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key">[[ value ]]</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="Metadata Only">
|
||||
@@ -31,23 +25,13 @@
|
||||
<a-switch v-model="inbound.sniffing.routeOnly"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="IPs Excluded">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.sniffing.ipsExcluded"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
></a-select>
|
||||
<a-select mode="tags" v-model="inbound.sniffing.ipsExcluded" :style="{ width: '100%' }" :token-separators="[',']"
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Domains Excluded">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.sniffing.domainsExcluded"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="domain:*/ext:*"
|
||||
></a-select>
|
||||
<a-select mode="tags" v-model="inbound.sniffing.domainsExcluded" :style="{ width: '100%' }"
|
||||
:token-separators="[',']" placeholder="domain:*/ext:*"></a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,67 +1,31 @@
|
||||
{{define "form/externalProxy"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form-item label="External Proxy">
|
||||
<a-switch v-model="externalProxy"></a-switch>
|
||||
<a-button
|
||||
icon="plus"
|
||||
v-if="externalProxy"
|
||||
type="primary"
|
||||
:style="{ marginLeft: '10px' }"
|
||||
size="small"
|
||||
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"
|
||||
></a-button>
|
||||
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small"
|
||||
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
|
||||
</a-form-item>
|
||||
<a-input-group
|
||||
:style="{ margin: '8px 0' }"
|
||||
compact
|
||||
v-for="(row, index) in inbound.stream.externalProxy"
|
||||
>
|
||||
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
|
||||
<template>
|
||||
<a-tooltip title="Force TLS">
|
||||
<a-select
|
||||
v-model="row.forceTls"
|
||||
:style="{ width: '20%', margin: '0px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="same"
|
||||
>{{ i18n "pages.inbounds.same" }}</a-select-option
|
||||
>
|
||||
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
|
||||
<a-select-option value="none">{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option value="tls">TLS</a-select-option>
|
||||
</a-select>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input
|
||||
:style="{ width: '30%' }"
|
||||
v-model.trim="row.dest"
|
||||
placeholder='{{ i18n "host" }}'
|
||||
></a-input>
|
||||
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
|
||||
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
|
||||
<a-input-number
|
||||
:style="{ width: '15%' }"
|
||||
v-model.number="row.port"
|
||||
min="1"
|
||||
max="65535"
|
||||
></a-input-number>
|
||||
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65535"></a-input-number>
|
||||
</a-tooltip>
|
||||
<a-input
|
||||
:style="{ width: '30%', top: '0' }"
|
||||
v-model.trim="row.remark"
|
||||
placeholder='{{ i18n "remark" }}'
|
||||
>
|
||||
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
|
||||
<template slot="addonAfter">
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
@click="inbound.stream.externalProxy.splice(index, 1)"
|
||||
></a-button>
|
||||
<a-button icon="minus" size="small" @click="inbound.stream.externalProxy.splice(index, 1)"></a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,260 +1,408 @@
|
||||
{{define "form/streamFinalMask"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"
|
||||
v-if="inbound.protocol == Protocols.HYSTERIA || ['kcp', 'xhttp', 'raw', 'tcp', 'httpupgrade', 'ws', 'grpc'].includes(inbound.stream.network)">
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button
|
||||
icon="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="inbound.stream.addUdpMask(inbound.protocol === Protocols.HYSTERIA ? 'salamander' : 'mkcp-aes128gcm')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<template
|
||||
v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0"
|
||||
>
|
||||
<a-form
|
||||
v-for="(mask, index) in inbound.stream.finalmask.udp"
|
||||
:key="index"
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => inbound.stream.delUdpMask(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="mask.type"
|
||||
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(inbound.stream.network === 'kcp') { inbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<template v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||
<a-select-option value="salamander"
|
||||
>Salamander (Hysteria2)</a-select-option
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm"
|
||||
>mKCP AES-128-GCM</a-select-option
|
||||
>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls"
|
||||
>Header DTLS 1.2</a-select-option
|
||||
>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat"
|
||||
>Header WeChat Video</a-select-option
|
||||
>
|
||||
<a-select-option value="header-wireguard"
|
||||
>Header WireGuard</a-select-option
|
||||
>
|
||||
<a-select-option value="mkcp-original"
|
||||
>mKCP Original</a-select-option
|
||||
>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom"
|
||||
>Header Custom</a-select-option
|
||||
>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
|
||||
<!-- TCP Masks – for raw/tcp/httpupgrade/ws/grpc/xhttp -->
|
||||
<template v-if="['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(inbound.stream.network)">
|
||||
<a-form-item label="TCP Masks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.stream.addTcpMask('fragment')"></a-button>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.finalmask.tcp && inbound.stream.finalmask.tcp.length > 0">
|
||||
<a-form v-for="(mask, index) in inbound.stream.finalmask.tcp" :key="index" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
TCP Mask [[ index + 1 ]]
|
||||
<a-icon type="delete" @click="() => inbound.stream.delTcpMask(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="mask.type" @change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="fragment">Fragment</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Password"
|
||||
v-if="['mkcp-aes128gcm', 'salamander', 'sudoku'].includes(mask.type)"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="mask.settings.password"
|
||||
placeholder="Obfuscation password"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Domain" v-if="mask.type === 'header-dns'">
|
||||
<a-input
|
||||
v-model.trim="mask.settings.domain"
|
||||
placeholder="e.g., www.example.com"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<template v-if="mask.type === 'xdns'">
|
||||
<a-form-item label="Domains">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="mask.settings.domains"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="e.g., www.example.com"
|
||||
></a-select>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-icon
|
||||
type="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-for="(c, index) in mask.settings.client" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => mask.settings.client.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="c.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
|
||||
<!-- Fragment settings -->
|
||||
<template v-if="mask.type === 'fragment'">
|
||||
<a-form-item label="Packets">
|
||||
<a-select v-model="mask.settings.packets" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||
<a-select-option value="1-3">1-3</a-select-option>
|
||||
<a-select-option value="1-5">1-5</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Packet">
|
||||
<a-input v-model.trim="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-divider :style="{ margin: '0' }"></a-divider>
|
||||
<a-form-item label="Server">
|
||||
<a-icon
|
||||
type="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-for="(s, index) in mask.settings.server" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => mask.settings.server.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="s.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Packet">
|
||||
<a-input v-model.trim="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model.number="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-icon
|
||||
type="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: '', delay: ''})"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-for="(n, index) in mask.settings.noise" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => mask.settings.noise.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model.trim="n.rand" placeholder="1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="n.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Packet">
|
||||
<a-input v-model.trim="n.packet" placeholder="binary data" />
|
||||
<a-form-item label="Length">
|
||||
<a-input v-model.trim="mask.settings.length" placeholder="e.g. 100-200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model.trim="n.delay" placeholder="10-20" />
|
||||
<a-input v-model.trim="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Split">
|
||||
<a-input v-model.trim="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
|
||||
|
||||
<!-- Sudoku settings (TCP) -->
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="Password">
|
||||
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input v-model.trim="mask.settings.customTable" placeholder="Custom Table" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input v-model.trim="mask.settings.customTables" placeholder="Custom Tables" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom (TCP) – clients/servers/errors are 2D arrays of groups -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<!-- Clients -->
|
||||
<a-form-item label="Clients">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="mask.settings.clients.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"></a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.clients" :key="'cg'+gi">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Clients Group [[ gi + 1 ]]
|
||||
<a-icon type="delete" @click="mask.settings.clients.splice(gi, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"></a-icon>
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="'ci'+ii">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model.number="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group compact v-if="item.type === 'base64'">
|
||||
<a-input v-model.trim="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
|
||||
</a-input-group>
|
||||
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Servers -->
|
||||
<a-form-item label="Servers">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="mask.settings.servers.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"></a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.servers" :key="'sg'+gi">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Servers Group [[ gi + 1 ]]
|
||||
<a-icon type="delete" @click="mask.settings.servers.splice(gi, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"></a-icon>
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="'si'+ii">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model.number="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group compact v-if="item.type === 'base64'">
|
||||
<a-input v-model.trim="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
|
||||
</a-input-group>
|
||||
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</a-form>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'">
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="inbound.stream.addUdpMask(inbound.protocol === Protocols.HYSTERIA ? 'salamander' : 'mkcp-aes128gcm')"></a-button>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0">
|
||||
<a-form v-for="(mask, index) in inbound.stream.finalmask.udp" :key="index" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask [[ index + 1 ]]
|
||||
<a-icon type="delete" @click="() => inbound.stream.delUdpMask(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="mask.type"
|
||||
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(inbound.stream.network === 'kcp') { inbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<template v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
|
||||
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
|
||||
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input
|
||||
v-model.trim="mask.settings.customTable"
|
||||
placeholder="Custom Table"
|
||||
/>
|
||||
<a-form-item label="Password" v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)">
|
||||
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input
|
||||
v-model.trim="mask.settings.customTables"
|
||||
placeholder="Custom Tables"
|
||||
/>
|
||||
<a-form-item label="Domain" v-if="mask.type === 'header-dns'">
|
||||
<a-input v-model.trim="mask.settings.domain" placeholder="e.g., www.example.com"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
|
||||
<template v-if="mask.type === 'xdns'">
|
||||
<a-form-item label="Domains">
|
||||
<a-select mode="tags" v-model="mask.settings.domains" :style="{ width: '100%' }" :token-separators="[',']"
|
||||
placeholder="e.g., www.example.com"></a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model.number="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20'})"></a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(n, index) in mask.settings.noise" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise [[ index + 1 ]]
|
||||
<a-icon type="delete" @click="() => mask.settings.noise.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="n.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
@change="t => { if(t === 'base64') n.packet = RandomUtil.randomBase64(); else if(t === 'array') n.packet = []; else n.packet = ''; }">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="n.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model.trim="n.rand" placeholder="0 or 1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group compact v-if="n.type === 'base64'">
|
||||
<a-input v-model.trim="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button icon="reload" @click="n.packet = RandomUtil.randomBase64()" />
|
||||
</a-input-group>
|
||||
<a-input v-else v-model.trim="n.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model.trim="n.delay" placeholder="10-20" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"></a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(c, index) in mask.settings.client" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client [[ index + 1 ]]
|
||||
<a-icon type="delete" @click="mask.settings.client.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="c.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
@change="t => { if(t === 'base64') c.packet = RandomUtil.randomBase64(); else if(t === 'array') c.packet = []; else c.packet = ''; }">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="c.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group compact v-if="c.type === 'base64'">
|
||||
<a-input v-model.trim="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button icon="reload" @click="c.packet = RandomUtil.randomBase64()" />
|
||||
</a-input-group>
|
||||
<a-input v-else v-model.trim="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-divider :style="{ margin: '0' }"></a-divider>
|
||||
<a-form-item label="Server">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"></a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(s, index) in mask.settings.server" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server [[ index + 1 ]]
|
||||
<a-icon type="delete" @click="mask.settings.server.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="s.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
@change="t => { if(t === 'base64') s.packet = RandomUtil.randomBase64(); else if(t === 'array') s.packet = []; else s.packet = ''; }">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="s.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group compact v-if="s.type === 'base64'">
|
||||
<a-input v-model.trim="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button icon="reload" @click="s.packet = RandomUtil.randomBase64()" />
|
||||
</a-input-group>
|
||||
<a-input v-else v-model.trim="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model.trim="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model.number="mask.settings.id" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- quicParams – only for xhttp H3 and hysteria -->
|
||||
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'xhttp'">
|
||||
<a-form-item label="QUIC Params">
|
||||
<a-switch v-model="inbound.stream.finalmask.enableQuicParams"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.finalmask.enableQuicParams">
|
||||
<a-form-item label="Congestion">
|
||||
<a-select v-model="inbound.stream.finalmask.quicParams.congestion"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="reno">Reno</a-select-option>
|
||||
<a-select-option value="bbr">BBR</a-select-option>
|
||||
<a-select-option value="brutal">Brutal</a-select-option>
|
||||
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Debug">
|
||||
<a-switch v-model="inbound.stream.finalmask.quicParams.debug"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="['brutal','force-brutal'].includes(inbound.stream.finalmask.quicParams.congestion)">
|
||||
<a-form-item label="Brutal Up">
|
||||
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalUp" :min="65537" placeholder="65537" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
|
||||
<a-form-item label="Brutal Down">
|
||||
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalDown" :min="65537" placeholder="65537" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model.trim="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
<a-form-item label="UDP Hop">
|
||||
<a-switch v-model="inbound.stream.finalmask.quicParams.hasUdpHop"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.finalmask.quicParams.hasUdpHop">
|
||||
<a-form-item label="Hop Ports">
|
||||
<a-input v-model.trim="inbound.stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model.number="mask.settings.id" :min="0" />
|
||||
<a-form-item label="Hop Interval (s)">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
<a-form-item label="Max Idle Timeout (s)">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period (s)">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Disable Path MTU Dis">
|
||||
<a-switch v-model="inbound.stream.finalmask.quicParams.disablePathMTUDiscovery"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Incoming Streams">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIncomingStreams" :min="8"
|
||||
placeholder="1024 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Stream Window">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Stream Window">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Conn Window">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Conn Window">
|
||||
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,9 +1,5 @@
|
||||
{{define "form/streamGRPC"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Service Name">
|
||||
<a-input v-model.trim="inbound.stream.grpc.serviceName"></a-input>
|
||||
</a-form-item>
|
||||
@@ -14,4 +10,4 @@
|
||||
<a-switch v-model="inbound.stream.grpc.multiMode"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,13 +1,7 @@
|
||||
{{define "form/streamHTTPUpgrade"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch
|
||||
v-model="inbound.stream.httpupgrade.acceptProxyProtocol"
|
||||
></a-switch>
|
||||
<a-switch v-model="inbound.stream.httpupgrade.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "host" }}'>
|
||||
<a-input v-model.trim="inbound.stream.httpupgrade.host"></a-input>
|
||||
@@ -16,39 +10,20 @@
|
||||
<a-input v-model.trim="inbound.stream.httpupgrade.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.httpupgrade.addHeader('', '')"
|
||||
></a-button>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.httpupgrade.addHeader('', '')"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.httpupgrade.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.httpupgrade.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.httpupgrade.removeHeader(index)"
|
||||
></a-button>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small"
|
||||
@click="inbound.stream.httpupgrade.removeHeader(index)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,14 +1,7 @@
|
||||
{{define "form/streamHysteria"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="UDP Idle Timeout">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.hysteria.udpIdleTimeout"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.hysteria.udpIdleTimeout" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Masquerade">
|
||||
<a-switch v-model="inbound.stream.hysteria.masqueradeSwitch"></a-switch>
|
||||
@@ -16,85 +9,50 @@
|
||||
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
|
||||
<a-divider :style="{ margin: '5px 0 0' }">Masquerade</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select
|
||||
v-model="inbound.stream.hysteria.masquerade.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.hysteria.masquerade.type" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="file">File</a-select-option>
|
||||
<a-select-option value="proxy">Proxy</a-select-option>
|
||||
<a-select-option value="string">String</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Dir"
|
||||
v-if="inbound.stream.hysteria.masquerade.type === 'file'"
|
||||
>
|
||||
<a-form-item label="Dir" v-if="inbound.stream.hysteria.masquerade.type === 'file'">
|
||||
<a-input v-model.trim="inbound.stream.hysteria.masquerade.dir"></a-input>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
|
||||
<a-form-item label="URL">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.hysteria.masquerade.url"
|
||||
></a-input>
|
||||
<a-input v-model.trim="inbound.stream.hysteria.masquerade.url"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Rewrite Host">
|
||||
<a-switch
|
||||
v-model="inbound.stream.hysteria.masquerade.rewriteHost"
|
||||
></a-switch>
|
||||
<a-switch v-model="inbound.stream.hysteria.masquerade.rewriteHost"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Insecure">
|
||||
<a-switch
|
||||
v-model="inbound.stream.hysteria.masquerade.insecure"
|
||||
></a-switch>
|
||||
<a-switch v-model="inbound.stream.hysteria.masquerade.insecure"></a-switch>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
|
||||
<a-form-item label="Content">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.hysteria.masquerade.content"
|
||||
></a-input>
|
||||
<a-input v-model.trim="inbound.stream.hysteria.masquerade.content"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="inbound.stream.hysteria.masquerade.addHeader('', '')"
|
||||
>+</a-button
|
||||
>
|
||||
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">+</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.hysteria.masquerade.headers"
|
||||
>
|
||||
<a-input
|
||||
style="width: 50%"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" style="margin: 0"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.hysteria.masquerade.headers">
|
||||
<a-input style="width: 50%" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" style="margin: 0">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
style="width: 50%"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.hysteria.masquerade.removeHeader(index)"
|
||||
>-</a-button
|
||||
>
|
||||
<a-input style="width: 50%" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button slot="addonAfter" size="small"
|
||||
@click="inbound.stream.hysteria.masquerade.removeHeader(index)">-</a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="Status Code">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.hysteria.masquerade.statusCode"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.hysteria.masquerade.statusCode"></a-input-number>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,46 +1,22 @@
|
||||
{{define "form/streamKCP"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="MTU">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.mtu"
|
||||
:min="576"
|
||||
:max="1460"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.mtu" :min="576" :max="1460"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TTI (ms)">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.tti"
|
||||
:min="10"
|
||||
:max="100"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.tti" :min="10" :max="100"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink (MB/s)">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.upCap"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.upCap" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Downlink (MB/s)">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.downCap"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.downCap" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="CWND Multiplier">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.cwndMultiplier"
|
||||
:min="1"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.cwndMultiplier" :min="1"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Sending Window">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.maxSendingWindow"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.maxSendingWindow" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,18 +1,10 @@
|
||||
{{define "form/streamSettings"}}
|
||||
<!-- select stream network -->
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
v-if="inbound.protocol != Protocols.HYSTERIA"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"
|
||||
v-if="inbound.protocol != Protocols.HYSTERIA">
|
||||
<a-form-item label='{{ i18n "transmission" }}'>
|
||||
<a-select
|
||||
v-model="inbound.stream.network"
|
||||
:style="{ width: '75%' }"
|
||||
@change="streamNetworkChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.network" :style="{ width: '75%' }" @change="streamNetworkChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="tcp">TCP (RAW)</a-select-option>
|
||||
<a-select-option value="kcp">mKCP</a-select-option>
|
||||
<a-select-option value="ws">WebSocket</a-select-option>
|
||||
@@ -25,42 +17,42 @@
|
||||
|
||||
<!-- tcp -->
|
||||
<template v-if="inbound.stream.network === 'tcp'">
|
||||
{{template "form/streamTCP"}}
|
||||
{{template "form/streamTCP" .}}
|
||||
</template>
|
||||
|
||||
<!-- kcp -->
|
||||
<template v-if="inbound.stream.network === 'kcp'">
|
||||
{{template "form/streamKCP"}}
|
||||
{{template "form/streamKCP" .}}
|
||||
</template>
|
||||
|
||||
<!-- ws -->
|
||||
<template v-if="inbound.stream.network === 'ws'">
|
||||
{{template "form/streamWS"}}
|
||||
{{template "form/streamWS" .}}
|
||||
</template>
|
||||
|
||||
<!-- grpc -->
|
||||
<template v-if="inbound.stream.network === 'grpc'">
|
||||
{{template "form/streamGRPC"}}
|
||||
{{template "form/streamGRPC" .}}
|
||||
</template>
|
||||
|
||||
<!-- hysteria -->
|
||||
<template v-if="inbound.stream.network === 'hysteria'">
|
||||
{{template "form/streamHysteria"}}
|
||||
{{template "form/streamHysteria" .}}
|
||||
</template>
|
||||
|
||||
<!-- httpupgrade -->
|
||||
<template v-if="inbound.stream.network === 'httpupgrade'">
|
||||
{{template "form/streamHTTPUpgrade"}}
|
||||
{{template "form/streamHTTPUpgrade" .}}
|
||||
</template>
|
||||
|
||||
<!-- xhttp -->
|
||||
<template v-if="inbound.stream.network === 'xhttp'">
|
||||
{{template "form/streamXHTTP"}}
|
||||
{{template "form/streamXHTTP" .}}
|
||||
</template>
|
||||
|
||||
<!-- sockopt -->
|
||||
<template> {{template "form/streamSockopt"}} </template>
|
||||
<template> {{template "form/streamSockopt" .}} </template>
|
||||
|
||||
<!-- finalmask -->
|
||||
<template> {{template "form/streamFinalMask"}} </template>
|
||||
{{end}}
|
||||
<template> {{template "form/streamFinalMask" .}} </template>
|
||||
{{end}}
|
||||
@@ -1,49 +1,27 @@
|
||||
{{define "form/streamSockopt"}}
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Sockopt">
|
||||
<a-switch v-model="inbound.stream.sockoptSwitch"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.sockoptSwitch">
|
||||
<a-form-item label="Route Mark">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.mark"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.mark" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Keep Alive Interval">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Keep Alive Idle">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Max Seg">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpMaxSeg"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpMaxSeg" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP User Timeout">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpUserTimeout"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpUserTimeout" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Window Clamp">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpWindowClamp"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpWindowClamp" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch v-model="inbound.stream.sockopt.acceptProxyProtocol"></a-switch>
|
||||
@@ -61,33 +39,20 @@
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.V6Only"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Domain Strategy">
|
||||
<a-select
|
||||
v-model="inbound.stream.sockopt.domainStrategy"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
<a-select v-model="inbound.stream.sockopt.domainStrategy" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Congestion">
|
||||
<a-select
|
||||
v-model="inbound.stream.sockopt.tcpcongestion"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
<a-select v-model="inbound.stream.sockopt.tcpcongestion" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="TProxy">
|
||||
<a-select
|
||||
v-model="inbound.stream.sockopt.tproxy"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.sockopt.tproxy" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="off">Off</a-select-option>
|
||||
<a-select-option value="redirect">Redirect</a-select-option>
|
||||
<a-select-option value="tproxy">TProxy</a-select-option>
|
||||
@@ -100,15 +65,9 @@
|
||||
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Trusted X-Forwarded-For">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.stream.sockopt.trustedXForwardedFor"
|
||||
:style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="CF-Connecting-IP"
|
||||
>CF-Connecting-IP</a-select-option
|
||||
>
|
||||
<a-select mode="tags" v-model="inbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
|
||||
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
|
||||
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
|
||||
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
||||
@@ -116,4 +75,4 @@
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,31 +1,19 @@
|
||||
{{define "form/streamTCP"}}
|
||||
<!-- tcp type -->
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Proxy Protocol" v-if="inbound.canEnableTls()">
|
||||
<a-switch v-model="inbound.stream.tcp.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='HTTP {{ i18n "camouflage" }}'>
|
||||
<a-switch
|
||||
:checked="inbound.stream.tcp.type === 'http'"
|
||||
@change="checked => inbound.stream.tcp.type = checked ? 'http' : 'none'"
|
||||
></a-switch>
|
||||
<a-switch :checked="inbound.stream.tcp.type === 'http'"
|
||||
@change="checked => inbound.stream.tcp.type = checked ? 'http' : 'none'"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-form
|
||||
v-if="inbound.stream.tcp.type === 'http'"
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form v-if="inbound.stream.tcp.type === 'http'" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<!-- tcp request -->
|
||||
<a-divider :style="{ margin: '0' }"
|
||||
>{{ i18n "pages.inbounds.stream.general.request" }}</a-divider
|
||||
>
|
||||
<a-divider :style="{ margin: '0' }">{{ i18n "pages.inbounds.stream.general.request" }}</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
|
||||
<a-input v-model.trim="inbound.stream.tcp.request.version"></a-input>
|
||||
</a-form-item>
|
||||
@@ -33,66 +21,35 @@
|
||||
<a-input v-model.trim="inbound.stream.tcp.request.method"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label"
|
||||
>{{ i18n "pages.inbounds.stream.tcp.path" }}
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.request.addPath('/')"
|
||||
></a-button>
|
||||
<template slot="label">{{ i18n "pages.inbounds.stream.tcp.path" }}
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addPath('/')"></a-button>
|
||||
</template>
|
||||
<template v-for="(path, index) in inbound.stream.tcp.request.path">
|
||||
<a-input v-model.trim="inbound.stream.tcp.request.path[index]">
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
slot="addonAfter"
|
||||
@click="inbound.stream.tcp.request.removePath(index)"
|
||||
v-if="inbound.stream.tcp.request.path.length>1"
|
||||
></a-button>
|
||||
<a-button icon="minus" size="small" slot="addonAfter" @click="inbound.stream.tcp.request.removePath(index)"
|
||||
v-if="inbound.stream.tcp.request.path.length>1"></a-button>
|
||||
</a-input>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.request.addHeader('Host', '')"
|
||||
></a-button>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addHeader('Host', '')"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.tcp.request.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.request.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.request.removeHeader(index)"
|
||||
></a-button>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small"
|
||||
@click="inbound.stream.tcp.request.removeHeader(index)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- tcp response -->
|
||||
<a-divider :style="{ margin: '0' }"
|
||||
>{{ i18n "pages.inbounds.stream.general.response" }}</a-divider
|
||||
>
|
||||
<a-divider :style="{ margin: '0' }">{{ i18n "pages.inbounds.stream.general.response" }}</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
|
||||
<a-input v-model.trim="inbound.stream.tcp.response.version"></a-input>
|
||||
</a-form-item>
|
||||
@@ -103,40 +60,22 @@
|
||||
<a-input v-model.trim="inbound.stream.tcp.response.reason"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.responseHeader" }}'>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')"
|
||||
></a-button>
|
||||
<a-button icon="plus" size="small"
|
||||
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.tcp.response.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.response.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<template slot="addonAfter">
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.response.removeHeader(index)"
|
||||
></a-button>
|
||||
<a-button icon="minus" size="small" @click="inbound.stream.tcp.response.removeHeader(index)"></a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,9 +1,5 @@
|
||||
{{define "form/streamWS"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch v-model="inbound.stream.ws.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
@@ -14,42 +10,22 @@
|
||||
<a-input v-model.trim="inbound.stream.ws.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Heartbeat Period">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.ws.heartbeatPeriod"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
<a-input-number v-model.number="inbound.stream.ws.heartbeatPeriod" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.ws.addHeader('', '')"
|
||||
></a-button>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.ws.addHeader('', '')"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.ws.headers">
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.ws.removeHeader(index)"
|
||||
></a-button>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.ws.removeHeader(index)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,9 +1,5 @@
|
||||
{{define "form/streamXHTTP"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "host" }}'>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
|
||||
</a-form-item>
|
||||
@@ -11,69 +7,34 @@
|
||||
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.xhttp.addHeader('', '')"
|
||||
></a-button>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.xhttp.addHeader('', '')"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.xhttp.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.xhttp.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.xhttp.removeHeader(index)"
|
||||
></a-button>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small"
|
||||
@click="inbound.stream.xhttp.removeHeader(index)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="Mode">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.mode"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in MODE_OPTION" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
<a-select v-model="inbound.stream.xhttp.mode" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Max Buffered Upload"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||
>
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"
|
||||
></a-input-number>
|
||||
<a-form-item label="Max Buffered Upload" v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-input-number v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Max Upload Size (Byte)"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||
>
|
||||
<a-form-item label="Max Upload Size (Byte)" v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Stream-Up Server"
|
||||
v-if="inbound.stream.xhttp.mode === 'stream-up'"
|
||||
>
|
||||
<a-form-item label="Stream-Up Server" v-if="inbound.stream.xhttp.mode === 'stream-up'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Bytes">
|
||||
@@ -84,22 +45,13 @@
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
|
||||
<a-form-item label="Padding Key">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.xPaddingKey"
|
||||
placeholder="x_padding"
|
||||
></a-input>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingKey" placeholder="x_padding"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Header">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.xPaddingHeader"
|
||||
placeholder="X-Padding"
|
||||
></a-input>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Placement">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.xPaddingPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.xhttp.xPaddingPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (queryInHeader)</a-select-option>
|
||||
<a-select-option value="queryInHeader">queryInHeader</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
@@ -108,10 +60,7 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Method">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.xPaddingMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.xhttp.xPaddingMethod" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (repeat-x)</a-select-option>
|
||||
<a-select-option value="repeat-x">repeat-x</a-select-option>
|
||||
<a-select-option value="tokenish">tokenish</a-select-option>
|
||||
@@ -119,10 +68,7 @@
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Uplink HTTP Method">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.uplinkHTTPMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (POST)</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
@@ -130,10 +76,7 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Session Placement">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.sessionPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
@@ -141,20 +84,12 @@
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Session Key"
|
||||
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.sessionKey"
|
||||
placeholder="x_session"
|
||||
></a-input>
|
||||
<a-form-item label="Session Key"
|
||||
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.sessionKey" placeholder="x_session"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Sequence Placement">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.seqPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select v-model="inbound.stream.xhttp.seqPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
@@ -162,23 +97,12 @@
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Sequence Key"
|
||||
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.seqKey"
|
||||
placeholder="x_seq"
|
||||
></a-input>
|
||||
<a-form-item label="Sequence Key"
|
||||
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.seqKey" placeholder="x_seq"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Uplink Data Placement"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||
>
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.uplinkDataPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-form-item label="Uplink Data Placement" v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-select v-model="inbound.stream.xhttp.uplinkDataPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (body)</a-select-option>
|
||||
<a-select-option value="body">body</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
@@ -186,27 +110,17 @@
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Uplink Data Key"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.uplinkDataKey"
|
||||
placeholder="x_data"
|
||||
></a-input>
|
||||
<a-form-item label="Uplink Data Key"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Uplink Chunk Size"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
||||
>
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.xhttp.uplinkChunkSize"
|
||||
:min="0"
|
||||
placeholder="0 (unlimited)"
|
||||
></a-input-number>
|
||||
<a-form-item label="Uplink Chunk Size"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize" :min="0"
|
||||
placeholder="0 (unlimited)"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="No SSE Header">
|
||||
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,14 +1,12 @@
|
||||
{{define "form/tlsSettings"}}
|
||||
<!-- tls enable -->
|
||||
<a-form v-if="inbound.canEnableTls()" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form v-if="inbound.canEnableTls()" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<template v-if="inbound.protocol !== Protocols.HYSTERIA">
|
||||
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
||||
<a-form-item label='{{ i18n "security" }}'>
|
||||
<a-radio-group v-model="inbound.stream.security" button-style="solid">
|
||||
<a-radio-button value="none">{{ i18n "none" }}</a-radio-button>
|
||||
<a-radio-button v-if="inbound.canEnableReality()"
|
||||
value="reality">Reality</a-radio-button>
|
||||
<a-radio-button v-if="inbound.canEnableReality()" value="reality">Reality</a-radio-button>
|
||||
<a-radio-button value="tls">TLS</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
@@ -21,8 +19,7 @@
|
||||
<a-input v-model.trim="inbound.stream.tls.sni"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Cipher Suites">
|
||||
<a-select v-model="inbound.stream.tls.cipherSuites"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="inbound.stream.tls.cipherSuites" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Auto</a-select-option>
|
||||
<a-select-option v-for="key,value in TLS_CIPHER_OPTION" :value="key">[[
|
||||
value ]]</a-select-option>
|
||||
@@ -30,14 +27,12 @@
|
||||
</a-form-item>
|
||||
<a-form-item label="Min/Max Version">
|
||||
<a-input-group compact>
|
||||
<a-select v-model="inbound.stream.tls.minVersion"
|
||||
:style="{ width: '50%' }"
|
||||
<a-select v-model="inbound.stream.tls.minVersion" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model="inbound.stream.tls.maxVersion"
|
||||
:style="{ width: '50%' }"
|
||||
<a-select v-model="inbound.stream.tls.maxVersion" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key
|
||||
]]</a-select-option>
|
||||
@@ -45,8 +40,7 @@
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="uTLS">
|
||||
<a-select v-model="inbound.stream.tls.settings.fingerprint"
|
||||
:style="{ width: '100%' }"
|
||||
<a-select v-model="inbound.stream.tls.settings.fingerprint" :style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>None</a-select-option>
|
||||
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key
|
||||
@@ -54,9 +48,7 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="ALPN">
|
||||
<a-select mode="multiple"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="inbound.stream.tls.alpn">
|
||||
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" v-model="inbound.stream.tls.alpn">
|
||||
<a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
@@ -75,8 +67,7 @@
|
||||
<a-form-item label='{{ i18n "certificate" }}'>
|
||||
<a-radio-group v-model="cert.useFile" button-style="solid"
|
||||
:style="{ display: 'inline-flex', whiteSpace: 'nowrap', maxWidth: '100%' }">
|
||||
<a-radio-button :value="true"
|
||||
:style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
|
||||
<a-radio-button :value="true" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
|
||||
i18n "pages.inbounds.certificatePath" }}</a-radio-button>
|
||||
<a-radio-button :value="false"
|
||||
:style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
|
||||
@@ -87,8 +78,7 @@
|
||||
<a-space>
|
||||
<a-button icon="plus" v-if="index === 0" type="primary" size="small"
|
||||
@click="inbound.stream.tls.addCert()"></a-button>
|
||||
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1"
|
||||
type="primary" size="small"
|
||||
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
|
||||
@click="inbound.stream.tls.removeCert(index)"></a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
@@ -100,8 +90,7 @@
|
||||
<a-input v-model.trim="cert.keyFile"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" icon="import"
|
||||
@click="setDefaultCertData(index)">
|
||||
<a-button type="primary" icon="import" @click="setDefaultCertData(index)">
|
||||
{{ i18n "pages.inbounds.setDefaultCert" }}</a-button>
|
||||
</a-form-item>
|
||||
</template>
|
||||
@@ -117,8 +106,7 @@
|
||||
<a-switch v-model="cert.oneTimeLoading"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='Usage Option'>
|
||||
<a-select v-model="cert.usage" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="cert.usage" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in USAGE_OPTION" :value="key">[[ key
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
@@ -133,13 +121,6 @@
|
||||
<a-form-item label='ECH config'>
|
||||
<a-input v-model="inbound.stream.tls.settings.echConfigList"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ECH force query'>
|
||||
<a-select v-model="inbound.stream.tls.echForceQuery"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in ['none', 'half', 'full']" :value="key">[[
|
||||
key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-space>
|
||||
<a-button type="primary" icon="import" @click="getNewEchCert">Get New
|
||||
@@ -151,7 +132,7 @@
|
||||
|
||||
<!-- reality settings -->
|
||||
<template v-if="inbound.stream.isReality">
|
||||
{{template "form/realitySettings"}}
|
||||
{{template "form/realitySettings" .}}
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
|
||||
background: var(--dark-color-surface-700, #111929);
|
||||
@@ -119,7 +120,8 @@
|
||||
</a-row>
|
||||
</span>
|
||||
<template slot="content">
|
||||
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
|
||||
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line
|
||||
]]</span>
|
||||
</template>
|
||||
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
||||
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
||||
@@ -139,7 +141,7 @@
|
||||
<a-icon type="reload"></a-icon>
|
||||
<span v-if="!isMobile">{{ i18n "pages.index.restartXray" }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" @click="openSelectV2rayVersion" class="jc-center">
|
||||
<a-space direction="horizontal" @click="openSelectV2rayVersion()" class="jc-center">
|
||||
<a-icon type="tool"></a-icon>
|
||||
<span v-if="!isMobile">
|
||||
[[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n
|
||||
@@ -169,6 +171,15 @@
|
||||
</a-col>
|
||||
<a-col :sm="24" :lg="12">
|
||||
<a-card title='3X-UI' hoverable>
|
||||
<template v-if="panelUpdateModal.info.updateAvailable" #extra>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"
|
||||
:title='`{{ i18n "pages.index.updatePanel" }}: ${panelUpdateModal.info.latestVersion}`'>
|
||||
<a-tag color="orange" style="cursor:pointer;margin:0" @click="openPanelUpdate">
|
||||
<a-icon type="cloud-download"></a-icon>[[ panelUpdateModal.info.latestVersion ]]
|
||||
<span v-if="!isMobile">{{ i18n "pages.index.updatePanel" }}</span>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a rel="noopener" href="https://github.com/MHSanaei/3x-ui/releases" target="_blank">
|
||||
<a-tag color="green">
|
||||
<span>v{{ .cur_ver }}</span>
|
||||
@@ -317,9 +328,35 @@
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
<a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
|
||||
<a-modal id="panel-update-modal" v-model="panelUpdateModal.visible" title='{{ i18n "pages.index.updatePanel" }}'
|
||||
:closable="true" @ok="() => panelUpdateModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
|
||||
<a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.panelUpdateDesc" }}' show-icon></a-alert>
|
||||
<a-list class="ant-version-list w-100" bordered>
|
||||
<a-list-item class="ant-version-list-item">
|
||||
<span>{{ i18n "pages.index.currentPanelVersion" }}</span>
|
||||
<a-tag color="green">v[[ panelUpdateModal.info.currentVersion || '{{ .cur_ver }}' ]]</a-tag>
|
||||
</a-list-item>
|
||||
<a-list-item class="ant-version-list-item" v-if="panelUpdateModal.info.updateAvailable">
|
||||
<span>{{ i18n "pages.index.latestPanelVersion" }}</span>
|
||||
<a-tag color="purple">
|
||||
[[ panelUpdateModal.info.latestVersion || '-' ]]
|
||||
</a-tag>
|
||||
</a-list-item>
|
||||
<a-list-item class="ant-version-list-item" v-else>
|
||||
<span>{{ i18n "pages.index.panelUpToDate" }}</span>
|
||||
<a-tag color="green">{{ i18n "pages.index.upToDate" }}</a-tag>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
<div class="mt-5 d-flex justify-end">
|
||||
<a-button type="primary" icon="cloud-download" :disabled="!panelUpdateModal.info.updateAvailable"
|
||||
@click="updatePanel">
|
||||
{{ i18n "pages.index.updatePanel" }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
<a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xrayUpdates" }}'
|
||||
:closable="true" @ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse accordion :active-key="versionModal.activeKey" @change="key => versionModal.activeKey = key">
|
||||
<a-collapse-panel key="1" header='Xray'>
|
||||
<a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}'
|
||||
show-icon></a-alert>
|
||||
@@ -344,57 +381,62 @@
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
|
||||
<div class="custom-geo-section">
|
||||
<a-alert type="info" show-icon class="mb-10"
|
||||
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
|
||||
<div class="mb-10">
|
||||
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
|
||||
{{ i18n "pages.index.customGeoAdd" }}
|
||||
</a-button>
|
||||
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
|
||||
<a-alert type="info" show-icon class="mb-10"
|
||||
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
|
||||
<div class="mb-10">
|
||||
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
|
||||
{{ i18n "pages.index.customGeoAdd" }}
|
||||
</a-button>
|
||||
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
|
||||
"pages.index.geofilesUpdateAll" }}</a-button>
|
||||
</div>
|
||||
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
|
||||
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
|
||||
<template slot="extDat" slot-scope="text, record">
|
||||
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
|
||||
</template>
|
||||
<template slot="lastUpdatedAt" slot-scope="text, record">
|
||||
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template slot="action" slot-scope="text, record">
|
||||
<a-space size="small">
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
|
||||
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
|
||||
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
|
||||
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
|
||||
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
|
||||
<template slot="extDat" slot-scope="text, record">
|
||||
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
|
||||
</template>
|
||||
<template slot="lastUpdatedAt" slot-scope="text, record">
|
||||
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template slot="action" slot-scope="text, record">
|
||||
<a-space size="small">
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
|
||||
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
|
||||
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)"
|
||||
:loading="customGeoActionId === record.id"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
|
||||
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-modal>
|
||||
<a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
|
||||
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
|
||||
<a-modal v-model="customGeoModal.visible"
|
||||
:title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
|
||||
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo"
|
||||
:ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
|
||||
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="geosite">geosite</a-select-option>
|
||||
<a-select-option value="geoip">geoip</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
|
||||
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
|
||||
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId"
|
||||
placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
|
||||
<a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
|
||||
@@ -434,7 +476,8 @@
|
||||
<a-checkbox v-model="logModal.syslog" @change="openLogs()">SysLog</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item style="float: right;">
|
||||
<a-button type="primary" icon="download" @click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
|
||||
<a-button type="primary" icon="download"
|
||||
@click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div class="ant-input log-container" v-html="logModal.formattedLogs"></div>
|
||||
@@ -512,7 +555,8 @@
|
||||
<sparkline :data="cpuHistoryLong" :labels="cpuHistoryLabels" :vb-width="840" :height="220"
|
||||
:stroke="status.cpu.color" :stroke-width="2.2" :show-grid="true" :show-axes="true" :tick-count-x="5"
|
||||
:max-points="cpuHistoryLong.length" :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true" />
|
||||
<div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point (total [[ cpuHistoryLong.length ]] points)</div>
|
||||
<div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point
|
||||
(total [[ cpuHistoryLong.length ]] points)</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
@@ -520,33 +564,93 @@
|
||||
{{template "component/aSidebar" .}}
|
||||
{{template "component/aThemeSwitch" .}}
|
||||
{{template "component/aCustomStatistic" .}}
|
||||
{{template "modals/textModal"}}
|
||||
{{template "modals/textModal" .}}
|
||||
<script>
|
||||
// Tiny Sparkline component using an inline SVG polyline
|
||||
Vue.component('sparkline', {
|
||||
props: {
|
||||
data: { type: Array, required: true },
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// viewBox width for drawing space; SVG width will be 100% of container
|
||||
vbWidth: { type: Number, default: 320 },
|
||||
height: { type: Number, default: 80 },
|
||||
stroke: { type: String, default: '#008771' },
|
||||
strokeWidth: { type: Number, default: 2 },
|
||||
maxPoints: { type: Number, default: 120 },
|
||||
showGrid: { type: Boolean, default: true },
|
||||
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||
fillOpacity: { type: Number, default: 0.15 },
|
||||
showMarker: { type: Boolean, default: true },
|
||||
markerRadius: { type: Number, default: 2.8 },
|
||||
vbWidth: {
|
||||
type: Number,
|
||||
default: 320
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
stroke: {
|
||||
type: String,
|
||||
default: '#008771'
|
||||
},
|
||||
strokeWidth: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
maxPoints: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
showGrid: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
gridColor: {
|
||||
type: String,
|
||||
default: 'rgba(0,0,0,0.1)'
|
||||
},
|
||||
fillOpacity: {
|
||||
type: Number,
|
||||
default: 0.15
|
||||
},
|
||||
showMarker: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
markerRadius: {
|
||||
type: Number,
|
||||
default: 2.8
|
||||
},
|
||||
// New opts for axes/labels/tooltip
|
||||
labels: { type: Array, default: () => [] }, // same length as data for x labels (e.g., timestamps)
|
||||
showAxes: { type: Boolean, default: false },
|
||||
yTickStep: { type: Number, default: 25 }, // percent ticks
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 32 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
showTooltip: { type: Boolean, default: false },
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}, // same length as data for x labels (e.g., timestamps)
|
||||
showAxes: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
yTickStep: {
|
||||
type: Number,
|
||||
default: 25
|
||||
}, // percent ticks
|
||||
tickCountX: {
|
||||
type: Number,
|
||||
default: 4
|
||||
},
|
||||
paddingLeft: {
|
||||
type: Number,
|
||||
default: 32
|
||||
},
|
||||
paddingRight: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
paddingTop: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
paddingBottom: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -609,7 +713,12 @@
|
||||
// draw at 25%, 50%, 75%
|
||||
return [0, 0.25, 0.5, 0.75, 1]
|
||||
.map(r => Math.round(this.paddingTop + h * r))
|
||||
.map(y => ({ x1: this.paddingLeft, y1: y, x2: this.paddingLeft + w, y2: y }))
|
||||
.map(y => ({
|
||||
x1: this.paddingLeft,
|
||||
y1: y,
|
||||
x2: this.paddingLeft + w,
|
||||
y2: y
|
||||
}))
|
||||
},
|
||||
lastPoint() {
|
||||
if (this.pointsArr.length === 0) return null
|
||||
@@ -621,7 +730,10 @@
|
||||
const ticks = []
|
||||
for (let p = 0; p <= 100; p += step) {
|
||||
const y = Math.round(this.paddingTop + (this.drawHeight - (p / 100) * this.drawHeight))
|
||||
ticks.push({ y, label: `${p}%` })
|
||||
ticks.push({
|
||||
y,
|
||||
label: `${p}%`
|
||||
})
|
||||
}
|
||||
return ticks
|
||||
},
|
||||
@@ -642,7 +754,10 @@
|
||||
positions.forEach(idx => {
|
||||
const label = labels[idx] != null ? String(labels[idx]) : String(idx)
|
||||
const x = Math.round(this.paddingLeft + idx * dx)
|
||||
ticks.push({ x, label })
|
||||
ticks.push({
|
||||
x,
|
||||
label
|
||||
})
|
||||
})
|
||||
return ticks
|
||||
},
|
||||
@@ -743,17 +858,36 @@
|
||||
this.disk = new CurTotal(0, 0);
|
||||
this.loads = [0, 0, 0];
|
||||
this.mem = new CurTotal(0, 0);
|
||||
this.netIO = { up: 0, down: 0 };
|
||||
this.netTraffic = { sent: 0, recv: 0 };
|
||||
this.publicIP = { ipv4: 0, ipv6: 0 };
|
||||
this.netIO = {
|
||||
up: 0,
|
||||
down: 0
|
||||
};
|
||||
this.netTraffic = {
|
||||
sent: 0,
|
||||
recv: 0
|
||||
};
|
||||
this.publicIP = {
|
||||
ipv4: 0,
|
||||
ipv6: 0
|
||||
};
|
||||
this.swap = new CurTotal(0, 0);
|
||||
this.tcpCount = 0;
|
||||
this.udpCount = 0;
|
||||
this.uptime = 0;
|
||||
this.appUptime = 0;
|
||||
this.appStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
this.appStats = {
|
||||
threads: 0,
|
||||
mem: 0,
|
||||
uptime: 0
|
||||
};
|
||||
|
||||
this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
|
||||
this.xray = {
|
||||
state: 'stop',
|
||||
stateMsg: "",
|
||||
errorMsg: "",
|
||||
version: "",
|
||||
color: ""
|
||||
};
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
@@ -799,9 +933,11 @@
|
||||
|
||||
const versionModal = {
|
||||
visible: false,
|
||||
activeKey: '1',
|
||||
versions: [],
|
||||
show(versions) {
|
||||
show(versions, activeKey = '1') {
|
||||
this.visible = true;
|
||||
this.activeKey = activeKey;
|
||||
this.versions = versions;
|
||||
},
|
||||
hide() {
|
||||
@@ -809,6 +945,36 @@
|
||||
},
|
||||
};
|
||||
|
||||
const panelUpdateModal = {
|
||||
visible: false,
|
||||
info: {
|
||||
currentVersion: '{{ .cur_ver }}',
|
||||
latestVersion: '',
|
||||
updateAvailable: false,
|
||||
},
|
||||
show(info) {
|
||||
this.visible = true;
|
||||
if (info) {
|
||||
this.info = info;
|
||||
}
|
||||
},
|
||||
hide() {
|
||||
this.visible = false;
|
||||
},
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const logModal = {
|
||||
visible: false,
|
||||
logs: [],
|
||||
@@ -832,24 +998,28 @@
|
||||
if (index > 0) formattedLogs += '<br>';
|
||||
|
||||
if (parts.length === 3) {
|
||||
const d = parts[0];
|
||||
const t = parts[1];
|
||||
const level = parts[2];
|
||||
const levelIndex = levels.indexOf(level, levels) || 5;
|
||||
const d = escapeHtml(parts[0]);
|
||||
const t = escapeHtml(parts[1]);
|
||||
const levelRaw = parts[2];
|
||||
const level = escapeHtml(levelRaw);
|
||||
const idx = levels.indexOf(levelRaw);
|
||||
const levelIndex = idx >= 0 ? idx : 5;
|
||||
|
||||
//formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
|
||||
formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
|
||||
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
|
||||
} else {
|
||||
const levelIndex = levels.indexOf(data, levels) || 5;
|
||||
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
|
||||
const idx = levels.indexOf(data);
|
||||
const levelIndex = idx >= 0 ? idx : 5;
|
||||
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${escapeHtml(data)}</span>`;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
if (message.startsWith("XRAY:"))
|
||||
message = "<b>XRAY: </b>" + message.substring(5);
|
||||
else
|
||||
message = "<b>X-UI: </b>" + message;
|
||||
if (message.startsWith("XRAY:")) {
|
||||
message = "<b>XRAY: </b>" + escapeHtml(message.substring(5));
|
||||
} else {
|
||||
message = "<b>X-UI: </b>" + escapeHtml(message);
|
||||
}
|
||||
}
|
||||
|
||||
formattedLogs += message ? ' - ' + message : '';
|
||||
@@ -863,20 +1033,20 @@
|
||||
};
|
||||
|
||||
const xraylogModal = {
|
||||
visible: false,
|
||||
logs: [],
|
||||
rows: 20,
|
||||
showDirect: true,
|
||||
showBlocked: true,
|
||||
showProxy: true,
|
||||
loading: false,
|
||||
show(logs) {
|
||||
this.visible = true;
|
||||
this.logs = logs;
|
||||
this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
|
||||
},
|
||||
formatLogs(logs) {
|
||||
let formattedLogs = `
|
||||
visible: false,
|
||||
logs: [],
|
||||
rows: 20,
|
||||
showDirect: true,
|
||||
showBlocked: true,
|
||||
showProxy: true,
|
||||
loading: false,
|
||||
show(logs) {
|
||||
this.visible = true;
|
||||
this.logs = logs;
|
||||
this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
|
||||
},
|
||||
formatLogs(logs) {
|
||||
let formattedLogs = `
|
||||
<style>
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
@@ -899,38 +1069,37 @@
|
||||
</tr>
|
||||
`;
|
||||
|
||||
logs.reverse().forEach((log, index) => {
|
||||
let outboundColor = '';
|
||||
if (log.Event === 1) {
|
||||
outboundColor = ' style="color: #e04141;"'; //red for blocked
|
||||
}
|
||||
else if (log.Event === 2) {
|
||||
outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
|
||||
}
|
||||
logs.reverse().forEach((log, index) => {
|
||||
let outboundColor = '';
|
||||
if (log.Event === 1) {
|
||||
outboundColor = ' style="color: #e04141;"'; //red for blocked
|
||||
} else if (log.Event === 2) {
|
||||
outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
|
||||
}
|
||||
|
||||
let text = ``;
|
||||
if (log.Email !== "") {
|
||||
text = `<td>${log.Email}</td>`;
|
||||
}
|
||||
let text = ``;
|
||||
if (log.Email !== "") {
|
||||
text = `<td>${escapeHtml(log.Email)}</td>`;
|
||||
}
|
||||
|
||||
formattedLogs += `
|
||||
formattedLogs += `
|
||||
<tr ${outboundColor}>
|
||||
<td><b>${IntlUtil.formatDate(log.DateTime)}</b></td>
|
||||
<td>${log.FromAddress}</td>
|
||||
<td>${log.ToAddress}</td>
|
||||
<td>${log.Inbound}</td>
|
||||
<td>${log.Outbound}</td>
|
||||
<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>
|
||||
<td>${escapeHtml(log.FromAddress)}</td>
|
||||
<td>${escapeHtml(log.ToAddress)}</td>
|
||||
<td>${escapeHtml(log.Inbound)}</td>
|
||||
<td>${escapeHtml(log.Outbound)}</td>
|
||||
${text}
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
return formattedLogs += "</table>";
|
||||
},
|
||||
hide() {
|
||||
this.visible = false;
|
||||
},
|
||||
};
|
||||
return formattedLogs += "</table>";
|
||||
},
|
||||
hide() {
|
||||
this.visible = false;
|
||||
},
|
||||
};
|
||||
const backupModal = {
|
||||
visible: false,
|
||||
show() {
|
||||
@@ -941,10 +1110,31 @@
|
||||
},
|
||||
};
|
||||
|
||||
const customGeoColumns = [
|
||||
{ title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
|
||||
{ title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
|
||||
{ title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
|
||||
const customGeoColumns = [{
|
||||
title: '{{ i18n "pages.index.customGeoExtColumn" }}',
|
||||
key: 'extDat',
|
||||
scopedSlots: {
|
||||
customRender: 'extDat'
|
||||
},
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '{{ i18n "pages.index.customGeoLastUpdated" }}',
|
||||
key: 'lastUpdatedAt',
|
||||
scopedSlots: {
|
||||
customRender: 'lastUpdatedAt'
|
||||
},
|
||||
width: 160
|
||||
},
|
||||
{
|
||||
title: '{{ i18n "pages.index.customGeoActions" }}',
|
||||
key: 'action',
|
||||
scopedSlots: {
|
||||
customRender: 'action'
|
||||
},
|
||||
width: 120,
|
||||
fixed: 'right'
|
||||
},
|
||||
];
|
||||
|
||||
const app = new Vue({
|
||||
@@ -958,11 +1148,15 @@
|
||||
spinning: false
|
||||
},
|
||||
status: new Status(),
|
||||
cpuHistory: [], // small live widget history
|
||||
cpuHistoryLong: [], // aggregated points from backend
|
||||
cpuHistoryLabels: [],
|
||||
cpuHistoryModal: { visible: false, bucket: 2 },
|
||||
cpuHistory: [], // small live widget history
|
||||
cpuHistoryLong: [], // aggregated points from backend
|
||||
cpuHistoryLabels: [],
|
||||
cpuHistoryModal: {
|
||||
visible: false,
|
||||
bucket: 2
|
||||
},
|
||||
versionModal,
|
||||
panelUpdateModal,
|
||||
logModal,
|
||||
xraylogModal,
|
||||
backupModal,
|
||||
@@ -1036,37 +1230,46 @@
|
||||
const labels = []
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000)
|
||||
const hh = String(d.getHours()).padStart(2,'0')
|
||||
const mm = String(d.getMinutes()).padStart(2,'0')
|
||||
const ss = String(d.getSeconds()).padStart(2,'0')
|
||||
labels.push(bucket>=60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
|
||||
vals.push(Math.max(0, Math.min(100, p.cpu)))
|
||||
}
|
||||
this.cpuHistoryLabels = labels
|
||||
this.cpuHistoryLong = vals
|
||||
}
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch bucketed cpu history', e)
|
||||
}
|
||||
},
|
||||
async openSelectV2rayVersion() {
|
||||
async openSelectV2rayVersion(activeKey = '1') {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
||||
this.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
versionModal.show(msg.obj);
|
||||
versionModal.show(msg.obj, activeKey);
|
||||
this.loadCustomGeo();
|
||||
},
|
||||
async openPanelUpdate() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.get('/panel/api/server/getPanelUpdateInfo');
|
||||
this.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
panelUpdateModal.show(msg.obj);
|
||||
},
|
||||
customGeoFormatTime(ts) {
|
||||
if (!ts) return '';
|
||||
return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
|
||||
},
|
||||
customGeoExtDisplay(record) {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
const fn = record.type === 'geoip' ?
|
||||
`geoip_${record.alias}.dat` :
|
||||
`geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
},
|
||||
async loadCustomGeo() {
|
||||
@@ -1195,22 +1398,43 @@
|
||||
},
|
||||
});
|
||||
},
|
||||
updatePanel() {
|
||||
this.$confirm({
|
||||
title: '{{ i18n "pages.index.panelUpdateDialog" }}',
|
||||
content: '{{ i18n "pages.index.panelUpdateDialogDesc" }}'
|
||||
.replace('#version#', panelUpdateModal.info.latestVersion || ''),
|
||||
okText: '{{ i18n "confirm"}}',
|
||||
class: themeSwitcher.currentTheme,
|
||||
cancelText: '{{ i18n "cancel"}}',
|
||||
onOk: async () => {
|
||||
panelUpdateModal.hide();
|
||||
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
|
||||
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||
if (!msg.success) {
|
||||
this.loading(false);
|
||||
return;
|
||||
}
|
||||
await PromiseUtil.sleep(15000);
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
},
|
||||
updateGeofile(fileName) {
|
||||
const isSingleFile = !!fileName;
|
||||
this.$confirm({
|
||||
title: '{{ i18n "pages.index.geofileUpdateDialog" }}',
|
||||
content: isSingleFile
|
||||
? '{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName)
|
||||
: '{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
|
||||
content: isSingleFile ?
|
||||
'{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName) :
|
||||
'{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
|
||||
okText: '{{ i18n "confirm"}}',
|
||||
class: themeSwitcher.currentTheme,
|
||||
cancelText: '{{ i18n "cancel"}}',
|
||||
onOk: async () => {
|
||||
versionModal.hide();
|
||||
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
|
||||
const url = isSingleFile
|
||||
? `/panel/api/server/updateGeofile/${fileName}`
|
||||
: `/panel/api/server/updateGeofile`;
|
||||
const url = isSingleFile ?
|
||||
`/panel/api/server/updateGeofile/${fileName}` :
|
||||
`/panel/api/server/updateGeofile`;
|
||||
await HttpUtil.post(url);
|
||||
this.loading(false);
|
||||
},
|
||||
@@ -1234,7 +1458,10 @@
|
||||
},
|
||||
async openLogs() {
|
||||
logModal.loading = true;
|
||||
const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog });
|
||||
const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, {
|
||||
level: logModal.level,
|
||||
syslog: logModal.syslog
|
||||
});
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
@@ -1244,7 +1471,12 @@
|
||||
},
|
||||
async openXrayLogs() {
|
||||
xraylogModal.loading = true;
|
||||
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
|
||||
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, {
|
||||
filter: xraylogModal.filter,
|
||||
showDirect: xraylogModal.showDirect,
|
||||
showBlocked: xraylogModal.showBlocked,
|
||||
showProxy: xraylogModal.showProxy
|
||||
});
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
@@ -1261,10 +1493,15 @@
|
||||
try {
|
||||
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||
const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||
const eventMap = {
|
||||
0: 'DIRECT',
|
||||
1: 'BLOCKED',
|
||||
2: 'PROXY'
|
||||
};
|
||||
const eventText = eventMap[l.Event] || String(l.Event ?? '');
|
||||
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`
|
||||
.trim();
|
||||
} catch (e) {
|
||||
return JSON.stringify(l);
|
||||
}
|
||||
@@ -1346,10 +1583,17 @@
|
||||
// Initial status fetch
|
||||
await this.getStatus();
|
||||
|
||||
// Silently check for panel updates so the indicator shows on load
|
||||
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then(msg => {
|
||||
if (msg && msg.success && msg.obj) {
|
||||
panelUpdateModal.info = msg.obj;
|
||||
}
|
||||
});
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
if (window.wsClient) {
|
||||
window.wsClient.connect();
|
||||
|
||||
|
||||
// Listen for status updates
|
||||
window.wsClient.on('status', (payload) => {
|
||||
this.setStatus(payload);
|
||||
@@ -1398,4 +1642,4 @@
|
||||
},
|
||||
});
|
||||
</script>
|
||||
{{ template "page/body_end" .}}
|
||||
{{ template "page/body_end" .}}
|
||||
@@ -108,8 +108,15 @@
|
||||
el: '#app',
|
||||
data: {
|
||||
themeSwitcher,
|
||||
loadingStates: { fetched: false, spinning: false },
|
||||
user: { username: "", password: "", twoFactorCode: "" },
|
||||
loadingStates: {
|
||||
fetched: false,
|
||||
spinning: false
|
||||
},
|
||||
user: {
|
||||
username: "",
|
||||
password: "",
|
||||
twoFactorCode: ""
|
||||
},
|
||||
twoFactorEnable: false,
|
||||
lang: "",
|
||||
animationStarted: false
|
||||
@@ -143,7 +150,11 @@
|
||||
},
|
||||
initHeadline() {
|
||||
const animationDelay = 2000;
|
||||
const headlines = this.$el.querySelectorAll('.headline');
|
||||
const rootEl = this.$el instanceof Element ? this.$el : document.getElementById('app');
|
||||
if (!rootEl || typeof rootEl.querySelectorAll !== 'function') {
|
||||
return;
|
||||
}
|
||||
const headlines = rootEl.querySelectorAll('.headline');
|
||||
headlines.forEach((headline) => {
|
||||
const first = headline.querySelector('.is-visible');
|
||||
if (!first) return;
|
||||
@@ -233,13 +244,18 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
pm_wait_for_forms.observe(pm_host, { childList: true, subtree: true });
|
||||
pm_wait_for_forms.observe(pm_host, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', pm_init, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', pm_init, {
|
||||
once: true
|
||||
});
|
||||
} else {
|
||||
pm_init();
|
||||
}
|
||||
</script>
|
||||
{{ template "page/body_end" .}}
|
||||
{{ template "page/body_end" .}}
|
||||
@@ -1,58 +1,41 @@
|
||||
{{define "modals/clientsBulkModal"}}
|
||||
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible"
|
||||
:title="clientsBulkModal.title"
|
||||
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading"
|
||||
:closable="true" :mask-closable="false"
|
||||
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}'
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible" :title="clientsBulkModal.title"
|
||||
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.client.method" }}'>
|
||||
<a-select v-model="clientsBulkModal.emailMethod" buttonStyle="solid"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="0">Random</a-select-option>
|
||||
<a-select-option :value="1">Random+Prefix</a-select-option>
|
||||
<a-select-option :value="2">Random+Prefix+Num</a-select-option>
|
||||
<a-select-option
|
||||
:value="3">Random+Prefix+Num+Postfix</a-select-option>
|
||||
<a-select-option :value="3">Random+Prefix+Num+Postfix</a-select-option>
|
||||
<a-select-option :value="4">Prefix+Num+Postfix</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.first" }}'
|
||||
v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.firstNum"
|
||||
:min="1"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.first" }}' v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.firstNum" :min="1"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.last" }}'
|
||||
v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.lastNum"
|
||||
:min="clientsBulkModal.firstNum"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.last" }}' v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.lastNum" :min="clientsBulkModal.firstNum"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.prefix" }}'
|
||||
v-if="clientsBulkModal.emailMethod>0">
|
||||
<a-form-item label='{{ i18n "pages.client.prefix" }}' v-if="clientsBulkModal.emailMethod>0">
|
||||
<a-input v-model.trim="clientsBulkModal.emailPrefix"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.postfix" }}'
|
||||
v-if="clientsBulkModal.emailMethod>2">
|
||||
<a-form-item label='{{ i18n "pages.client.postfix" }}' v-if="clientsBulkModal.emailMethod>2">
|
||||
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.clientCount" }}'
|
||||
v-if="clientsBulkModal.emailMethod < 2">
|
||||
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1"
|
||||
:max="500"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
|
||||
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="500"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "security" }}'
|
||||
v-if="inbound.protocol === Protocols.VMESS">
|
||||
<a-select v-model="clientsBulkModal.security"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
|
||||
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[
|
||||
key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='Flow'
|
||||
v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
|
||||
<a-select v-model="clientsBulkModal.flow"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-form-item label='Flow' v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
|
||||
<a-select v-model="clientsBulkModal.flow" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value selected>{{ i18n "none"
|
||||
}}</a-select-option>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[
|
||||
@@ -67,9 +50,7 @@
|
||||
}}</span>
|
||||
</template>
|
||||
Subscription
|
||||
<a-icon
|
||||
@click="clientsBulkModal.subId = RandomUtil.randomLowerAndNum(16)"
|
||||
type="sync"></a-icon>
|
||||
<a-icon @click="clientsBulkModal.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="clientsBulkModal.subId"></a-input>
|
||||
@@ -84,8 +65,7 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number :style="{ width: '50%' }"
|
||||
v-model.number="clientsBulkModal.tgId" min="0"></a-input-number>
|
||||
<a-input-number :style="{ width: '50%' }" v-model.number="clientsBulkModal.tgId" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="app.ipLimitEnable">
|
||||
<template slot="label">
|
||||
@@ -97,8 +77,7 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="clientsBulkModal.limitIp"
|
||||
min="0"></a-input-number>
|
||||
<a-input-number v-model.number="clientsBulkModal.limitIp" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
@@ -110,17 +89,13 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="clientsBulkModal.totalGB"
|
||||
:min="0"></a-input-number>
|
||||
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||
<a-switch v-model="clientsBulkModal.delayedStart"
|
||||
@click="clientsBulkModal.expiryTime=0"></a-switch>
|
||||
<a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.expireDays" }}'
|
||||
v-if="clientsBulkModal.delayedStart">
|
||||
<a-input-number v-model.number="delayedExpireDays"
|
||||
:min="0"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.expireDays" }}' v-if="clientsBulkModal.delayedStart">
|
||||
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-else>
|
||||
<template slot="label">
|
||||
@@ -133,15 +108,11 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-date-picker v-if="datepicker == 'gregorian'"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="clientsBulkModal.expiryTime"></a-date-picker>
|
||||
<a-persian-datepicker v-else
|
||||
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="clientsBulkModal.expiryTime"
|
||||
v-model="clientsBulkModal.expiryTime">
|
||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="clientsBulkModal.expiryTime" v-model="clientsBulkModal.expiryTime">
|
||||
</a-persian-datepicker>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="clientsBulkModal.expiryTime != 0">
|
||||
@@ -154,13 +125,11 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="clientsBulkModal.reset"
|
||||
:min="0"></a-input-number>
|
||||
<a-input-number v-model.number="clientsBulkModal.reset" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<script>
|
||||
|
||||
const clientsBulkModal = {
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
@@ -219,7 +188,7 @@
|
||||
title = '',
|
||||
okText = '{{ i18n "sure" }}',
|
||||
dbInbound = null,
|
||||
confirm = (inbound, dbInbound) => { }
|
||||
confirm = (inbound, dbInbound) => {}
|
||||
}) {
|
||||
this.visible = true;
|
||||
this.title = title;
|
||||
@@ -245,12 +214,19 @@
|
||||
},
|
||||
newClient(protocol) {
|
||||
switch (protocol) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings.shadowsockses[0].method);
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
case Protocols.VMESS:
|
||||
return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS:
|
||||
return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN:
|
||||
return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings
|
||||
.shadowsockses[0].method);
|
||||
case Protocols.HYSTERIA:
|
||||
return new Inbound.HysteriaSettings.Hysteria();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
close() {
|
||||
@@ -271,7 +247,8 @@
|
||||
return this.clientsBulkModal.inbound;
|
||||
},
|
||||
get delayedExpireDays() {
|
||||
return this.clientsBulkModal.expiryTime < 0 ? this.clientsBulkModal.expiryTime / -86400000 : 0;
|
||||
return this.clientsBulkModal.expiryTime < 0 ? this.clientsBulkModal.expiryTime / -86400000 :
|
||||
0;
|
||||
},
|
||||
get datepicker() {
|
||||
return app.datepicker;
|
||||
@@ -281,6 +258,5 @@
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,19 +1,15 @@
|
||||
{{define "modals/clientsModal"}}
|
||||
<a-modal id="client-modal" v-model="clientModal.visible"
|
||||
:title="clientModal.title" @ok="clientModal.ok"
|
||||
:confirm-loading="clientModal.confirmLoading" :closable="true"
|
||||
:mask-closable="false"
|
||||
:class="themeSwitcher.currentTheme"
|
||||
:ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
|
||||
<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
|
||||
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:class="themeSwitcher.currentTheme" :ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
|
||||
<template v-if="isEdit">
|
||||
<a-tag v-if="isExpiry || isTrafficExhausted" color="red"
|
||||
:style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account
|
||||
is (Expired|Traffic Ended) And Disabled</a-tag>
|
||||
</template>
|
||||
{{template "form/client"}}
|
||||
{{template "form/client" .}}
|
||||
</a-modal>
|
||||
<script>
|
||||
|
||||
const clientModal = {
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
@@ -30,12 +26,20 @@
|
||||
delayedStart: false,
|
||||
ok() {
|
||||
if (clientModal.isEdit) {
|
||||
ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id, clientModal.oldClientId);
|
||||
ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id, clientModal
|
||||
.oldClientId);
|
||||
} else {
|
||||
ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id);
|
||||
}
|
||||
},
|
||||
show({ title = '', okText = '{{ i18n "sure" }}', index = null, dbInbound = null, confirm = () => { }, isEdit = false }) {
|
||||
show({
|
||||
title = '',
|
||||
okText = '{{ i18n "sure" }}',
|
||||
index = null,
|
||||
dbInbound = null,
|
||||
confirm = () => {},
|
||||
isEdit = false
|
||||
}) {
|
||||
this.visible = true;
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
@@ -55,30 +59,41 @@
|
||||
}
|
||||
this.clientStats = this.dbInbound.clientStats.find(row => row.email === this.clients[this.index].email);
|
||||
this.confirm = confirm;
|
||||
},
|
||||
},
|
||||
getClientId(protocol, client) {
|
||||
switch (protocol) {
|
||||
case Protocols.TROJAN: return client.password;
|
||||
case Protocols.SHADOWSOCKS: return client.email;
|
||||
case Protocols.HYSTERIA: return client.auth;
|
||||
default: return client.id;
|
||||
case Protocols.TROJAN:
|
||||
return client.password;
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return client.email;
|
||||
case Protocols.HYSTERIA:
|
||||
return client.auth;
|
||||
default:
|
||||
return client.id;
|
||||
}
|
||||
},
|
||||
addClient(inbound, clients) {
|
||||
switch (inbound.protocol) {
|
||||
case Protocols.VMESS: return clients.push(new Inbound.VmessSettings.VMESS());
|
||||
case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS());
|
||||
case Protocols.TROJAN: return clients.push(new Inbound.TrojanSettings.Trojan());
|
||||
case Protocols.SHADOWSOCKS: return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(clients[0].method, RandomUtil.randomShadowsocksPassword(inbound.settings.method)));
|
||||
case Protocols.HYSTERIA: return clients.push(new Inbound.HysteriaSettings.Hysteria());
|
||||
default: return null;
|
||||
case Protocols.VMESS:
|
||||
return clients.push(new Inbound.VmessSettings.VMESS());
|
||||
case Protocols.VLESS:
|
||||
return clients.push(new Inbound.VLESSSettings.VLESS());
|
||||
case Protocols.TROJAN:
|
||||
return clients.push(new Inbound.TrojanSettings.Trojan());
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(clients[0].method, RandomUtil
|
||||
.randomShadowsocksPassword(inbound.settings.method)));
|
||||
case Protocols.HYSTERIA:
|
||||
return clients.push(new Inbound.HysteriaSettings.Hysteria());
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
close() {
|
||||
clientModal.visible = false;
|
||||
clientModal.loading(false);
|
||||
},
|
||||
loading(loading=true) {
|
||||
loading(loading = true) {
|
||||
clientModal.confirmLoading = loading;
|
||||
},
|
||||
};
|
||||
@@ -110,7 +125,8 @@
|
||||
return true
|
||||
},
|
||||
get isExpiry() {
|
||||
return this.clientModal.isEdit && this.client.expiryTime >0 ? (this.client.expiryTime < new Date().getTime()) : false;
|
||||
return this.clientModal.isEdit && this.client.expiryTime > 0 ? (this.client.expiryTime <
|
||||
new Date().getTime()) : false;
|
||||
},
|
||||
get delayedStart() {
|
||||
return this.clientModal.delayedStart;
|
||||
@@ -150,8 +166,7 @@
|
||||
return;
|
||||
}
|
||||
document.getElementById("clientIPs").value = "";
|
||||
} catch (error) {
|
||||
}
|
||||
} catch (error) {}
|
||||
},
|
||||
resetClientTraffic(email, dbInboundId, iconElement) {
|
||||
this.$confirm({
|
||||
@@ -162,7 +177,8 @@
|
||||
cancelText: '{{ i18n "cancel"}}',
|
||||
onOk: async () => {
|
||||
iconElement.disabled = true;
|
||||
const msg = await HttpUtil.postWithModal('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + email);
|
||||
const msg = await HttpUtil.postWithModal('/panel/api/inbounds/' +
|
||||
dbInboundId + '/resetClientTraffic/' + email);
|
||||
if (msg.success) {
|
||||
this.clientModal.clientStats.up = 0;
|
||||
this.clientModal.clientStats.down = 0;
|
||||
@@ -173,6 +189,5 @@
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -5,10 +5,12 @@
|
||||
<a-list-item v-for="dns in dnsPresetsDatabase" :style="{ padding: '12px 16px' }">
|
||||
<div class="ant-dns-presets-line">
|
||||
<a-space direction="horizontal" size="small" align="center">
|
||||
<a-tag :color="dns.family ? 'purple' : 'green'">[[ dns.family ? '{{ i18n "pages.xray.dns.dnsPresetFamily" }}' : 'DNS' ]]</a-tag>
|
||||
<a-tag :color="dns.family ? 'purple' : 'green'">[[ dns.family ? '{{ i18n "pages.xray.dns.dnsPresetFamily" }}'
|
||||
: 'DNS' ]]</a-tag>
|
||||
<span class="ant-dns-presets-list-name">[[ dns.name ]]</span>
|
||||
</a-space>
|
||||
<a-button class="ant-dns-presets-install" type="primary" @click="dnsPresetsModal.install(dns.data)">{{ i18n "install" }}</a-button>
|
||||
<a-button class="ant-dns-presets-install" type="primary"
|
||||
@click="dnsPresetsModal.install(dns.data)">{{ i18n "install" }}</a-button>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
@@ -36,8 +38,7 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const dnsPresetsDatabase = [
|
||||
{
|
||||
const dnsPresetsDatabase = [{
|
||||
name: 'Google DNS',
|
||||
family: false,
|
||||
data: [
|
||||
@@ -96,7 +97,11 @@
|
||||
install(selectedPreset) {
|
||||
return ObjectUtil.execute(dnsPresetsModal.selected, selectedPreset);
|
||||
},
|
||||
show({ title = '', selected = (selectedPreset) => { }, isEdit = false }) {
|
||||
show({
|
||||
title = '',
|
||||
selected = (selectedPreset) => {},
|
||||
isEdit = false
|
||||
}) {
|
||||
this.title = title;
|
||||
this.selected = selected;
|
||||
this.visible = true;
|
||||
|
||||
@@ -513,9 +513,20 @@
|
||||
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
|
||||
:style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
|
||||
</div>
|
||||
<a-divider orientation="center">Link</a-divider>
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="green">Link</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
|
||||
@click="copy(infoModal.wireguardLinks[index])"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<code :style="{ display: 'block', whiteSpace: 'normal', wordBreak: 'break-all' }">[[
|
||||
infoModal.wireguardLinks[index] ]]</code>
|
||||
</tr-info-row>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
@@ -524,7 +535,10 @@
|
||||
function refreshIPs(email) {
|
||||
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
|
||||
if (!msg.success) {
|
||||
return { text: 'No IP Record', array: [] };
|
||||
return {
|
||||
text: 'No IP Record',
|
||||
array: []
|
||||
};
|
||||
}
|
||||
|
||||
const formatIpRecord = (record) => {
|
||||
@@ -564,7 +578,10 @@
|
||||
try {
|
||||
ips = JSON.parse(ips);
|
||||
} catch (e) {
|
||||
return { text: String(ips), array: [String(ips)] };
|
||||
return {
|
||||
text: String(ips),
|
||||
array: [String(ips)]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,20 +593,32 @@
|
||||
// New format or object array
|
||||
if (Array.isArray(ips) && ips.length > 0 && typeof ips[0] === 'object') {
|
||||
const result = ips.map((item) => formatIpRecord(item)).filter(Boolean);
|
||||
return { text: result.join(' | '), array: result };
|
||||
return {
|
||||
text: result.join(' | '),
|
||||
array: result
|
||||
};
|
||||
}
|
||||
|
||||
// Old format - simple array of IPs
|
||||
if (Array.isArray(ips) && ips.length > 0) {
|
||||
const result = ips.map((ip) => String(ip));
|
||||
return { text: result.join(', '), array: result };
|
||||
return {
|
||||
text: result.join(', '),
|
||||
array: result
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for any other format
|
||||
return { text: String(ips), array: [String(ips)] };
|
||||
return {
|
||||
text: String(ips),
|
||||
array: [String(ips)]
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
return { text: 'Error loading IPs', array: [] };
|
||||
return {
|
||||
text: 'Error loading IPs',
|
||||
array: []
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -603,6 +632,7 @@
|
||||
upStats: 0,
|
||||
downStats: 0,
|
||||
links: [],
|
||||
wireguardLinks: [],
|
||||
index: null,
|
||||
isExpired: false,
|
||||
subLink: '',
|
||||
@@ -615,7 +645,8 @@
|
||||
this.dbInbound = new DBInbound(dbInbound);
|
||||
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
|
||||
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
|
||||
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
|
||||
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this
|
||||
.clientSettings.email) || null) : null;
|
||||
|
||||
if (
|
||||
[
|
||||
@@ -633,9 +664,11 @@
|
||||
}
|
||||
}
|
||||
if (this.inbound.protocol == Protocols.WIREGUARD) {
|
||||
this.links = this.inbound.genInboundLinks(dbInbound.remark).split('\r\n')
|
||||
this.links = this.inbound.genWireguardConfigs(dbInbound.remark).split('\r\n')
|
||||
this.wireguardLinks = this.inbound.genWireguardLinks(dbInbound.remark).split('\r\n')
|
||||
} else {
|
||||
this.links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, this.clientSettings);
|
||||
this.wireguardLinks = [];
|
||||
}
|
||||
if (this.clientSettings) {
|
||||
if (this.clientSettings.subId) {
|
||||
@@ -739,7 +772,8 @@
|
||||
return ColorUtils.usageColor(stats.up + stats.down, app.trafficDiff, stats.total);
|
||||
},
|
||||
getRemStats() {
|
||||
remained = this.infoModal.clientStats.total - this.infoModal.clientStats.up - this.infoModal.clientStats.down;
|
||||
remained = this.infoModal.clientStats.total - this.infoModal.clientStats.up - this.infoModal.clientStats
|
||||
.down;
|
||||
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
||||
},
|
||||
refreshIPs() {
|
||||
@@ -762,7 +796,7 @@
|
||||
this.infoModal.clientIps = 'No IP Record';
|
||||
this.infoModal.clientIpsArray = [];
|
||||
})
|
||||
.catch(() => { });
|
||||
.catch(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a-modal id="inbound-modal" v-model="inModal.visible" :title="inModal.title" :dialog-style="{ top: '20px' }"
|
||||
@ok="inModal.ok" :confirm-loading="inModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:class="themeSwitcher.currentTheme" :ok-text="inModal.okText" cancel-text='{{ i18n "close" }}'>
|
||||
{{template "form/inbound"}}
|
||||
{{template "form/inbound" .}}
|
||||
</a-modal>
|
||||
<script>
|
||||
// Make inModal globally available to ensure it works with any base path
|
||||
@@ -23,7 +23,7 @@
|
||||
okText = '{{ i18n "sure" }}',
|
||||
inbound = null,
|
||||
dbInbound = null,
|
||||
confirm = (inbound, dbInbound) => { },
|
||||
confirm = (inbound, dbInbound) => {},
|
||||
isEdit = false,
|
||||
}) {
|
||||
this.title = title;
|
||||
@@ -127,17 +127,17 @@
|
||||
get client() {
|
||||
return inModal.inbound &&
|
||||
inModal.inbound.clients &&
|
||||
inModal.inbound.clients.length > 0
|
||||
? inModal.inbound.clients[0]
|
||||
: null;
|
||||
inModal.inbound.clients.length > 0 ?
|
||||
inModal.inbound.clients[0] :
|
||||
null;
|
||||
},
|
||||
get datepicker() {
|
||||
return app.datepicker;
|
||||
},
|
||||
get delayedExpireDays() {
|
||||
return this.client && this.client.expiryTime < 0
|
||||
? this.client.expiryTime / -86400000
|
||||
: 0;
|
||||
return this.client && this.client.expiryTime < 0 ?
|
||||
this.client.expiryTime / -86400000 :
|
||||
0;
|
||||
},
|
||||
set delayedExpireDays(days) {
|
||||
this.client.expiryTime = -86400000 * days;
|
||||
@@ -147,14 +147,12 @@
|
||||
},
|
||||
set externalProxy(value) {
|
||||
if (value) {
|
||||
inModal.inbound.stream.externalProxy = [
|
||||
{
|
||||
forceTls: "same",
|
||||
dest: window.location.hostname,
|
||||
port: inModal.inbound.port,
|
||||
remark: "",
|
||||
},
|
||||
];
|
||||
inModal.inbound.stream.externalProxy = [{
|
||||
forceTls: "same",
|
||||
dest: window.location.hostname,
|
||||
port: inModal.inbound.port,
|
||||
remark: "",
|
||||
}, ];
|
||||
} else {
|
||||
inModal.inbound.stream.externalProxy = [];
|
||||
}
|
||||
@@ -182,8 +180,8 @@
|
||||
) {
|
||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(
|
||||
(c) =>
|
||||
c.flow === "xtls-rprx-vision" ||
|
||||
c.flow === "xtls-rprx-vision-udp443",
|
||||
c.flow === "xtls-rprx-vision" ||
|
||||
c.flow === "xtls-rprx-vision-udp443",
|
||||
);
|
||||
if (
|
||||
hasVisionFlow &&
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
{{define "modals/nordModal"}}
|
||||
<a-modal id="nord-modal" v-model="nordModal.visible" title="NordVPN NordLynx"
|
||||
:confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true"
|
||||
:footer="null" :class="themeSwitcher.currentTheme">
|
||||
:confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true" :footer="null"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<template v-if="nordModal.nordData == null">
|
||||
<a-tabs default-active-key="token" :class="themeSwitcher.currentTheme">
|
||||
<a-tab-pane key="token" tab='{{ i18n "pages.xray.outbound.accessToken" }}'>
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }"
|
||||
:style="{ marginTop: '20px' }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.accessToken" }}'>
|
||||
<a-input v-model="nordModal.token" placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
|
||||
<a-input v-model="nordModal.token"
|
||||
placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
|
||||
<div :style="{ marginTop: '10px' }">
|
||||
<a-button type="primary" icon="login" @click="login()" :loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
|
||||
<a-button type="primary" icon="login" @click="login()"
|
||||
:loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="key" tab='{{ i18n "pages.xray.outbound.privateKey" }}'>
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }"
|
||||
:style="{ marginTop: '20px' }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.privateKey" }}'>
|
||||
<a-input v-model="nordModal.manualKey" placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
|
||||
<a-input v-model="nordModal.manualKey"
|
||||
placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
|
||||
<div :style="{ marginTop: '10px' }">
|
||||
<a-button type="primary" icon="save" @click="saveKey()" :loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
|
||||
<a-button type="primary" icon="save" @click="saveKey()"
|
||||
:loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -39,7 +45,8 @@
|
||||
</table>
|
||||
<a-button @click="logout" :loading="nordModal.confirmLoading" type="danger">{{ i18n "logout" }}</a-button>
|
||||
<a-divider :style="{ margin: '0' }">{{ i18n "pages.xray.outbound.settings" }}</a-divider>
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '10px' }">
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }"
|
||||
:style="{ marginTop: '10px' }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.country" }}'>
|
||||
<a-select v-model="nordModal.countryId" @change="fetchServers" show-search option-filter-prop="label">
|
||||
<a-select-option v-for="c in nordModal.countries" :key="c.id" :value="c.id" :label="c.name">
|
||||
@@ -69,11 +76,13 @@
|
||||
<a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<template v-if="nordOutboundIndex>=0">
|
||||
<a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
|
||||
<a-button @click="resetOutbound" :loading="nordModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
|
||||
<a-button @click="resetOutbound" :loading="nordModal.confirmLoading"
|
||||
type="danger">{{ i18n "reset" }}</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
|
||||
<a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
|
||||
<a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading"
|
||||
type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
@@ -115,7 +124,9 @@
|
||||
},
|
||||
async login() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: this.token });
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/reg', {
|
||||
token: this.token
|
||||
});
|
||||
if (msg.success) {
|
||||
this.nordData = JSON.parse(msg.obj);
|
||||
await this.fetchCountries();
|
||||
@@ -124,7 +135,9 @@
|
||||
},
|
||||
async saveKey() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: this.manualKey });
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/setKey', {
|
||||
key: this.manualKey
|
||||
});
|
||||
if (msg.success) {
|
||||
this.nordData = JSON.parse(msg.obj);
|
||||
await this.fetchCountries();
|
||||
@@ -160,7 +173,9 @@
|
||||
this.cities = [];
|
||||
this.serverId = null;
|
||||
this.cityId = null;
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: this.countryId });
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/servers', {
|
||||
countryId: this.countryId
|
||||
});
|
||||
if (msg.success) {
|
||||
const data = JSON.parse(msg.obj);
|
||||
const locations = data.locations || [];
|
||||
@@ -173,7 +188,7 @@
|
||||
}
|
||||
});
|
||||
this.cities = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
|
||||
this.servers = (data.servers || []).map(s => {
|
||||
const firstLocId = (s.location_ids || [])[0];
|
||||
const city = locToCity[firstLocId];
|
||||
@@ -195,7 +210,7 @@
|
||||
addOutbound() {
|
||||
const server = this.servers.find(s => s.id === this.serverId);
|
||||
if (!server) return;
|
||||
|
||||
|
||||
const tech = server.technologies.find(t => t.id === 35);
|
||||
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
|
||||
|
||||
@@ -221,7 +236,7 @@
|
||||
resetOutbound(index) {
|
||||
const server = this.servers.find(s => s.id === this.serverId);
|
||||
if (!server || index === -1) return;
|
||||
|
||||
|
||||
const tech = server.technologies.find(t => t.id === 35);
|
||||
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
|
||||
|
||||
@@ -242,7 +257,7 @@
|
||||
}
|
||||
};
|
||||
app.templateSettings.outbounds[index] = outbound;
|
||||
|
||||
|
||||
// Sync routing rules
|
||||
app.templateSettings.routing.rules.forEach(r => {
|
||||
if (r.outboundTag === oldTag) {
|
||||
@@ -262,7 +277,8 @@
|
||||
},
|
||||
delRouting() {
|
||||
if (app.templateSettings && app.templateSettings.routing) {
|
||||
app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag.startsWith("nord-"));
|
||||
app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag
|
||||
.startsWith("nord-"));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -276,10 +292,14 @@
|
||||
methods: {
|
||||
login: () => nordModal.login(),
|
||||
saveKey: () => nordModal.saveKey(),
|
||||
logout() { nordModal.logout(this.nordOutboundIndex) },
|
||||
logout() {
|
||||
nordModal.logout(this.nordOutboundIndex)
|
||||
},
|
||||
fetchServers: () => nordModal.fetchServers(),
|
||||
addOutbound: () => nordModal.addOutbound(),
|
||||
resetOutbound() { nordModal.resetOutbound(this.nordOutboundIndex) },
|
||||
resetOutbound() {
|
||||
nordModal.resetOutbound(this.nordOutboundIndex)
|
||||
},
|
||||
onCityChange() {
|
||||
if (this.filteredServers.length > 0) {
|
||||
this.nordModal.serverId = this.filteredServers[0].id;
|
||||
@@ -290,8 +310,9 @@
|
||||
},
|
||||
computed: {
|
||||
nordOutboundIndex: {
|
||||
get: function () {
|
||||
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) : -1;
|
||||
get: function() {
|
||||
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag
|
||||
.startsWith("nord-")) : -1;
|
||||
}
|
||||
},
|
||||
filteredServers: function() {
|
||||
@@ -303,4 +324,4 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,17 +1,13 @@
|
||||
{{define "modals/promptModal"}}
|
||||
<a-modal id="prompt-modal" v-model="promptModal.visible" :title="promptModal.title"
|
||||
:closable="true" @ok="promptModal.ok" :mask-closable="false"
|
||||
:confirm-loading="promptModal.confirmLoading"
|
||||
:ok-text="promptModal.okText" cancel-text='{{ i18n "cancel" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-input id="prompt-modal-input" :type="promptModal.type"
|
||||
v-model="promptModal.value"
|
||||
:autosize="{minRows: 10, maxRows: 20}"
|
||||
@keydown.enter.native="promptModal.keyEnter"
|
||||
@keydown.ctrl.83="promptModal.ctrlS"></a-input>
|
||||
<a-modal id="prompt-modal" v-model="promptModal.visible" :title="promptModal.title" :closable="true"
|
||||
@ok="promptModal.ok" :mask-closable="false" :confirm-loading="promptModal.confirmLoading"
|
||||
:ok-text="promptModal.okText" cancel-text='{{ i18n "cancel" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-input id="prompt-modal-input" :type="promptModal.type" v-model="promptModal.value"
|
||||
:autosize="{minRows: 10, maxRows: 20}" @keydown.enter.native="promptModal.keyEnter"
|
||||
@keydown.ctrl.83="promptModal.ctrlS"></a-input>
|
||||
</a-modal>
|
||||
|
||||
<script>
|
||||
|
||||
const promptModal = {
|
||||
title: '',
|
||||
type: '',
|
||||
@@ -55,7 +51,7 @@
|
||||
close() {
|
||||
this.visible = false;
|
||||
},
|
||||
loading(loading=true) {
|
||||
loading(loading = true) {
|
||||
this.confirmLoading = loading;
|
||||
},
|
||||
};
|
||||
@@ -66,6 +62,5 @@
|
||||
promptModal: promptModal,
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -57,39 +57,44 @@
|
||||
border-radius: 1rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* QR code transition effects */
|
||||
.qr-cv {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-transition-enter-active, .qr-transition-leave-active {
|
||||
|
||||
.qr-transition-enter-active,
|
||||
.qr-transition-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.qr-transition-enter, .qr-transition-leave-to {
|
||||
|
||||
.qr-transition-enter,
|
||||
.qr-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.qr-transition-enter-to, .qr-transition-leave {
|
||||
|
||||
.qr-transition-enter-to,
|
||||
.qr-transition-leave {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
|
||||
.qr-flash {
|
||||
animation: qr-flash-animation 0.6s;
|
||||
}
|
||||
|
||||
|
||||
@keyframes qr-flash-animation {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -105,7 +110,7 @@
|
||||
qrcodes: [],
|
||||
visible: false,
|
||||
subId: '',
|
||||
show: function (title = '', dbInbound, client) {
|
||||
show: function(title = '', dbInbound, client) {
|
||||
this.title = title;
|
||||
this.dbInbound = dbInbound;
|
||||
this.inbound = dbInbound.toInbound();
|
||||
@@ -135,7 +140,7 @@
|
||||
}
|
||||
this.visible = true;
|
||||
},
|
||||
close: function () {
|
||||
close: function() {
|
||||
this.visible = false;
|
||||
},
|
||||
};
|
||||
@@ -159,7 +164,7 @@
|
||||
console.error("Failed to get status:", e);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
toggleIPv4(index) {
|
||||
const row = qrModal.qrcodes[index];
|
||||
row.useIPv4 = !row.useIPv4;
|
||||
@@ -170,13 +175,13 @@
|
||||
if (!this.serverStatus || !this.serverStatus.publicIP) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (row.useIPv4 && this.serverStatus.publicIP.ipv4) {
|
||||
// Replace the hostname or IP in the link with the IPv4 address
|
||||
const originalLink = row.originalLink;
|
||||
const url = new URL(originalLink);
|
||||
const ipv4 = this.serverStatus.publicIP.ipv4;
|
||||
|
||||
|
||||
if (qrModal.inbound.protocol == Protocols.WIREGUARD) {
|
||||
// Special handling for WireGuard config
|
||||
const endpointRegex = /Endpoint = ([^:]+):(\d+)/;
|
||||
@@ -196,19 +201,19 @@
|
||||
// Restore original link
|
||||
row.link = row.originalLink;
|
||||
}
|
||||
|
||||
|
||||
// Update QR code with transition effect
|
||||
const canvasElement = document.querySelector('#qrCode-' + index);
|
||||
if (canvasElement) {
|
||||
// Add flash animation class
|
||||
canvasElement.classList.add('qr-flash');
|
||||
|
||||
|
||||
// Remove the class after animation completes
|
||||
setTimeout(() => {
|
||||
canvasElement.classList.remove('qr-flash');
|
||||
}, 600);
|
||||
}
|
||||
|
||||
|
||||
this.setQrCode("qrCode-" + index, row.link);
|
||||
},
|
||||
copy(content) {
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
fileName: '',
|
||||
qrcode: null,
|
||||
visible: false,
|
||||
show: function (title = '', content = '', fileName = '') {
|
||||
show: function(title = '', content = '', fileName = '') {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.fileName = fileName;
|
||||
this.visible = true;
|
||||
},
|
||||
copy: function (content = '') {
|
||||
copy: function(content = '') {
|
||||
ClipboardManager
|
||||
.copyText(content)
|
||||
.then(() => {
|
||||
@@ -35,7 +35,7 @@
|
||||
this.close();
|
||||
})
|
||||
},
|
||||
close: function () {
|
||||
close: function() {
|
||||
this.visible = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,12 +55,12 @@
|
||||
|
||||
twoFactorModal.close()
|
||||
},
|
||||
show: function ({
|
||||
show: function({
|
||||
title = '',
|
||||
description = '',
|
||||
token = '',
|
||||
type = 'set',
|
||||
confirm = (success) => { }
|
||||
confirm = (success) => {}
|
||||
}) {
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
@@ -78,7 +78,7 @@
|
||||
secret: twoFactorModal.token,
|
||||
});
|
||||
},
|
||||
close: function () {
|
||||
close: function() {
|
||||
twoFactorModal.enteredCode = "";
|
||||
twoFactorModal.visible = false;
|
||||
},
|
||||
@@ -91,34 +91,34 @@
|
||||
twoFactorModal: twoFactorModal,
|
||||
},
|
||||
updated() {
|
||||
if (
|
||||
this.twoFactorModal.visible &&
|
||||
this.twoFactorModal.type === 'set' &&
|
||||
document.getElementById('twofactor-qrcode')
|
||||
) {
|
||||
this.setQrCode('twofactor-qrcode', this.twoFactorModal.totpObject.toString());
|
||||
}
|
||||
if (
|
||||
this.twoFactorModal.visible &&
|
||||
this.twoFactorModal.type === 'set' &&
|
||||
document.getElementById('twofactor-qrcode')
|
||||
) {
|
||||
this.setQrCode('twofactor-qrcode', this.twoFactorModal.totpObject.toString());
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setQrCode(elementId, content) {
|
||||
new QRious({
|
||||
element: document.getElementById(elementId),
|
||||
size: 200,
|
||||
value: content,
|
||||
background: 'white',
|
||||
backgroundAlpha: 0,
|
||||
foreground: 'black',
|
||||
padding: 2,
|
||||
level: 'L'
|
||||
});
|
||||
},
|
||||
copy(content) {
|
||||
ClipboardManager
|
||||
.copyText(content)
|
||||
.then(() => {
|
||||
app.$message.success('{{ i18n "copied" }}')
|
||||
})
|
||||
},
|
||||
setQrCode(elementId, content) {
|
||||
new QRious({
|
||||
element: document.getElementById(elementId),
|
||||
size: 200,
|
||||
value: content,
|
||||
background: 'white',
|
||||
backgroundAlpha: 0,
|
||||
foreground: 'black',
|
||||
padding: 2,
|
||||
level: 'L'
|
||||
});
|
||||
},
|
||||
copy(content) {
|
||||
ClipboardManager
|
||||
.copyText(content)
|
||||
.then(() => {
|
||||
app.$message.success('{{ i18n "copied" }}')
|
||||
})
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{{define "modals/warpModal"}}
|
||||
<a-modal id="warp-modal" v-model="warpModal.visible" title="Cloudflare WARP"
|
||||
:confirm-loading="warpModal.confirmLoading" :closable="true" :mask-closable="true"
|
||||
:footer="null" :class="themeSwitcher.currentTheme">
|
||||
<a-modal id="warp-modal" v-model="warpModal.visible" title="Cloudflare WARP" :confirm-loading="warpModal.confirmLoading"
|
||||
:closable="true" :mask-closable="true" :footer="null" :class="themeSwitcher.currentTheme">
|
||||
<template v-if="ObjectUtil.isEmpty(warpModal.warpData)">
|
||||
<a-button icon="api" @click="register" :loading="warpModal.confirmLoading">{{ i18n "create" }}</a-button>
|
||||
</template>
|
||||
@@ -81,11 +80,13 @@
|
||||
<a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<template v-if="warpOutboundIndex>=0">
|
||||
<a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
|
||||
<a-button @click="resetOutbound" :loading="warpModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
|
||||
<a-button @click="resetOutbound" :loading="warpModal.confirmLoading"
|
||||
type="danger">{{ i18n "reset" }}</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
|
||||
<a-button @click="addOutbound" :loading="warpModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
|
||||
<a-button @click="addOutbound" :loading="warpModal.confirmLoading"
|
||||
type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -93,7 +94,6 @@
|
||||
</template>
|
||||
</a-modal>
|
||||
<script>
|
||||
|
||||
const warpModal = {
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
@@ -188,7 +188,9 @@
|
||||
},
|
||||
async updateLicense(l) {
|
||||
warpModal.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: l });
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/license', {
|
||||
license: l
|
||||
});
|
||||
if (msg.success) {
|
||||
warpModal.warpData = JSON.parse(msg.obj);
|
||||
warpModal.warpConfig = null;
|
||||
@@ -235,12 +237,12 @@
|
||||
},
|
||||
computed: {
|
||||
warpOutboundIndex: {
|
||||
get: function () {
|
||||
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag == 'warp') : -1;
|
||||
get: function() {
|
||||
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag ==
|
||||
'warp') : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,15 +1,7 @@
|
||||
{{define "modals/balancerModal"}}
|
||||
<a-modal
|
||||
id="balancer-modal"
|
||||
v-model="balancerModal.visible"
|
||||
:title="balancerModal.title"
|
||||
@ok="balancerModal.ok"
|
||||
:confirm-loading="balancerModal.confirmLoading"
|
||||
:ok-button-props="{ props: { disabled: !balancerModal.isValid } }"
|
||||
:closable="true"
|
||||
:mask-closable="false"
|
||||
:ok-text="balancerModal.okText"
|
||||
cancel-text='{{ i18n "close" }}'
|
||||
<a-modal id="balancer-modal" v-model="balancerModal.visible" :title="balancerModal.title" @ok="balancerModal.ok"
|
||||
:confirm-loading="balancerModal.confirmLoading" :ok-button-props="{ props: { disabled: !balancerModal.isValid } }"
|
||||
:closable="true" :mask-closable="false" :ok-text="balancerModal.okText" cancel-text='{{ i18n "close" }}'
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.xray.balancer.tag" }}' has-feedback
|
||||
@@ -35,7 +27,8 @@
|
||||
<a-form-item label="Fallback">
|
||||
<a-select v-model="balancerModal.balancer.fallbackTag" clearable
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="tag in [ '', ...balancerModal.outboundTags]" :value="tag">[[ tag ]]</a-select-option>
|
||||
<a-select-option v-for="tag in [ '', ...balancerModal.outboundTags]" :value="tag">[[ tag
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</table>
|
||||
@@ -58,7 +51,7 @@
|
||||
fallbackTag: ''
|
||||
},
|
||||
outboundTags: [],
|
||||
balancerTags:[],
|
||||
balancerTags: [],
|
||||
ok() {
|
||||
if (balancerModal.balancer.selector.length == 0) {
|
||||
balancerModal.emptySelector = true;
|
||||
@@ -67,7 +60,14 @@
|
||||
balancerModal.emptySelector = false;
|
||||
ObjectUtil.execute(balancerModal.confirm, balancerModal.balancer);
|
||||
},
|
||||
show({ title = '', okText = '{{ i18n "sure" }}', balancerTags = [], balancer, confirm = (balancer) => { }, isEdit = false }) {
|
||||
show({
|
||||
title = '',
|
||||
okText = '{{ i18n "sure" }}',
|
||||
balancerTags = [],
|
||||
balancer,
|
||||
confirm = (balancer) => {},
|
||||
isEdit = false
|
||||
}) {
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
this.confirm = confirm;
|
||||
@@ -83,7 +83,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 = app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
|
||||
obj.tag);
|
||||
this.isEdit = isEdit;
|
||||
this.check();
|
||||
this.checkSelector();
|
||||
@@ -92,7 +93,7 @@
|
||||
this.visible = false;
|
||||
this.loading(false);
|
||||
},
|
||||
loading(loading=true) {
|
||||
loading(loading = true) {
|
||||
this.confirmLoading = loading;
|
||||
},
|
||||
check() {
|
||||
@@ -115,9 +116,7 @@
|
||||
data: {
|
||||
balancerModal: balancerModal
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
methods: {}
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -75,15 +75,19 @@
|
||||
okText: '{{ i18n "confirm" }}',
|
||||
isEdit: false,
|
||||
confirm: null,
|
||||
dnsServer: { ...defaultDnsObject },
|
||||
dnsServer: {
|
||||
...defaultDnsObject
|
||||
},
|
||||
ok() {
|
||||
ObjectUtil.execute(dnsModal.confirm, { ...dnsModal.dnsServer });
|
||||
ObjectUtil.execute(dnsModal.confirm, {
|
||||
...dnsModal.dnsServer
|
||||
});
|
||||
},
|
||||
show({
|
||||
title = '',
|
||||
okText = '{{ i18n "confirm" }}',
|
||||
dnsServer,
|
||||
confirm = (dnsServer) => { },
|
||||
confirm = (dnsServer) => {},
|
||||
isEdit = false
|
||||
}) {
|
||||
this.title = title;
|
||||
@@ -95,7 +99,9 @@
|
||||
if (isEdit) {
|
||||
switch (typeof dnsServer) {
|
||||
case 'string':
|
||||
const dnsObj = { ...defaultDnsObject };
|
||||
const dnsObj = {
|
||||
...defaultDnsObject
|
||||
};
|
||||
|
||||
dnsObj.address = dnsServer;
|
||||
|
||||
@@ -106,7 +112,9 @@
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
this.dnsServer = { ...defaultDnsObject };
|
||||
this.dnsServer = {
|
||||
...defaultDnsObject
|
||||
};
|
||||
|
||||
this.dnsServer.domains = [];
|
||||
this.dnsServer.expectIPs = [];
|
||||
|
||||
@@ -23,11 +23,19 @@
|
||||
okText: '{{ i18n "confirm" }}',
|
||||
isEdit: false,
|
||||
confirm: null,
|
||||
fakeDns: { ...fakednsDefaultData },
|
||||
fakeDns: {
|
||||
...fakednsDefaultData
|
||||
},
|
||||
ok() {
|
||||
ObjectUtil.execute(fakednsModal.confirm, fakednsModal.fakeDns);
|
||||
},
|
||||
show({ title = '', okText = '{{ i18n "confirm" }}', fakeDns, confirm = (fakeDns) => { }, isEdit = false }) {
|
||||
show({
|
||||
title = '',
|
||||
okText = '{{ i18n "confirm" }}',
|
||||
fakeDns,
|
||||
confirm = (fakeDns) => {},
|
||||
isEdit = false
|
||||
}) {
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
this.confirm = confirm;
|
||||
@@ -35,7 +43,9 @@
|
||||
if (isEdit) {
|
||||
this.fakeDns = fakeDns;
|
||||
} else {
|
||||
this.fakeDns = { ...fakednsDefaultData }
|
||||
this.fakeDns = {
|
||||
...fakednsDefaultData
|
||||
}
|
||||
}
|
||||
this.isEdit = isEdit;
|
||||
},
|
||||
@@ -51,6 +61,5 @@
|
||||
fakednsModal: fakednsModal,
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,12 +1,11 @@
|
||||
{{define "modals/outModal"}}
|
||||
<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' }"
|
||||
:ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
{{template "form/outbound"}}
|
||||
:confirm-loading="outModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:ok-button-props="{ props: { disabled: !outModal.isValid } }" :style="{ overflow: 'hidden' }"
|
||||
:ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
{{template "form/outbound" .}}
|
||||
</a-modal>
|
||||
<script>
|
||||
|
||||
const outModal = {
|
||||
title: '',
|
||||
visible: false,
|
||||
@@ -25,7 +24,14 @@
|
||||
ok() {
|
||||
ObjectUtil.execute(outModal.confirm, outModal.outbound.toJson());
|
||||
},
|
||||
show({ title='', okText='{{ i18n "sure" }}', outbound, confirm=(outbound)=>{}, isEdit=false, tags=[] }) {
|
||||
show({
|
||||
title = '',
|
||||
okText = '{{ i18n "sure" }}',
|
||||
outbound,
|
||||
confirm = (outbound) => {},
|
||||
isEdit = false,
|
||||
tags = []
|
||||
}) {
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
this.confirm = confirm;
|
||||
@@ -42,11 +48,11 @@
|
||||
outModal.visible = false;
|
||||
outModal.loading(false);
|
||||
},
|
||||
loading(loading=true) {
|
||||
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 {
|
||||
@@ -56,25 +62,25 @@
|
||||
},
|
||||
toggleJson(jsonTab) {
|
||||
textAreaObj = document.getElementById('outboundJson');
|
||||
if(jsonTab){
|
||||
if(this.cm != null) {
|
||||
this.cm.toTextArea();
|
||||
this.cm=null;
|
||||
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 => {
|
||||
this.cm.on('change', editor => {
|
||||
value = editor.getValue();
|
||||
if(this.isJsonString(value)){
|
||||
if (this.isJsonString(value)) {
|
||||
this.outbound = Outbound.fromJson(JSON.parse(value));
|
||||
this.check();
|
||||
}
|
||||
});
|
||||
this.activeKey = '2';
|
||||
} else {
|
||||
if(this.cm != null) {
|
||||
this.cm.toTextArea();
|
||||
this.cm=null;
|
||||
if (this.cm != null) {
|
||||
this.cm.toTextArea();
|
||||
this.cm = null;
|
||||
}
|
||||
this.activeKey = '1';
|
||||
}
|
||||
@@ -100,20 +106,21 @@
|
||||
},
|
||||
methods: {
|
||||
streamNetworkChange() {
|
||||
if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound.canEnableTlsFlow()) {
|
||||
if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound
|
||||
.canEnableTlsFlow()) {
|
||||
delete this.outModal.outbound.settings.flow;
|
||||
}
|
||||
},
|
||||
canEnableTls() {
|
||||
return this.outModal.outbound.canEnableTls();
|
||||
},
|
||||
convertLink(){
|
||||
convertLink() {
|
||||
newOutbound = Outbound.fromLink(outModal.link);
|
||||
if(newOutbound){
|
||||
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 +129,5 @@
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,138 +0,0 @@
|
||||
{{define "modals/reverseModal"}}
|
||||
<a-modal id="reverse-modal" v-model="reverseModal.visible" :title="reverseModal.title" @ok="reverseModal.ok"
|
||||
:confirm-loading="reverseModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:ok-text="reverseModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select v-model="reverseModal.reverse.type" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="x,y in reverseTypes" :value="y">[[ x ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.tag" }}'>
|
||||
<a-input v-model.trim="reverseModal.reverse.tag"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.domain" }}'>
|
||||
<a-input v-model.trim="reverseModal.reverse.domain"></a-input>
|
||||
</a-form-item>
|
||||
<template v-if="reverseModal.reverse.type=='bridge'">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.intercon" }}'>
|
||||
<a-select v-model="reverseModal.rules[0].outboundTag" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="x in reverseModal.outboundTags" :value="x">[[ x ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.rules.outbound" }}'>
|
||||
<a-select v-model="reverseModal.rules[1].outboundTag" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="x in reverseModal.outboundTags" :value="x">[[ x ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.intercon" }}'>
|
||||
<a-checkbox-group
|
||||
v-model="reverseModal.rules[0].inboundTag"
|
||||
:options="reverseModal.inboundTags"></a-checkbox-group>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.rules.inbound" }}'>
|
||||
<a-checkbox-group
|
||||
v-model="reverseModal.rules[1].inboundTag"
|
||||
:options="reverseModal.inboundTags"></a-checkbox-group>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<script>
|
||||
const reverseModal = {
|
||||
title: '',
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
okText: '{{ i18n "sure" }}',
|
||||
isEdit: false,
|
||||
confirm: null,
|
||||
reverse: {
|
||||
tag: "",
|
||||
type: "",
|
||||
domain: ""
|
||||
},
|
||||
rules: [
|
||||
{ outboundTag: '', inboundTag: []},
|
||||
{ outboundTag: '', inboundTag: []}
|
||||
],
|
||||
inboundTags: [],
|
||||
outboundTags: [],
|
||||
ok() {
|
||||
reverseModal.rules[0].domain = ["full:" + reverseModal.reverse.domain];
|
||||
reverseModal.rules[0].type = 'field';
|
||||
reverseModal.rules[1].type = 'field';
|
||||
|
||||
if(reverseModal.reverse.type == 'bridge'){
|
||||
reverseModal.rules[0].inboundTag = [reverseModal.reverse.tag];
|
||||
reverseModal.rules[1].inboundTag = [reverseModal.reverse.tag];
|
||||
} else {
|
||||
reverseModal.rules[0].outboundTag = reverseModal.reverse.tag;
|
||||
reverseModal.rules[1].outboundTag = reverseModal.reverse.tag;
|
||||
}
|
||||
ObjectUtil.execute(reverseModal.confirm, reverseModal.reverse, reverseModal.rules);
|
||||
},
|
||||
show({ title='', okText='{{ i18n "sure" }}', reverse, rules, confirm=(reverse, rules)=>{}, isEdit=false }) {
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
this.confirm = confirm;
|
||||
this.visible = true;
|
||||
if(isEdit) {
|
||||
this.reverse = {
|
||||
tag: reverse.tag,
|
||||
type: reverse.type,
|
||||
domain: reverse.domain,
|
||||
};
|
||||
reverse;
|
||||
rules0 = rules.filter(r => r.domain != null);
|
||||
if(rules0.length == 0) rules0 = [{ outboundTag: '', domain: ["full:" + this.reverse.domain], inboundTag: []}];
|
||||
rules1 = rules.filter(r => r.domain == null);
|
||||
if(rules1.length == 0) rules1 = [{ outboundTag: '', inboundTag: []}];
|
||||
this.rules = [];
|
||||
this.rules.push({
|
||||
domain: rules0[0].domain,
|
||||
outboundTag: rules0[0].outboundTag,
|
||||
inboundTag: rules0.map(r => r.inboundTag).flat()
|
||||
});
|
||||
this.rules.push({
|
||||
outboundTag: rules1[0].outboundTag,
|
||||
inboundTag: rules1.map(r => r.inboundTag).flat()
|
||||
});
|
||||
} else {
|
||||
this.reverse = {
|
||||
tag: "reverse-" + app.reverseData.length,
|
||||
type: "bridge",
|
||||
domain: "reverse.xui"
|
||||
}
|
||||
this.rules = [
|
||||
{ outboundTag: '', inboundTag: []},
|
||||
{ outboundTag: '', inboundTag: []}
|
||||
]
|
||||
}
|
||||
this.isEdit = isEdit;
|
||||
this.inboundTags = app.templateSettings.inbounds.filter((i) => !ObjectUtil.isEmpty(i.tag)).map(obj => obj.tag);
|
||||
this.inboundTags.push(...app.inboundTags);
|
||||
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);
|
||||
},
|
||||
close() {
|
||||
reverseModal.visible = false;
|
||||
reverseModal.loading(false);
|
||||
},
|
||||
loading(loading=true) {
|
||||
reverseModal.confirmLoading = loading;
|
||||
},
|
||||
};
|
||||
|
||||
new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#reverse-modal',
|
||||
data: {
|
||||
reverseModal: reverseModal,
|
||||
reverseTypes: { bridge: '{{ i18n "pages.xray.outbound.bridge" }}', portal:'{{ i18n "pages.xray.outbound.portal" }}'},
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,5 +1,7 @@
|
||||
{{define "modals/ruleModal"}}
|
||||
<a-modal id="rule-modal" v-model="ruleModal.visible" :title="ruleModal.title" @ok="ruleModal.ok" :confirm-loading="ruleModal.confirmLoading" :closable="true" :mask-closable="false" :ok-text="ruleModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-modal id="rule-modal" v-model="ruleModal.visible" :title="ruleModal.title" @ok="ruleModal.ok"
|
||||
:confirm-loading="ruleModal.confirmLoading" :closable="true" :mask-closable="false" :ok-text="ruleModal.okText"
|
||||
cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
@@ -42,15 +44,19 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='Attributes'>
|
||||
<a-button icon="plus" size="small" :style="{ marginLeft: '10px' }" @click="ruleModal.rule.attrs.push(['', ''])"></a-button>
|
||||
<a-button icon="plus" size="small" :style="{ marginLeft: '10px' }"
|
||||
@click="ruleModal.rule.attrs.push(['', ''])"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span: 24}">
|
||||
<a-input-group compact v-for="(attr,index) in ruleModal.rule.attrs">
|
||||
<a-input :style="{ width: '50%' }" v-model="attr[0]" placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
||||
<a-input :style="{ width: '50%' }" v-model="attr[0]"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model="attr[1]" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small" @click="ruleModal.rule.attrs.splice(index,1)"></a-button>
|
||||
<a-input :style="{ width: '50%' }" v-model="attr[1]"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small"
|
||||
@click="ruleModal.rule.attrs.splice(index,1)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
@@ -196,16 +202,20 @@
|
||||
this.inboundTags = app.templateSettings.inbounds.filter((i) => !ObjectUtil.isEmpty(i.tag)).map(obj => obj.tag);
|
||||
this.inboundTags.push(...app.inboundTags);
|
||||
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)];
|
||||
if (app.templateSettings.reverse) {
|
||||
if (app.templateSettings.reverse.bridges) {
|
||||
this.inboundTags.push(...app.templateSettings.reverse.bridges.map(b => b.tag));
|
||||
}
|
||||
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(b => b.tag));
|
||||
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
|
||||
obj.tag)];
|
||||
if (app.clientReverseTags) {
|
||||
app.clientReverseTags.forEach(tag => {
|
||||
if (tag && !this.outboundTags.includes(tag)) {
|
||||
this.outboundTags.push(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.balancerTags = [""];
|
||||
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
||||
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
|
||||
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag))
|
||||
.map(obj => obj.tag)
|
||||
];
|
||||
}
|
||||
},
|
||||
close() {
|
||||
@@ -234,7 +244,8 @@
|
||||
rule.outboundTag = value.outboundTag == "" ? undefined : value.outboundTag;
|
||||
rule.balancerTag = value.balancerTag == "" ? undefined : value.balancerTag;
|
||||
for (const [key, value] of Object.entries(rule)) {
|
||||
if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0) && !(typeof value === 'object' && Object.keys(value).length === 0) && value !== '') {
|
||||
if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0) && !(
|
||||
typeof value === 'object' && Object.keys(value).length === 0) && value !== '') {
|
||||
newRule[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -249,4 +260,4 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -79,7 +79,8 @@
|
||||
</template>
|
||||
{{ template "settings/panel/subscription/general" . }}
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
|
||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable"
|
||||
:style="{ paddingTop: '20px' }">
|
||||
<template #tab>
|
||||
<a-icon type="code"></a-icon>
|
||||
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
|
||||
@@ -102,7 +103,7 @@
|
||||
{{template "component/aSidebar" .}}
|
||||
{{template "component/aThemeSwitch" .}}
|
||||
{{template "component/aSettingListItem" .}}
|
||||
{{template "modals/twoFactorModal"}}
|
||||
{{template "modals/twoFactorModal" .}}
|
||||
<script>
|
||||
const app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
@@ -124,9 +125,19 @@
|
||||
user: {},
|
||||
lang: LanguageManager.getLanguage(),
|
||||
inboundOptions: [],
|
||||
remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' },
|
||||
remarkModels: {
|
||||
i: 'Inbound',
|
||||
e: 'Email',
|
||||
o: 'Other'
|
||||
},
|
||||
remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'],
|
||||
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
||||
datepickerList: [{
|
||||
name: 'Gregorian (Standard)',
|
||||
value: 'gregorian'
|
||||
}, {
|
||||
name: 'Jalalian (شمسی)',
|
||||
value: 'jalalian'
|
||||
}],
|
||||
remarkSample: '',
|
||||
defaultFragment: {
|
||||
packets: "tlshello",
|
||||
@@ -134,17 +145,19 @@
|
||||
interval: "10-20",
|
||||
maxSplit: "300-400"
|
||||
},
|
||||
defaultNoises: [
|
||||
{ type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" }
|
||||
],
|
||||
defaultNoises: [{
|
||||
type: "rand",
|
||||
packet: "10-20",
|
||||
delay: "10-16",
|
||||
applyTo: "ip"
|
||||
}],
|
||||
defaultMux: {
|
||||
enabled: true,
|
||||
concurrency: 8,
|
||||
xudpConcurrency: 16,
|
||||
xudpProxyUDP443: "reject"
|
||||
},
|
||||
defaultRules: [
|
||||
{
|
||||
defaultRules: [{
|
||||
type: "field",
|
||||
outboundTag: "direct",
|
||||
domain: [
|
||||
@@ -160,26 +173,75 @@
|
||||
]
|
||||
},
|
||||
],
|
||||
directIPsOptions: [
|
||||
{ label: 'Private IP', value: 'geoip:private' },
|
||||
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
|
||||
{ label: '🇨🇳 China', value: 'geoip:cn' },
|
||||
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
|
||||
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
|
||||
{ label: '🇪🇸 Spain', value: 'geoip:es' },
|
||||
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
|
||||
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
|
||||
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
|
||||
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
|
||||
directIPsOptions: [{
|
||||
label: 'Private IP',
|
||||
value: 'geoip:private'
|
||||
},
|
||||
{
|
||||
label: '🇮🇷 Iran',
|
||||
value: 'geoip:ir'
|
||||
},
|
||||
{
|
||||
label: '🇨🇳 China',
|
||||
value: 'geoip:cn'
|
||||
},
|
||||
{
|
||||
label: '🇷🇺 Russia',
|
||||
value: 'geoip:ru'
|
||||
},
|
||||
{
|
||||
label: '🇻🇳 Vietnam',
|
||||
value: 'geoip:vn'
|
||||
},
|
||||
{
|
||||
label: '🇪🇸 Spain',
|
||||
value: 'geoip:es'
|
||||
},
|
||||
{
|
||||
label: '🇮🇩 Indonesia',
|
||||
value: 'geoip:id'
|
||||
},
|
||||
{
|
||||
label: '🇺🇦 Ukraine',
|
||||
value: 'geoip:ua'
|
||||
},
|
||||
{
|
||||
label: '🇹🇷 Türkiye',
|
||||
value: 'geoip:tr'
|
||||
},
|
||||
{
|
||||
label: '🇧🇷 Brazil',
|
||||
value: 'geoip:br'
|
||||
},
|
||||
],
|
||||
diretDomainsOptions: [
|
||||
{ label: 'Private DNS', value: 'geosite:private' },
|
||||
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
|
||||
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
||||
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
|
||||
{ label: 'Apple', value: 'geosite:apple' },
|
||||
{ label: 'Meta', value: 'geosite:meta' },
|
||||
{ label: 'Google', value: 'geosite:google' },
|
||||
diretDomainsOptions: [{
|
||||
label: 'Private DNS',
|
||||
value: 'geosite:private'
|
||||
},
|
||||
{
|
||||
label: '🇮🇷 Iran',
|
||||
value: 'geosite:category-ir'
|
||||
},
|
||||
{
|
||||
label: '🇨🇳 China',
|
||||
value: 'geosite:cn'
|
||||
},
|
||||
{
|
||||
label: '🇷🇺 Russia',
|
||||
value: 'geosite:category-ru'
|
||||
},
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'geosite:apple'
|
||||
},
|
||||
{
|
||||
label: 'Meta',
|
||||
value: 'geosite:meta'
|
||||
},
|
||||
{
|
||||
label: 'Google',
|
||||
value: 'geosite:google'
|
||||
},
|
||||
],
|
||||
get remarkModel() {
|
||||
rm = this.allSetting.remarkModel;
|
||||
@@ -317,7 +379,13 @@
|
||||
this.loading(true);
|
||||
await PromiseUtil.sleep(5000);
|
||||
|
||||
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = this.allSetting;
|
||||
const {
|
||||
webDomain,
|
||||
webPort,
|
||||
webBasePath,
|
||||
webCertFile,
|
||||
webKeyFile
|
||||
} = this.allSetting;
|
||||
const newProtocol = (webCertFile || webKeyFile) ? "https:" : "http:";
|
||||
|
||||
let base = webBasePath ? webBasePath.replace(/^\//, "") : "";
|
||||
@@ -358,7 +426,8 @@
|
||||
type: 'set',
|
||||
confirm: (success) => {
|
||||
if (success) {
|
||||
Vue.prototype.$message['success']('{{ i18n "pages.settings.security.twoFactorModalSetSuccess" }}')
|
||||
Vue.prototype.$message['success'](
|
||||
'{{ i18n "pages.settings.security.twoFactorModalSetSuccess" }}')
|
||||
|
||||
this.allSetting.twoFactorToken = newTwoFactorToken
|
||||
}
|
||||
@@ -374,7 +443,8 @@
|
||||
type: 'confirm',
|
||||
confirm: (success) => {
|
||||
if (success) {
|
||||
Vue.prototype.$message['success']('{{ i18n "pages.settings.security.twoFactorModalDeleteSuccess" }}')
|
||||
Vue.prototype.$message['success'](
|
||||
'{{ i18n "pages.settings.security.twoFactorModalDeleteSuccess" }}')
|
||||
|
||||
this.allSetting.twoFactorEnable = false
|
||||
this.allSetting.twoFactorToken = ""
|
||||
@@ -384,7 +454,12 @@
|
||||
}
|
||||
},
|
||||
addNoise() {
|
||||
const newNoise = { type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" };
|
||||
const newNoise = {
|
||||
type: "rand",
|
||||
packet: "10-20",
|
||||
delay: "10-16",
|
||||
applyTo: "ip"
|
||||
};
|
||||
this.noisesArray = [...this.noisesArray, newNoise];
|
||||
},
|
||||
removeNoise(index) {
|
||||
@@ -394,44 +469,60 @@
|
||||
},
|
||||
updateNoiseType(index, value) {
|
||||
const updatedNoises = [...this.noisesArray];
|
||||
updatedNoises[index] = { ...updatedNoises[index], type: value };
|
||||
updatedNoises[index] = {
|
||||
...updatedNoises[index],
|
||||
type: value
|
||||
};
|
||||
this.noisesArray = updatedNoises;
|
||||
},
|
||||
updateNoisePacket(index, value) {
|
||||
const updatedNoises = [...this.noisesArray];
|
||||
updatedNoises[index] = { ...updatedNoises[index], packet: value };
|
||||
updatedNoises[index] = {
|
||||
...updatedNoises[index],
|
||||
packet: value
|
||||
};
|
||||
this.noisesArray = updatedNoises;
|
||||
},
|
||||
updateNoiseDelay(index, value) {
|
||||
const updatedNoises = [...this.noisesArray];
|
||||
updatedNoises[index] = { ...updatedNoises[index], delay: value };
|
||||
updatedNoises[index] = {
|
||||
...updatedNoises[index],
|
||||
delay: value
|
||||
};
|
||||
this.noisesArray = updatedNoises;
|
||||
},
|
||||
updateNoiseApplyTo(index, value) {
|
||||
const updatedNoises = [...this.noisesArray];
|
||||
updatedNoises[index] = { ...updatedNoises[index], applyTo: value };
|
||||
updatedNoises[index] = {
|
||||
...updatedNoises[index],
|
||||
applyTo: value
|
||||
};
|
||||
this.noisesArray = updatedNoises;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
ldapInboundTagList: {
|
||||
get: function () {
|
||||
get: function() {
|
||||
const csv = this.allSetting.ldapInboundTags || "";
|
||||
return csv.length ? csv.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
},
|
||||
set: function (list) {
|
||||
set: function(list) {
|
||||
this.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : '';
|
||||
}
|
||||
},
|
||||
fragment: {
|
||||
get: function () { return this.allSetting?.subJsonFragment != ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.allSetting?.subJsonFragment != "";
|
||||
},
|
||||
set: function(v) {
|
||||
this.allSetting.subJsonFragment = v ? JSON.stringify(this.defaultFragment) : "";
|
||||
}
|
||||
},
|
||||
fragmentPackets: {
|
||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).packets : ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).packets : "";
|
||||
},
|
||||
set: function(v) {
|
||||
if (v != "") {
|
||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||
newFragment.packets = v;
|
||||
@@ -440,8 +531,10 @@
|
||||
}
|
||||
},
|
||||
fragmentLength: {
|
||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).length : ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).length : "";
|
||||
},
|
||||
set: function(v) {
|
||||
if (v != "") {
|
||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||
newFragment.length = v;
|
||||
@@ -450,8 +543,10 @@
|
||||
}
|
||||
},
|
||||
fragmentInterval: {
|
||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).interval : ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).interval : "";
|
||||
},
|
||||
set: function(v) {
|
||||
if (v != "") {
|
||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||
newFragment.interval = v;
|
||||
@@ -460,8 +555,10 @@
|
||||
}
|
||||
},
|
||||
fragmentMaxSplit: {
|
||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).maxSplit : ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).maxSplit : "";
|
||||
},
|
||||
set: function(v) {
|
||||
if (v != "") {
|
||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||
newFragment.maxSplit = v;
|
||||
@@ -492,50 +589,60 @@
|
||||
}
|
||||
},
|
||||
enableMux: {
|
||||
get: function () { return this.allSetting?.subJsonMux != ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.allSetting?.subJsonMux != "";
|
||||
},
|
||||
set: function(v) {
|
||||
this.allSetting.subJsonMux = v ? JSON.stringify(this.defaultMux) : "";
|
||||
}
|
||||
},
|
||||
muxConcurrency: {
|
||||
get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).concurrency : -1; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).concurrency : -1;
|
||||
},
|
||||
set: function(v) {
|
||||
newMux = JSON.parse(this.allSetting.subJsonMux);
|
||||
newMux.concurrency = v;
|
||||
this.allSetting.subJsonMux = JSON.stringify(newMux);
|
||||
}
|
||||
},
|
||||
muxXudpConcurrency: {
|
||||
get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpConcurrency : -1; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpConcurrency : -1;
|
||||
},
|
||||
set: function(v) {
|
||||
newMux = JSON.parse(this.allSetting.subJsonMux);
|
||||
newMux.xudpConcurrency = v;
|
||||
this.allSetting.subJsonMux = JSON.stringify(newMux);
|
||||
}
|
||||
},
|
||||
muxXudpProxyUDP443: {
|
||||
get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpProxyUDP443 : "reject"; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpProxyUDP443 : "reject";
|
||||
},
|
||||
set: function(v) {
|
||||
newMux = JSON.parse(this.allSetting.subJsonMux);
|
||||
newMux.xudpProxyUDP443 = v;
|
||||
this.allSetting.subJsonMux = JSON.stringify(newMux);
|
||||
}
|
||||
},
|
||||
enableDirect: {
|
||||
get: function () { return this.allSetting?.subJsonRules != ""; },
|
||||
set: function (v) {
|
||||
get: function() {
|
||||
return this.allSetting?.subJsonRules != "";
|
||||
},
|
||||
set: function(v) {
|
||||
this.allSetting.subJsonRules = v ? JSON.stringify(this.defaultRules) : "";
|
||||
}
|
||||
},
|
||||
directIPs: {
|
||||
get: function () {
|
||||
get: function() {
|
||||
if (!this.enableDirect) return [];
|
||||
const rules = JSON.parse(this.allSetting.subJsonRules);
|
||||
if (!Array.isArray(rules)) return [];
|
||||
const ipRule = rules.find(r => r.ip);
|
||||
return ipRule?.ip ?? [];
|
||||
},
|
||||
set: function (v) {
|
||||
set: function(v) {
|
||||
let rules = JSON.parse(this.allSetting.subJsonRules);
|
||||
if (!Array.isArray(rules)) return;
|
||||
|
||||
@@ -554,14 +661,14 @@
|
||||
}
|
||||
},
|
||||
directDomains: {
|
||||
get: function () {
|
||||
get: function() {
|
||||
if (!this.enableDirect) return [];
|
||||
const rules = JSON.parse(this.allSetting.subJsonRules);
|
||||
if (!Array.isArray(rules)) return [];
|
||||
const domainRule = rules.find(r => r.domain);
|
||||
return domainRule?.domain ?? [];
|
||||
},
|
||||
set: function (v) {
|
||||
set: function(v) {
|
||||
let rules = JSON.parse(this.allSetting.subJsonRules);
|
||||
if (!Array.isArray(rules)) return;
|
||||
if (v.length == 0) {
|
||||
@@ -576,7 +683,7 @@
|
||||
}
|
||||
},
|
||||
confAlerts: {
|
||||
get: function () {
|
||||
get: function() {
|
||||
if (!this.allSetting) return [];
|
||||
var alerts = []
|
||||
if (window.location.protocol !== "https:") alerts.push('{{ i18n "secAlertSSL" }}');
|
||||
@@ -584,11 +691,13 @@
|
||||
panelPath = window.location.pathname.split('/').length < 4
|
||||
if (panelPath && this.allSetting.webBasePath == '/') alerts.push('{{ i18n "secAlertPanelURI" }}');
|
||||
if (this.allSetting.subEnable) {
|
||||
subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath;
|
||||
subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this
|
||||
.allSetting.subPath;
|
||||
if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}');
|
||||
}
|
||||
if (this.allSetting.subJsonEnable) {
|
||||
subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath;
|
||||
subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname :
|
||||
this.allSetting.subJsonPath;
|
||||
if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}');
|
||||
}
|
||||
return alerts
|
||||
|
||||
@@ -124,6 +124,13 @@
|
||||
v-model="allSetting.externalTrafficInformURI"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.restartXrayOnClientDisable"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.restartXrayOnClientDisableDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.restartXrayOnClientDisable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="5" header='{{ i18n "pages.settings.dateAndTime" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
@@ -162,7 +169,8 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>LDAP Port</template>
|
||||
<template #control>
|
||||
<a-input-number :min="1" :max="65535" v-model="allSetting.ldapPort" :style="{ width: '100%' }"></a-input-number>
|
||||
<a-input-number :min="1" :max="65535" v-model="allSetting.ldapPort"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
@@ -239,10 +247,13 @@
|
||||
<template #title>Inbound tags</template>
|
||||
<template #description>Select inbounds to manage (auto create/delete)</template>
|
||||
<template #control>
|
||||
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }" v-model="ldapInboundTagList">
|
||||
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">[[ opt.label ]]</a-select-option>
|
||||
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }"
|
||||
v-model="ldapInboundTagList">
|
||||
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">[[ opt.label
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
<div v-if="inboundOptions.length==0" style="margin-top:6px;color:#999">No inbounds found. Please create one in Inbounds.</div>
|
||||
<div v-if="inboundOptions.length==0" style="margin-top:6px;color:#999">No inbounds found. Please create
|
||||
one in Inbounds.</div>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
@@ -260,19 +271,22 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>Default total (GB)</template>
|
||||
<template #control>
|
||||
<a-input-number :min="0" v-model="allSetting.ldapDefaultTotalGB" :style="{ width: '100%' }"></a-input-number>
|
||||
<a-input-number :min="0" v-model="allSetting.ldapDefaultTotalGB"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>Default expiry (days)</template>
|
||||
<template #control>
|
||||
<a-input-number :min="0" v-model="allSetting.ldapDefaultExpiryDays" :style="{ width: '100%' }"></a-input-number>
|
||||
<a-input-number :min="0" v-model="allSetting.ldapDefaultExpiryDays"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>Default Limit IP</template>
|
||||
<template #control>
|
||||
<a-input-number :min="0" v-model="allSetting.ldapDefaultLimitIP" :style="{ width: '100%' }"></a-input-number>
|
||||
<a-input-number :min="0" v-model="allSetting.ldapDefaultLimitIP"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subPortDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model="allSetting.subPort" :min="1"
|
||||
:min="65535"
|
||||
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
@@ -67,8 +66,7 @@
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subURIDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text"
|
||||
placeholder="(http|https)://domain[:port]/path/"
|
||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||
v-model="allSetting.subURI"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
@@ -104,8 +102,7 @@
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subSupportUrlDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subSupportUrl"
|
||||
placeholder="https://example.com"></a-input>
|
||||
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
@@ -113,8 +110,7 @@
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subProfileUrlDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subProfileUrl"
|
||||
placeholder="https://example.com"></a-input>
|
||||
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
@@ -141,8 +137,7 @@
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subRoutingRulesDesc"}}</template>
|
||||
<template #control>
|
||||
<a-textarea v-model="allSetting.subRoutingRules"
|
||||
placeholder="happ://routing/add/..."></a-textarea>
|
||||
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
@@ -170,8 +165,7 @@
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subUpdatesDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input-number :min="1" v-model="allSetting.subUpdates"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||
<style>
|
||||
.subscription-page tr-qr-box.qr-box {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.subscription-page tr-qr-box.qr-box .qr-tag {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.subscription-page tr-qr-box.qr-box .qr-bg,
|
||||
.subscription-page tr-qr-box.qr-box .qr-bg-sub {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.subscription-page .subscription-link-box {
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
@@ -83,7 +100,8 @@
|
||||
<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="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24"
|
||||
style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
@@ -135,7 +153,10 @@
|
||||
app.sId
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item label='{{ i18n "subscription.status" }}'>
|
||||
<template v-if="isUnlimited">
|
||||
<template v-if="!app.enabled">
|
||||
<a-tag color="red">{{ i18n "subscription.inactive" }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="isUnlimited">
|
||||
<a-tag color="purple">{{ i18n
|
||||
"subscription.unlimited" }}</a-tag>
|
||||
</template>
|
||||
@@ -193,8 +214,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<a-form layout="vertical">
|
||||
@@ -255,11 +274,11 @@
|
||||
</a-layout>
|
||||
|
||||
<!-- Bootstrap data for external JS -->
|
||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
|
||||
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>
|
||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
||||
data-subclash-url="{{ .subClashUrl }}" 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 }}" data-enabled="{{ .enabled }}"></template>
|
||||
<textarea id="subscription-links" style="display:none">{{ range .result }}{{ . }}
|
||||
{{ end }}</textarea>
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<span>{{ i18n "pages.xray.balancer.addBalancer"}}</span>
|
||||
</a-button>
|
||||
<a-table :columns="balancerColumns" bordered :row-key="r => r.key" :data-source="balancersData"
|
||||
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0" :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0"
|
||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||
<template slot="action" slot-scope="text, balancer, index">
|
||||
<span>[[ index+1 ]]</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
@@ -18,7 +19,7 @@
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteBalancer(index)">
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete"></a-icon>
|
||||
<a-icon type="delete"></a-icon>
|
||||
<span>{{ i18n "delete"}}</span>
|
||||
</span>
|
||||
</a-menu-item>
|
||||
@@ -32,7 +33,8 @@
|
||||
<a-tag :style="{ margin: '0' }" v-if="balancer.strategy=='leastPing'" color="green">Least Ping</a-tag>
|
||||
</template>
|
||||
<template slot="selector" slot-scope="text, balancer, index">
|
||||
<a-tag class="info-large-tag" :style="{ margin: '1' }" v-for="sel in balancer.selector">[[ sel ]]</a-tag>
|
||||
<a-tag class="info-large-tag" :style="{ margin: '1' }" v-for="sel in balancer.selector">[[ sel
|
||||
]]</a-tag>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-radio-group v-if="observatoryEnable || burstObservatoryEnable" v-model="obsSettings" @change="changeObsCode"
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.generalConfigsDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
@@ -15,11 +14,9 @@
|
||||
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="freedomStrategy"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="freedomStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in OutboundDomainStrategies"
|
||||
:value="s">
|
||||
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -30,11 +27,9 @@
|
||||
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="routingStrategy"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="routingStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in routingDomainStrategies"
|
||||
:value="s">
|
||||
<a-select-option v-for="s in routingDomainStrategies" :value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -45,8 +40,7 @@
|
||||
<template #description>{{ i18n "pages.xray.outboundTestUrlDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-input v-model="outboundTestUrl"
|
||||
:placeholder="'https://www.google.com/generate_204'"
|
||||
<a-input v-model="outboundTestUrl" :placeholder="'https://www.google.com/generate_204'"
|
||||
:style="{ width: '100%' }"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
@@ -93,8 +87,7 @@
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.logConfigsDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
@@ -104,8 +97,7 @@
|
||||
<template #description>{{ i18n "pages.xray.logLevelDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="logLevel"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="logLevel" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in log.loglevel" :value="s">
|
||||
<span>[[ s ]]</span>
|
||||
@@ -118,8 +110,7 @@
|
||||
<template #description>{{ i18n "pages.xray.accessLogDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="accessLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="accessLog" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
@@ -135,8 +126,7 @@
|
||||
<template #description>{{ i18n "pages.xray.errorLogDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="errorLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="errorLog" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
@@ -152,8 +142,7 @@
|
||||
<template #description>{{ i18n "pages.xray.maskAddressDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="maskAddressLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="maskAddressLog" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
@@ -176,8 +165,7 @@
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConfigsDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
@@ -191,8 +179,7 @@
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
@@ -201,11 +188,9 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.blockips" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" v-model="blockedIPs"
|
||||
:style="{ width: '100%' }"
|
||||
<a-select mode="tags" v-model="blockedIPs" :style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.IPsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -214,22 +199,18 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.blockdomains" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" v-model="blockedDomains"
|
||||
:style="{ width: '100%' }"
|
||||
<a-select mode="tags" v-model="blockedDomains" :style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.BlockDomainsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.BlockDomainsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning"
|
||||
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
@@ -238,11 +219,9 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.directips" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="directIPs"
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="directIPs"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.IPsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -251,22 +230,18 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.directdomains" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="directDomains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="directDomains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.DomainsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.DomainsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning"
|
||||
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
@@ -274,22 +249,18 @@
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="ipv4Domains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="ipv4Domains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.ServicesOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning"
|
||||
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
{{ i18n "pages.xray.warpRoutingDesc" }}
|
||||
</template>
|
||||
</a-alert>
|
||||
@@ -298,18 +269,15 @@
|
||||
<template #title>{{ i18n "pages.xray.warpRouting" }}</template>
|
||||
<template #control>
|
||||
<template v-if="WarpExist">
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="warpDomains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="warpDomains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.ServicesOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="primary" icon="cloud"
|
||||
@click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
@@ -317,11 +285,9 @@
|
||||
<template #title>{{ i18n "pages.xray.nordRouting" }}</template>
|
||||
<template #control>
|
||||
<template v-if="NordExist">
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="nordDomains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="nordDomains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.ServicesOptions">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -333,8 +299,7 @@
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="6"
|
||||
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||
<a-collapse-panel key="6" header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
||||
<a-button type="danger" @click="resetXrayConfigToDefault">
|
||||
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
<template #control>
|
||||
<a-select v-model="dnsStrategy" :style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']">
|
||||
<a-select-option :value="l" :label="l"
|
||||
v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']">
|
||||
<span>[[ l ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
@@ -1,129 +1,159 @@
|
||||
{{define "settings/xray/outbounds"}}
|
||||
<a-space direction="vertical" size="middle">
|
||||
<a-row>
|
||||
<a-col :xs="12" :sm="12" :lg="12">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<a-space direction="vertical" size="middle" class="outbounds-modern">
|
||||
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
|
||||
<a-col :xs="24" :sm="14" :lg="14">
|
||||
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
|
||||
<a-button type="primary" icon="plus" @click="addOutbound">
|
||||
<span v-if="!isMobile">{{ i18n
|
||||
"pages.xray.outbound.addOutbound" }}</span>
|
||||
</a-button>
|
||||
<a-button type="primary" icon="cloud"
|
||||
@click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="api"
|
||||
@click="showNord()">NordVPN</a-button>
|
||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
||||
<a-col :xs="24" :sm="10" :lg="10" class="outbounds-toolbar-right">
|
||||
<a-button-group>
|
||||
<a-button icon="sync" @click="refreshOutboundTraffic()"
|
||||
:loading="refreshing"></a-button>
|
||||
<a-popconfirm placement="topRight"
|
||||
@confirm="resetOutboundTraffic(-1)"
|
||||
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
|
||||
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
|
||||
title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
ok-text='{{ i18n "reset"}}'
|
||||
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}'
|
||||
cancel-text='{{ i18n "cancel"}}'>
|
||||
<a-icon slot="icon" type="question-circle-o"
|
||||
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
|
||||
:style="{ color: '#008771' }"></a-icon>
|
||||
<a-button icon="retweet"></a-button>
|
||||
</a-popconfirm>
|
||||
</a-button-group>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key"
|
||||
<a-table :columns="outboundColumns" :row-key="r => r.key"
|
||||
:data-source="outboundData"
|
||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
|
||||
:scroll="isMobile ? { x: 720 } : {}"
|
||||
:pagination="false"
|
||||
:indent-size="0"
|
||||
class="outbounds-table"
|
||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||
<template slot="action" slot-scope="text, outbound, index">
|
||||
<span>[[ index+1 ]]</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-icon @click="e => e.preventDefault()" type="more"
|
||||
:style="{ fontSize: '16px', textDecoration: '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>
|
||||
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="editOutbound(index)">
|
||||
<a-icon type="edit"></a-icon>
|
||||
<span>{{ i18n "edit" }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="resetOutboundTraffic(index)">
|
||||
<span>
|
||||
<div class="outbound-action-cell">
|
||||
<span class="outbound-index">[[ index+1 ]]</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small" class="outbound-action-btn"
|
||||
@click="e => e.preventDefault()">
|
||||
<a-icon type="more"></a-icon>
|
||||
</a-button>
|
||||
<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>
|
||||
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="editOutbound(index)">
|
||||
<a-icon type="edit"></a-icon>
|
||||
<span>{{ i18n "edit" }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="resetOutboundTraffic(index)">
|
||||
<a-icon type="retweet"></a-icon>
|
||||
<span>{{ i18n "pages.inbounds.resetTraffic"}}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteOutbound(index)">
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete"></a-icon>
|
||||
<span>{{ i18n "delete"}}</span>
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="identity" slot-scope="text, outbound">
|
||||
<div class="outbound-identity-cell">
|
||||
<a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span class="outbound-tag">[[ outbound.tag ]]</span>
|
||||
</a-tooltip>
|
||||
<div class="outbound-protocol-cell">
|
||||
<span class="outbound-pill"
|
||||
:class="outboundProtocolTone(outbound.protocol)">
|
||||
[[ outbound.protocol ]]
|
||||
</span>
|
||||
<template
|
||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
||||
<span class="outbound-pill"
|
||||
:class="outboundNetworkTone(outbound.streamSettings.network)">
|
||||
[[ outbound.streamSettings.network ]]
|
||||
</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteOutbound(index)">
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete"></a-icon>
|
||||
<span>{{ i18n "delete"}}</span>
|
||||
<span class="outbound-pill"
|
||||
:class="outboundSecurityTone(outbound.streamSettings.security)"
|
||||
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
|
||||
[[ outbound.streamSettings.security ]]
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="address" slot-scope="text, outbound, index">
|
||||
<p :style="{ margin: '0 5px' }"
|
||||
v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
|
||||
<template slot="address" slot-scope="text, outbound">
|
||||
<div class="outbound-address-list">
|
||||
<a-tooltip
|
||||
v-for="addr in outboundAddresses(outbound)"
|
||||
:key="addr"
|
||||
:title="addr"
|
||||
:overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span class="outbound-address-pill">[[ addr ]]</span>
|
||||
</a-tooltip>
|
||||
<span class="outbound-address-empty"
|
||||
v-if="outboundAddresses(outbound).length === 0">—</span>
|
||||
</div>
|
||||
</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>
|
||||
<template slot="traffic" slot-scope="text, outbound, index">
|
||||
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
|
||||
<template slot="traffic" slot-scope="text, outbound">
|
||||
<div class="outbound-traffic-cell">
|
||||
<span class="outbound-traffic-up" :title='`{{ i18n "pages.index.upload" }}`'>
|
||||
<a-icon type="arrow-up"></a-icon>
|
||||
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
|
||||
</span>
|
||||
<span class="outbound-traffic-sep" aria-hidden="true"></span>
|
||||
<span class="outbound-traffic-down" :title='`{{ i18n "pages.index.download" }}`'>
|
||||
<a-icon type="arrow-down"></a-icon>
|
||||
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="test" slot-scope="text, outbound, index">
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.xray.outbound.test"
|
||||
}}</template>
|
||||
<template slot="title">{{ i18n "pages.xray.outbound.test" }}</template>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon="thunderbolt"
|
||||
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
|
||||
class="outbound-test-btn"
|
||||
:loading="isOutboundTesting(index)"
|
||||
@click="testOutbound(index)"
|
||||
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
|
||||
:disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template slot="testResult" slot-scope="text, outbound, index">
|
||||
<div
|
||||
v-if="outboundTestStates[index] && outboundTestStates[index].result">
|
||||
<a-tag v-if="outboundTestStates[index].result.success"
|
||||
color="green">
|
||||
[[ outboundTestStates[index].result.delay ]]ms
|
||||
<span v-if="outboundTestStates[index].result.statusCode">
|
||||
([[ outboundTestStates[index].result.statusCode
|
||||
]])</span>
|
||||
</a-tag>
|
||||
<div class="outbound-result-cell" v-if="outboundTestResult(index)">
|
||||
<span v-if="outboundTestResult(index).success"
|
||||
class="outbound-result-pill outbound-result-ok">
|
||||
<a-icon type="check-circle" theme="filled"></a-icon>
|
||||
[[ outboundTestResult(index).delay ]] ms
|
||||
<span class="outbound-result-status"
|
||||
v-if="outboundTestResult(index).statusCode">
|
||||
· [[ outboundTestResult(index).statusCode ]]
|
||||
</span>
|
||||
</span>
|
||||
<a-tooltip v-else
|
||||
:title="outboundTestStates[index].result.error">
|
||||
<a-tag color="red">
|
||||
Failed
|
||||
</a-tag>
|
||||
:title="outboundTestResult(index).error"
|
||||
:overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span class="outbound-result-pill outbound-result-fail">
|
||||
<a-icon type="close-circle" theme="filled"></a-icon>
|
||||
{{ i18n "pages.xray.outbound.testFailed" }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
|
||||
<a-icon type="loading" />
|
||||
<span class="outbound-result-loading" v-else-if="isOutboundTesting(index)">
|
||||
<a-icon type="loading"></a-icon>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
<span class="outbound-result-idle" v-else>—</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{{define "settings/xray/reverse"}}
|
||||
<template v-if="reverseData.length > 0">
|
||||
<a-space direction="vertical" size="middle">
|
||||
<a-button type="primary" icon="plus" @click="addReverse()">
|
||||
<span>{{ i18n "pages.xray.outbound.addReverse" }}</span>
|
||||
</a-button>
|
||||
<a-table :columns="reverseColumns" bordered :row-key="r => r.key" :data-source="reverseData"
|
||||
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0"
|
||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||
<template slot="action" slot-scope="text, reverse, index">
|
||||
<span>[[ index+1 ]]</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-icon @click="e => e.preventDefault()" type="more"
|
||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||
<a-menu-item @click="editReverse(index)">
|
||||
<a-icon type="edit"></a-icon>
|
||||
<span>{{ i18n "edit" }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteReverse(index)">
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete"></a-icon>
|
||||
<span>{{ i18n "delete"}}</span>
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-empty description='{{ i18n "emptyReverseDesc" }}' :style="{ margin: '10px' }">
|
||||
<a-button type="primary" icon="plus" @click="addReverse()" :style="{ marginTop: '10px' }">
|
||||
{{ i18n "pages.xray.outbound.addReverse" }}
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</template>
|
||||
{{end}}
|
||||
@@ -1,123 +1,193 @@
|
||||
{{define "settings/xray/routing"}}
|
||||
<a-space direction="vertical" size="middle">
|
||||
<a-button type="primary" icon="plus" @click="addRule">{{ i18n "pages.xray.rules.add" }}</a-button>
|
||||
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered :row-key="r => r.key"
|
||||
:data-source="routingRuleData" :scroll="isMobile ? {} : { x: 1000 }" :pagination="false" :indent-size="0"
|
||||
<a-space direction="vertical" size="middle" class="routing-modern">
|
||||
<a-button type="primary" icon="plus" @click="addRule">{{ i18n
|
||||
"pages.xray.rules.add" }}</a-button>
|
||||
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns"
|
||||
:row-key="r => r.key"
|
||||
:data-source="routingRuleData"
|
||||
:scroll="{}"
|
||||
:pagination="false"
|
||||
:indent-size="0"
|
||||
class="routing-table"
|
||||
v-on:onSort="replaceRule">
|
||||
<template slot="action" slot-scope="text, rule, index">
|
||||
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
|
||||
<span class="ant-table-row-index"> [[ index+1 ]] </span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-icon @click="e => e.preventDefault()" type="more"
|
||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||
<a-menu-item v-if="index>0" @click="replaceRule(index,0)">
|
||||
<a-icon type="vertical-align-top"></a-icon>
|
||||
{{ i18n "pages.xray.rules.first"}}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="index>0" @click="replaceRule(index,index-1)">
|
||||
<a-icon type="arrow-up"></a-icon>
|
||||
{{ i18n "pages.xray.rules.up"}}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="index<routingRuleData.length-1" @click="replaceRule(index,index+1)">
|
||||
<a-icon type="arrow-down"></a-icon>
|
||||
{{ i18n "pages.xray.rules.down"}}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="index<routingRuleData.length-1"
|
||||
@click="replaceRule(index,routingRuleData.length-1)">
|
||||
<a-icon type="vertical-align-bottom"></a-icon>
|
||||
{{ i18n "pages.xray.rules.last"}}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="editRule(index)">
|
||||
<a-icon type="edit"></a-icon>
|
||||
{{ i18n "edit" }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteRule(index)">
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
<div class="routing-action-cell">
|
||||
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
|
||||
<span class="routing-index">[[ index+1 ]]</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small" class="routing-action-btn"
|
||||
@click="e => e.preventDefault()">
|
||||
<a-icon type="more"></a-icon>
|
||||
</a-button>
|
||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||
<a-menu-item @click="editRule(index)">
|
||||
<a-icon type="edit"></a-icon>
|
||||
<span>{{ i18n "edit" }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteRule(index)">
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete"></a-icon>
|
||||
<span>{{ i18n "delete" }}</span>
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="inbound" slot-scope="text, rule, index">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<p v-if="rule.inboundTag">Inbound Tag: [[ rule.inboundTag ]]</p>
|
||||
<p v-if="rule.user">User email: [[ rule.user ]]</p>
|
||||
</template>
|
||||
[[ [rule.inboundTag,rule.user].join('\n') ]]
|
||||
</a-popover>
|
||||
|
||||
<template slot="source" slot-scope="text, rule">
|
||||
<div class="criterion-flow">
|
||||
<a-tooltip v-if="rule.sourceIP"
|
||||
:title="'Source IP: ' + joinCsv(rule.sourceIP)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">IP</span>
|
||||
<span class="criterion-value">[[ csv(rule.sourceIP)[0] ]]</span>
|
||||
<span v-if="csv(rule.sourceIP).length > 1" class="criterion-more">+[[ csv(rule.sourceIP).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.sourcePort"
|
||||
:title="'Source Port: ' + joinCsv(rule.sourcePort)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Port</span>
|
||||
<span class="criterion-value">[[ csv(rule.sourcePort)[0] ]]</span>
|
||||
<span v-if="csv(rule.sourcePort).length > 1" class="criterion-more">+[[ csv(rule.sourcePort).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.vlessRoute"
|
||||
:title="'VLESS Route: ' + joinCsv(rule.vlessRoute)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">VLESS</span>
|
||||
<span class="criterion-value">[[ csv(rule.vlessRoute)[0] ]]</span>
|
||||
<span v-if="csv(rule.vlessRoute).length > 1" class="criterion-more">+[[ csv(rule.vlessRoute).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span class="routing-criteria-empty"
|
||||
v-if="!rule.sourceIP && !rule.sourcePort && !rule.vlessRoute">—</span>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="outbound" slot-scope="text, rule, index">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<p v-if="rule.outboundTag">Outbound Tag: [[ rule.outboundTag ]]</p>
|
||||
</template>
|
||||
[[ rule.outboundTag ]]
|
||||
</a-popover>
|
||||
|
||||
<template slot="network" slot-scope="text, rule">
|
||||
<div class="criterion-flow">
|
||||
<a-tooltip v-if="rule.network"
|
||||
:title="'L4: ' + joinCsv(rule.network)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">L4</span>
|
||||
<span class="criterion-value">[[ csv(rule.network)[0] ]]</span>
|
||||
<span v-if="csv(rule.network).length > 1" class="criterion-more">+[[ csv(rule.network).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.protocol"
|
||||
:title="'Protocol: ' + joinCsv(rule.protocol)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Protocol</span>
|
||||
<span class="criterion-value">[[ csv(rule.protocol)[0] ]]</span>
|
||||
<span v-if="csv(rule.protocol).length > 1" class="criterion-more">+[[ csv(rule.protocol).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.attrs"
|
||||
:title="'Attrs: ' + joinCsv(rule.attrs)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Attrs</span>
|
||||
<span class="criterion-value">[[ csv(rule.attrs)[0] ]]</span>
|
||||
<span v-if="csv(rule.attrs).length > 1" class="criterion-more">+[[ csv(rule.attrs).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span class="routing-criteria-empty"
|
||||
v-if="!rule.network && !rule.protocol && !rule.attrs">—</span>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="balancer" slot-scope="text, rule, index">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<p v-if="rule.balancerTag">Balancer Tag: [[ rule.balancerTag ]]</p>
|
||||
</template>
|
||||
[[ rule.balancerTag ]]
|
||||
</a-popover>
|
||||
|
||||
<template slot="destination" slot-scope="text, rule">
|
||||
<div class="criterion-flow">
|
||||
<a-tooltip v-if="rule.ip"
|
||||
:title="'Destination IP: ' + joinCsv(rule.ip)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">IP</span>
|
||||
<span class="criterion-value">[[ csv(rule.ip)[0] ]]</span>
|
||||
<span v-if="csv(rule.ip).length > 1" class="criterion-more">+[[ csv(rule.ip).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.domain"
|
||||
:title="'Domain: ' + joinCsv(rule.domain)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Domain</span>
|
||||
<span class="criterion-value">[[ csv(rule.domain)[0] ]]</span>
|
||||
<span v-if="csv(rule.domain).length > 1" class="criterion-more">+[[ csv(rule.domain).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.port"
|
||||
:title="'Destination Port: ' + joinCsv(rule.port)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Port</span>
|
||||
<span class="criterion-value">[[ csv(rule.port)[0] ]]</span>
|
||||
<span v-if="csv(rule.port).length > 1" class="criterion-more">+[[ csv(rule.port).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span class="routing-criteria-empty"
|
||||
v-if="!rule.ip && !rule.domain && !rule.port">—</span>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="info" slot-scope="text, rule, index">
|
||||
<a-popover placement="bottomRight"
|
||||
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.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>
|
||||
</tr>
|
||||
<tr v-if="rule.protocol">
|
||||
<td>Protocol</td>
|
||||
<td><a-tag color="green" v-for="r in rule.protocol.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.attrs">
|
||||
<td>Attrs</td>
|
||||
<td><a-tag color="blue" v-for="r in rule.attrs.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.ip">
|
||||
<td>IP</td>
|
||||
<td><a-tag color="green" v-for="r in rule.ip.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.domain">
|
||||
<td>Domain</td>
|
||||
<td><a-tag color="blue" v-for="r in rule.domain.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.port">
|
||||
<td>Port</td>
|
||||
<td><a-tag color="green" v-for="r in rule.port.split(',')">[[ r ]]</a-tag></td>
|
||||
</tr>
|
||||
<tr v-if="rule.balancerTag">
|
||||
<td>Balancer Tag</td>
|
||||
<td><a-tag color="blue">[[ rule.balancerTag ]]</a-tag></td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
|
||||
<a-icon type="info"></a-icon>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
|
||||
<template slot="inbound" slot-scope="text, rule">
|
||||
<div class="criterion-flow">
|
||||
<a-tooltip v-if="rule.inboundTag"
|
||||
:title="'Inbound Tag: ' + joinCsv(rule.inboundTag)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Tag</span>
|
||||
<span class="criterion-value">[[ csv(rule.inboundTag)[0] ]]</span>
|
||||
<span v-if="csv(rule.inboundTag).length > 1" class="criterion-more">+[[ csv(rule.inboundTag).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="rule.user"
|
||||
:title="'Client: ' + joinCsv(rule.user)"
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
:trigger="['hover', 'click']">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">User</span>
|
||||
<span class="criterion-value">[[ csv(rule.user)[0] ]]</span>
|
||||
<span v-if="csv(rule.user).length > 1" class="criterion-more">+[[ csv(rule.user).length - 1 ]]</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span class="routing-criteria-empty"
|
||||
v-if="!rule.inboundTag && !rule.user">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template slot="target" slot-scope="text, rule">
|
||||
<div class="routing-target-cell">
|
||||
<div class="routing-target-row" v-if="rule.outboundTag">
|
||||
<a-icon type="export" class="routing-target-icon"></a-icon>
|
||||
<span class="outbound-pill tone-emerald">[[ rule.outboundTag ]]</span>
|
||||
</div>
|
||||
<div class="routing-target-row" v-if="rule.balancerTag">
|
||||
<a-icon type="cluster" class="routing-target-icon"></a-icon>
|
||||
<span class="outbound-pill tone-violet">[[ rule.balancerTag ]]</span>
|
||||
</div>
|
||||
<span class="routing-criteria-empty"
|
||||
v-if="!rule.outboundTag && !rule.balancerTag">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</a-table-sortable>
|
||||
</a-space>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
1503
web/html/xray.html
1503
web/html/xray.html
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,25 @@ var job *CheckClientIpJob
|
||||
|
||||
const defaultXrayAPIPort = 62789
|
||||
|
||||
// ipStaleAfterSeconds controls how long a client IP kept in the
|
||||
// per-client tracking table (model.InboundClientIps.Ips) is considered
|
||||
// still "active" before it's evicted during the next scan.
|
||||
//
|
||||
// Without this eviction, an IP that connected once and then went away
|
||||
// keeps sitting in the table with its old timestamp. Because the
|
||||
// excess-IP selector sorts ascending ("oldest wins, newest loses") to
|
||||
// protect the original/current connections, that stale entry keeps
|
||||
// occupying a slot and the IP that is *actually* currently using the
|
||||
// config gets classified as "new excess" and banned by fail2ban on
|
||||
// every single run — producing the continuous ban loop from #4077.
|
||||
//
|
||||
// 30 minutes is chosen so an actively-streaming client (where xray
|
||||
// emits a fresh `accepted` log line whenever it opens a new TCP) will
|
||||
// always refresh its timestamp well within the window, but a client
|
||||
// that has really stopped using the config will drop out of the table
|
||||
// in a bounded time and free its slot.
|
||||
const ipStaleAfterSeconds = int64(30 * 60)
|
||||
|
||||
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
||||
func NewCheckClientIpJob() *CheckClientIpJob {
|
||||
job = new(CheckClientIpJob)
|
||||
@@ -180,6 +199,9 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
||||
inboundClientIps[email][ip] = timestamp
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
j.checkError(err)
|
||||
}
|
||||
|
||||
shouldCleanLog := false
|
||||
for email, ipTimestamps := range inboundClientIps {
|
||||
@@ -202,6 +224,62 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
||||
return shouldCleanLog
|
||||
}
|
||||
|
||||
// mergeClientIps combines the persisted (old) and freshly observed (new)
|
||||
// IP-with-timestamp lists for a single client into a map. An entry is
|
||||
// dropped if its last-seen timestamp is older than staleCutoff.
|
||||
//
|
||||
// Extracted as a helper so updateInboundClientIps can stay DB-oriented
|
||||
// and the merge policy can be exercised by a unit test.
|
||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
|
||||
ipMap := make(map[string]int64, len(old)+len(new))
|
||||
for _, ipTime := range old {
|
||||
if ipTime.Timestamp < staleCutoff {
|
||||
continue
|
||||
}
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
for _, ipTime := range new {
|
||||
if ipTime.Timestamp < staleCutoff {
|
||||
continue
|
||||
}
|
||||
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
}
|
||||
return ipMap
|
||||
}
|
||||
|
||||
// partitionLiveIps splits the merged ip map into live (seen in the
|
||||
// current scan) and historical (only in the db blob, still inside the
|
||||
// staleness window).
|
||||
//
|
||||
// only live ips count toward the per-client limit. historical ones stay
|
||||
// in the db so the panel keeps showing them, but they must not take a
|
||||
// protected slot. the 30min cutoff alone isn't tight enough: an ip that
|
||||
// stopped connecting a few minutes ago still looks fresh to
|
||||
// mergeClientIps, and since the over-limit picker sorts ascending and
|
||||
// keeps the oldest, those idle entries used to win the slot while the
|
||||
// ip actually connecting got classified as excess and sent to fail2ban
|
||||
// every tick. see #4077 / #4091.
|
||||
//
|
||||
// live is sorted ascending so the "protect original, ban newcomer"
|
||||
// rule still holds when several ips are really connecting at once.
|
||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||
for ip, ts := range ipMap {
|
||||
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
||||
if observedThisScan[ip] {
|
||||
live = append(live, entry)
|
||||
} else {
|
||||
historical = append(historical, entry)
|
||||
}
|
||||
}
|
||||
sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
|
||||
sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
|
||||
return live, historical
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
|
||||
cmd := "fail2ban-client"
|
||||
args := []string{"-h"}
|
||||
@@ -310,26 +388,17 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||
}
|
||||
|
||||
// Merge old and new IPs, keeping the latest timestamp for each IP
|
||||
ipMap := make(map[string]int64)
|
||||
for _, ipTime := range oldIpsWithTime {
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
for _, ipTime := range newIpsWithTime {
|
||||
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
}
|
||||
// Merge old and new IPs, evicting entries that haven't been
|
||||
// re-observed in a while. See mergeClientIps / #4077 for why.
|
||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
|
||||
|
||||
// Convert back to slice and sort by timestamp (oldest first)
|
||||
// This ensures we always protect the original/current connections and ban new excess ones.
|
||||
allIps := make([]IPWithTimestamp, 0, len(ipMap))
|
||||
for ip, timestamp := range ipMap {
|
||||
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||
// only ips seen in this scan count toward the limit. see
|
||||
// partitionLiveIps.
|
||||
observedThisScan := make(map[string]bool, len(newIpsWithTime))
|
||||
for _, ipTime := range newIpsWithTime {
|
||||
observedThisScan[ipTime.IP] = true
|
||||
}
|
||||
sort.Slice(allIps, func(i, j int) bool {
|
||||
return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first)
|
||||
})
|
||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||
|
||||
shouldCleanLog := false
|
||||
j.disAllowedIps = []string{}
|
||||
@@ -344,35 +413,39 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
log.SetOutput(logIpFile)
|
||||
log.SetFlags(log.LstdFlags)
|
||||
|
||||
// Check if we exceed the limit
|
||||
if len(allIps) > limitIp {
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
var keptLive []IPWithTimestamp
|
||||
if len(liveIps) > limitIp {
|
||||
shouldCleanLog = true
|
||||
|
||||
// Keep the oldest IPs (currently active connections) and ban the new excess ones.
|
||||
keptIps := allIps[:limitIp]
|
||||
bannedIps := allIps[limitIp:]
|
||||
// protect the oldest live ip, ban newcomers.
|
||||
keptLive = liveIps[:limitIp]
|
||||
bannedLive := liveIps[limitIp:]
|
||||
|
||||
// Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
|
||||
for _, ipTime := range bannedIps {
|
||||
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
|
||||
// filter.d/3x-ipl.conf with
|
||||
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
// don't change the wording.
|
||||
for _, ipTime := range bannedLive {
|
||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||
}
|
||||
|
||||
// Actually disconnect banned IPs by temporarily removing and re-adding user
|
||||
// This forces Xray to drop existing connections from banned IPs
|
||||
if len(bannedIps) > 0 {
|
||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||
}
|
||||
|
||||
// Update database with only the currently active (kept) IPs
|
||||
jsonIps, _ := json.Marshal(keptIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
// force xray to drop existing connections from banned ips
|
||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||
} else {
|
||||
// Under limit, save all IPs
|
||||
jsonIps, _ := json.Marshal(allIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
keptLive = liveIps
|
||||
}
|
||||
|
||||
// keep kept-live + historical in the blob so the panel keeps showing
|
||||
// recently seen ips. banned live ips are already in the fail2ban log
|
||||
// and will reappear in the next scan if they reconnect.
|
||||
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
|
||||
dbIps = append(dbIps, keptLive...)
|
||||
dbIps = append(dbIps, historicalIps...)
|
||||
jsonIps, _ := json.Marshal(dbIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
|
||||
db := database.GetDB()
|
||||
err = db.Save(inboundClientIps).Error
|
||||
if err != nil {
|
||||
@@ -381,7 +454,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
}
|
||||
|
||||
if len(j.disAllowedIps) > 0 {
|
||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
|
||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d new IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
|
||||
}
|
||||
|
||||
return shouldCleanLog
|
||||
|
||||
250
web/job/check_client_ip_job_integration_test.go
Normal file
250
web/job/check_client_ip_job_integration_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
// 3x-ui logger must be initialised once before any code path that can
|
||||
// log a warning. otherwise log.Warningf panics on a nil logger.
|
||||
var loggerInitOnce sync.Once
|
||||
|
||||
// setupIntegrationDB wires a temp sqlite db and log folder so
|
||||
// updateInboundClientIps can run end to end. closes the db before
|
||||
// TempDir cleanup so windows doesn't complain about the file being in
|
||||
// use.
|
||||
func setupIntegrationDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
loggerInitOnce.Do(func() {
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
})
|
||||
|
||||
dbDir := t.TempDir()
|
||||
logDir := t.TempDir()
|
||||
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
t.Setenv("XUI_LOG_FOLDER", logDir)
|
||||
|
||||
// updateInboundClientIps calls log.SetOutput on the package global,
|
||||
// which would leak to other tests in the same binary.
|
||||
origLogWriter := log.Writer()
|
||||
origLogFlags := log.Flags()
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(origLogWriter)
|
||||
log.SetFlags(origLogFlags)
|
||||
})
|
||||
|
||||
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||
t.Fatalf("database.InitDB failed: %v", err)
|
||||
}
|
||||
// LIFO cleanup order: this runs before t.TempDir's own cleanup.
|
||||
t.Cleanup(func() {
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Logf("database.CloseDB warning: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seed an inbound whose settings json has a single client with the
|
||||
// given email and ip limit.
|
||||
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
||||
t.Helper()
|
||||
settings := map[string]any{
|
||||
"clients": []map[string]any{
|
||||
{
|
||||
"email": email,
|
||||
"limitIp": limitIp,
|
||||
"enable": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 4321,
|
||||
Settings: string(settingsJSON),
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seed an InboundClientIps row with the given blob.
|
||||
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
|
||||
t.Helper()
|
||||
blob, err := json.Marshal(ips)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ips: %v", err)
|
||||
}
|
||||
row := &model.InboundClientIps{
|
||||
ClientEmail: email,
|
||||
Ips: string(blob),
|
||||
}
|
||||
if err := database.GetDB().Create(row).Error; err != nil {
|
||||
t.Fatalf("seed InboundClientIps: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// read the persisted blob and parse it back.
|
||||
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
||||
t.Helper()
|
||||
row := &model.InboundClientIps{}
|
||||
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
||||
t.Fatalf("read InboundClientIps for %s: %v", email, err)
|
||||
}
|
||||
if row.Ips == "" {
|
||||
return nil
|
||||
}
|
||||
var out []IPWithTimestamp
|
||||
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
||||
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// make a lookup map so asserts don't depend on slice order.
|
||||
func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
||||
out := make(map[string]int64, len(entries))
|
||||
for _, e := range entries {
|
||||
out[e.IP] = e.Timestamp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
|
||||
// few minutes ago, only one live ip is actually connecting. pre-fix:
|
||||
// live ip got banned every tick and never appeared in the panel.
|
||||
// post-fix: no ban, live ip persisted, historical ips still visible.
|
||||
func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
|
||||
const email = "pr4091-repro"
|
||||
seedInboundWithClient(t, "inbound-pr4091", email, 3)
|
||||
|
||||
now := time.Now().Unix()
|
||||
// idle but still within the 30min staleness window.
|
||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
||||
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
||||
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
||||
})
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
// the one that's actually connecting (user's 128.71.x.x).
|
||||
live := []IPWithTimestamp{
|
||||
{IP: "128.71.1.1", Timestamp: now},
|
||||
}
|
||||
|
||||
shouldCleanLog := j.updateInboundClientIps(row, email, live)
|
||||
|
||||
if shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||
}
|
||||
if len(j.disAllowedIps) != 0 {
|
||||
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
|
||||
}
|
||||
|
||||
persisted := ipSet(readClientIps(t, email))
|
||||
for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
|
||||
if _, ok := persisted[want]; !ok {
|
||||
t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
|
||||
}
|
||||
}
|
||||
if got := persisted["128.71.1.1"]; got != now {
|
||||
t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
|
||||
}
|
||||
|
||||
// 3xipl.log must not contain a ban line.
|
||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
||||
body, _ := os.ReadFile(readIpLimitLogPath())
|
||||
t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// opposite invariant: when several ips are actually live and exceed
|
||||
// the limit, the newcomer still gets banned.
|
||||
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
|
||||
const email = "pr4091-abuse"
|
||||
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
||||
|
||||
now := time.Now().Unix()
|
||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
||||
})
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
||||
// is deterministic: 10.1.0.1 is the original (older), 192.0.2.9
|
||||
// joined later and must get banned.
|
||||
live := []IPWithTimestamp{
|
||||
{IP: "10.1.0.1", Timestamp: now - 5},
|
||||
{IP: "192.0.2.9", Timestamp: now},
|
||||
}
|
||||
|
||||
shouldCleanLog := j.updateInboundClientIps(row, email, live)
|
||||
|
||||
if !shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||
}
|
||||
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" {
|
||||
t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
||||
}
|
||||
|
||||
persisted := ipSet(readClientIps(t, email))
|
||||
if _, ok := persisted["10.1.0.1"]; !ok {
|
||||
t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted)
|
||||
}
|
||||
if _, ok := persisted["192.0.2.9"]; ok {
|
||||
t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted)
|
||||
}
|
||||
|
||||
// 3xipl.log must contain the ban line in the exact fail2ban format.
|
||||
body, err := os.ReadFile(readIpLimitLogPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read 3xipl.log: %v", err)
|
||||
}
|
||||
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9"
|
||||
if !contains(string(body), wantSubstr) {
|
||||
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
|
||||
}
|
||||
}
|
||||
|
||||
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
||||
// does via xray.GetIPLimitLogPath but without importing xray here
|
||||
// just for the path helper (which would pull a lot more deps into the
|
||||
// test binary). The env-derived log folder is deterministic.
|
||||
func readIpLimitLogPath() string {
|
||||
folder := os.Getenv("XUI_LOG_FOLDER")
|
||||
if folder == "" {
|
||||
folder = filepath.Join(".", "log")
|
||||
}
|
||||
return filepath.Join(folder, "3xipl.log")
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
146
web/job/check_client_ip_job_test.go
Normal file
146
web/job/check_client_ip_job_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||
// #4077: after a ban expires, a single IP that reconnects used to get
|
||||
// banned again immediately because a long-disconnected IP stayed in the
|
||||
// DB with an ancient timestamp and kept "protecting" itself against
|
||||
// eviction. Guard against that regression here.
|
||||
old := []IPWithTimestamp{
|
||||
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
|
||||
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
|
||||
}
|
||||
new := []IPWithTimestamp{
|
||||
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
||||
}
|
||||
|
||||
got := mergeClientIps(old, new, 1000)
|
||||
|
||||
want := map[string]int64{"2.2.2.2": 2000}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
||||
// Backwards-compat: entries that aren't stale are still carried forward,
|
||||
// so enforcement survives access-log rotation.
|
||||
old := []IPWithTimestamp{
|
||||
{IP: "1.1.1.1", Timestamp: 1500},
|
||||
}
|
||||
got := mergeClientIps(old, nil, 1000)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 1500}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||
|
||||
got := mergeClientIps(old, new, 1000)
|
||||
|
||||
if got["1.1.1.1"] != 1700 {
|
||||
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
||||
// A log line with a clock-skewed old timestamp must not resurrect a
|
||||
// stale IP past the cutoff.
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
||||
got := mergeClientIps(nil, new, 1000)
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("stale new IP should have been dropped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
||||
// Defensive: a zero cutoff (e.g. during very first run on a fresh
|
||||
// install) must not over-evict.
|
||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
||||
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
||||
|
||||
got := mergeClientIps(old, new, 0)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func collectIps(entries []IPWithTimestamp) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.IP)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing.T) {
|
||||
// #4091: db holds A, B, C from minutes ago (still in the 30min
|
||||
// window) but they're not connecting anymore. only D is. old code
|
||||
// merged all four, sorted ascending, kept [A,B,C] and banned D
|
||||
// every tick. pin the new rule: only live ips count toward the limit.
|
||||
ipMap := map[string]int64{
|
||||
"A": 1000,
|
||||
"B": 1100,
|
||||
"C": 1200,
|
||||
"D": 2000,
|
||||
}
|
||||
observed := map[string]bool{"D": true}
|
||||
|
||||
live, historical := partitionLiveIps(ipMap, observed)
|
||||
|
||||
if got := collectIps(live); !reflect.DeepEqual(got, []string{"D"}) {
|
||||
t.Fatalf("live set should only contain the ip observed this scan\ngot: %v\nwant: [D]", got)
|
||||
}
|
||||
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B", "C"}) {
|
||||
t.Fatalf("historical set should contain db-only ips in ascending order\ngot: %v\nwant: [A B C]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionLiveIps_ConcurrentLiveIpsStillBanNewcomers(t *testing.T) {
|
||||
// keep the "protect original, ban newcomer" policy when several ips
|
||||
// are really live. with limit=1, A must stay and B must be banned.
|
||||
ipMap := map[string]int64{
|
||||
"A": 5000,
|
||||
"B": 5500,
|
||||
}
|
||||
observed := map[string]bool{"A": true, "B": true}
|
||||
|
||||
live, historical := partitionLiveIps(ipMap, observed)
|
||||
|
||||
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
||||
t.Fatalf("both live ips should be in the live set, ascending\ngot: %v\nwant: [A B]", got)
|
||||
}
|
||||
if len(historical) != 0 {
|
||||
t.Fatalf("no historical ips expected, got %v", historical)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
|
||||
// quiet tick: nothing observed => nothing live. everything merged
|
||||
// is historical. keeps the panel from wiping recent-but-idle ips.
|
||||
ipMap := map[string]int64{
|
||||
"A": 1000,
|
||||
"B": 1100,
|
||||
}
|
||||
observed := map[string]bool{}
|
||||
|
||||
live, historical := partitionLiveIps(ipMap, observed)
|
||||
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("no live ips expected, got %v", live)
|
||||
}
|
||||
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
||||
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ func NewXrayTrafficJob() *XrayTrafficJob {
|
||||
return new(XrayTrafficJob)
|
||||
}
|
||||
|
||||
// Run collects traffic statistics from Xray and updates the database, triggering restart if needed.
|
||||
// Run collects traffic statistics from Xray, updates the database, and pushes
|
||||
// real-time updates over WebSocket using compact delta payloads — no REST
|
||||
// fallback, scales to 10k–20k+ clients per inbound.
|
||||
func (j *XrayTrafficJob) Run() {
|
||||
if !j.xrayService.IsXrayRunning() {
|
||||
return
|
||||
@@ -33,7 +35,7 @@ func (j *XrayTrafficJob) Run() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err, needRestart0 := j.inboundService.AddTraffic(traffics, clientTraffics)
|
||||
needRestart0, clientsDisabled, err := j.inboundService.AddTraffic(traffics, clientTraffics)
|
||||
if err != nil {
|
||||
logger.Warning("add inbound traffic failed:", err)
|
||||
}
|
||||
@@ -41,6 +43,18 @@ func (j *XrayTrafficJob) Run() {
|
||||
if err != nil {
|
||||
logger.Warning("add outbound traffic failed:", err)
|
||||
}
|
||||
if clientsDisabled {
|
||||
restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable()
|
||||
if settingErr != nil {
|
||||
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
|
||||
}
|
||||
if restartOnDisable {
|
||||
if err := j.xrayService.RestartXray(true); err != nil {
|
||||
logger.Warning("restart xray after disabling clients failed:", err)
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
if ExternalTrafficInformEnable, err := j.settingService.GetExternalTrafficInformEnable(); ExternalTrafficInformEnable {
|
||||
j.informTrafficToExternalAPI(traffics, clientTraffics)
|
||||
} else if err != nil {
|
||||
@@ -50,50 +64,85 @@ func (j *XrayTrafficJob) Run() {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
|
||||
// If no frontend client is connected, skip all WebSocket broadcasting routines,
|
||||
// including expensive DB queries for online clients and JSON marshaling.
|
||||
// If no frontend client is connected, skip all WebSocket broadcasting
|
||||
// routines — including the active-client DB query and JSON marshaling.
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
}
|
||||
|
||||
// Update online clients list and map
|
||||
// Online presence + traffic deltas — small payload, always fits in WS.
|
||||
// Force non-nil slice/map so JSON marshalling produces [] / {} instead of
|
||||
// `null` when everyone is offline. The frontend's traffic handler treats
|
||||
// a missing/null onlineClients field as "no update", so without this the
|
||||
// "everyone went offline" transition was silently dropped — stale online
|
||||
// users lingered in the list and the online filter kept showing them.
|
||||
onlineClients := j.inboundService.GetOnlineClients()
|
||||
if onlineClients == nil {
|
||||
onlineClients = []string{}
|
||||
}
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
}
|
||||
if lastOnlineMap == nil {
|
||||
lastOnlineMap = make(map[string]int64)
|
||||
}
|
||||
|
||||
// Broadcast traffic update (deltas and online stats) via WebSocket
|
||||
trafficUpdate := map[string]any{
|
||||
websocket.BroadcastTraffic(map[string]any{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
"lastOnlineMap": lastOnlineMap,
|
||||
}
|
||||
websocket.BroadcastTraffic(trafficUpdate)
|
||||
})
|
||||
|
||||
// Fetch updated inbounds from database with accumulated traffic values
|
||||
// This ensures frontend receives the actual total traffic for real-time UI refresh.
|
||||
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("get all inbounds for websocket failed:", err)
|
||||
// Compact delta payload: per-client absolute counters for clients active
|
||||
// this cycle, plus inbound-level absolute totals. Frontend applies both
|
||||
// in-place — typical payload ~10–50KB even for 10k+ client deployments.
|
||||
// Replaces the old full-inbound-list broadcast that hit WS size limits
|
||||
// (5–10MB) and forced the frontend into a REST refetch.
|
||||
clientStatsPayload := map[string]any{}
|
||||
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
|
||||
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
|
||||
logger.Warning("get active client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStatsPayload["clients"] = stats
|
||||
}
|
||||
}
|
||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
||||
} else if len(inboundSummary) > 0 {
|
||||
clientStatsPayload["inbounds"] = inboundSummary
|
||||
}
|
||||
if len(clientStatsPayload) > 0 {
|
||||
websocket.BroadcastClientStats(clientStatsPayload)
|
||||
}
|
||||
|
||||
updatedOutbounds, err := j.outboundService.GetOutboundsTraffic()
|
||||
if err != nil {
|
||||
// Outbounds list is small (one row per outbound, no per-client expansion)
|
||||
// so the full snapshot still fits comfortably in WS.
|
||||
if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil {
|
||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||
} else if err != nil {
|
||||
logger.Warning("get all outbounds for websocket failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The WebSocket hub will automatically check the payload size.
|
||||
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
|
||||
if updatedInbounds != nil {
|
||||
websocket.BroadcastInbounds(updatedInbounds)
|
||||
// activeEmails returns the set of client emails that had non-zero traffic in
|
||||
// the current collection window. Idle clients are skipped — no need to push
|
||||
// their (unchanged) counters to the frontend.
|
||||
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
|
||||
if len(clientTraffics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updatedOutbounds != nil {
|
||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||
emails := make([]string, 0, len(clientTraffics))
|
||||
for _, ct := range clientTraffics {
|
||||
if ct == nil || ct.Email == "" {
|
||||
continue
|
||||
}
|
||||
if ct.Up == 0 && ct.Down == 0 {
|
||||
continue
|
||||
}
|
||||
emails = append(emails, ct.Email)
|
||||
}
|
||||
return emails
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
@@ -26,6 +27,12 @@ type InboundService struct {
|
||||
xrayApi xray.XrayAPI
|
||||
}
|
||||
|
||||
type CopyClientsResult struct {
|
||||
Added []string `json:"added"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// GetInbounds retrieves all inbounds for a specific user.
|
||||
// Returns a slice of inbound models with their associated client statistics.
|
||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||
@@ -270,7 +277,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if client.Email == "" {
|
||||
return inbound, false, common.NewError("empty client ID")
|
||||
}
|
||||
case "hysteria":
|
||||
case "hysteria", "hysteria2":
|
||||
if client.Auth == "" {
|
||||
return inbound, false, common.NewError("empty client ID")
|
||||
}
|
||||
@@ -359,10 +366,22 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, client := range clients {
|
||||
err := s.DelClientIPs(db, client.Email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
// Bulk-delete client IPs for every email in this inbound. The previous
|
||||
// per-client loop fired one DELETE per row — at 7k+ clients that meant
|
||||
// thousands of synchronous SQL roundtrips and a multi-second freeze.
|
||||
// Chunked to stay under SQLite's bind-variable limit on huge inbounds.
|
||||
if len(clients) > 0 {
|
||||
emails := make([]string, 0, len(clients))
|
||||
for i := range clients {
|
||||
if clients[i].Email != "" {
|
||||
emails = append(emails, clients[i].Email)
|
||||
}
|
||||
}
|
||||
for _, batch := range chunkStrings(uniqueNonEmptyStrings(emails), sqliteMaxVars) {
|
||||
if err := db.Where("client_email IN ?", batch).
|
||||
Delete(model.InboundClientIps{}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +398,66 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
|
||||
return inbound, nil
|
||||
}
|
||||
|
||||
// SetInboundEnable toggles only the enable flag of an inbound, without
|
||||
// rewriting the (potentially multi-MB) settings JSON. Used by the UI's
|
||||
// per-row enable switch — for inbounds with thousands of clients the full
|
||||
// UpdateInbound path is an order of magnitude too slow for an interactive
|
||||
// toggle (parses + reserialises every client, runs O(N) traffic diff).
|
||||
//
|
||||
// Returns (needRestart, error). needRestart is true when the xray runtime
|
||||
// could not be re-synced from the cached config and a full restart is
|
||||
// required to pick up the change.
|
||||
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if inbound.Enable == enable {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(model.Inbound{}).Where("id = ?", id).
|
||||
Update("enable", enable).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
inbound.Enable = enable
|
||||
|
||||
// Sync xray runtime: drop the live inbound, add it back if we're enabling.
|
||||
// "User not found"-style errors from DelInbound mean the inbound was
|
||||
// already absent from the live config — that's fine. Any other error
|
||||
// means the live config and DB diverged, so we ask the caller to
|
||||
// schedule a restart.
|
||||
needRestart := false
|
||||
s.xrayApi.Init(p.GetAPIPort())
|
||||
defer s.xrayApi.Close()
|
||||
|
||||
if err := s.xrayApi.DelInbound(inbound.Tag); err != nil &&
|
||||
!strings.Contains(err.Error(), "not found") {
|
||||
logger.Debug("SetInboundEnable: DelInbound via api failed:", err)
|
||||
needRestart = true
|
||||
}
|
||||
if !enable {
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: build runtime config failed:", err)
|
||||
return true, nil
|
||||
}
|
||||
inboundJson, err := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ")
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: marshal runtime config failed:", err)
|
||||
return true, nil
|
||||
}
|
||||
if err := s.xrayApi.AddInbound(inboundJson); err != nil {
|
||||
logger.Debug("SetInboundEnable: AddInbound via api failed:", err)
|
||||
needRestart = true
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
// UpdateInbound modifies an existing inbound configuration.
|
||||
// It validates changes, updates the database, and syncs with the running Xray instance.
|
||||
// Returns the updated inbound, whether Xray needs restart, and any error.
|
||||
@@ -582,6 +661,11 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
// updateClientTraffics syncs the ClientTraffic rows with the inbound's clients
|
||||
// list: removes rows for emails that disappeared, inserts rows for newly-added
|
||||
// emails. Uses sets for O(N) lookup — the previous nested-loop implementation
|
||||
// was O(N²) and degraded into multi-second pauses on inbounds with thousands
|
||||
// of clients (toggling, saving, or deleting any such inbound felt frozen).
|
||||
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
|
||||
oldClients, err := s.GetClients(oldInbound)
|
||||
if err != nil {
|
||||
@@ -592,36 +676,48 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
|
||||
return err
|
||||
}
|
||||
|
||||
var emailExists bool
|
||||
|
||||
for _, oldClient := range oldClients {
|
||||
emailExists = false
|
||||
for _, newClient := range newClients {
|
||||
if oldClient.Email == newClient.Email {
|
||||
emailExists = true
|
||||
break
|
||||
}
|
||||
// Email is the unique key for ClientTraffic rows. Clients without an
|
||||
// email have no stats row to sync — skip them on both sides instead of
|
||||
// risking a unique-constraint hit or accidental delete of an unrelated row.
|
||||
oldEmails := make(map[string]struct{}, len(oldClients))
|
||||
for i := range oldClients {
|
||||
if oldClients[i].Email == "" {
|
||||
continue
|
||||
}
|
||||
if !emailExists {
|
||||
err = s.DelClientStat(tx, oldClient.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldEmails[oldClients[i].Email] = struct{}{}
|
||||
}
|
||||
newEmails := make(map[string]struct{}, len(newClients))
|
||||
for i := range newClients {
|
||||
if newClients[i].Email == "" {
|
||||
continue
|
||||
}
|
||||
newEmails[newClients[i].Email] = struct{}{}
|
||||
}
|
||||
|
||||
// Removed clients — drop their stats rows.
|
||||
for i := range oldClients {
|
||||
email := oldClients[i].Email
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
if _, kept := newEmails[email]; kept {
|
||||
continue
|
||||
}
|
||||
if err := s.DelClientStat(tx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, newClient := range newClients {
|
||||
emailExists = false
|
||||
for _, oldClient := range oldClients {
|
||||
if newClient.Email == oldClient.Email {
|
||||
emailExists = true
|
||||
break
|
||||
}
|
||||
// Added clients — create their stats rows.
|
||||
for i := range newClients {
|
||||
email := newClients[i].Email
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
if !emailExists {
|
||||
err = s.AddClientStat(tx, oldInbound.Id, &newClient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, existed := oldEmails[email]; existed {
|
||||
continue
|
||||
}
|
||||
if err := s.AddClientStat(tx, oldInbound.Id, &newClients[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -675,7 +771,7 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
||||
if client.Email == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
case "hysteria":
|
||||
case "hysteria", "hysteria2":
|
||||
if client.Auth == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
@@ -750,6 +846,202 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
||||
return needRestart, tx.Save(oldInbound).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
|
||||
switch protocol {
|
||||
case model.Trojan:
|
||||
return client.Password
|
||||
case model.Shadowsocks:
|
||||
return client.Email
|
||||
case model.Hysteria:
|
||||
return client.Auth
|
||||
default:
|
||||
return client.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtocol model.Protocol, client model.Client, subID string) (bool, error) {
|
||||
client.SubID = subID
|
||||
client.UpdatedAt = time.Now().UnixMilli()
|
||||
clientID := s.getClientPrimaryKey(sourceProtocol, client)
|
||||
if clientID == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
|
||||
settingsBytes, err := json.Marshal(map[string][]model.Client{
|
||||
"clients": {client},
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
updatePayload := &model.Inbound{
|
||||
Id: sourceInboundID,
|
||||
Settings: string(settingsBytes),
|
||||
}
|
||||
return s.UpdateInboundClient(updatePayload, clientID)
|
||||
}
|
||||
|
||||
func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
|
||||
switch targetProtocol {
|
||||
case model.VMESS, model.VLESS:
|
||||
return uuid.NewString()
|
||||
default:
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) {
|
||||
nowTs := time.Now().UnixMilli()
|
||||
target := source
|
||||
target.Email = email
|
||||
target.CreatedAt = nowTs
|
||||
target.UpdatedAt = nowTs
|
||||
|
||||
target.ID = ""
|
||||
target.Password = ""
|
||||
target.Auth = ""
|
||||
target.Flow = ""
|
||||
|
||||
switch targetProtocol {
|
||||
case model.VMESS:
|
||||
target.ID = s.generateRandomCredential(targetProtocol)
|
||||
case model.VLESS:
|
||||
target.ID = s.generateRandomCredential(targetProtocol)
|
||||
if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" {
|
||||
target.Flow = flow
|
||||
}
|
||||
case model.Trojan, model.Shadowsocks:
|
||||
target.Password = s.generateRandomCredential(targetProtocol)
|
||||
case model.Hysteria:
|
||||
target.Auth = s.generateRandomCredential(targetProtocol)
|
||||
default:
|
||||
target.ID = s.generateRandomCredential(targetProtocol)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
|
||||
base := fmt.Sprintf("%s_%d", originalEmail, targetID)
|
||||
candidate := base
|
||||
suffix := 0
|
||||
for {
|
||||
if _, exists := occupied[strings.ToLower(candidate)]; !exists {
|
||||
occupied[strings.ToLower(candidate)] = struct{}{}
|
||||
return candidate
|
||||
}
|
||||
suffix++
|
||||
candidate = fmt.Sprintf("%s_%d", base, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
|
||||
result := &CopyClientsResult{
|
||||
Added: []string{},
|
||||
Skipped: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
if targetInboundID == sourceInboundID {
|
||||
return result, false, common.NewError("source and target inbounds must be different")
|
||||
}
|
||||
|
||||
targetInbound, err := s.GetInbound(targetInboundID)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
sourceInbound, err := s.GetInbound(sourceInboundID)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
|
||||
sourceClients, err := s.GetClients(sourceInbound)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
if len(sourceClients) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
allowedEmails := map[string]struct{}{}
|
||||
if len(clientEmails) > 0 {
|
||||
for _, email := range clientEmails {
|
||||
allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
occupiedEmails := map[string]struct{}{}
|
||||
allEmails, err := s.getAllEmails()
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
for _, email := range allEmails {
|
||||
clean := strings.Trim(email, "\"")
|
||||
if clean != "" {
|
||||
occupiedEmails[strings.ToLower(clean)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
newClients := make([]model.Client, 0)
|
||||
needRestart := false
|
||||
for _, sourceClient := range sourceClients {
|
||||
originalEmail := strings.TrimSpace(sourceClient.Email)
|
||||
if originalEmail == "" {
|
||||
continue
|
||||
}
|
||||
if len(allowedEmails) > 0 {
|
||||
if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if sourceClient.SubID == "" {
|
||||
newSubID := uuid.NewString()
|
||||
subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceInbound.Protocol, sourceClient, newSubID)
|
||||
if subErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
|
||||
continue
|
||||
}
|
||||
if subNeedRestart {
|
||||
needRestart = true
|
||||
}
|
||||
sourceClient.SubID = newSubID
|
||||
}
|
||||
|
||||
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
|
||||
targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow)
|
||||
if buildErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
|
||||
continue
|
||||
}
|
||||
newClients = append(newClients, targetClient)
|
||||
result.Added = append(result.Added, targetEmail)
|
||||
}
|
||||
|
||||
if len(newClients) == 0 {
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
settingsPayload, err := json.Marshal(map[string][]model.Client{
|
||||
"clients": newClients,
|
||||
})
|
||||
if err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
|
||||
addNeedRestart, err := s.AddInboundClient(&model.Inbound{
|
||||
Id: targetInboundID,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
if addNeedRestart {
|
||||
needRestart = true
|
||||
}
|
||||
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
|
||||
oldInbound, err := s.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
@@ -769,17 +1061,19 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
||||
client_key = "password"
|
||||
case "shadowsocks":
|
||||
client_key = "email"
|
||||
case "hysteria":
|
||||
case "hysteria", "hysteria2":
|
||||
client_key = "auth"
|
||||
}
|
||||
|
||||
interfaceClients := settings["clients"].([]any)
|
||||
var newClients []any
|
||||
needApiDel := false
|
||||
clientFound := false
|
||||
for _, client := range interfaceClients {
|
||||
c := client.(map[string]any)
|
||||
c_id := c[client_key].(string)
|
||||
if c_id == clientId {
|
||||
clientFound = true
|
||||
email, _ = c["email"].(string)
|
||||
needApiDel, _ = c["enable"].(bool)
|
||||
} else {
|
||||
@@ -787,6 +1081,10 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
||||
}
|
||||
}
|
||||
|
||||
if !clientFound {
|
||||
return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
|
||||
}
|
||||
|
||||
if len(newClients) == 0 {
|
||||
return false, common.NewError("no client remained in Inbound")
|
||||
}
|
||||
@@ -877,7 +1175,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||
case "shadowsocks":
|
||||
oldClientId = oldClient.Email
|
||||
newClientId = clients[0].Email
|
||||
case "hysteria":
|
||||
case "hysteria", "hysteria2":
|
||||
oldClientId = oldClient.Auth
|
||||
newClientId = clients[0].Auth
|
||||
default:
|
||||
@@ -1019,7 +1317,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||
return needRestart, tx.Save(oldInbound).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
|
||||
var err error
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
@@ -1033,11 +1331,11 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
|
||||
}()
|
||||
err = s.addInboundTraffic(tx, inboundTraffics)
|
||||
if err != nil {
|
||||
return err, false
|
||||
return false, false, err
|
||||
}
|
||||
err = s.addClientTraffic(tx, clientTraffics)
|
||||
if err != nil {
|
||||
return err, false
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
needRestart0, count, err := s.autoRenewClients(tx)
|
||||
@@ -1047,11 +1345,13 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
|
||||
logger.Debugf("%v clients renewed", count)
|
||||
}
|
||||
|
||||
disabledClientsCount := int64(0)
|
||||
needRestart1, count, err := s.disableInvalidClients(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling invalid clients:", err)
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v clients disabled", count)
|
||||
disabledClientsCount = count
|
||||
}
|
||||
|
||||
needRestart2, count, err := s.disableInvalidInbounds(tx)
|
||||
@@ -1060,7 +1360,7 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v inbounds disabled", count)
|
||||
}
|
||||
return nil, (needRestart0 || needRestart1 || needRestart2)
|
||||
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||
@@ -1117,20 +1417,27 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
return err
|
||||
}
|
||||
|
||||
// Index by email for O(N) merge — the previous nested loop was O(N²)
|
||||
// and dominated each cron tick on inbounds with thousands of active
|
||||
// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
|
||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
|
||||
for i := range traffics {
|
||||
if traffics[i] != nil {
|
||||
trafficByEmail[traffics[i].Email] = traffics[i]
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
for dbTraffic_index := range dbClientTraffics {
|
||||
for traffic_index := range traffics {
|
||||
if dbClientTraffics[dbTraffic_index].Email == traffics[traffic_index].Email {
|
||||
dbClientTraffics[dbTraffic_index].Up += traffics[traffic_index].Up
|
||||
dbClientTraffics[dbTraffic_index].Down += traffics[traffic_index].Down
|
||||
dbClientTraffics[dbTraffic_index].AllTime += (traffics[traffic_index].Up + traffics[traffic_index].Down)
|
||||
|
||||
// Add user in onlineUsers array on traffic
|
||||
if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 {
|
||||
onlineClients = append(onlineClients, traffics[traffic_index].Email)
|
||||
dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
|
||||
}
|
||||
break
|
||||
}
|
||||
t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dbClientTraffics[dbTraffic_index].Up += t.Up
|
||||
dbClientTraffics[dbTraffic_index].Down += t.Down
|
||||
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
|
||||
if t.Up+t.Down > 0 {
|
||||
onlineClients = append(onlineClients, t.Email)
|
||||
dbClientTraffics[dbTraffic_index].LastOnline = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,9 +1537,17 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
for _, traffic := range traffics {
|
||||
inbound_ids = append(inbound_ids, traffic.InboundId)
|
||||
}
|
||||
err = tx.Model(model.Inbound{}).Where("id IN ?", inbound_ids).Find(&inbounds).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
// Dedupe so an inbound hosting N expired clients is fetched and saved once
|
||||
// per tick instead of N times across chunk boundaries.
|
||||
inbound_ids = uniqueInts(inbound_ids)
|
||||
// Chunked to stay under SQLite's bind-variable limit when many inbounds
|
||||
// are touched in a single tick.
|
||||
for _, batch := range chunkInts(inbound_ids, sqliteMaxVars) {
|
||||
var page []*model.Inbound
|
||||
if err = tx.Model(model.Inbound{}).Where("id IN ?", batch).Find(&page).Error; err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
inbounds = append(inbounds, page...)
|
||||
}
|
||||
for inbound_index := range inbounds {
|
||||
settings := map[string]any{}
|
||||
@@ -1337,46 +1652,105 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
|
||||
if p != nil {
|
||||
var results []struct {
|
||||
Tag string
|
||||
Email string
|
||||
}
|
||||
var clientsToDisable []struct {
|
||||
InboundId int
|
||||
Tag string
|
||||
Email string
|
||||
}
|
||||
|
||||
err := tx.Table("inbounds").
|
||||
Select("inbounds.tag, client_traffics.email").
|
||||
Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
|
||||
Where("((client_traffics.total > 0 AND client_traffics.up + client_traffics.down >= client_traffics.total) OR (client_traffics.expiry_time > 0 AND client_traffics.expiry_time <= ?)) AND client_traffics.enable = ?", now, true).
|
||||
Scan(&results).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
err := tx.Table("inbounds").
|
||||
Select("inbounds.id as inbound_id, inbounds.tag, client_traffics.email").
|
||||
Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
|
||||
Where("((client_traffics.total > 0 AND client_traffics.up + client_traffics.down >= client_traffics.total) OR (client_traffics.expiry_time > 0 AND client_traffics.expiry_time <= ?)) AND client_traffics.enable = ?", now, true).
|
||||
Scan(&clientsToDisable).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if p != nil {
|
||||
s.xrayApi.Init(p.GetAPIPort())
|
||||
for _, result := range results {
|
||||
err1 := s.xrayApi.RemoveUser(result.Tag, result.Email)
|
||||
for _, client := range clientsToDisable {
|
||||
err1 := s.xrayApi.RemoveUser(client.Tag, client.Email)
|
||||
if err1 == nil {
|
||||
logger.Debug("Client disabled by api:", result.Email)
|
||||
logger.Debug("Client disabled by api:", client.Email)
|
||||
} else {
|
||||
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
|
||||
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", client.Email)) {
|
||||
logger.Debug("User is already disabled. Nothing to do more...")
|
||||
} else {
|
||||
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
|
||||
logger.Debug("User is already disabled. Nothing to do more...")
|
||||
} else {
|
||||
logger.Debug("Error in disabling client by api:", err1)
|
||||
needRestart = true
|
||||
}
|
||||
logger.Debug("Error in disabling client by api:", err1)
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
s.xrayApi.Close()
|
||||
}
|
||||
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
|
||||
Update("enable", false)
|
||||
err := result.Error
|
||||
err = result.Error
|
||||
count := result.RowsAffected
|
||||
return needRestart, count, err
|
||||
if err != nil {
|
||||
return needRestart, count, err
|
||||
}
|
||||
|
||||
// Also set enable=false in inbounds.settings JSON so clients are visibly disabled
|
||||
if len(clientsToDisable) > 0 {
|
||||
inboundEmailMap := make(map[int]map[string]struct{})
|
||||
for _, c := range clientsToDisable {
|
||||
if inboundEmailMap[c.InboundId] == nil {
|
||||
inboundEmailMap[c.InboundId] = make(map[string]struct{})
|
||||
}
|
||||
inboundEmailMap[c.InboundId][c.Email] = struct{}{}
|
||||
}
|
||||
inboundIds := make([]int, 0, len(inboundEmailMap))
|
||||
for id := range inboundEmailMap {
|
||||
inboundIds = append(inboundIds, id)
|
||||
}
|
||||
var inbounds []*model.Inbound
|
||||
if err = tx.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error; err != nil {
|
||||
logger.Warning("disableInvalidClients fetch inbounds:", err)
|
||||
return needRestart, count, nil
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
settings := map[string]any{}
|
||||
if jsonErr := json.Unmarshal([]byte(inbound.Settings), &settings); jsonErr != nil {
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
emailSet := inboundEmailMap[inbound.Id]
|
||||
changed := false
|
||||
for i := range clients {
|
||||
c, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := c["email"].(string)
|
||||
if _, shouldDisable := emailSet[email]; shouldDisable {
|
||||
c["enable"] = false
|
||||
c["updated_at"] = time.Now().Unix() * 1000
|
||||
clients[i] = c
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
settings["clients"] = clients
|
||||
modifiedSettings, jsonErr := json.MarshalIndent(settings, "", " ")
|
||||
if jsonErr != nil {
|
||||
continue
|
||||
}
|
||||
inbound.Settings = string(modifiedSettings)
|
||||
}
|
||||
}
|
||||
if err = tx.Save(inbounds).Error; err != nil {
|
||||
logger.Warning("disableInvalidClients update inbound settings:", err)
|
||||
}
|
||||
}
|
||||
|
||||
return needRestart, count, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInboundTags() (string, error) {
|
||||
@@ -1390,6 +1764,24 @@ func (s *InboundService) GetInboundTags() (string, error) {
|
||||
return string(tags), nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientReverseTags() (string, error) {
|
||||
db := database.GetDB()
|
||||
var rawTags []string
|
||||
err := db.Raw(`
|
||||
SELECT DISTINCT JSON_EXTRACT(client.value, '$.reverse.tag')
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
WHERE inbounds.protocol = 'vless'
|
||||
AND JSON_EXTRACT(client.value, '$.reverse.tag') IS NOT NULL
|
||||
AND JSON_EXTRACT(client.value, '$.reverse.tag') != ''
|
||||
`).Scan(&rawTags).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return "[]", err
|
||||
}
|
||||
result, _ := json.Marshal(rawTags)
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||
db := database.GetDB()
|
||||
db.Exec(`
|
||||
@@ -2092,15 +2484,24 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
|
||||
}
|
||||
}
|
||||
|
||||
var traffics []*xray.ClientTraffic
|
||||
err = db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
logger.Warning("No ClientTraffic records found for emails:", emails)
|
||||
return nil, nil
|
||||
// Chunked to stay under SQLite's bind-variable limit when a single Telegram
|
||||
// account owns thousands of clients across inbounds.
|
||||
uniqEmails := uniqueNonEmptyStrings(emails)
|
||||
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
|
||||
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
|
||||
var page []*xray.ClientTraffic
|
||||
if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
continue
|
||||
}
|
||||
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", emails, err)
|
||||
return nil, err
|
||||
traffics = append(traffics, page...)
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
logger.Warning("No ClientTraffic records found for emails:", emails)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate UUID and other client data for each traffic record
|
||||
@@ -2115,6 +2516,133 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a
|
||||
// single SQL statement. SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 on builds
|
||||
// before 3.32 and 32766 after; staying under 999 keeps queries portable
|
||||
// across forks/old binaries and also bounds per-query memory on truly large
|
||||
// installs (>32k clients) where even modern SQLite would refuse a single IN.
|
||||
const sqliteMaxVars = 900
|
||||
|
||||
// uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
|
||||
// removed, preserving the order of first occurrence.
|
||||
func uniqueNonEmptyStrings(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueInts returns a deduplicated copy of in, preserving order of first occurrence.
|
||||
func uniqueInts(in []int) []int {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int]struct{}, len(in))
|
||||
out := make([]int, 0, len(in))
|
||||
for _, v := range in {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chunkStrings splits s into consecutive sub-slices of at most size elements.
|
||||
// Returns nil for an empty input or non-positive size.
|
||||
func chunkStrings(s []string, size int) [][]string {
|
||||
if size <= 0 || len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([][]string, 0, (len(s)+size-1)/size)
|
||||
for i := 0; i < len(s); i += size {
|
||||
end := i + size
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
out = append(out, s[i:end])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chunkInts splits s into consecutive sub-slices of at most size elements.
|
||||
// Returns nil for an empty input or non-positive size.
|
||||
func chunkInts(s []int, size int) [][]int {
|
||||
if size <= 0 || len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([][]int, 0, (len(s)+size-1)/size)
|
||||
for i := 0; i < len(s); i += size {
|
||||
end := i + size
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
out = append(out, s[i:end])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetActiveClientTraffics returns the absolute ClientTraffic rows for the given
|
||||
// emails. Used by the WebSocket delta path to push per-client absolute
|
||||
// counters without re-serializing the full inbound list. The query is chunked
|
||||
// to stay under SQLite's bind-variable limit on very large active sets.
|
||||
// Empty input returns (nil, nil).
|
||||
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
|
||||
uniq := uniqueNonEmptyStrings(emails)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
traffics := make([]*xray.ClientTraffic, 0, len(uniq))
|
||||
for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
|
||||
var page []*xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
traffics = append(traffics, page...)
|
||||
}
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
// InboundTrafficSummary is the minimal projection of an inbound's traffic
|
||||
// counters used by the WebSocket delta path. Excludes Settings/StreamSettings
|
||||
// blobs so the broadcast stays compact even with many inbounds.
|
||||
type InboundTrafficSummary struct {
|
||||
Id int `json:"id"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
Total int64 `json:"total"`
|
||||
AllTime int64 `json:"allTime"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
|
||||
// GetInboundsTrafficSummary returns inbound-level absolute traffic counters
|
||||
// (no per-client expansion). Companion to GetActiveClientTraffics — together
|
||||
// they replace the heavy "full inbound list" broadcast on each cron tick.
|
||||
func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) {
|
||||
db := database.GetDB()
|
||||
var summaries []InboundTrafficSummary
|
||||
if err := db.Model(&model.Inbound{}).
|
||||
Select("id, up, down, total, all_time, enable").
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
|
||||
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
|
||||
t, client, err := s.GetClientByEmail(email)
|
||||
@@ -2133,9 +2661,17 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
|
||||
func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, download int64) error {
|
||||
db := database.GetDB()
|
||||
|
||||
// Keep all_time monotonic: it represents historical cumulative usage and
|
||||
// must never be less than the currently-tracked up+down. Without this,
|
||||
// the UI showed "Общий трафик" (allTime) below the live consumed value
|
||||
// after admins manually edited a client's counters.
|
||||
result := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", email).
|
||||
Updates(map[string]any{"up": upload, "down": download})
|
||||
Updates(map[string]any{
|
||||
"up": upload,
|
||||
"down": download,
|
||||
"all_time": gorm.Expr("CASE WHEN COALESCE(all_time, 0) < ? THEN ? ELSE all_time END", upload+download, upload+download),
|
||||
})
|
||||
|
||||
err := result.Error
|
||||
if err != nil {
|
||||
@@ -2476,11 +3012,16 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
|
||||
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
// Step 1: Get ClientTraffic records for emails in the input list
|
||||
var clients []xray.ClientTraffic
|
||||
err := db.Where("email IN ?", emails).Find(&clients).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, nil, err
|
||||
// Step 1: Get ClientTraffic records for emails in the input list.
|
||||
// Chunked to stay under SQLite's bind-variable limit on huge inputs.
|
||||
uniqEmails := uniqueNonEmptyStrings(emails)
|
||||
clients := make([]xray.ClientTraffic, 0, len(uniqEmails))
|
||||
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
|
||||
var page []xray.ClientTraffic
|
||||
if err := db.Where("email IN ?", batch).Find(&page).Error; err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, nil, err
|
||||
}
|
||||
clients = append(clients, page...)
|
||||
}
|
||||
|
||||
// Step 2: Sort clients by (Up + Down) descending
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
)
|
||||
|
||||
@@ -12,6 +21,13 @@ import (
|
||||
// It handles panel restart, updates, and system-level panel controls.
|
||||
type PanelService struct{}
|
||||
|
||||
// PanelUpdateInfo contains the current and latest available panel versions.
|
||||
type PanelUpdateInfo struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||
p, err := os.FindProcess(syscall.Getpid())
|
||||
if err != nil {
|
||||
@@ -26,3 +42,161 @@ func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpdateInfo checks GitHub for the latest 3x-ui release.
|
||||
func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
latest, err := fetchLatestPanelVersion()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := config.GetVersion()
|
||||
return &PanelUpdateInfo{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
UpdateAvailable: isNewerVersion(latest, current),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartUpdate starts the official updater outside of the current web request.
|
||||
func (s *PanelService) StartUpdate() error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("panel web update is supported only on Linux installations")
|
||||
}
|
||||
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
return fmt.Errorf("bash is required to run the panel updater: %w", err)
|
||||
}
|
||||
curl, err := exec.LookPath("curl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("curl is required to download the panel updater: %w", err)
|
||||
}
|
||||
|
||||
mainFolder, serviceFolder := resolveUpdateFolders()
|
||||
updateScript := fmt.Sprintf("set -o pipefail; %s -fLs https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh | %s", shellQuote(curl), shellQuote(bash))
|
||||
|
||||
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
|
||||
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
|
||||
cmd := exec.Command(systemdRun,
|
||||
"--unit", unitName,
|
||||
"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
|
||||
"--setenv", "XUI_SERVICE="+serviceFolder,
|
||||
bash, "-lc", updateScript,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
output := strings.TrimSpace(string(out))
|
||||
if !strings.Contains(output, "System has not been booted with systemd") &&
|
||||
!strings.Contains(output, "Failed to connect to bus") {
|
||||
return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
|
||||
}
|
||||
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
|
||||
} else {
|
||||
logger.Infof("started panel update job via systemd-run unit %s", unitName)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(bash, "-lc", updateScript)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"XUI_MAIN_FOLDER="+mainFolder,
|
||||
"XUI_SERVICE="+serviceFolder,
|
||||
)
|
||||
setDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start panel update job: %w", err)
|
||||
}
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
logger.Warning("failed to release panel update process:", err)
|
||||
}
|
||||
logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchLatestPanelVersion() (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var release Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if release.TagName == "" {
|
||||
return "", fmt.Errorf("latest panel release tag is empty")
|
||||
}
|
||||
return release.TagName, nil
|
||||
}
|
||||
|
||||
func resolveUpdateFolders() (string, string) {
|
||||
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
||||
if mainFolder == "" {
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
mainFolder = filepath.Dir(exePath)
|
||||
}
|
||||
}
|
||||
if mainFolder == "" {
|
||||
mainFolder = "/usr/local/x-ui"
|
||||
}
|
||||
|
||||
serviceFolder := os.Getenv("XUI_SERVICE")
|
||||
if serviceFolder == "" {
|
||||
serviceFolder = "/etc/systemd/system"
|
||||
}
|
||||
return mainFolder, serviceFolder
|
||||
}
|
||||
|
||||
func isNewerVersion(latest string, current string) bool {
|
||||
cmp, ok := compareVersionStrings(latest, current)
|
||||
if !ok {
|
||||
return normalizeVersionTag(latest) != normalizeVersionTag(current)
|
||||
}
|
||||
return cmp > 0
|
||||
}
|
||||
|
||||
func compareVersionStrings(a string, b string) (int, bool) {
|
||||
aParts, okA := parseVersionParts(a)
|
||||
bParts, okB := parseVersionParts(b)
|
||||
if !okA || !okB {
|
||||
return 0, false
|
||||
}
|
||||
for i := 0; i < len(aParts); i++ {
|
||||
if aParts[i] > bParts[i] {
|
||||
return 1, true
|
||||
}
|
||||
if aParts[i] < bParts[i] {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func parseVersionParts(version string) ([3]int, bool) {
|
||||
var result [3]int
|
||||
parts := strings.Split(normalizeVersionTag(version), ".")
|
||||
if len(parts) != 3 {
|
||||
return result, false
|
||||
}
|
||||
for i, part := range parts {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return result, false
|
||||
}
|
||||
result[i] = n
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func normalizeVersionTag(version string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
7
web/service/panel_other.go
Normal file
7
web/service/panel_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package service
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func setDetachedProcess(cmd *exec.Cmd) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user