Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0a3929ff | ||
|
|
570ab8e5e0 | ||
|
|
1240e4c962 | ||
|
|
c117b8b272 | ||
|
|
6041d10e3d | ||
|
|
4800f8fb70 | ||
|
|
a9770e1da2 | ||
|
|
3f15d21f13 | ||
|
|
a6b3623634 | ||
|
|
947fd4fae1 | ||
|
|
e69a31dd59 | ||
|
|
719ae0e014 | ||
|
|
5bcf6a8aeb | ||
|
|
945fefde12 | ||
|
|
313a2acbf6 | ||
|
|
b747730211 | ||
|
|
692a73788a | ||
|
|
3287fa4d80 | ||
|
|
1393f981bc | ||
|
|
9a2c1c6b43 | ||
|
|
278aa1c85c | ||
|
|
8fe297ef9d | ||
|
|
c881d1015a | ||
|
|
c061337ce7 | ||
|
|
260eedf8c4 | ||
|
|
69ccdba734 | ||
|
|
4c797dc154 | ||
|
|
f000322a06 | ||
|
|
0ea8b5352a | ||
|
|
68240061aa | ||
|
|
0695f677ba | ||
|
|
70f6d6b21a | ||
|
|
e8c509c720 | ||
|
|
83a1c721c7 | ||
|
|
7ccc0877a1 | ||
|
|
ad659e48cf | ||
|
|
784ed39930 | ||
|
|
538f7fd5d7 | ||
|
|
cf38226b5d | ||
|
|
575ee854c8 | ||
|
|
9936af80dd | ||
|
|
4a75bd0a48 | ||
|
|
b0c223c631 | ||
|
|
313b51f96f | ||
|
|
020cd63e22 | ||
|
|
6e46e9b16e | ||
|
|
713a7328f6 |
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
XUI_DEBUG=true
|
||||
XUI_DB_FOLDER=x-ui
|
||||
XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -17,7 +17,8 @@ on:
|
||||
- '**.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'x-ui.service'
|
||||
- 'x-ui.service.debian'
|
||||
- 'x-ui.service.rhel'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -78,14 +79,15 @@ jobs:
|
||||
|
||||
mkdir x-ui
|
||||
cp xui-release x-ui/
|
||||
cp x-ui.service x-ui/
|
||||
cp x-ui.service.debian x-ui/
|
||||
cp x-ui.service.rhel x-ui/
|
||||
cp x-ui.sh x-ui/
|
||||
mv x-ui/xui-release x-ui/x-ui
|
||||
mkdir x-ui/bin
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.10.15/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.8/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -183,7 +185,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.10.15/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/"
|
||||
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"
|
||||
|
||||
5
CONTRIBUTING.md
Normal file
5
CONTRIBUTING.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## Local Development Setup
|
||||
|
||||
- Create a directory named `x-ui` in the project root
|
||||
- Rename `.env.example` to `.env `
|
||||
- Run `main.go`
|
||||
@@ -27,14 +27,14 @@ case $1 in
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.10.15/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
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 ../../
|
||||
@@ -8,7 +8,7 @@ ARG TARGETARCH
|
||||
RUN apk --no-cache --update add \
|
||||
build-base \
|
||||
gcc \
|
||||
wget \
|
||||
curl \
|
||||
unzip
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is only for personal using, please do not use it for illegal purposes, please do not use it in a production environment.
|
||||
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||
|
||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func GetLogFolder() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(".", "log")
|
||||
}
|
||||
return "/var/log"
|
||||
return "/var/log/x-ui"
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.8.5
|
||||
2.8.7
|
||||
79
go.mod
79
go.mod
@@ -1,104 +1,103 @@
|
||||
module github.com/mhsanaei/3x-ui/v2
|
||||
|
||||
go 1.25.2
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/gzip v1.2.4
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mymmrac/telego v1.3.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||
github.com/mymmrac/telego v1.4.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.2.4
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.9
|
||||
github.com/shirou/gopsutil/v4 v4.25.12
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.67.0
|
||||
github.com/valyala/fasthttp v1.69.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.250911.1-0.20251015080723-b69a376aa1b6
|
||||
github.com/xtls/xray-core v1.251208.0
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/sys v0.37.0
|
||||
golang.org/x/text v0.30.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/text v0.32.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.2 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
|
||||
github.com/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
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.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/miekg/dns v1.1.68 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/miekg/dns v1.1.69 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pires/go-proxyproto v0.8.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.1 // indirect
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagernet/sing v0.7.12 // indirect
|
||||
github.com/sagernet/sing v0.7.14 // indirect
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.4 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535 // indirect
|
||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
golang.org/x/tools v0.40.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-20251014184007-4626949a642f // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
|
||||
183
go.sum
183
go.sum
@@ -1,36 +1,35 @@
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
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/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=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||
github.com/cloudflare/circl v1.6.2/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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=
|
||||
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM=
|
||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
||||
github.com/gin-contrib/gzip v1.2.4 h1:yNz4EhPC2kHSZJD1oc1zwp7MLEhEZ3goQeGM3a1b6jU=
|
||||
github.com/gin-contrib/gzip v1.2.4/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -54,12 +53,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
||||
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -107,8 +106,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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
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=
|
||||
@@ -121,19 +120,19 @@ github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIi
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
|
||||
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mymmrac/telego v1.3.0 h1:y2bDDCioLgkcs+5luUaPgTNHKel1Qh30iUxFcMUrowg=
|
||||
github.com/mymmrac/telego v1.3.0/go.mod h1:0D2l/IA/gUFn4oqsi1O4/tSnlezw5jNV/ReFRDUEKk8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
||||
github.com/mymmrac/telego v1.4.0 h1:z74W5lfOTgLplQXuZPjDsRvvvI0iQatO2gp/XZz7s3I=
|
||||
github.com/mymmrac/telego v1.4.0/go.mod h1:u9fKXZSOCOdMj6K0U69fQqeAvDE+2RGkHKkDksijp3o=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||
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=
|
||||
@@ -146,10 +145,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
|
||||
@@ -158,110 +157,114 @@ 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.7.12 h1:MpMbO56crPRZTbltoj1wGk4Xj9+GiwH1wTO4s3fz1EA=
|
||||
github.com/sagernet/sing v0.7.12/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||
github.com/sagernet/sing v0.7.14 h1:5QQRDCUvYNOMyVp3LuK/hYEBAIv0VsbD3x/l9zH467s=
|
||||
github.com/sagernet/sing v0.7.14/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/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1L9iaKCTxdy3Em8Wv4ChIAGnfiz18Cda70g4=
|
||||
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg=
|
||||
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
|
||||
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
|
||||
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e h1:5QefA066A1tF8gHIiADmOVOV5LS43gt3ONnlEl3xkwI=
|
||||
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
|
||||
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
||||
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
||||
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535 h1:nwobseOLLRtdbP6z7Z2aVI97u8ZptTgD1ofovhAKmeU=
|
||||
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
||||
github.com/xtls/xray-core v1.250911.1-0.20251015080723-b69a376aa1b6 h1:gwgJxWb9OABUJAYxiS33nQzk3MRVjidzBnHBrzKnxOw=
|
||||
github.com/xtls/xray-core v1.250911.1-0.20251015080723-b69a376aa1b6/go.mod h1:72ZU/srfutsNPmw9y8SCGRy0iccvshIRk8BNGR8D2Ik=
|
||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
|
||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
||||
github.com/xtls/xray-core v1.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0eyAes=
|
||||
github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
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=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
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=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -273,8 +276,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||
|
||||
712
install.sh
712
install.sh
@@ -8,6 +8,9 @@ plain='\033[0m'
|
||||
|
||||
cur_dir=$(pwd)
|
||||
|
||||
xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
|
||||
xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
||||
|
||||
# check root
|
||||
[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1
|
||||
|
||||
@@ -15,7 +18,7 @@ cur_dir=$(pwd)
|
||||
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
|
||||
@@ -26,41 +29,59 @@ echo "The OS release is: $release"
|
||||
|
||||
arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64 | x64 | amd64) echo 'amd64' ;;
|
||||
i*86 | x86) echo '386' ;;
|
||||
armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;;
|
||||
armv7* | armv7 | arm) echo 'armv7' ;;
|
||||
armv6* | armv6) echo 'armv6' ;;
|
||||
armv5* | armv5) echo 'armv5' ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;;
|
||||
x86_64 | x64 | amd64) echo 'amd64' ;;
|
||||
i*86 | x86) echo '386' ;;
|
||||
armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;;
|
||||
armv7* | armv7 | arm) echo 'armv7' ;;
|
||||
armv6* | armv6) echo 'armv6' ;;
|
||||
armv5* | armv5) echo 'armv5' ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "Arch: $(arch)"
|
||||
|
||||
# Simple helpers
|
||||
is_ipv4() {
|
||||
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1
|
||||
}
|
||||
is_ipv6() {
|
||||
[[ "$1" =~ : ]] && return 0 || return 1
|
||||
}
|
||||
is_ip() {
|
||||
is_ipv4 "$1" || is_ipv6 "$1"
|
||||
}
|
||||
is_domain() {
|
||||
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+[A-Za-z]{2,}$ ]] && return 0 || return 1
|
||||
}
|
||||
|
||||
install_base() {
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install -y -q wget curl tar tzdata
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install -y -q curl tar tzdata socat
|
||||
;;
|
||||
centos | rhel | almalinux | rocky | ol)
|
||||
yum -y update && yum install -y -q wget curl tar tzdata
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update && dnf install -y -q curl tar tzdata socat
|
||||
;;
|
||||
fedora | amzn | virtuozzo)
|
||||
dnf -y update && dnf install -y -q wget curl tar tzdata
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update && yum install -y curl tar tzdata socat
|
||||
else
|
||||
dnf -y update && dnf install -y -q curl tar tzdata socat
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Syu && pacman -Syu --noconfirm wget curl tar tzdata
|
||||
arch | manjaro | parch)
|
||||
pacman -Syu && pacman -Syu --noconfirm curl tar tzdata socat
|
||||
;;
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh && zypper -q install -y wget curl tar timezone
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh && zypper -q install -y curl tar timezone socat
|
||||
;;
|
||||
alpine)
|
||||
apk update && apk add wget curl tar tzdata
|
||||
alpine)
|
||||
apk update && apk add curl tar tzdata socat
|
||||
;;
|
||||
*)
|
||||
apt-get update && apt-get install -y -q wget curl tar tzdata
|
||||
*)
|
||||
apt-get update && apt-get install -y -q curl tar tzdata socat
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -71,17 +92,451 @@ gen_random_string() {
|
||||
echo "$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
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to install acme.sh${plain}"
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}acme.sh installed successfully${plain}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
setup_ssl_certificate() {
|
||||
local domain="$1"
|
||||
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
|
||||
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 >/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
|
||||
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
|
||||
|
||||
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
|
||||
# Secure permissions: private key readable only by owner
|
||||
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
|
||||
echo -e "${green}SSL certificate installed and configured successfully!${plain}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${yellow}Certificate files not found${plain}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Issue Let's Encrypt IP certificate with shortlived profile (~6 days validity)
|
||||
# Requires acme.sh and port 80 open for HTTP-01 challenge
|
||||
setup_ip_certificate() {
|
||||
local ipv4="$1"
|
||||
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}Port 80 must be open and accessible from the internet.${plain}"
|
||||
|
||||
# Check for acme.sh
|
||||
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}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate IP address
|
||||
if [[ -z "$ipv4" ]]; then
|
||||
echo -e "${red}IPv4 address is required${plain}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! is_ipv4 "$ipv4"; then
|
||||
echo -e "${red}Invalid IPv4 address: $ipv4${plain}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create certificate directory
|
||||
local certDir="/root/cert/ip"
|
||||
mkdir -p "$certDir"
|
||||
|
||||
# Build domain arguments
|
||||
local domain_args="-d ${ipv4}"
|
||||
if [[ -n "$ipv6" ]] && is_ipv6 "$ipv6"; then
|
||||
domain_args="${domain_args} -d ${ipv6}"
|
||||
echo -e "${green}Including IPv6 address: ${ipv6}${plain}"
|
||||
fi
|
||||
|
||||
# Set reload command for auto-renewal (add || true so it doesn't fail during first install)
|
||||
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true"
|
||||
|
||||
# Issue certificate with shortlived profile
|
||||
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1
|
||||
|
||||
~/.acme.sh/acme.sh --issue \
|
||||
${domain_args} \
|
||||
--standalone \
|
||||
--server letsencrypt \
|
||||
--certificate-profile shortlived \
|
||||
--days 6 \
|
||||
--httpport 80 \
|
||||
--force
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to issue IP certificate${plain}"
|
||||
echo -e "${yellow}Please ensure port 80 is open and accessible from the internet${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
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${green}Certificate issued successfully, installing...${plain}"
|
||||
|
||||
# Install certificate
|
||||
# Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
|
||||
# but the cert files are still installed. We check for files instead of exit code.
|
||||
~/.acme.sh/acme.sh --installcert -d ${ipv4} \
|
||||
--key-file "${certDir}/privkey.pem" \
|
||||
--fullchain-file "${certDir}/fullchain.pem" \
|
||||
--reloadcmd "${reloadCmd}" 2>&1 || true
|
||||
|
||||
# Verify certificate files exist (don't rely on exit code - reloadcmd failure causes non-zero)
|
||||
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
|
||||
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
|
||||
|
||||
# Secure permissions: private key readable only by owner
|
||||
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}"
|
||||
echo -e " Cert: ${certDir}/fullchain.pem"
|
||||
echo -e " Key: ${certDir}/privkey.pem"
|
||||
else
|
||||
echo -e "${green}Certificate paths configured successfully${plain}"
|
||||
fi
|
||||
|
||||
echo -e "${green}IP certificate installed and configured successfully!${plain}"
|
||||
echo -e "${green}Certificate valid for ~6 days, auto-renews via acme.sh cron job.${plain}"
|
||||
echo -e "${yellow}acme.sh will automatically renew and reload x-ui before expiry.${plain}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Comprehensive manual SSL certificate issuance via acme.sh
|
||||
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
|
||||
echo "acme.sh could not be found. Installing now..."
|
||||
cd ~ || return 1
|
||||
curl -s https://get.acme.sh | sh
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Failed to install acme.sh${plain}"
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}acme.sh installed successfully${plain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# get the domain here, and we need to verify it
|
||||
local domain=""
|
||||
while true; do
|
||||
read -rp "Please enter your domain name: " domain
|
||||
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}"
|
||||
|
||||
# check if there already exists a certificate
|
||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
||||
if [ "${currentCert}" == "${domain}" ]; then
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
||||
echo -e "${yellow}Current certificate details:${plain}"
|
||||
echo "$certInfo"
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||
fi
|
||||
|
||||
# create a directory for the certificate
|
||||
certPath="/root/cert/${domain}"
|
||||
if [ ! -d "$certPath" ]; then
|
||||
mkdir -p "$certPath"
|
||||
else
|
||||
rm -rf "$certPath"
|
||||
mkdir -p "$certPath"
|
||||
fi
|
||||
|
||||
# get the port number for the standalone server
|
||||
local WebPort=80
|
||||
read -rp "Please choose which port to use (default is 80): " WebPort
|
||||
if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then
|
||||
echo -e "${yellow}Your input ${WebPort} is invalid, will use default port 80.${plain}"
|
||||
WebPort=80
|
||||
fi
|
||||
echo -e "${green}Will use port: ${WebPort} to issue certificates. Please make sure this port is open.${plain}"
|
||||
|
||||
# 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
|
||||
|
||||
# issue the certificate
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
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
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||
fi
|
||||
|
||||
# Setup reload command
|
||||
reloadCmd="systemctl restart x-ui || rc-service x-ui restart"
|
||||
echo -e "${green}Default --reloadcmd for ACME is: ${yellow}systemctl restart x-ui || rc-service x-ui restart${plain}"
|
||||
echo -e "${green}This command will run on every certificate issue and renew.${plain}"
|
||||
read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd
|
||||
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
|
||||
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; systemctl restart x-ui"
|
||||
echo -e "${green}\t2.${plain} Input your own command"
|
||||
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}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# install the certificate
|
||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||
--key-file /root/cert/${domain}/privkey.pem \
|
||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||
fi
|
||||
|
||||
# enable auto-renew
|
||||
~/.acme.sh/acme.sh --upgrade --auto-upgrade
|
||||
if [ $? -ne 0 ]; then
|
||||
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
|
||||
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
|
||||
fi
|
||||
|
||||
# start panel
|
||||
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
|
||||
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
|
||||
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"
|
||||
echo -e "${green}Certificate paths set for the panel${plain}"
|
||||
echo -e "${green}Certificate File: $webCertFile${plain}"
|
||||
echo -e "${green}Private Key File: $webKeyFile${plain}"
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
# Reusable interactive SSL setup (domain or IP)
|
||||
# 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 server_ip="$3"
|
||||
|
||||
local ssl_choice=""
|
||||
|
||||
echo -e "${yellow}Choose SSL certificate setup method:${plain}"
|
||||
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
||||
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
||||
echo -e "${blue}Note:${plain} Both options require port 80 open. IP certs use shortlived profile."
|
||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||
|
||||
# Default to 2 (IP cert) if not 1
|
||||
if [[ "$ssl_choice" != "1" ]]; 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}"
|
||||
ssl_cert_issue
|
||||
# Extract the domain that was used from the certificate
|
||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
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
|
||||
;;
|
||||
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
|
||||
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
config_after_install() {
|
||||
local existing_hasDefaultCredential=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
|
||||
local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
# 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 | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
local URL_lists=(
|
||||
"https://api4.ipify.org"
|
||||
"https://ipv4.icanhazip.com"
|
||||
"https://v4.api.ipinfo.io/ip"
|
||||
"https://ipv4.myexternalip.com/raw"
|
||||
"https://4.ident.me"
|
||||
"https://check-host.net/ip"
|
||||
"https://ipv4.icanhazip.com"
|
||||
"https://v4.api.ipinfo.io/ip"
|
||||
"https://ipv4.myexternalip.com/raw"
|
||||
"https://4.ident.me"
|
||||
"https://check-host.net/ip"
|
||||
)
|
||||
local server_ip=""
|
||||
for ip_address in "${URL_lists[@]}"; do
|
||||
@@ -90,13 +545,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
|
||||
@@ -105,46 +560,92 @@ 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}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}For security, SSL certificate is required for all panels.${plain}"
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
|
||||
/usr/local/x-ui/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}"
|
||||
echo -e "This is a fresh installation, generating random login info for security concerns:"
|
||||
echo -e "###############################################"
|
||||
echo -e "${green}Username: ${config_username}${plain}"
|
||||
echo -e "${green}Password: ${config_password}${plain}"
|
||||
echo -e "${green}Port: ${config_port}${plain}"
|
||||
prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
|
||||
|
||||
# Display final credentials and access information
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} Panel Installation Complete! ${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green}Username: ${config_username}${plain}"
|
||||
echo -e "${green}Password: ${config_password}${plain}"
|
||||
echo -e "${green}Port: ${config_port}${plain}"
|
||||
echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
|
||||
echo -e "${green}Access URL: http://${server_ip}:${config_port}/${config_webBasePath}${plain}"
|
||||
echo -e "###############################################"
|
||||
echo -e "${green}Access URL: https://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
|
||||
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
|
||||
else
|
||||
local config_webBasePath=$(gen_random_string 18)
|
||||
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
||||
/usr/local/x-ui/x-ui setting -webBasePath "${config_webBasePath}"
|
||||
${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}"
|
||||
echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}"
|
||||
echo -e "${green}Access URL: http://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
|
||||
|
||||
# If the panel is already installed but no certificate is configured, prompt for SSL now
|
||||
if [[ -z "${existing_cert}" ]]; then
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
|
||||
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
|
||||
else
|
||||
# If a cert already exists, just show the access URL
|
||||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
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}"
|
||||
/usr/local/x-ui/x-ui setting -username "${config_username}" -password "${config_password}"
|
||||
${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}"
|
||||
echo -e "Generated new random login credentials:"
|
||||
echo -e "###############################################"
|
||||
echo -e "${green}Username: ${config_username}${plain}"
|
||||
echo -e "${green}Password: ${config_password}${plain}"
|
||||
echo -e "###############################################"
|
||||
else
|
||||
echo -e "${green}Username, Password, and WebBasePath are properly set. Exiting...${plain}"
|
||||
echo -e "${green}Username, Password, and WebBasePath are properly set.${plain}"
|
||||
fi
|
||||
|
||||
# Existing install: if no cert configured, prompt user for SSL setup
|
||||
# Properly detect empty cert by checking if cert: line exists and has content after it
|
||||
existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
if [[ -z "$existing_cert" ]]; then
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
||||
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
|
||||
else
|
||||
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
/usr/local/x-ui/x-ui migrate
|
||||
|
||||
${xui_folder}/x-ui migrate
|
||||
}
|
||||
|
||||
install_x-ui() {
|
||||
cd /usr/local/
|
||||
|
||||
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/')
|
||||
@@ -157,7 +658,7 @@ install_x-ui() {
|
||||
fi
|
||||
fi
|
||||
echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
|
||||
wget --inet4-only -N -O /usr/local/x-ui-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
|
||||
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
|
||||
exit 1
|
||||
@@ -166,36 +667,36 @@ 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"
|
||||
wget --inet4-only -N -O /usr/local/x-ui-linux-$(arch).tar.gz ${url}
|
||||
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url}
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
wget --inet4-only -O /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
|
||||
curl -4fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to download x-ui.sh${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Stop x-ui service and remove old resources
|
||||
if [[ -e /usr/local/x-ui/ ]]; then
|
||||
if [[ -e ${xui_folder}/ ]]; then
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop
|
||||
else
|
||||
systemctl stop x-ui
|
||||
fi
|
||||
rm /usr/local/x-ui/ -rf
|
||||
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
|
||||
@@ -203,21 +704,36 @@ install_x-ui() {
|
||||
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
|
||||
mkdir -p /var/log/x-ui
|
||||
config_after_install
|
||||
|
||||
# Etckeeper compatibility
|
||||
if [ -d "/etc/.git" ]; then
|
||||
if [ -f "/etc/.gitignore" ]; then
|
||||
if ! grep -q "x-ui/x-ui.db" "/etc/.gitignore"; then
|
||||
echo "" >> "/etc/.gitignore"
|
||||
echo "x-ui/x-ui.db" >> "/etc/.gitignore"
|
||||
echo -e "${green}Added x-ui.db to /etc/.gitignore for etckeeper${plain}"
|
||||
fi
|
||||
else
|
||||
echo "x-ui/x-ui.db" > "/etc/.gitignore"
|
||||
echo -e "${green}Created /etc/.gitignore and added x-ui.db for etckeeper${plain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $release == "alpine" ]]; then
|
||||
wget --inet4-only -O /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
|
||||
curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to download x-ui.rc${plain}"
|
||||
exit 1
|
||||
@@ -226,12 +742,72 @@ install_x-ui() {
|
||||
rc-update add x-ui
|
||||
rc-service x-ui start
|
||||
else
|
||||
cp -f x-ui.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl start x-ui
|
||||
# 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
|
||||
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
|
||||
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
|
||||
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.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
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl start x-ui
|
||||
else
|
||||
echo -e "${red}Failed to install x-ui.service file${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
|
||||
echo -e ""
|
||||
echo -e "┌───────────────────────────────────────────────────────┐
|
||||
@@ -248,7 +824,7 @@ install_x-ui() {
|
||||
│ ${blue}x-ui log${plain} - Check logs │
|
||||
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
||||
│ ${blue}x-ui update${plain} - Update │
|
||||
│ ${blue}x-ui legacy${plain} - legacy version │
|
||||
│ ${blue}x-ui legacy${plain} - Legacy version │
|
||||
│ ${blue}x-ui install${plain} - Install │
|
||||
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
||||
└───────────────────────────────────────────────────────┘"
|
||||
|
||||
22
main.go
22
main.go
@@ -78,6 +78,10 @@ func runWebServer() {
|
||||
case syscall.SIGHUP:
|
||||
logger.Info("Received SIGHUP signal. Restarting servers...")
|
||||
|
||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
|
||||
service.StopBot()
|
||||
// --
|
||||
|
||||
err := server.Stop()
|
||||
if err != nil {
|
||||
logger.Debug("Error stopping web server:", err)
|
||||
@@ -106,6 +110,10 @@ func runWebServer() {
|
||||
log.Println("Sub server restarted successfully.")
|
||||
|
||||
default:
|
||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
||||
service.StopBot()
|
||||
// ------------------------------------------------------------
|
||||
|
||||
server.Stop()
|
||||
subServer.Stop()
|
||||
log.Println("Shutting down servers.")
|
||||
@@ -321,6 +329,20 @@ func updateCert(publicKey string, privateKey string) {
|
||||
} else {
|
||||
fmt.Println("set certificate private key success")
|
||||
}
|
||||
|
||||
err = settingService.SetSubCertFile(publicKey)
|
||||
if err != nil {
|
||||
fmt.Println("set certificate for subscription public key failed:", err)
|
||||
} else {
|
||||
fmt.Println("set certificate for subscription public key success")
|
||||
}
|
||||
|
||||
err = settingService.SetSubKeyFile(privateKey)
|
||||
if err != nil {
|
||||
fmt.Println("set certificate for subscription private key failed:", err)
|
||||
} else {
|
||||
fmt.Println("set certificate for subscription private key success")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("both public and private key should be entered.")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
@@ -197,9 +198,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||
|
||||
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
||||
newConfigJson := make(map[string]any)
|
||||
for key, value := range s.configJson {
|
||||
newConfigJson[key] = value
|
||||
}
|
||||
maps.Copy(newConfigJson, s.configJson)
|
||||
|
||||
newConfigJson["outbounds"] = newOutbounds
|
||||
newConfigJson["remarks"] = s.SubService.genRemark(inbound, client.Email, extPrxy["remark"].(string))
|
||||
|
||||
|
||||
@@ -179,9 +179,15 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
if inbound.Protocol != model.VMESS {
|
||||
return ""
|
||||
}
|
||||
var address string
|
||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||
address = s.address
|
||||
} else {
|
||||
address = inbound.Listen
|
||||
}
|
||||
obj := map[string]any{
|
||||
"v": "2",
|
||||
"add": s.address,
|
||||
"add": address,
|
||||
"port": inbound.Port,
|
||||
"type": "none",
|
||||
}
|
||||
@@ -317,7 +323,13 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
}
|
||||
|
||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
var address string
|
||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||
address = s.address
|
||||
} else {
|
||||
address = inbound.Listen
|
||||
}
|
||||
|
||||
if inbound.Protocol != model.VLESS {
|
||||
return ""
|
||||
}
|
||||
@@ -472,8 +484,8 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
externalProxies, _ := stream["externalProxy"].([]any)
|
||||
|
||||
if len(externalProxies) > 0 {
|
||||
links := ""
|
||||
for index, externalProxy := range externalProxies {
|
||||
links := make([]string, 0, len(externalProxies))
|
||||
for _, externalProxy := range externalProxies {
|
||||
ep, _ := externalProxy.(map[string]any)
|
||||
newSecurity, _ := ep["forceTls"].(string)
|
||||
dest, _ := ep["dest"].(string)
|
||||
@@ -499,12 +511,9 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
|
||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
||||
|
||||
if index > 0 {
|
||||
links += "\n"
|
||||
}
|
||||
links += url.String()
|
||||
links = append(links, url.String())
|
||||
}
|
||||
return links
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port)
|
||||
@@ -523,7 +532,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
}
|
||||
|
||||
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
var address string
|
||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||
address = s.address
|
||||
} else {
|
||||
address = inbound.Listen
|
||||
}
|
||||
if inbound.Protocol != model.Trojan {
|
||||
return ""
|
||||
}
|
||||
@@ -719,7 +733,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
}
|
||||
|
||||
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
var address string
|
||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||
address = s.address
|
||||
} else {
|
||||
address = inbound.Listen
|
||||
}
|
||||
if inbound.Protocol != model.Shadowsocks {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -13,6 +13,5 @@ func HashPasswordAsBcrypt(password string) (string, error) {
|
||||
|
||||
// CheckPasswordHash verifies if the given password matches the bcrypt hash.
|
||||
func CheckPasswordHash(hash, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
@@ -1,144 +1,160 @@
|
||||
package ldaputil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
UseTLS bool
|
||||
BindDN string
|
||||
Password string
|
||||
BaseDN string
|
||||
UserFilter string
|
||||
UserAttr string
|
||||
FlagField string
|
||||
TruthyVals []string
|
||||
Invert bool
|
||||
Host string
|
||||
Port int
|
||||
UseTLS bool
|
||||
BindDN string
|
||||
Password string
|
||||
BaseDN string
|
||||
UserFilter string
|
||||
UserAttr string
|
||||
FlagField string
|
||||
TruthyVals []string
|
||||
Invert bool
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns map[email]enabled
|
||||
func FetchVlessFlags(cfg Config) (map[string]bool, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
if cfg.UseTLS {
|
||||
conn, err = ldap.DialTLS("tcp", addr, &tls.Config{InsecureSkipVerify: false})
|
||||
} else {
|
||||
conn, err = ldap.Dial("tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
scheme := "ldap"
|
||||
if cfg.UseTLS {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "mail"
|
||||
}
|
||||
// if field not set we fallback to legacy vless_enabled
|
||||
if cfg.FlagField == "" {
|
||||
cfg.FlagField = "vless_enabled"
|
||||
}
|
||||
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
|
||||
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
cfg.UserFilter,
|
||||
[]string{cfg.UserAttr, cfg.FlagField},
|
||||
nil,
|
||||
)
|
||||
var opts []ldap.DialOpt
|
||||
if cfg.UseTLS {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
}))
|
||||
}
|
||||
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := ldap.DialURL(ldapURL, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
result := make(map[string]bool, len(res.Entries))
|
||||
for _, e := range res.Entries {
|
||||
user := e.GetAttributeValue(cfg.UserAttr)
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
val := e.GetAttributeValue(cfg.FlagField)
|
||||
enabled := false
|
||||
for _, t := range cfg.TruthyVals {
|
||||
if val == t {
|
||||
enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.Invert {
|
||||
enabled = !enabled
|
||||
}
|
||||
result[user] = enabled
|
||||
}
|
||||
return result, nil
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "mail"
|
||||
}
|
||||
// if field not set we fallback to legacy vless_enabled
|
||||
if cfg.FlagField == "" {
|
||||
cfg.FlagField = "vless_enabled"
|
||||
}
|
||||
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
cfg.UserFilter,
|
||||
[]string{cfg.UserAttr, cfg.FlagField},
|
||||
nil,
|
||||
)
|
||||
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]bool, len(res.Entries))
|
||||
for _, e := range res.Entries {
|
||||
user := e.GetAttributeValue(cfg.UserAttr)
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
val := e.GetAttributeValue(cfg.FlagField)
|
||||
enabled := false
|
||||
for _, t := range cfg.TruthyVals {
|
||||
if val == t {
|
||||
enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.Invert {
|
||||
enabled = !enabled
|
||||
}
|
||||
result[user] = enabled
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser searches user by cfg.UserAttr and attempts to bind with provided password.
|
||||
func AuthenticateUser(cfg Config, username, password string) (bool, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
if cfg.UseTLS {
|
||||
conn, err = ldap.DialTLS("tcp", addr, &tls.Config{InsecureSkipVerify: false})
|
||||
} else {
|
||||
conn, err = ldap.Dial("tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
// Optional initial bind for search
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
scheme := "ldap"
|
||||
if cfg.UseTLS {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "uid"
|
||||
}
|
||||
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
|
||||
|
||||
// Build filter to find specific user
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UserAttr, ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
userDN := res.Entries[0].DN
|
||||
// Try to bind as the user
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
var opts []ldap.DialOpt
|
||||
if cfg.UseTLS {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
}))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(ldapURL, opts...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Optional initial bind for search
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "uid"
|
||||
}
|
||||
|
||||
// Build filter to find specific user
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UserAttr, ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
userDN := res.Entries[0].DN
|
||||
// Try to bind as the user
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ var (
|
||||
// init initializes the character sequences used for random string generation.
|
||||
// It sets up arrays for numbers, lowercase letters, uppercase letters, and combinations.
|
||||
func init() {
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := range 10 {
|
||||
numSeq[i] = rune('0' + i)
|
||||
}
|
||||
for i := 0; i < 26; i++ {
|
||||
for i := range 26 {
|
||||
lowerSeq[i] = rune('a' + i)
|
||||
upperSeq[i] = rune('A' + i)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func init() {
|
||||
// Seq generates a random string of length n containing alphanumeric characters (numbers, lowercase and uppercase letters).
|
||||
func Seq(n int) string {
|
||||
runes := make([]rune, n)
|
||||
for i := 0; i < n; i++ {
|
||||
for i := range n {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(allSeq))))
|
||||
if err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
|
||||
@@ -7,7 +7,7 @@ import "reflect"
|
||||
func GetFields(t reflect.Type) []reflect.StructField {
|
||||
num := t.NumField()
|
||||
fields := make([]reflect.StructField, 0, num)
|
||||
for i := 0; i < num; i++ {
|
||||
for i := range num {
|
||||
fields = append(fields, t.Field(i))
|
||||
}
|
||||
return fields
|
||||
@@ -17,7 +17,7 @@ func GetFields(t reflect.Type) []reflect.StructField {
|
||||
func GetFieldValues(v reflect.Value) []reflect.Value {
|
||||
num := v.NumField()
|
||||
fields := make([]reflect.Value, 0, num)
|
||||
for i := 0; i < num; i++ {
|
||||
for i := range num {
|
||||
fields = append(fields, v.Field(i))
|
||||
}
|
||||
return fields
|
||||
|
||||
@@ -47,11 +47,11 @@ func CPUPercentRaw() (float64, error) {
|
||||
var out [5]uint64
|
||||
switch len(raw) {
|
||||
case 5 * 8:
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
out[i] = binary.LittleEndian.Uint64(raw[i*8 : (i+1)*8])
|
||||
}
|
||||
case 5 * 4:
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
out[i] = uint64(binary.LittleEndian.Uint32(raw[i*4 : (i+1)*4]))
|
||||
}
|
||||
default:
|
||||
|
||||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -317,7 +317,7 @@ TcpStreamSettings.TcpResponse = class extends XrayCommonClass {
|
||||
|
||||
class KcpStreamSettings extends XrayCommonClass {
|
||||
constructor(
|
||||
mtu = 1350,
|
||||
mtu = 1250,
|
||||
tti = 50,
|
||||
uplinkCapacity = 5,
|
||||
downlinkCapacity = 20,
|
||||
@@ -729,8 +729,8 @@ class RealityStreamSettings extends XrayCommonClass {
|
||||
constructor(
|
||||
show = false,
|
||||
xver = 0,
|
||||
target = 'google.com:443',
|
||||
serverNames = 'google.com,www.google.com',
|
||||
target = '',
|
||||
serverNames = '',
|
||||
privateKey = '',
|
||||
minClientVer = '',
|
||||
maxClientVer = '',
|
||||
@@ -740,6 +740,14 @@ class RealityStreamSettings extends XrayCommonClass {
|
||||
settings = new RealityStreamSettings.Settings()
|
||||
) {
|
||||
super();
|
||||
// If target/serverNames are not provided, use random values
|
||||
if (!target && !serverNames) {
|
||||
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
|
||||
? getRandomRealityTarget()
|
||||
: { target: 'www.apple.com:443', sni: 'www.apple.com,apple.com' };
|
||||
target = randomTarget.target;
|
||||
serverNames = randomTarget.sni;
|
||||
}
|
||||
this.show = show;
|
||||
this.xver = xver;
|
||||
this.target = target;
|
||||
@@ -849,6 +857,7 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||
V6Only = false,
|
||||
tcpWindowClamp = 600,
|
||||
interfaceName = "",
|
||||
trustedXForwardedFor = [],
|
||||
) {
|
||||
super();
|
||||
this.acceptProxyProtocol = acceptProxyProtocol;
|
||||
@@ -867,6 +876,7 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||
this.V6Only = V6Only;
|
||||
this.tcpWindowClamp = tcpWindowClamp;
|
||||
this.interfaceName = interfaceName;
|
||||
this.trustedXForwardedFor = trustedXForwardedFor;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
@@ -888,11 +898,12 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||
json.V6Only,
|
||||
json.tcpWindowClamp,
|
||||
json.interface,
|
||||
json.trustedXForwardedFor || [],
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
const result = {
|
||||
acceptProxyProtocol: this.acceptProxyProtocol,
|
||||
tcpFastOpen: this.tcpFastOpen,
|
||||
mark: this.mark,
|
||||
@@ -910,6 +921,10 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||
tcpWindowClamp: this.tcpWindowClamp,
|
||||
interface: this.interfaceName,
|
||||
};
|
||||
if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
|
||||
result.trustedXForwardedFor = this.trustedXForwardedFor;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,6 +1221,14 @@ class Inbound extends XrayCommonClass {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vision seed applies only when vision flow is selected
|
||||
canEnableVisionSeed() {
|
||||
if (!this.canEnableTlsFlow()) return false;
|
||||
const clients = this.settings?.vlesses;
|
||||
if (!Array.isArray(clients)) return false;
|
||||
return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION || c?.flow === TLS_FLOW_CONTROL.VISION_UDP443);
|
||||
}
|
||||
|
||||
canEnableReality() {
|
||||
if (![Protocols.VLESS, Protocols.TROJAN].includes(this.protocol)) return false;
|
||||
return ["tcp", "http", "grpc", "xhttp"].includes(this.network);
|
||||
@@ -1862,6 +1885,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
encryption = "none",
|
||||
fallbacks = [],
|
||||
selectedAuth = undefined,
|
||||
testseed = [900, 500, 900, 256],
|
||||
) {
|
||||
super(protocol);
|
||||
this.vlesses = vlesses;
|
||||
@@ -1869,6 +1893,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
this.encryption = encryption;
|
||||
this.fallbacks = fallbacks;
|
||||
this.selectedAuth = selectedAuth;
|
||||
this.testseed = testseed;
|
||||
}
|
||||
|
||||
addFallback() {
|
||||
@@ -1880,13 +1905,20 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
// Ensure testseed is always initialized as an array
|
||||
let testseed = [900, 500, 900, 256];
|
||||
if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) {
|
||||
testseed = json.testseed;
|
||||
}
|
||||
|
||||
const obj = new Inbound.VLESSSettings(
|
||||
Protocols.VLESS,
|
||||
(json.clients || []).map(client => Inbound.VLESSSettings.VLESS.fromJson(client)),
|
||||
json.decryption,
|
||||
json.encryption,
|
||||
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
|
||||
json.selectedAuth
|
||||
json.selectedAuth,
|
||||
testseed
|
||||
);
|
||||
return obj;
|
||||
}
|
||||
@@ -1912,6 +1944,10 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
json.selectedAuth = this.selectedAuth;
|
||||
}
|
||||
|
||||
if (this.testseed && this.testseed.length >= 4) {
|
||||
json.testseed = this.testseed;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -2470,7 +2506,7 @@ Inbound.HttpSettings.HttpAccount = class extends XrayCommonClass {
|
||||
Inbound.WireguardSettings = class extends XrayCommonClass {
|
||||
constructor(
|
||||
protocol,
|
||||
mtu = 1420,
|
||||
mtu = 1250,
|
||||
secretKey = Wireguard.generateKeypair().privateKey,
|
||||
peers = [new Inbound.WireguardSettings.Peer()],
|
||||
noKernelTun = false
|
||||
|
||||
@@ -164,7 +164,7 @@ class TcpStreamSettings extends CommonClass {
|
||||
|
||||
class KcpStreamSettings extends CommonClass {
|
||||
constructor(
|
||||
mtu = 1350,
|
||||
mtu = 1250,
|
||||
tti = 50,
|
||||
uplinkCapacity = 5,
|
||||
downlinkCapacity = 20,
|
||||
@@ -432,6 +432,7 @@ class SockoptStreamSettings extends CommonClass {
|
||||
tcpMptcp = false,
|
||||
penetrate = false,
|
||||
addressPortStrategy = Address_Port_Strategy.NONE,
|
||||
trustedXForwardedFor = [],
|
||||
) {
|
||||
super();
|
||||
this.dialerProxy = dialerProxy;
|
||||
@@ -440,6 +441,7 @@ class SockoptStreamSettings extends CommonClass {
|
||||
this.tcpMptcp = tcpMptcp;
|
||||
this.penetrate = penetrate;
|
||||
this.addressPortStrategy = addressPortStrategy;
|
||||
this.trustedXForwardedFor = trustedXForwardedFor;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
@@ -450,12 +452,13 @@ class SockoptStreamSettings extends CommonClass {
|
||||
json.tcpKeepAliveInterval,
|
||||
json.tcpMptcp,
|
||||
json.penetrate,
|
||||
json.addressPortStrategy
|
||||
json.addressPortStrategy,
|
||||
json.trustedXForwardedFor || []
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
const result = {
|
||||
dialerProxy: this.dialerProxy,
|
||||
tcpFastOpen: this.tcpFastOpen,
|
||||
tcpKeepAliveInterval: this.tcpKeepAliveInterval,
|
||||
@@ -463,6 +466,10 @@ class SockoptStreamSettings extends CommonClass {
|
||||
penetrate: this.penetrate,
|
||||
addressPortStrategy: this.addressPortStrategy
|
||||
};
|
||||
if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
|
||||
result.trustedXForwardedFor = this.trustedXForwardedFor;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +621,13 @@ class Outbound extends CommonClass {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vision seed applies only when vision flow is selected
|
||||
canEnableVisionSeed() {
|
||||
if (!this.canEnableTlsFlow()) return false;
|
||||
const flow = this.settings?.flow;
|
||||
return flow === TLS_FLOW_CONTROL.VISION || flow === TLS_FLOW_CONTROL.VISION_UDP443;
|
||||
}
|
||||
|
||||
canEnableReality() {
|
||||
if (![Protocols.VLESS, Protocols.Trojan].includes(this.protocol)) return false;
|
||||
return ["tcp", "http", "grpc", "xhttp"].includes(this.stream.network);
|
||||
@@ -1050,13 +1064,15 @@ Outbound.VmessSettings = class extends CommonClass {
|
||||
}
|
||||
};
|
||||
Outbound.VLESSSettings = class extends CommonClass {
|
||||
constructor(address, port, id, flow, encryption) {
|
||||
constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) {
|
||||
super();
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.encryption = encryption;
|
||||
this.testpre = testpre;
|
||||
this.testseed = testseed;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
@@ -1066,18 +1082,27 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||
json.port,
|
||||
json.id,
|
||||
json.flow,
|
||||
json.encryption
|
||||
json.encryption,
|
||||
json.testpre || 0,
|
||||
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
const result = {
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
id: this.id,
|
||||
flow: this.flow,
|
||||
encryption: this.encryption,
|
||||
};
|
||||
if (this.testpre > 0) {
|
||||
result.testpre = this.testpre;
|
||||
}
|
||||
if (this.testseed && this.testseed.length >= 4) {
|
||||
result.testseed = this.testseed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
Outbound.TrojanSettings = class extends CommonClass {
|
||||
@@ -1208,7 +1233,7 @@ Outbound.HttpSettings = class extends CommonClass {
|
||||
|
||||
Outbound.WireguardSettings = class extends CommonClass {
|
||||
constructor(
|
||||
mtu = 1420,
|
||||
mtu = 1250,
|
||||
secretKey = '',
|
||||
address = [''],
|
||||
workers = 2,
|
||||
|
||||
31
web/assets/js/model/reality_targets.js
Normal file
31
web/assets/js/model/reality_targets.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
const REALITY_TARGETS = [
|
||||
{ target: 'www.icloud.com:443', sni: 'www.icloud.com,icloud.com' },
|
||||
{ target: 'www.apple.com:443', sni: 'www.apple.com,apple.com' },
|
||||
{ target: 'www.tesla.com:443', sni: 'www.tesla.com,tesla.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com,sony.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com,nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com,amd.com' },
|
||||
{ target: 'azure.microsoft.com:443', sni: 'azure.microsoft.com,www.azure.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com,amazon.com' },
|
||||
{ target: 'www.bing.com:443', sni: 'www.bing.com,bing.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com,oracle.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com,intel.com' },
|
||||
{ target: 'www.microsoft.com:443', sni: 'www.microsoft.com,microsoft.com' },
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random Reality target configuration from the predefined list
|
||||
* @returns {Object} Object with target and sni properties
|
||||
*/
|
||||
function getRandomRealityTarget() {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
// Return a copy to avoid reference issues
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,14 +138,14 @@
|
||||
return `streisand://import/${encodeURIComponent(this.app.subUrl)}`;
|
||||
},
|
||||
v2raytunUrl() {
|
||||
return this.app.subUrl;
|
||||
return this.app.subUrl;
|
||||
},
|
||||
npvtunUrl() {
|
||||
return this.app.subUrl;
|
||||
return this.app.subUrl;
|
||||
},
|
||||
happUrl() {
|
||||
return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
|
||||
}
|
||||
happUrl() {
|
||||
return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
renderLink,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
const oneMinute = 1000 * 60; // MilliseConds in a Minute
|
||||
const oneHour = oneMinute * 60; // The milliseconds of one hour
|
||||
const oneDay = oneHour * 24; // The Number of MilliseConds A Day
|
||||
const oneWeek = oneDay * 7; // The milliseconds per week
|
||||
const oneMonth = oneDay * 30; // The milliseconds of a month
|
||||
|
||||
/**
|
||||
* Decrease according to the number of days
|
||||
*
|
||||
* @param days to reduce the number of days to be reduced
|
||||
*/
|
||||
Date.prototype.minusDays = function (days) {
|
||||
return this.minusMillis(oneDay * days);
|
||||
};
|
||||
|
||||
/**
|
||||
* Increase according to the number of days
|
||||
*
|
||||
* @param days The number of days to be increased
|
||||
*/
|
||||
Date.prototype.plusDays = function (days) {
|
||||
return this.plusMillis(oneDay * days);
|
||||
};
|
||||
|
||||
/**
|
||||
* A few
|
||||
*
|
||||
* @param hours to be reduced
|
||||
*/
|
||||
Date.prototype.minusHours = function (hours) {
|
||||
return this.minusMillis(oneHour * hours);
|
||||
};
|
||||
|
||||
/**
|
||||
* Increase hourly
|
||||
*
|
||||
* @param hours to increase the number of hours
|
||||
*/
|
||||
Date.prototype.plusHours = function (hours) {
|
||||
return this.plusMillis(oneHour * hours);
|
||||
};
|
||||
|
||||
/**
|
||||
* Make reduction in minutes
|
||||
*
|
||||
* @param minutes to reduce the number of minutes
|
||||
*/
|
||||
Date.prototype.minusMinutes = function (minutes) {
|
||||
return this.minusMillis(oneMinute * minutes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add in minutes
|
||||
*
|
||||
* @param minutes to increase the number of minutes
|
||||
*/
|
||||
Date.prototype.plusMinutes = function (minutes) {
|
||||
return this.plusMillis(oneMinute * minutes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrease in milliseconds
|
||||
*
|
||||
* @param millis to reduce the milliseconds
|
||||
*/
|
||||
Date.prototype.minusMillis = function(millis) {
|
||||
let time = this.getTime() - millis;
|
||||
let newDate = new Date();
|
||||
newDate.setTime(time);
|
||||
return newDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add in milliseconds to increase
|
||||
*
|
||||
* @param millis to increase the milliseconds to increase
|
||||
*/
|
||||
Date.prototype.plusMillis = function(millis) {
|
||||
let time = this.getTime() + millis;
|
||||
let newDate = new Date();
|
||||
newDate.setTime(time);
|
||||
return newDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Setting time is 00: 00: 00.000 on the day
|
||||
*/
|
||||
Date.prototype.setMinTime = function () {
|
||||
this.setHours(0);
|
||||
this.setMinutes(0);
|
||||
this.setSeconds(0);
|
||||
this.setMilliseconds(0);
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Setting time is 23: 59: 59.999 on the same day
|
||||
*/
|
||||
Date.prototype.setMaxTime = function () {
|
||||
this.setHours(23);
|
||||
this.setMinutes(59);
|
||||
this.setSeconds(59);
|
||||
this.setMilliseconds(999);
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatting date
|
||||
*/
|
||||
Date.prototype.formatDate = function () {
|
||||
return this.getFullYear() + "-" + NumberFormatter.addZero(this.getMonth() + 1) + "-" + NumberFormatter.addZero(this.getDate());
|
||||
};
|
||||
|
||||
/**
|
||||
* Format time
|
||||
*/
|
||||
Date.prototype.formatTime = function () {
|
||||
return NumberFormatter.addZero(this.getHours()) + ":" + NumberFormatter.addZero(this.getMinutes()) + ":" + NumberFormatter.addZero(this.getSeconds());
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatting date plus time
|
||||
*
|
||||
* @param split Date and time separation symbols, default is a space
|
||||
*/
|
||||
Date.prototype.formatDateTime = function (split = ' ') {
|
||||
return this.formatDate() + split + this.formatTime();
|
||||
};
|
||||
|
||||
class DateUtil {
|
||||
// String to date object
|
||||
static parseDate(str) {
|
||||
return new Date(str.replace(/-/g, '/'));
|
||||
}
|
||||
|
||||
static formatMillis(millis) {
|
||||
return moment(millis).format('YYYY-M-D HH:mm:ss');
|
||||
}
|
||||
|
||||
static firstDayOfMonth() {
|
||||
const date = new Date();
|
||||
date.setDate(1);
|
||||
date.setMinTime();
|
||||
return date;
|
||||
}
|
||||
|
||||
static convertToJalalian(date) {
|
||||
return date && moment.isMoment(date) ? date.format('jYYYY/jMM/jDD HH:mm:ss') : null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -142,7 +142,7 @@ class RandomUtil {
|
||||
let length = 32;
|
||||
|
||||
if ([SSMethods.BLAKE3_AES_128_GCM].includes(method)) {
|
||||
length = 16;
|
||||
length = 16;
|
||||
}
|
||||
|
||||
const array = new Uint8Array(length);
|
||||
@@ -154,28 +154,28 @@ class RandomUtil {
|
||||
|
||||
static randomBase32String(length = 16) {
|
||||
const array = new Uint8Array(length);
|
||||
|
||||
|
||||
window.crypto.getRandomValues(array);
|
||||
|
||||
|
||||
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let result = '';
|
||||
let bits = 0;
|
||||
let buffer = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
buffer = (buffer << 8) | array[i];
|
||||
bits += 8;
|
||||
|
||||
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
result += base32Chars[(buffer >>> bits) & 0x1F];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (bits > 0) {
|
||||
result += base32Chars[(buffer << (5 - bits)) & 0x1F];
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -882,4 +882,38 @@ class FileManager {
|
||||
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
class IntlUtil {
|
||||
static formatDate(date) {
|
||||
const language = LanguageManager.getLanguage()
|
||||
|
||||
let intlOptions = {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric"
|
||||
}
|
||||
|
||||
const intl = new Intl.DateTimeFormat(
|
||||
language,
|
||||
intlOptions
|
||||
)
|
||||
|
||||
return intl.format(new Date(date))
|
||||
}
|
||||
static formatRelativeTime(date) {
|
||||
const language = LanguageManager.getLanguage()
|
||||
const now = new Date()
|
||||
|
||||
// Handle delayed start (negative expiryTime values)
|
||||
const diff = date < 0
|
||||
? Math.round(date / (1000 * 60 * 60 * 24))
|
||||
: Math.round((date - now) / (1000 * 60 * 60 * 24))
|
||||
const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' })
|
||||
|
||||
return formatter.format(diff, 'day');
|
||||
}
|
||||
}
|
||||
145
web/assets/js/websocket.js
Normal file
145
web/assets/js/websocket.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* WebSocket client for real-time updates
|
||||
*/
|
||||
class WebSocketClient {
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.listeners = new Map();
|
||||
this.isConnected = false;
|
||||
this.shouldReconnect = true;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
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, []);
|
||||
}
|
||||
this.listeners.get(event).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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('WebSocket is not connected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global WebSocket client instance
|
||||
// Safely get basePath from global scope (defined in page.html)
|
||||
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -125,6 +126,9 @@ 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)
|
||||
}
|
||||
|
||||
// delInbound deletes an inbound configuration by its ID.
|
||||
@@ -143,6 +147,10 @@ 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)
|
||||
}
|
||||
|
||||
// updateInbound updates an existing inbound configuration.
|
||||
@@ -169,6 +177,10 @@ 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)
|
||||
}
|
||||
|
||||
// getClientIps retrieves the IP addresses associated with a client by email.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -67,6 +68,8 @@ func (a *ServerController) refreshStatus() {
|
||||
// collect cpu history when status is fresh
|
||||
if a.lastStatus != nil {
|
||||
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
|
||||
// Broadcast status update via WebSocket
|
||||
websocket.BroadcastStatus(a.lastStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +158,16 @@ func (a *ServerController) stopXrayService(c *gin.Context) {
|
||||
err := a.serverService.StopXrayService()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
|
||||
websocket.BroadcastXrayState("error", err.Error())
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
||||
websocket.BroadcastXrayState("stop", "")
|
||||
websocket.BroadcastNotification(
|
||||
I18nWeb(c, "pages.xray.stopSuccess"),
|
||||
"Xray service has been stopped",
|
||||
"warning",
|
||||
)
|
||||
}
|
||||
|
||||
// restartXrayService restarts the Xray service.
|
||||
@@ -165,9 +175,16 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
|
||||
err := a.serverService.RestartXrayService()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
|
||||
websocket.BroadcastXrayState("error", err.Error())
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
||||
websocket.BroadcastXrayState("running", "")
|
||||
websocket.BroadcastNotification(
|
||||
I18nWeb(c, "pages.xray.restartSuccess"),
|
||||
"Xray service has been restarted successfully",
|
||||
"success",
|
||||
)
|
||||
}
|
||||
|
||||
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
||||
@@ -193,10 +210,10 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
|
||||
//getting tags for freedom and blackhole outbounds
|
||||
config, err := a.settingService.GetDefaultXrayConfig()
|
||||
if err == nil && config != nil {
|
||||
if cfgMap, ok := config.(map[string]interface{}); ok {
|
||||
if outbounds, ok := cfgMap["outbounds"].([]interface{}); ok {
|
||||
if cfgMap, ok := config.(map[string]any); ok {
|
||||
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
||||
for _, outbound := range outbounds {
|
||||
if obMap, ok := outbound.(map[string]interface{}); ok {
|
||||
if obMap, ok := outbound.(map[string]any); ok {
|
||||
switch obMap["protocol"] {
|
||||
case "freedom":
|
||||
if tag, ok := obMap["tag"].(string); ok {
|
||||
|
||||
189
web/controller/websocket.go
Normal file
189
web/controller/websocket.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
var upgrader = ws.Upgrader{
|
||||
ReadBufferSize: 4096, // Increased from 1024 for better performance
|
||||
WriteBufferSize: 4096, // Increased from 1024 for better performance
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
// WebSocketController handles WebSocket connections for real-time updates
|
||||
type WebSocketController struct {
|
||||
BaseController
|
||||
hub *websocket.Hub
|
||||
}
|
||||
|
||||
// NewWebSocketController creates a new WebSocket controller
|
||||
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
|
||||
return &WebSocketController{
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket handles WebSocket connections
|
||||
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
|
||||
w.hub.Register(client)
|
||||
logger.Debugf("WebSocket client %s registered from %s", clientID, 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
|
||||
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
|
||||
defer func() {
|
||||
if r := common.Recover("WebSocket readPump panic"); r != nil {
|
||||
logger.Error("WebSocket readPump panic recovered:", r)
|
||||
}
|
||||
w.hub.Unregister(client)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
||||
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 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
|
||||
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
if r := common.Recover("WebSocket writePump panic"); r != nil {
|
||||
logger.Error("WebSocket writePump panic recovered:", r)
|
||||
}
|
||||
ticker.Stop()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, 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 {
|
||||
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
|
||||
logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,30 +74,30 @@ type AllSetting struct {
|
||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
|
||||
// LDAP settings
|
||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||
LdapHost string `json:"ldapHost" form:"ldapHost"`
|
||||
LdapPort int `json:"ldapPort" form:"ldapPort"`
|
||||
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
|
||||
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
|
||||
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
|
||||
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
|
||||
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
|
||||
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid
|
||||
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
|
||||
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
|
||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||
LdapHost string `json:"ldapHost" form:"ldapHost"`
|
||||
LdapPort int `json:"ldapPort" form:"ldapPort"`
|
||||
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
|
||||
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
|
||||
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
|
||||
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
|
||||
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
|
||||
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid
|
||||
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
|
||||
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
|
||||
// Generic flag configuration
|
||||
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
|
||||
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
|
||||
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
|
||||
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
||||
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
||||
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
|
||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
|
||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
|
||||
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
|
||||
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
|
||||
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
|
||||
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
||||
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
||||
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
|
||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
|
||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
|
||||
// JSON subscription routing rules
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ var (
|
||||
type WebServer interface {
|
||||
GetCron() *cron.Cron // Get the cron scheduler
|
||||
GetCtx() context.Context // Get the server context
|
||||
GetWSHub() any // Get the WebSocket hub (using any to avoid circular dependency)
|
||||
}
|
||||
|
||||
// SubServer interface defines methods for accessing the subscription server instance.
|
||||
|
||||
@@ -24,6 +24,40 @@
|
||||
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 {
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior-x: contain;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
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;
|
||||
}
|
||||
.ant-tabs-tab-prev,
|
||||
.ant-tabs-tab-next {
|
||||
display: none !important; /* Hide arrows */
|
||||
}
|
||||
.ant-tabs-nav-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<title>{{ .host }} – {{ i18n .title}}</title>
|
||||
{{ end }}
|
||||
@@ -44,12 +78,12 @@
|
||||
<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/date-util.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" }}
|
||||
|
||||
@@ -111,20 +111,12 @@
|
||||
<template v-if="client.expiryTime !=0 && client.reset >0">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(client._expiryTime) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]]
|
||||
</template>
|
||||
</span>
|
||||
<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"> [[ remainedDays(client.expiryTime) ]] </td>
|
||||
<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>
|
||||
@@ -136,18 +128,10 @@
|
||||
<template v-else>
|
||||
<a-popover v-if="client.expiryTime != 0" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(client._expiryTime) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]]
|
||||
</template>
|
||||
</span>
|
||||
<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)"> [[ remainedDays(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">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
@@ -232,20 +216,12 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<template v-if="client.expiryTime !=0 && client.reset >0">
|
||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ remainedDays(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>
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(client._expiryTime) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]]
|
||||
</template>
|
||||
</span>
|
||||
<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-popover>
|
||||
@@ -256,18 +232,10 @@
|
||||
<td colspan="3" :style="{ textAlign: 'center' }">
|
||||
<a-popover v-if="client.expiryTime != 0" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(client._expiryTime) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]]
|
||||
</template>
|
||||
</span>
|
||||
<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)"> [[ remainedDays(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">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
@@ -289,12 +257,7 @@
|
||||
</template>
|
||||
<template slot="createdAt" slot-scope="text, client, index">
|
||||
<template v-if="client.created_at">
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(client.created_at) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(client.created_at)) ]]
|
||||
</template>
|
||||
[[ IntlUtil.formatDate(client.created_at) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
-
|
||||
@@ -302,12 +265,7 @@
|
||||
</template>
|
||||
<template slot="updatedAt" slot-scope="text, client, index">
|
||||
<template v-if="client.updated_at">
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(client.updated_at) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(client.updated_at)) ]]
|
||||
</template>
|
||||
[[ IntlUtil.formatDate(client.updated_at) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
-
|
||||
|
||||
@@ -52,9 +52,7 @@
|
||||
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
|
||||
<span v-if="datepicker == 'gregorian'">[[
|
||||
moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span>
|
||||
<span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span>
|
||||
<span>[[ IntlUtil.formatDate(dbInbound.lastTrafficResetTime) ]]</span>
|
||||
</span>
|
||||
</template>
|
||||
{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{{define "form/outbound"}}
|
||||
<!-- base -->
|
||||
<a-tabs :active-key="outModal.activeKey" :style="{ padding: '0', backgroundColor: 'transparent' }" @change="(activeKey) => {outModal.toggleJson(activeKey == '2'); }">
|
||||
<a-tabs :active-key="outModal.activeKey" :style="{ padding: '0', backgroundColor: 'transparent' }"
|
||||
@change="(activeKey) => {outModal.toggleJson(activeKey == '2'); }">
|
||||
<a-tab-pane key="1" tab="Form">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "protocol" }}'>
|
||||
@@ -8,8 +9,10 @@
|
||||
<a-select-option v-for="x,y in Protocols" :value="x">[[ y ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.tag" }}' has-feedback :validate-status="outModal.duplicateTag? 'warning' : 'success'">
|
||||
<a-input v-model.trim="outbound.tag" @change="outModal.check()" placeholder='{{ i18n "pages.xray.outbound.tagDesc" }}'></a-input>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.tag" }}' has-feedback
|
||||
:validate-status="outModal.duplicateTag? 'warning' : 'success'">
|
||||
<a-input v-model.trim="outbound.tag" @change="outModal.check()"
|
||||
placeholder='{{ i18n "pages.xray.outbound.tagDesc" }}'></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.sendThrough" }}'>
|
||||
<a-input v-model="outbound.sendThrough"></a-input>
|
||||
@@ -59,12 +62,13 @@
|
||||
<a-form-item label="Noises">
|
||||
<a-button icon="plus" type="primary" size="small" @click="outbound.settings.addNoise()"></a-button>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Noise Configurations -->
|
||||
<a-form v-for="(noise, index) in outbound.settings.noises" :key="index" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
|
||||
<!-- Noise Configurations -->
|
||||
<a-form v-for="(noise, index) in outbound.settings.noises" :key="index" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Noise [[ index + 1 ]]
|
||||
<a-icon v-if="outbound.settings.noises.length > 1" type="delete" @click="() => outbound.settings.delNoise(index)"
|
||||
<a-icon v-if="outbound.settings.noises.length > 1" type="delete"
|
||||
@click="() => outbound.settings.delNoise(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='Type'>
|
||||
@@ -108,7 +112,7 @@
|
||||
<a-select-option v-for="s in ['reject','drop','skip']" :value="s">[[ s ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="outbound.settings.nonIPQuery === 'skip'" label='Block Types' >
|
||||
<a-form-item v-if="outbound.settings.nonIPQuery === 'skip'" label='Block Types'>
|
||||
<a-input v-model.number="outbound.settings.blockTypes"></a-input>
|
||||
</a-form-item>
|
||||
</template>
|
||||
@@ -129,21 +133,21 @@
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.xray.wireguard.secretKey" }}
|
||||
<a-icon type="sync"
|
||||
@click="[outbound.settings.pubKey, outbound.settings.secretKey] = Object.values(Wireguard.generateKeypair())">
|
||||
</a-icon>
|
||||
</a-tooltip>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.xray.wireguard.secretKey" }}
|
||||
<a-icon type="sync"
|
||||
@click="[outbound.settings.pubKey, outbound.settings.secretKey] = Object.values(Wireguard.generateKeypair())">
|
||||
</a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="outbound.settings.secretKey"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.wireguard.publicKey" }}'>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.wireguard.publicKey" }}'>
|
||||
<a-input disabled v-model="outbound.settings.pubKey"></a-input>
|
||||
</a-form-item>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.wireguard.domainStrategy" }}'>
|
||||
<a-select v-model="outbound.settings.domainStrategy" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="wds in ['', ...WireguardDomainStrategy]" :value="wds">[[ wds ]]</a-select-option>
|
||||
@@ -171,8 +175,11 @@
|
||||
<a-form-item label="Peers">
|
||||
<a-button icon="plus" type="primary" size="small" @click="outbound.settings.addPeer()"></a-button>
|
||||
</a-form-item>
|
||||
<a-form v-for="(peer, index) in outbound.settings.peers" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Peer [[ index + 1 ]] <a-icon v-if="outbound.settings.peers.length>1" type="delete" @click="() => outbound.settings.delPeer(index)" :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
<a-form v-for="(peer, index) in outbound.settings.peers" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Peer [[ index + 1 ]] <a-icon v-if="outbound.settings.peers.length>1"
|
||||
type="delete" @click="() => outbound.settings.delPeer(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.wireguard.endpoint" }}'>
|
||||
<a-input v-model.trim="peer.endpoint"></a-input>
|
||||
@@ -190,7 +197,8 @@
|
||||
</template>
|
||||
<template v-for="(aip, index) in peer.allowedIPs" :style="{ marginBottom: '10px' }">
|
||||
<a-input v-model.trim="peer.allowedIPs[index]">
|
||||
<a-button icon="minus" v-if="peer.allowedIPs.length>1" slot="addonAfter" size="small" @click="peer.allowedIPs.splice(index, 1)"></a-button>
|
||||
<a-button icon="minus" v-if="peer.allowedIPs.length>1" slot="addonAfter" size="small"
|
||||
@click="peer.allowedIPs.splice(index, 1)"></a-button>
|
||||
</a-input>
|
||||
</template>
|
||||
</a-form-item>
|
||||
@@ -210,7 +218,7 @@
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- VLESS/VMess user settings -->
|
||||
<!-- VLESS/VMess user settings -->
|
||||
<template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)">
|
||||
<a-form-item label='ID'>
|
||||
<a-input v-model.trim="outbound.settings.id"></a-input>
|
||||
@@ -239,6 +247,33 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<!-- XTLS Vision Advanced Settings -->
|
||||
<template v-if="outbound.canEnableVisionSeed()">
|
||||
<a-form-item label="Vision Pre-Connect">
|
||||
<a-input-number v-model.number="outbound.settings.testpre" :min="0" :max="10" :style="{ width: '100%' }"
|
||||
placeholder="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Vision Seed">
|
||||
<a-row :gutter="8">
|
||||
<a-col :span="6">
|
||||
<a-input-number v-model.number="outbound.settings.testseed[0]" :min="0" :max="9999"
|
||||
:style="{ width: '100%' }" placeholder="900" addon-before="[0]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number v-model.number="outbound.settings.testseed[1]" :min="0" :max="9999"
|
||||
:style="{ width: '100%' }" placeholder="500" addon-before="[1]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number v-model.number="outbound.settings.testseed[2]" :min="0" :max="9999"
|
||||
:style="{ width: '100%' }" placeholder="900" addon-before="[2]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number v-model.number="outbound.settings.testseed[3]" :min="0" :max="9999"
|
||||
:style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Servers (trojan/shadowsocks/socks/http) settings -->
|
||||
@@ -264,7 +299,8 @@
|
||||
<template v-if="outbound.protocol === Protocols.Shadowsocks">
|
||||
<a-form-item label='{{ i18n "encryption" }}'>
|
||||
<a-select v-model="outbound.settings.method" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="(method, method_name) in SSMethods" :value="method">[[ method_name ]]</a-select-option>
|
||||
<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 label='UDP over TCP'>
|
||||
@@ -279,7 +315,8 @@
|
||||
<!-- stream settings -->
|
||||
<template v-if="outbound.canEnableStream()">
|
||||
<a-form-item label='{{ i18n "transmission" }}'>
|
||||
<a-select v-model="outbound.stream.network" @change="streamNetworkChange" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="outbound.stream.network" @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>
|
||||
@@ -290,7 +327,8 @@
|
||||
</a-form-item>
|
||||
<template v-if="outbound.stream.network === 'tcp'">
|
||||
<a-form-item label='HTTP {{ i18n "camouflage" }}'>
|
||||
<a-switch :checked="outbound.stream.tcp.type === 'http'" @change="checked => outbound.stream.tcp.type = checked ? 'http' : 'none'"></a-switch>
|
||||
<a-switch :checked="outbound.stream.tcp.type === 'http'"
|
||||
@change="checked => outbound.stream.tcp.type = checked ? 'http' : 'none'"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="outbound.stream.tcp.type == 'http'">
|
||||
<a-form-item label='{{ i18n "host" }}'>
|
||||
@@ -353,7 +391,7 @@
|
||||
<a-input-number v-model.number="outbound.stream.ws.heartbeatPeriod" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- grpc -->
|
||||
<template v-if="outbound.stream.network === 'grpc'">
|
||||
<a-form-item label='Service Name'>
|
||||
@@ -390,7 +428,8 @@
|
||||
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="No gRPC Header" v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
|
||||
<a-form-item label="No gRPC Header"
|
||||
v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
|
||||
<a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Min Upload Interval (Ms)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
|
||||
@@ -437,7 +476,8 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="ALPN">
|
||||
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" v-model="outbound.stream.tls.alpn">
|
||||
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="outbound.stream.tls.alpn">
|
||||
<a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
@@ -485,7 +525,8 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='Address Port Strategy'>
|
||||
<a-select v-model="outbound.stream.sockopt.addressPortStrategy" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="outbound.stream.sockopt.addressPortStrategy"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in Address_Port_Strategy" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
@@ -501,6 +542,15 @@
|
||||
<a-form-item label="Penetrate">
|
||||
<a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Trusted X-Forwarded-For">
|
||||
<a-select mode="tags" v-model="outbound.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>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- mux settings -->
|
||||
@@ -526,11 +576,12 @@
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="2" tab="JSON" force-render="true">
|
||||
<a-space direction="vertical" :size="10" :style="{ marginTop: '10px' }">
|
||||
<a-input addon-before='{{ i18n "pages.xray.outbound.link" }}' v-model.trim="outModal.link" placeholder="vmess:// vless:// trojan:// ss://">
|
||||
<a-input addon-before='{{ i18n "pages.xray.outbound.link" }}' v-model.trim="outModal.link"
|
||||
placeholder="vmess:// vless:// trojan:// ss://">
|
||||
<a-icon slot="addonAfter" type="form" @click="convertLink"></a-icon>
|
||||
</a-input>
|
||||
<textarea :style="{ position: 'absolute', left: '-800px' }" id="outboundJson"></textarea>
|
||||
</a-space>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -5,68 +5,119 @@
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vlesses.length">
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' +
|
||||
inbound.settings.vlesses.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
<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>
|
||||
</table>
|
||||
</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="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>
|
||||
</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>
|
||||
<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="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>
|
||||
{{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}}
|
||||
@@ -12,10 +12,26 @@
|
||||
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='Target'>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> Target <a-icon @click="randomizeRealityTarget()"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.reality.target"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='SNI'>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> SNI <a-icon @click="randomizeRealityTarget()"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.reality.serverNames"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Max Time Diff (ms)'>
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
<a-form-item label="Interface Name">
|
||||
<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-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>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -60,16 +60,20 @@
|
||||
<a-form-item label="VerifyPeerCertInNames">
|
||||
<a-input v-model.trim="inbound.stream.tls.verifyPeerCertInNames"></a-input>
|
||||
</a-form-item>
|
||||
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
||||
<template v-for="cert,index in inbound.stream.tls.certs">
|
||||
<a-form-item label='{{ i18n "certificate" }}'>
|
||||
<a-radio-group v-model="cert.useFile" button-style="solid">
|
||||
<a-radio-button :value="true">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button>
|
||||
<a-radio-button :value="false">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button>
|
||||
<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' }">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button>
|
||||
<a-radio-button :value="false" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-button icon="plus" v-if="index === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()"
|
||||
:style="{ marginLeft: '10px' }"></a-button>
|
||||
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
|
||||
@click="inbound.stream.tls.removeCert(index)" :style="{ marginLeft: '10px' }"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<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"
|
||||
@click="inbound.stream.tls.removeCert(index)"></a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<template v-if="cert.useFile">
|
||||
<a-form-item label='{{ i18n "pages.inbounds.publicKey" }}'>
|
||||
|
||||
@@ -384,15 +384,12 @@
|
||||
</template>
|
||||
<template slot="expiryTime" slot-scope="text, dbInbound">
|
||||
<a-popover v-if="dbInbound.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content" v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(dbInbound.expiryTime) ]]
|
||||
</template>
|
||||
<template v-else slot="content">
|
||||
[[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]]
|
||||
<template slot="content">
|
||||
[[ IntlUtil.formatDate(dbInbound.expiryTime) ]]
|
||||
</template>
|
||||
<a-tag :style="{ minWidth: '50px' }"
|
||||
:color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
|
||||
[[ remainedDays(dbInbound._expiryTime) ]]
|
||||
[[ IntlUtil.formatRelativeTime(dbInbound.expiryTime) ]]
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else color="purple" class="infinite-tag">
|
||||
@@ -549,12 +546,7 @@
|
||||
<td>
|
||||
<a-tag :style="{ minWidth: '50px', textAlign: 'center' }"
|
||||
v-if="dbInbound.expiryTime > 0" :color="dbInbound.isExpiry? 'red': 'blue'">
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(dbInbound.expiryTime) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]]
|
||||
</template>
|
||||
[[ IntlUtil.formatDate(dbInbound.expiryTime) ]]
|
||||
</a-tag>
|
||||
<a-tag v-else :style="{ textAlign: 'center' }" color="purple" class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
@@ -602,6 +594,7 @@
|
||||
{{template "page/body_scripts" .}}
|
||||
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/model/reality_targets.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
|
||||
{{template "component/aSidebar" .}}
|
||||
@@ -1135,8 +1128,11 @@
|
||||
},
|
||||
openEditClient(dbInboundId, client) {
|
||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||
if (!dbInbound) return;
|
||||
clients = this.getInboundClients(dbInbound);
|
||||
if (!clients || !Array.isArray(clients)) return;
|
||||
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
|
||||
if (index < 0) return;
|
||||
clientModal.show({
|
||||
title: '{{ i18n "pages.client.edit"}}',
|
||||
okText: '{{ i18n "pages.client.submitEdit"}}',
|
||||
@@ -1151,11 +1147,14 @@
|
||||
});
|
||||
},
|
||||
findIndexOfClient(protocol, clients, client) {
|
||||
if (!clients || !Array.isArray(clients) || !client) {
|
||||
return -1;
|
||||
}
|
||||
switch (protocol) {
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return clients.findIndex(item => item.password === client.password && item.email === client.email);
|
||||
default: return clients.findIndex(item => item.id === client.id && item.email === client.email);
|
||||
return clients.findIndex(item => item && item.password === client.password && item.email === client.email);
|
||||
default: return clients.findIndex(item => item && item.id === client.id && item.email === client.email);
|
||||
}
|
||||
},
|
||||
async addClient(clients, dbInboundId, modal) {
|
||||
@@ -1278,11 +1277,15 @@
|
||||
},
|
||||
showInfo(dbInboundId, client) {
|
||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||
if (!dbInbound) return;
|
||||
index = 0;
|
||||
if (dbInbound.isMultiUser()) {
|
||||
inbound = dbInbound.toInbound();
|
||||
clients = inbound.clients;
|
||||
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
|
||||
clients = inbound && inbound.clients ? inbound.clients : null;
|
||||
if (clients && Array.isArray(clients)) {
|
||||
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
|
||||
if (index < 0) index = 0;
|
||||
}
|
||||
}
|
||||
newDbInbound = this.checkFallback(dbInbound);
|
||||
infoModal.show(newDbInbound, index);
|
||||
@@ -1295,9 +1298,12 @@
|
||||
async switchEnableClient(dbInboundId, client) {
|
||||
this.loading()
|
||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||
if (!dbInbound) return;
|
||||
inbound = dbInbound.toInbound();
|
||||
clients = inbound.clients;
|
||||
clients = inbound && inbound.clients ? inbound.clients : null;
|
||||
if (!clients || !Array.isArray(clients)) return;
|
||||
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
|
||||
if (index < 0 || !clients[index]) return;
|
||||
clients[index].enable = !clients[index].enable;
|
||||
clientId = this.getClientId(dbInbound.protocol, clients[index]);
|
||||
await this.updateClient(clients[index], dbInboundId, clientId);
|
||||
@@ -1310,7 +1316,9 @@
|
||||
}
|
||||
},
|
||||
getInboundClients(dbInbound) {
|
||||
return dbInbound.toInbound().clients;
|
||||
if (!dbInbound) return null;
|
||||
const inbound = dbInbound.toInbound();
|
||||
return inbound && inbound.clients ? inbound.clients : null;
|
||||
},
|
||||
resetClientTraffic(client, dbInboundId, confirmation = true) {
|
||||
if (confirmation) {
|
||||
@@ -1406,13 +1414,6 @@
|
||||
if (remainedSeconds >= resetSeconds) return 0;
|
||||
return 100 * (1 - (remainedSeconds / resetSeconds));
|
||||
},
|
||||
remainedDays(expTime) {
|
||||
if (expTime == 0) return null;
|
||||
if (expTime < 0) return TimeFormatter.formatSecond(expTime / -1000);
|
||||
now = new Date().getTime();
|
||||
if (expTime < now) return '{{ i18n "depleted" }}';
|
||||
return TimeFormatter.formatSecond((expTime - now) / 1000);
|
||||
},
|
||||
statsExpColor(dbInbound, email) {
|
||||
if (email.length == 0) return '#7a316f';
|
||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
||||
@@ -1457,10 +1458,12 @@
|
||||
formatLastOnline(email) {
|
||||
const ts = this.getLastOnline(email)
|
||||
if (!ts) return '-'
|
||||
if (this.datepicker === 'gregorian') {
|
||||
return DateUtil.formatMillis(ts)
|
||||
// Check if IntlUtil is available (may not be loaded yet)
|
||||
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
|
||||
return IntlUtil.formatDate(ts)
|
||||
}
|
||||
return DateUtil.convertToJalalian(moment(ts))
|
||||
// Fallback to simple date formatting if IntlUtil is not available
|
||||
return new Date(ts).toLocaleString()
|
||||
},
|
||||
isRemovable(dbInboundId) {
|
||||
return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
|
||||
@@ -1584,13 +1587,71 @@
|
||||
}
|
||||
this.loading();
|
||||
this.getDefaultSettings();
|
||||
if (this.isRefreshEnabled) {
|
||||
this.startDataRefreshLoop();
|
||||
|
||||
// Initial data fetch
|
||||
this.getDBInbounds().then(() => {
|
||||
this.loading(false);
|
||||
});
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
if (window.wsClient) {
|
||||
window.wsClient.connect();
|
||||
|
||||
// Listen for inbounds updates
|
||||
window.wsClient.on('inbounds', (payload) => {
|
||||
if (payload && Array.isArray(payload)) {
|
||||
// Use setInbounds to properly convert to DBInbound objects with methods
|
||||
this.setInbounds(payload);
|
||||
this.searchInbounds(this.searchKey);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for traffic updates
|
||||
window.wsClient.on('traffic', (payload) => {
|
||||
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
|
||||
// because clientTraffics contains delta/incremental values, not total accumulated values.
|
||||
// Total traffic is updated via the 'inbounds' event which contains accumulated values from database.
|
||||
|
||||
// Update online clients list in real-time
|
||||
if (payload && Array.isArray(payload.onlineClients)) {
|
||||
this.onlineClients = payload.onlineClients;
|
||||
// Recalculate client counts to update online status
|
||||
this.dbInbounds.forEach(dbInbound => {
|
||||
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
|
||||
if (inbound && this.clientCount[dbInbound.id]) {
|
||||
this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update last online map in real-time
|
||||
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback to polling if WebSocket fails
|
||||
window.wsClient.on('error', () => {
|
||||
console.warn('WebSocket connection failed, falling back to polling');
|
||||
if (this.isRefreshEnabled) {
|
||||
this.startDataRefreshLoop();
|
||||
}
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
|
||||
console.warn('WebSocket reconnection failed, falling back to polling');
|
||||
if (this.isRefreshEnabled) {
|
||||
this.startDataRefreshLoop();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to polling if WebSocket is not available
|
||||
if (this.isRefreshEnabled) {
|
||||
this.startDataRefreshLoop();
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.getDBInbounds();
|
||||
}
|
||||
this.loading(false);
|
||||
},
|
||||
computed: {
|
||||
total() {
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
|
||||
formattedLogs += `
|
||||
<tr ${outboundColor}>
|
||||
<td><b>${new Date(log.DateTime).toLocaleString()}</b></td>
|
||||
<td><b>${IntlUtil.formatDate(log.DateTime)}</b></td>
|
||||
<td>${log.FromAddress}</td>
|
||||
<td>${log.ToAddress}</td>
|
||||
<td>${log.Inbound}</td>
|
||||
@@ -1102,6 +1102,20 @@
|
||||
});
|
||||
fileInput.click();
|
||||
},
|
||||
startPolling() {
|
||||
// Fallback polling mechanism
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (window.wsClient && window.wsClient.isConnected) {
|
||||
clearInterval(pollInterval);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
if (window.location.protocol !== "https:") {
|
||||
@@ -1113,13 +1127,57 @@
|
||||
this.ipLimitEnable = msg.obj.ipLimitEnable;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
await PromiseUtil.sleep(2000);
|
||||
// Initial status fetch
|
||||
await this.getStatus();
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
if (window.wsClient) {
|
||||
window.wsClient.connect();
|
||||
|
||||
// Listen for status updates
|
||||
window.wsClient.on('status', (payload) => {
|
||||
this.setStatus(payload);
|
||||
});
|
||||
|
||||
// Listen for Xray state changes
|
||||
window.wsClient.on('xray_state', (payload) => {
|
||||
if (this.status && this.status.xray) {
|
||||
this.status.xray.state = payload.state;
|
||||
this.status.xray.errorMsg = payload.errorMsg || '';
|
||||
switch (payload.state) {
|
||||
case 'running':
|
||||
this.status.xray.color = "green";
|
||||
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
|
||||
break;
|
||||
case 'stop':
|
||||
this.status.xray.color = "orange";
|
||||
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
|
||||
break;
|
||||
case 'error':
|
||||
this.status.xray.color = "red";
|
||||
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notifications disabled - white notifications are not needed
|
||||
|
||||
// Fallback to polling if WebSocket fails
|
||||
window.wsClient.on('error', () => {
|
||||
console.warn('WebSocket connection failed, falling back to polling');
|
||||
this.startPolling();
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
|
||||
console.warn('WebSocket reconnection failed, falling back to polling');
|
||||
this.startPolling();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to polling if WebSocket is not available
|
||||
this.startPolling();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,12 +199,7 @@
|
||||
<td>{{ i18n "pages.inbounds.createdAt" }}</td>
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings && infoModal.clientSettings.created_at">
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
<a-tag>[[ DateUtil.formatMillis(infoModal.clientSettings.created_at) ]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>[[ DateUtil.convertToJalalian(moment(infoModal.clientSettings.created_at)) ]]</a-tag>
|
||||
</template>
|
||||
<a-tag>[[ IntlUtil.formatDate(infoModal.clientSettings.created_at) ]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>-</a-tag>
|
||||
@@ -215,12 +210,7 @@
|
||||
<td>{{ i18n "pages.inbounds.updatedAt" }}</td>
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at">
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
<a-tag>[[ DateUtil.formatMillis(infoModal.clientSettings.updated_at) ]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>[[ DateUtil.convertToJalalian(moment(infoModal.clientSettings.updated_at)) ]]</a-tag>
|
||||
</template>
|
||||
<a-tag>[[ IntlUtil.formatDate(infoModal.clientSettings.updated_at) ]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>-</a-tag>
|
||||
@@ -282,12 +272,7 @@
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings.expiryTime > 0">
|
||||
<a-tag :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)">
|
||||
<template v-if="app.datepicker === 'gregorian'">
|
||||
[[ DateUtil.formatMillis(infoModal.clientSettings.expiryTime) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[ DateUtil.convertToJalalian(moment(infoModal.clientSettings.expiryTime)) ]]
|
||||
</template>
|
||||
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime) ]]
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-tag v-else-if="infoModal.clientSettings.expiryTime < 0" color="green">[[ infoModal.clientSettings.expiryTime / -86400000 ]] {{ i18n "pages.client.days" }}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
</a-modal>
|
||||
<script>
|
||||
|
||||
const inModal = {
|
||||
// Make inModal globally available to ensure it works with any base path
|
||||
const inModal = window.inModal = {
|
||||
title: '',
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
@@ -26,6 +27,14 @@
|
||||
} else {
|
||||
this.inbound = new Inbound();
|
||||
}
|
||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
||||
// This ensures Vue reactivity works properly
|
||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
||||
if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed) || this.inbound.settings.testseed.length < 4) {
|
||||
// Create a new array to ensure Vue reactivity
|
||||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
}
|
||||
if (dbInbound) {
|
||||
this.dbInbound = new DBInbound(dbInbound);
|
||||
} else {
|
||||
@@ -42,9 +51,43 @@
|
||||
loading(loading = true) {
|
||||
inModal.confirmLoading = loading;
|
||||
},
|
||||
// Vision Seed methods - always available regardless of Vue context
|
||||
updateTestseed(index, value) {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Ensure testseed is initialized
|
||||
if (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed)) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||
}
|
||||
// Ensure array has enough elements
|
||||
while (inModal.inbound.settings.testseed.length <= index) {
|
||||
inModal.inbound.settings.testseed.push(0);
|
||||
}
|
||||
// Update value
|
||||
inModal.inbound.settings.testseed[index] = value;
|
||||
},
|
||||
setRandomTestseed() {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Ensure testseed is initialized
|
||||
if (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
// Create new array with random values
|
||||
inModal.inbound.settings.testseed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)];
|
||||
},
|
||||
resetTestseed() {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Reset testseed to default values
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
};
|
||||
|
||||
new Vue({
|
||||
// Store Vue instance globally to ensure methods are always accessible
|
||||
let inboundModalVueInstance = null;
|
||||
|
||||
inboundModalVueInstance = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#inbound-modal',
|
||||
data: {
|
||||
@@ -60,7 +103,7 @@
|
||||
return inModal.isEdit;
|
||||
},
|
||||
get client() {
|
||||
return inModal.inbound.clients[0];
|
||||
return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
|
||||
},
|
||||
get datepicker() {
|
||||
return app.datepicker;
|
||||
@@ -87,6 +130,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'inModal.inbound.stream.security'(newVal, oldVal) {
|
||||
// Clear flow when security changes from reality/tls to none
|
||||
if (inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
|
||||
inModal.inbound.settings.vlesses.forEach(client => {
|
||||
client.flow = "";
|
||||
});
|
||||
}
|
||||
},
|
||||
// Ensure testseed is always initialized when vision flow is enabled
|
||||
'inModal.inbound.settings.vlesses': {
|
||||
handler() {
|
||||
if (inModal.inbound.protocol === Protocols.VLESS && inModal.inbound.settings && inModal.inbound.settings.vlesses) {
|
||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443');
|
||||
if (hasVisionFlow && (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4)) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
streamNetworkChange() {
|
||||
if (!inModal.inbound.canEnableTls()) {
|
||||
@@ -158,6 +223,13 @@
|
||||
this.inbound.stream.reality.mldsa65Seed = '';
|
||||
this.inbound.stream.reality.settings.mldsa65Verify = '';
|
||||
},
|
||||
randomizeRealityTarget() {
|
||||
if (typeof getRandomRealityTarget !== 'undefined') {
|
||||
const randomTarget = getRandomRealityTarget();
|
||||
this.inbound.stream.reality.target = randomTarget.target;
|
||||
this.inbound.stream.reality.serverNames = randomTarget.sni;
|
||||
}
|
||||
},
|
||||
async getNewEchCert() {
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni: inModal.inbound.stream.tls.sni });
|
||||
@@ -197,8 +269,29 @@
|
||||
this.inbound.settings.decryption = 'none';
|
||||
this.inbound.settings.encryption = 'none';
|
||||
this.inbound.settings.selectedAuth = undefined;
|
||||
},
|
||||
// Vision Seed methods - must be in Vue methods for proper binding
|
||||
updateTestseed(index, value) {
|
||||
// Ensure testseed is initialized
|
||||
if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed)) {
|
||||
this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
|
||||
}
|
||||
// Ensure array has enough elements
|
||||
while (this.inbound.settings.testseed.length <= index) {
|
||||
this.inbound.settings.testseed.push(0);
|
||||
}
|
||||
// Update value using Vue.set for reactivity
|
||||
this.$set(this.inbound.settings.testseed, index, value);
|
||||
},
|
||||
setRandomTestseed() {
|
||||
// Create new array with random values and use Vue.set for reactivity
|
||||
const newSeed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)];
|
||||
this.$set(this.inbound.settings, 'testseed', newSeed);
|
||||
},
|
||||
resetTestseed() {
|
||||
// Reset testseed to default values using Vue.set for reactivity
|
||||
this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
oldAllSetting: new AllSetting(),
|
||||
allSetting: new AllSetting(),
|
||||
saveBtnDisable: true,
|
||||
entryHost: null,
|
||||
entryPort: null,
|
||||
entryProtocol: null,
|
||||
entryIsIP: false,
|
||||
user: {},
|
||||
lang: LanguageManager.getLanguage(),
|
||||
inboundOptions: [],
|
||||
@@ -233,6 +237,31 @@
|
||||
loading(spinning = true) {
|
||||
this.loadingStates.spinning = spinning;
|
||||
},
|
||||
_isIp(h) {
|
||||
if (typeof h !== "string") return false;
|
||||
|
||||
// IPv4: four dot-separated octets 0-255
|
||||
const v4 = h.split(".");
|
||||
if (
|
||||
v4.length === 4 &&
|
||||
v4.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
||||
) return true;
|
||||
|
||||
// IPv6: hex groups, optional single :: compression
|
||||
if (!h.includes(":") || h.includes(":::")) return false;
|
||||
const parts = h.split("::");
|
||||
if (parts.length > 2) return false;
|
||||
|
||||
const splitGroups = s => (s ? s.split(":").filter(Boolean) : []);
|
||||
const head = splitGroups(parts[0]);
|
||||
const tail = splitGroups(parts[1]);
|
||||
const validGroup = seg => /^[0-9a-fA-F]{1,4}$/.test(seg);
|
||||
|
||||
if (![...head, ...tail].every(validGroup)) return false;
|
||||
const groups = head.length + tail.length;
|
||||
|
||||
return parts.length === 2 ? groups < 8 : groups === 8;
|
||||
},
|
||||
async getAllSetting() {
|
||||
const msg = await HttpUtil.post("/panel/setting/all");
|
||||
|
||||
@@ -307,16 +336,41 @@
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post("/panel/setting/restartPanel");
|
||||
this.loading(false);
|
||||
if (msg.success) {
|
||||
this.loading(true);
|
||||
await PromiseUtil.sleep(5000);
|
||||
var { webCertFile, webKeyFile, webDomain: host, webPort: port, webBasePath: base } = this.allSetting;
|
||||
if (host == this.oldAllSetting.webDomain) host = null;
|
||||
if (port == this.oldAllSetting.webPort) port = null;
|
||||
const isTLS = webCertFile !== "" || webKeyFile !== "";
|
||||
const url = URLBuilder.buildURL({ host, port, isTLS, base, path: "panel/settings" });
|
||||
window.location.replace(url);
|
||||
if (!msg.success) return;
|
||||
|
||||
this.loading(true);
|
||||
await PromiseUtil.sleep(5000);
|
||||
|
||||
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = this.allSetting;
|
||||
const newProtocol = (webCertFile || webKeyFile) ? "https:" : "http:";
|
||||
|
||||
let base = webBasePath ? webBasePath.replace(/^\//, "") : "";
|
||||
if (base && !base.endsWith("/")) base += "/";
|
||||
|
||||
if (!this.entryIsIP) {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = `/${base}panel/settings`;
|
||||
url.protocol = newProtocol;
|
||||
window.location.replace(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
let finalHost = this.entryHost;
|
||||
let finalPort = this.entryPort || "";
|
||||
|
||||
if (webDomain && this._isIp(webDomain)) {
|
||||
finalHost = webDomain;
|
||||
}
|
||||
|
||||
if (webPort && Number(webPort) !== Number(this.entryPort)) {
|
||||
finalPort = String(webPort);
|
||||
}
|
||||
|
||||
const url = new URL(`${newProtocol}//${finalHost}`);
|
||||
if (finalPort) url.port = finalPort;
|
||||
url.pathname = `/${base}panel/settings`;
|
||||
|
||||
window.location.replace(url.toString());
|
||||
},
|
||||
toggleTwoFactor(newValue) {
|
||||
if (newValue) {
|
||||
@@ -568,6 +622,10 @@
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.entryHost = window.location.hostname;
|
||||
this.entryPort = window.location.port;
|
||||
this.entryProtocol = window.location.protocol;
|
||||
this.entryIsIP = this._isIp(this.entryHost);
|
||||
await this.getAllSetting();
|
||||
await this.loadInboundTags();
|
||||
while (true) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
|
||||
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/util/date-util.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||
{{ template "page/head_end" .}}
|
||||
|
||||
@@ -21,28 +20,20 @@
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-popover
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
title='{{ i18n "menu.settings" }}'
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme" title='{{ i18n "menu.settings" }}'
|
||||
placement="bottomRight" trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10">
|
||||
<a-theme-switch-login></a-theme-switch-login>
|
||||
<span>{{ i18n "pages.settings.language"
|
||||
}}</span>
|
||||
<a-select ref="selectLang" class="w-100"
|
||||
v-model="lang"
|
||||
<a-select ref="selectLang" class="w-100" v-model="lang"
|
||||
@change="LanguageManager.setLanguage(lang)"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="l.value"
|
||||
label="English"
|
||||
v-for="l in LanguageManager.supportedLanguages"
|
||||
:key="l.value">
|
||||
<span role="img"
|
||||
:aria-label="l.name"
|
||||
v-text="l.icon"></span>
|
||||
<span
|
||||
v-text="l.name"></span>
|
||||
<a-select-option :value="l.value" label="English"
|
||||
v-for="l in LanguageManager.supportedLanguages" :key="l.value">
|
||||
<span role="img" :aria-label="l.name" v-text="l.icon"></span>
|
||||
<span v-text="l.name"></span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
@@ -54,42 +45,31 @@
|
||||
<a-form layout="vertical">
|
||||
<a-form-item>
|
||||
<a-space direction="vertical" align="center">
|
||||
<a-row type="flex" :gutter="[8,8]"
|
||||
justify="center" style="width:100%">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24"
|
||||
style="text-align:center;">
|
||||
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple"
|
||||
class="qr-tag">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
"pages.settings.subSettings"}}</span>
|
||||
</a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner
|
||||
class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode"
|
||||
class="qr-cv"
|
||||
title='{{ i18n "copy" }}'
|
||||
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode" class="qr-cv" title='{{ i18n "copy" }}'
|
||||
@click="copy(app.subUrl)"></canvas>
|
||||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</a-col>
|
||||
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12" style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple"
|
||||
class="qr-tag">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
"pages.settings.subSettings"}}
|
||||
Json</span>
|
||||
</a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner
|
||||
class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode-subjson"
|
||||
class="qr-cv"
|
||||
title='{{ i18n "copy" }}'
|
||||
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode-subjson" class="qr-cv" title='{{ i18n "copy" }}'
|
||||
@click="copy(app.subJsonUrl)"></canvas>
|
||||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
@@ -101,79 +81,49 @@
|
||||
|
||||
<a-form-item>
|
||||
<a-descriptions bordered :column="1" size="small">
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.subId" }}'>[[
|
||||
<a-descriptions-item label='{{ i18n "subscription.subId" }}'>[[
|
||||
app.sId
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.status" }}'>
|
||||
<a-descriptions-item label='{{ i18n "subscription.status" }}'>
|
||||
<template v-if="isUnlimited">
|
||||
<a-tag color="purple">{{ i18n
|
||||
"subscription.unlimited" }}</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag
|
||||
:color="isActive ? 'green' : 'red'">[[
|
||||
<a-tag :color="isActive ? 'green' : 'red'">[[
|
||||
isActive ? '{{ i18n
|
||||
"subscription.active" }}' : '{{ i18n
|
||||
"subscription.inactive" }}'
|
||||
]]</a-tag>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.downloaded" }}'>[[
|
||||
<a-descriptions-item label='{{ i18n "subscription.downloaded" }}'>[[
|
||||
app.download
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.uploaded" }}'>[[
|
||||
<a-descriptions-item label='{{ i18n "subscription.uploaded" }}'>[[
|
||||
app.upload
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "usage" }}'>[[ app.used
|
||||
<a-descriptions-item label='{{ i18n "usage" }}'>[[ app.used
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.totalQuota" }}'>[[
|
||||
<a-descriptions-item label='{{ i18n "subscription.totalQuota" }}'>[[
|
||||
app.total
|
||||
]]</a-descriptions-item>
|
||||
<a-descriptions-item v-if="app.totalByte > 0"
|
||||
label='{{ i18n "remained" }}'>[[
|
||||
<a-descriptions-item v-if="app.totalByte > 0" label='{{ i18n "remained" }}'>[[
|
||||
app.remained ]]</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "lastOnline" }}'>
|
||||
<a-descriptions-item label='{{ i18n "lastOnline" }}'>
|
||||
<template v-if="app.lastOnlineMs > 0">
|
||||
<template
|
||||
v-if="app.datepicker === 'gregorian'">
|
||||
[[
|
||||
DateUtil.formatMillis(app.lastOnlineMs)
|
||||
]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[
|
||||
DateUtil.convertToJalalian(moment(app.lastOnlineMs))
|
||||
]]
|
||||
</template>
|
||||
[[ IntlUtil.formatDate(app.lastOnlineMs) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>-</span>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
label='{{ i18n "subscription.expiry" }}'>
|
||||
<a-descriptions-item label='{{ i18n "subscription.expiry" }}'>
|
||||
<template v-if="app.expireMs === 0">
|
||||
{{ i18n "subscription.noExpiry" }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-if="app.datepicker === 'gregorian'">
|
||||
[[
|
||||
DateUtil.formatMillis(app.expireMs)
|
||||
]]
|
||||
</template>
|
||||
<template v-else>
|
||||
[[
|
||||
DateUtil.convertToJalalian(moment(app.expireMs))
|
||||
]]
|
||||
</template>
|
||||
[[ IntlUtil.formatDate(app.expireMs) ]]
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
@@ -181,32 +131,48 @@
|
||||
</a-form>
|
||||
|
||||
<br />
|
||||
<a-list bordered>
|
||||
<a-list-item v-for="(link, idx) in links" :key="link">
|
||||
<div style="width:100%; text-align:center;">
|
||||
<a-button type="primary" :block="isMobile"
|
||||
@click="copy(link)">[[ linkName(link, idx)
|
||||
]]</a-button>
|
||||
<div v-for="(link, idx) in links" :key="link"
|
||||
style="position: relative; margin-bottom: 20px; text-align: center;">
|
||||
<div class="qr-box" style="display: inline-block; width: 100%; max-width: 100%;">
|
||||
<a-tag color="purple"
|
||||
style="margin-bottom: -10px; position: relative; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
|
||||
<span>[[ linkName(link, idx) ]]</span>
|
||||
</a-tag>
|
||||
<div @click="copy(link)" style="
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 25px 20px 15px 20px;
|
||||
margin-top: -12px;
|
||||
word-break: break-all;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
" onmouseover="this.style.background='rgba(0, 0, 0, 0.3)'; this.style.borderColor='rgba(255, 255, 255, 0.2)'"
|
||||
onmouseout="this.style.background='rgba(0, 0, 0, 0.2)'; this.style.borderColor='rgba(255, 255, 255, 0.1)'">
|
||||
[[ link ]]
|
||||
</div>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item>
|
||||
<a-row type="flex" justify="center" :gutter="[8,8]"
|
||||
style="width:100%">
|
||||
<a-col :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<a-row type="flex" justify="center" :gutter="[8,8]" style="width:100%">
|
||||
<a-col :xs="24" :sm="12" style="text-align:center;">
|
||||
<!-- Android dropdown -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button icon="android" :block="isMobile"
|
||||
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
||||
size="large" type="primary">
|
||||
:style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
|
||||
Android <a-icon type="down" />
|
||||
</a-button>
|
||||
<a-menu slot="overlay"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-menu slot="overlay" :class="themeSwitcher.currentTheme">
|
||||
<a-menu-item key="android-v2box"
|
||||
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
|
||||
<a-menu-item key="android-v2rayng"
|
||||
@@ -215,39 +181,32 @@
|
||||
@click="copy(app.subUrl)">Sing-box</a-menu-item>
|
||||
<a-menu-item key="android-v2raytun"
|
||||
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
|
||||
<a-menu-item key="android-npvtunnel"
|
||||
@click="copy(app.subUrl)">NPV
|
||||
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
|
||||
Tunnel</a-menu-item>
|
||||
<a-menu-item key="android-happ"
|
||||
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
|
||||
<a-menu-item key="android-happ"
|
||||
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<a-col :xs="24" :sm="12" style="text-align:center;">
|
||||
<!-- iOS dropdown -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button icon="apple" :block="isMobile"
|
||||
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
||||
size="large" type="primary">
|
||||
:style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
|
||||
iOS <a-icon type="down" />
|
||||
</a-button>
|
||||
<a-menu slot="overlay"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-menu slot="overlay" :class="themeSwitcher.currentTheme">
|
||||
<a-menu-item key="ios-shadowrocket"
|
||||
@click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
|
||||
<a-menu-item key="ios-v2box"
|
||||
@click="open(v2boxUrl)">V2Box</a-menu-item>
|
||||
<a-menu-item key="ios-v2box" @click="open(v2boxUrl)">V2Box</a-menu-item>
|
||||
<a-menu-item key="ios-streisand"
|
||||
@click="open(streisandUrl)">Streisand</a-menu-item>
|
||||
<a-menu-item key="ios-v2raytun"
|
||||
@click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
|
||||
<a-menu-item key="ios-npvtunnel"
|
||||
@click="copy(npvtunUrl)">NPV
|
||||
<a-menu-item key="ios-npvtunnel" @click="copy(npvtunUrl)">NPV
|
||||
Tunnel
|
||||
</a-menu-item>
|
||||
<a-menu-item key="ios-happ"
|
||||
@click="open(happUrl)">Happ</a-menu-item>
|
||||
<a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</a-col>
|
||||
@@ -261,17 +220,12 @@
|
||||
</a-layout>
|
||||
|
||||
<!-- Bootstrap data for external JS -->
|
||||
<template id="subscription-data" data-sid="{{ .sId }}"
|
||||
data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
||||
data-download="{{ .download }}"
|
||||
data-upload="{{ .upload }}" data-used="{{ .used }}"
|
||||
data-total="{{ .total }}" data-remained="{{ .remained }}"
|
||||
data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||
data-downloadbyte="{{ .downloadByte }}"
|
||||
data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
||||
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
||||
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||
data-datepicker="{{ .datepicker }}"></template>
|
||||
<textarea id="subscription-links"
|
||||
style="display:none">{{ range .result }}{{ . }}
|
||||
<textarea id="subscription-links" style="display:none">{{ range .result }}{{ . }}
|
||||
{{ end }}</textarea>
|
||||
|
||||
{{template "component/aThemeSwitch" .}}
|
||||
|
||||
@@ -56,6 +56,13 @@
|
||||
<a-switch v-model="dnsDisableFallbackIfMatch"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.dns.enableParallelQuery" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.dns.enableParallelQueryDesc" }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="dnsEnableParallelQuery"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.dns.useSystemHosts" }}</template>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<a-row>
|
||||
<a-col :xs="12" :sm="12" :lg="12">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<a-button type="primary" icon="plus" @click="addOutbound()">
|
||||
{{ i18n "pages.xray.outbound.addOutbound" }}
|
||||
<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-space>
|
||||
|
||||
@@ -527,10 +527,10 @@
|
||||
findOutboundTraffic(o) {
|
||||
for (const otraffic of this.outboundsTraffic) {
|
||||
if (otraffic.tag == o.tag) {
|
||||
return SizeFormatter.sizeFormat(otraffic.up) + ' / ' + SizeFormatter.sizeFormat(otraffic.down);
|
||||
return `↑ ${SizeFormatter.sizeFormat(otraffic.up)} / ${SizeFormatter.sizeFormat(otraffic.down)} ↓`
|
||||
}
|
||||
}
|
||||
return SizeFormatter.sizeFormat(0) + ' / ' + SizeFormatter.sizeFormat(0);
|
||||
return `${SizeFormatter.sizeFormat(0)} / ${SizeFormatter.sizeFormat(0)}`
|
||||
},
|
||||
findOutboundAddress(o) {
|
||||
serverObj = null;
|
||||
@@ -968,6 +968,17 @@
|
||||
await this.getXraySetting();
|
||||
await this.getXrayResult();
|
||||
await this.getOutboundsTraffic();
|
||||
|
||||
if (window.wsClient) {
|
||||
window.wsClient.connect();
|
||||
window.wsClient.on('outbounds', (payload) => {
|
||||
if (payload) {
|
||||
this.outboundsTraffic = payload;
|
||||
this.$forceUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (true) {
|
||||
await PromiseUtil.sleep(800);
|
||||
this.saveBtnDisable = this.oldXraySetting === this.xraySetting;
|
||||
@@ -1315,7 +1326,8 @@
|
||||
newTemplateSettings.dns = {
|
||||
servers: [],
|
||||
queryStrategy: "UseIP",
|
||||
tag: "dns_inbound"
|
||||
tag: "dns_inbound",
|
||||
enableParallelQuery: false
|
||||
};
|
||||
newTemplateSettings.fakedns = null;
|
||||
} else {
|
||||
@@ -1391,6 +1403,20 @@
|
||||
this.templateSettings = newTemplateSettings;
|
||||
}
|
||||
},
|
||||
dnsEnableParallelQuery: {
|
||||
get: function () {
|
||||
return this.enableDNS ? (this.templateSettings.dns.enableParallelQuery || false) : false;
|
||||
},
|
||||
set: function (newValue) {
|
||||
newTemplateSettings = this.templateSettings;
|
||||
if (newValue) {
|
||||
newTemplateSettings.dns.enableParallelQuery = newValue;
|
||||
} else {
|
||||
delete newTemplateSettings.dns.enableParallelQuery
|
||||
}
|
||||
this.templateSettings = newTemplateSettings;
|
||||
}
|
||||
},
|
||||
dnsUseSystemHosts: {
|
||||
get: function () {
|
||||
return this.enableDNS ? this.templateSettings.dns.useSystemHosts : false;
|
||||
|
||||
@@ -22,7 +22,11 @@ func NewCheckCpuJob() *CheckCpuJob {
|
||||
|
||||
// Run checks CPU usage over the last minute and sends a Telegram alert if it exceeds the threshold.
|
||||
func (j *CheckCpuJob) Run() {
|
||||
threshold, _ := j.settingService.GetTgCpu()
|
||||
threshold, err := j.settingService.GetTgCpu()
|
||||
if err != nil || threshold <= 0 {
|
||||
// If threshold cannot be retrieved or is not set, skip sending notifications
|
||||
return
|
||||
}
|
||||
|
||||
// get latest status of server
|
||||
percent, err := cpu.Percent(1*time.Minute, false)
|
||||
|
||||
@@ -45,7 +45,7 @@ func (j *ClearLogsJob) Run() {
|
||||
}
|
||||
|
||||
// Clear log files and copy to previous logs
|
||||
for i := 0; i < len(logFiles); i++ {
|
||||
for i := range len(logFiles) {
|
||||
if i > 0 {
|
||||
// Copy to previous logs
|
||||
logFilePrev, err := os.OpenFile(logFilesPrev[i-1], os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
|
||||
@@ -322,66 +322,6 @@ func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ensureClientExists adds client with defaults to inbound tag if not present
|
||||
func (j *LdapSyncJob) ensureClientExists(inboundTag string, email string, defGB int, defExpiryDays int, defLimitIP int) {
|
||||
inbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("ensureClientExists: get inbounds failed:", err)
|
||||
return
|
||||
}
|
||||
var target *model.Inbound
|
||||
for _, ib := range inbounds {
|
||||
if ib.Tag == inboundTag {
|
||||
target = ib
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
logger.Debugf("ensureClientExists: inbound tag %s not found", inboundTag)
|
||||
return
|
||||
}
|
||||
// check if email already exists in this inbound
|
||||
clients, err := j.inboundService.GetClients(target)
|
||||
if err == nil {
|
||||
for _, c := range clients {
|
||||
if c.Email == email {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build new client according to protocol
|
||||
newClient := model.Client{
|
||||
Email: email,
|
||||
Enable: true,
|
||||
LimitIP: defLimitIP,
|
||||
TotalGB: int64(defGB),
|
||||
}
|
||||
if defExpiryDays > 0 {
|
||||
newClient.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
switch target.Protocol {
|
||||
case model.Trojan:
|
||||
newClient.Password = uuid.NewString()
|
||||
case model.Shadowsocks:
|
||||
newClient.Password = uuid.NewString()
|
||||
default: // VMESS/VLESS and others using ID
|
||||
newClient.ID = uuid.NewString()
|
||||
}
|
||||
|
||||
// prepare inbound payload with only the new client
|
||||
payload := &model.Inbound{Id: target.Id}
|
||||
payload.Settings = `{"clients":[` + j.clientToJSON(newClient) + `]}`
|
||||
|
||||
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
|
||||
logger.Warning("ensureClientExists: add client failed:", err)
|
||||
} else {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
logger.Infof("LDAP auto-create: %s in %s", email, inboundTag)
|
||||
}
|
||||
}
|
||||
|
||||
// clientToJSON serializes minimal client fields to JSON object string without extra deps
|
||||
func (j *LdapSyncJob) clientToJSON(c model.Client) string {
|
||||
// construct minimal JSON manually to avoid importing json for simple case
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -48,6 +49,45 @@ func (j *XrayTrafficJob) Run() {
|
||||
if needRestart0 || needRestart1 {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
|
||||
// Get online clients and last online map for real-time status updates
|
||||
onlineClients := j.inboundService.GetOnlineClients()
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
lastOnlineMap = make(map[string]int64)
|
||||
}
|
||||
|
||||
// Fetch updated inbounds from database with accumulated traffic values
|
||||
// This ensures frontend receives the actual total traffic, not just delta values
|
||||
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("get all inbounds for websocket failed:", err)
|
||||
}
|
||||
|
||||
updatedOutbounds, err := j.outboundService.GetOutboundsTraffic()
|
||||
if err != nil {
|
||||
logger.Warning("get all outbounds for websocket failed:", err)
|
||||
}
|
||||
|
||||
// Broadcast traffic update via WebSocket with accumulated values from database
|
||||
trafficUpdate := map[string]interface{}{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
"lastOnlineMap": lastOnlineMap,
|
||||
}
|
||||
websocket.BroadcastTraffic(trafficUpdate)
|
||||
|
||||
// Broadcast full inbounds update for real-time UI refresh
|
||||
if updatedInbounds != nil {
|
||||
websocket.BroadcastInbounds(updatedInbounds)
|
||||
}
|
||||
|
||||
if updatedOutbounds != nil {
|
||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
|
||||
@@ -1010,12 +1010,12 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
if len(traffics) == 0 {
|
||||
// Empty onlineUsers
|
||||
if p != nil {
|
||||
p.SetOnlineClients(nil)
|
||||
p.SetOnlineClients(make([]string, 0))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var onlineClients []string
|
||||
onlineClients := make([]string, 0)
|
||||
|
||||
emails := make([]string, 0, len(traffics))
|
||||
for _, traffic := range traffics {
|
||||
@@ -1569,21 +1569,20 @@ func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bo
|
||||
return !clientOldEnabled, needRestart, nil
|
||||
}
|
||||
|
||||
|
||||
// SetClientEnableByEmail sets client enable state to desired value; returns (changed, needRestart, error)
|
||||
func (s *InboundService) SetClientEnableByEmail(clientEmail string, enable bool) (bool, bool, error) {
|
||||
current, err := s.checkIsEnabledByEmail(clientEmail)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if current == enable {
|
||||
return false, false, nil
|
||||
}
|
||||
newEnabled, needRestart, err := s.ToggleClientEnableByEmail(clientEmail)
|
||||
if err != nil {
|
||||
return false, needRestart, err
|
||||
}
|
||||
return newEnabled == enable, needRestart, nil
|
||||
current, err := s.checkIsEnabledByEmail(clientEmail)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if current == enable {
|
||||
return false, false, nil
|
||||
}
|
||||
newEnabled, needRestart, err := s.ToggleClientEnableByEmail(clientEmail)
|
||||
if err != nil {
|
||||
return false, needRestart, err
|
||||
}
|
||||
return newEnabled == enable, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int) (bool, error) {
|
||||
|
||||
@@ -529,6 +529,18 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check HTTP status code - GitHub API returns object instead of array on error
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
var errorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(bodyBytes, &errorResponse) == nil && errorResponse.Message != "" {
|
||||
return nil, fmt.Errorf("GitHub API error: %s", errorResponse.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(make([]byte, bufferSize))
|
||||
buffer.Reset()
|
||||
if _, err := buffer.ReadFrom(resp.Body); err != nil {
|
||||
@@ -794,17 +806,17 @@ func (s *ServerService) GetXrayLogs(
|
||||
for i, part := range parts {
|
||||
|
||||
if i == 0 {
|
||||
dateTime, err := time.Parse("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1])
|
||||
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry.DateTime = dateTime
|
||||
entry.DateTime = dateTime.UTC()
|
||||
}
|
||||
|
||||
if part == "from" {
|
||||
entry.FromAddress = parts[i+1]
|
||||
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
|
||||
} else if part == "accepted" {
|
||||
entry.ToAddress = parts[i+1]
|
||||
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
|
||||
} else if strings.HasPrefix(part, "[") {
|
||||
entry.Inbound = part[1:]
|
||||
} else if strings.HasSuffix(part, "]") {
|
||||
@@ -1193,7 +1205,7 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
|
||||
return keyPair, nil
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewEchCert(sni string) (interface{}, error) {
|
||||
func (s *ServerService) GetNewEchCert(sni string) (any, error) {
|
||||
// Run the command
|
||||
cmd := exec.Command(xray.GetBinaryPath(), "tls", "ech", "--serverName", sni)
|
||||
var out bytes.Buffer
|
||||
@@ -1211,7 +1223,7 @@ func (s *ServerService) GetNewEchCert(sni string) (interface{}, error) {
|
||||
configList := lines[1]
|
||||
serverKeys := lines[3]
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"echServerKeys": serverKeys,
|
||||
"echConfigList": configList,
|
||||
}, nil
|
||||
|
||||
@@ -74,26 +74,26 @@ var defaultValueMap = map[string]string{
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
"ldapPort": "389",
|
||||
"ldapUseTLS": "false",
|
||||
"ldapBindDN": "",
|
||||
"ldapPassword": "",
|
||||
"ldapBaseDN": "",
|
||||
"ldapUserFilter": "(objectClass=person)",
|
||||
"ldapUserAttr": "mail",
|
||||
"ldapVlessField": "vless_enabled",
|
||||
"ldapSyncCron": "@every 1m",
|
||||
"ldapFlagField": "",
|
||||
"ldapTruthyValues": "true,1,yes,on",
|
||||
"ldapInvertFlag": "false",
|
||||
"ldapInboundTags": "",
|
||||
"ldapAutoCreate": "false",
|
||||
"ldapAutoDelete": "false",
|
||||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "0",
|
||||
"ldapDefaultLimitIP": "0",
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
"ldapPort": "389",
|
||||
"ldapUseTLS": "false",
|
||||
"ldapBindDN": "",
|
||||
"ldapPassword": "",
|
||||
"ldapBaseDN": "",
|
||||
"ldapUserFilter": "(objectClass=person)",
|
||||
"ldapUserAttr": "mail",
|
||||
"ldapVlessField": "vless_enabled",
|
||||
"ldapSyncCron": "@every 1m",
|
||||
"ldapFlagField": "",
|
||||
"ldapTruthyValues": "true,1,yes,on",
|
||||
"ldapInvertFlag": "false",
|
||||
"ldapInboundTags": "",
|
||||
"ldapAutoCreate": "false",
|
||||
"ldapAutoDelete": "false",
|
||||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "0",
|
||||
"ldapDefaultLimitIP": "0",
|
||||
}
|
||||
|
||||
// SettingService provides business logic for application settings management.
|
||||
@@ -479,10 +479,18 @@ func (s *SettingService) GetSubDomain() (string, error) {
|
||||
return s.getString("subDomain")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSubCertFile(subCertFile string) error {
|
||||
return s.setString("subCertFile", subCertFile)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubCertFile() (string, error) {
|
||||
return s.getString("subCertFile")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSubKeyFile(subKeyFile string) error {
|
||||
return s.setString("subKeyFile", subKeyFile)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubKeyFile() (string, error) {
|
||||
return s.getString("subKeyFile")
|
||||
}
|
||||
@@ -565,83 +573,83 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
||||
|
||||
// LDAP exported getters
|
||||
func (s *SettingService) GetLdapEnable() (bool, error) {
|
||||
return s.getBool("ldapEnable")
|
||||
return s.getBool("ldapEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapHost() (string, error) {
|
||||
return s.getString("ldapHost")
|
||||
return s.getString("ldapHost")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapPort() (int, error) {
|
||||
return s.getInt("ldapPort")
|
||||
return s.getInt("ldapPort")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapUseTLS() (bool, error) {
|
||||
return s.getBool("ldapUseTLS")
|
||||
return s.getBool("ldapUseTLS")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapBindDN() (string, error) {
|
||||
return s.getString("ldapBindDN")
|
||||
return s.getString("ldapBindDN")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapPassword() (string, error) {
|
||||
return s.getString("ldapPassword")
|
||||
return s.getString("ldapPassword")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapBaseDN() (string, error) {
|
||||
return s.getString("ldapBaseDN")
|
||||
return s.getString("ldapBaseDN")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapUserFilter() (string, error) {
|
||||
return s.getString("ldapUserFilter")
|
||||
return s.getString("ldapUserFilter")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapUserAttr() (string, error) {
|
||||
return s.getString("ldapUserAttr")
|
||||
return s.getString("ldapUserAttr")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapVlessField() (string, error) {
|
||||
return s.getString("ldapVlessField")
|
||||
return s.getString("ldapVlessField")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapSyncCron() (string, error) {
|
||||
return s.getString("ldapSyncCron")
|
||||
return s.getString("ldapSyncCron")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapFlagField() (string, error) {
|
||||
return s.getString("ldapFlagField")
|
||||
return s.getString("ldapFlagField")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapTruthyValues() (string, error) {
|
||||
return s.getString("ldapTruthyValues")
|
||||
return s.getString("ldapTruthyValues")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapInvertFlag() (bool, error) {
|
||||
return s.getBool("ldapInvertFlag")
|
||||
return s.getBool("ldapInvertFlag")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapInboundTags() (string, error) {
|
||||
return s.getString("ldapInboundTags")
|
||||
return s.getString("ldapInboundTags")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapAutoCreate() (bool, error) {
|
||||
return s.getBool("ldapAutoCreate")
|
||||
return s.getBool("ldapAutoCreate")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapAutoDelete() (bool, error) {
|
||||
return s.getBool("ldapAutoDelete")
|
||||
return s.getBool("ldapAutoDelete")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapDefaultTotalGB() (int, error) {
|
||||
return s.getInt("ldapDefaultTotalGB")
|
||||
return s.getInt("ldapDefaultTotalGB")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapDefaultExpiryDays() (int, error) {
|
||||
return s.getInt("ldapDefaultExpiryDays")
|
||||
return s.getInt("ldapDefaultExpiryDays")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
|
||||
return s.getInt("ldapDefaultLimitIP")
|
||||
return s.getInt("ldapDefaultLimitIP")
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||
|
||||
@@ -38,7 +38,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
bot *telego.Bot
|
||||
bot *telego.Bot
|
||||
|
||||
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
|
||||
botCancel context.CancelFunc
|
||||
// tgBotMutex protects concurrent access to botCancel variable
|
||||
tgBotMutex sync.Mutex
|
||||
// botWG waits for the OnReceive Long Polling goroutine to finish.
|
||||
botWG sync.WaitGroup
|
||||
|
||||
botHandler *th.BotHandler
|
||||
adminIds []int64
|
||||
isRunning bool
|
||||
@@ -166,6 +174,10 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// If Start is called again (e.g. during reload), ensure any previous long-polling
|
||||
// loop is stopped before creating a new bot / receiver.
|
||||
StopBot()
|
||||
|
||||
// Initialize hash storage to store callback queries
|
||||
hashStorage = global.NewHashStorage(20 * time.Minute)
|
||||
|
||||
@@ -199,17 +211,21 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAdminIds := make([]int64, 0)
|
||||
// Parse admin IDs from comma-separated string
|
||||
if tgBotID != "" {
|
||||
for _, adminID := range strings.Split(tgBotID, ",") {
|
||||
id, err := strconv.Atoi(adminID)
|
||||
id, err := strconv.ParseInt(adminID, 10, 64)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
|
||||
return err
|
||||
}
|
||||
adminIds = append(adminIds, int64(id))
|
||||
parsedAdminIds = append(parsedAdminIds, int64(id))
|
||||
}
|
||||
}
|
||||
tgBotMutex.Lock()
|
||||
adminIds = parsedAdminIds
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
// Get Telegram bot proxy URL
|
||||
tgBotProxy, err := t.settingService.GetTgBotProxy()
|
||||
@@ -244,10 +260,12 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
}
|
||||
|
||||
// Start receiving Telegram bot messages
|
||||
if !isRunning {
|
||||
tgBotMutex.Lock()
|
||||
alreadyRunning := isRunning || botCancel != nil
|
||||
tgBotMutex.Unlock()
|
||||
if !alreadyRunning {
|
||||
logger.Info("Telegram bot receiver started")
|
||||
go t.OnReceive()
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -292,6 +310,8 @@ func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*tel
|
||||
|
||||
// IsRunning checks if the Telegram bot is currently running.
|
||||
func (t *Tgbot) IsRunning() bool {
|
||||
tgBotMutex.Lock()
|
||||
defer tgBotMutex.Unlock()
|
||||
return isRunning
|
||||
}
|
||||
|
||||
@@ -306,14 +326,40 @@ func (t *Tgbot) SetHostname() {
|
||||
hostname = host
|
||||
}
|
||||
|
||||
// Stop stops the Telegram bot and cleans up resources.
|
||||
// Stop safely stops the Telegram bot's Long Polling operation.
|
||||
// This method now calls the global StopBot function and cleans up other resources.
|
||||
func (t *Tgbot) Stop() {
|
||||
if botHandler != nil {
|
||||
botHandler.Stop()
|
||||
}
|
||||
StopBot()
|
||||
logger.Info("Stop Telegram receiver ...")
|
||||
isRunning = false
|
||||
tgBotMutex.Lock()
|
||||
adminIds = nil
|
||||
tgBotMutex.Unlock()
|
||||
}
|
||||
|
||||
// StopBot safely stops the Telegram bot's Long Polling operation by cancelling its context.
|
||||
// This is the global function called from main.go's signal handler and t.Stop().
|
||||
func StopBot() {
|
||||
// Don't hold the mutex while cancelling/waiting.
|
||||
tgBotMutex.Lock()
|
||||
cancel := botCancel
|
||||
botCancel = nil
|
||||
handler := botHandler
|
||||
botHandler = nil
|
||||
isRunning = false
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
if handler != nil {
|
||||
handler.Stop()
|
||||
}
|
||||
|
||||
if cancel != nil {
|
||||
logger.Info("Sending cancellation signal to Telegram bot...")
|
||||
// Cancels the context passed to UpdatesViaLongPolling; this closes updates channel
|
||||
// and lets botHandler.Start() exit cleanly.
|
||||
cancel()
|
||||
botWG.Wait()
|
||||
logger.Info("Telegram bot successfully stopped.")
|
||||
}
|
||||
}
|
||||
|
||||
// encodeQuery encodes the query string if it's longer than 64 characters.
|
||||
@@ -345,188 +391,209 @@ func (t *Tgbot) OnReceive() {
|
||||
params := telego.GetUpdatesParams{
|
||||
Timeout: 30, // Increased timeout to reduce API calls
|
||||
}
|
||||
// Strict singleton: never start a second long-polling loop.
|
||||
tgBotMutex.Lock()
|
||||
if botCancel != nil || isRunning {
|
||||
tgBotMutex.Unlock()
|
||||
logger.Warning("TgBot OnReceive called while already running; ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
updates, _ := bot.UpdatesViaLongPolling(context.Background(), ¶ms)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
botCancel = cancel
|
||||
isRunning = true
|
||||
// Add to WaitGroup before releasing the lock so StopBot() can't return
|
||||
// before this receiver goroutine is accounted for.
|
||||
botWG.Add(1)
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
botHandler, _ = th.NewBotHandler(bot, updates)
|
||||
|
||||
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
delete(userStates, message.Chat.ID)
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
|
||||
return nil
|
||||
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||
|
||||
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
// Use goroutine with worker pool for concurrent command processing
|
||||
go func() {
|
||||
messageWorkerPool <- struct{}{} // Acquire worker
|
||||
defer func() { <-messageWorkerPool }() // Release worker
|
||||
// Get updates channel using the context.
|
||||
updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms)
|
||||
go func() {
|
||||
defer botWG.Done()
|
||||
h, _ := th.NewBotHandler(bot, updates)
|
||||
tgBotMutex.Lock()
|
||||
botHandler = h
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
delete(userStates, message.Chat.ID)
|
||||
t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
|
||||
}()
|
||||
return nil
|
||||
}, th.AnyCommand())
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
|
||||
return nil
|
||||
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||
|
||||
botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
// Use goroutine with worker pool for concurrent callback processing
|
||||
go func() {
|
||||
messageWorkerPool <- struct{}{} // Acquire worker
|
||||
defer func() { <-messageWorkerPool }() // Release worker
|
||||
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
// Use goroutine with worker pool for concurrent command processing
|
||||
go func() {
|
||||
messageWorkerPool <- struct{}{} // Acquire worker
|
||||
defer func() { <-messageWorkerPool }() // Release worker
|
||||
|
||||
delete(userStates, query.Message.GetChat().ID)
|
||||
t.answerCallback(&query, checkAdmin(query.From.ID))
|
||||
}()
|
||||
return nil
|
||||
}, th.AnyCallbackQueryWithMessage())
|
||||
|
||||
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
if userState, exists := userStates[message.Chat.ID]; exists {
|
||||
switch userState {
|
||||
case "awaiting_id":
|
||||
if client_Id == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_Id = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_Id) {
|
||||
userStates[message.Chat.ID] = "awaiting_id"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_id"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_password_tr":
|
||||
if client_TrPassword == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_TrPassword = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_TrPassword) {
|
||||
userStates[message.Chat.ID] = "awaiting_password_tr"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_password_sh":
|
||||
if client_ShPassword == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_ShPassword = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_ShPassword) {
|
||||
userStates[message.Chat.ID] = "awaiting_password_sh"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_email":
|
||||
if client_Email == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_Email = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_Email) {
|
||||
userStates[message.Chat.ID] = "awaiting_email"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_comment":
|
||||
if client_Comment == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_Comment = strings.TrimSpace(message.Text)
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
|
||||
}()
|
||||
return nil
|
||||
}, th.AnyCommand())
|
||||
|
||||
} else {
|
||||
if message.UsersShared != nil {
|
||||
if checkAdmin(message.From.ID) {
|
||||
for _, sharedUser := range message.UsersShared.Users {
|
||||
userID := sharedUser.UserID
|
||||
needRestart, err := t.inboundService.SetClientTelegramUserID(message.UsersShared.RequestID, userID)
|
||||
if needRestart {
|
||||
t.xrayService.SetToNeedRestart()
|
||||
}
|
||||
output := ""
|
||||
if err != nil {
|
||||
output += t.I18nBot("tgbot.messages.selectUserFailed")
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.userSaved")
|
||||
}
|
||||
t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove())
|
||||
h.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
// Use goroutine with worker pool for concurrent callback processing
|
||||
go func() {
|
||||
messageWorkerPool <- struct{}{} // Acquire worker
|
||||
defer func() { <-messageWorkerPool }() // Release worker
|
||||
|
||||
delete(userStates, query.Message.GetChat().ID)
|
||||
t.answerCallback(&query, checkAdmin(query.From.ID))
|
||||
}()
|
||||
return nil
|
||||
}, th.AnyCallbackQueryWithMessage())
|
||||
|
||||
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
if userState, exists := userStates[message.Chat.ID]; exists {
|
||||
switch userState {
|
||||
case "awaiting_id":
|
||||
if client_Id == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_Id = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_Id) {
|
||||
userStates[message.Chat.ID] = "awaiting_id"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_id"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_password_tr":
|
||||
if client_TrPassword == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_TrPassword = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_TrPassword) {
|
||||
userStates[message.Chat.ID] = "awaiting_password_tr"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_password_sh":
|
||||
if client_ShPassword == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_ShPassword = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_ShPassword) {
|
||||
userStates[message.Chat.ID] = "awaiting_password_sh"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_email":
|
||||
if client_Email == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_Email = strings.TrimSpace(message.Text)
|
||||
if t.isSingleWord(client_Email) {
|
||||
userStates[message.Chat.ID] = "awaiting_email"
|
||||
|
||||
cancel_btn_markup := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
|
||||
),
|
||||
)
|
||||
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
|
||||
} else {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
case "awaiting_comment":
|
||||
if client_Comment == strings.TrimSpace(message.Text) {
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
client_Comment = strings.TrimSpace(message.Text)
|
||||
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
|
||||
delete(userStates, message.Chat.ID)
|
||||
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
|
||||
t.addClient(message.Chat.ID, message_text)
|
||||
}
|
||||
|
||||
} else {
|
||||
if message.UsersShared != nil {
|
||||
if checkAdmin(message.From.ID) {
|
||||
for _, sharedUser := range message.UsersShared.Users {
|
||||
userID := sharedUser.UserID
|
||||
needRestart, err := t.inboundService.SetClientTelegramUserID(message.UsersShared.RequestID, userID)
|
||||
if needRestart {
|
||||
t.xrayService.SetToNeedRestart()
|
||||
}
|
||||
output := ""
|
||||
if err != nil {
|
||||
output += t.I18nBot("tgbot.messages.selectUserFailed")
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.userSaved")
|
||||
}
|
||||
t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove())
|
||||
}
|
||||
} else {
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove())
|
||||
}
|
||||
} else {
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, th.AnyMessage())
|
||||
return nil
|
||||
}, th.AnyMessage())
|
||||
|
||||
botHandler.Start()
|
||||
h.Start()
|
||||
}()
|
||||
}
|
||||
|
||||
// answerCommand processes incoming command messages from Telegram users.
|
||||
@@ -852,8 +919,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
t.searchClient(chatId, email, callbackQuery.Message.GetMessageID())
|
||||
case "add_client_limit_traffic_c":
|
||||
limitTraffic, _ := strconv.Atoi(dataArray[1])
|
||||
client_TotalGB = int64(limitTraffic) * 1024 * 1024 * 1024
|
||||
limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64)
|
||||
client_TotalGB = limitTraffic * 1024 * 1024 * 1024
|
||||
messageId := callbackQuery.Message.GetMessageID()
|
||||
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
if err != nil {
|
||||
@@ -957,7 +1024,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
|
||||
case "reset_exp_c":
|
||||
if len(dataArray) == 3 {
|
||||
days, err := strconv.Atoi(dataArray[2])
|
||||
days, err := strconv.ParseInt(dataArray[2], 10, 64)
|
||||
if err == nil {
|
||||
var date int64
|
||||
if days > 0 {
|
||||
@@ -1062,7 +1129,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
t.searchClient(chatId, email, callbackQuery.Message.GetMessageID())
|
||||
case "add_client_reset_exp_c":
|
||||
client_ExpiryTime = 0
|
||||
days, _ := strconv.Atoi(dataArray[1])
|
||||
days, _ := strconv.ParseInt(dataArray[1], 10, 64)
|
||||
var date int64
|
||||
if client_ExpiryTime > 0 {
|
||||
if client_ExpiryTime-time.Now().Unix()*1000 < 0 {
|
||||
@@ -2899,10 +2966,12 @@ func (t *Tgbot) clientInfoMsg(
|
||||
}
|
||||
|
||||
status := t.I18nBot("tgbot.offline")
|
||||
isOnline := false
|
||||
if p.IsRunning() {
|
||||
for _, online := range p.GetOnlineClients() {
|
||||
if online == traffic.Email {
|
||||
status = t.I18nBot("tgbot.online")
|
||||
isOnline = true
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -2915,6 +2984,9 @@ func (t *Tgbot) clientInfoMsg(
|
||||
}
|
||||
if printOnline {
|
||||
output += t.I18nBot("tgbot.messages.online", "Status=="+status)
|
||||
if !isOnline && traffic.LastOnline > 0 {
|
||||
output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
if printActive {
|
||||
output += t.I18nBot("tgbot.messages.active", "Enable=="+active)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||
ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap"
|
||||
ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap"
|
||||
"github.com/xlzd/gotp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -49,38 +49,38 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||
return nil
|
||||
}
|
||||
|
||||
// If LDAP enabled and local password check fails, attempt LDAP auth
|
||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||
if !ldapEnabled {
|
||||
return nil
|
||||
}
|
||||
// If LDAP enabled and local password check fails, attempt LDAP auth
|
||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||
if !ldapEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
host, _ := s.settingService.GetLdapHost()
|
||||
port, _ := s.settingService.GetLdapPort()
|
||||
useTLS, _ := s.settingService.GetLdapUseTLS()
|
||||
bindDN, _ := s.settingService.GetLdapBindDN()
|
||||
ldapPass, _ := s.settingService.GetLdapPassword()
|
||||
baseDN, _ := s.settingService.GetLdapBaseDN()
|
||||
userFilter, _ := s.settingService.GetLdapUserFilter()
|
||||
userAttr, _ := s.settingService.GetLdapUserAttr()
|
||||
host, _ := s.settingService.GetLdapHost()
|
||||
port, _ := s.settingService.GetLdapPort()
|
||||
useTLS, _ := s.settingService.GetLdapUseTLS()
|
||||
bindDN, _ := s.settingService.GetLdapBindDN()
|
||||
ldapPass, _ := s.settingService.GetLdapPassword()
|
||||
baseDN, _ := s.settingService.GetLdapBaseDN()
|
||||
userFilter, _ := s.settingService.GetLdapUserFilter()
|
||||
userAttr, _ := s.settingService.GetLdapUserAttr()
|
||||
|
||||
cfg := ldaputil.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
UseTLS: useTLS,
|
||||
BindDN: bindDN,
|
||||
Password: ldapPass,
|
||||
BaseDN: baseDN,
|
||||
UserFilter: userFilter,
|
||||
UserAttr: userAttr,
|
||||
}
|
||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
// On successful LDAP auth, continue 2FA checks below
|
||||
}
|
||||
cfg := ldaputil.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
UseTLS: useTLS,
|
||||
BindDN: bindDN,
|
||||
Password: ldapPass,
|
||||
BaseDN: baseDN,
|
||||
UserFilter: userFilter,
|
||||
UserAttr: userAttr,
|
||||
}
|
||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
// On successful LDAP auth, continue 2FA checks below
|
||||
}
|
||||
|
||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||
if err != nil {
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "تنسيق البيانات المدخلة مش صحيح."
|
||||
"emptyUsername" = "اسم المستخدم مطلوب"
|
||||
"emptyPassword" = "الباسورد مطلوب"
|
||||
"wrongUsernameOrPassword" = "اسم المستخدم أو كلمة المرور أو كود المصادقة الثنائية غير صحيح."
|
||||
"wrongUsernameOrPassword" = "اسم المستخدم أو كلمة المرور أو كود المصادقة الثنائية غير صحيح."
|
||||
"successLogin" = "لقد تم تسجيل الدخول إلى حسابك بنجاح."
|
||||
|
||||
[pages.index]
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "بيعطل استعلامات DNS الاحتياطية"
|
||||
"disableFallbackIfMatch" = "تعطيل النسخ الاحتياطي عند التطابق"
|
||||
"disableFallbackIfMatchDesc" = "بيعطل استعلامات DNS الاحتياطية لما يتحقق تطابق مع قائمة الدومينات"
|
||||
"enableParallelQuery" = "تفعيل الاستعلام المتوازي"
|
||||
"enableParallelQueryDesc" = "تفعيل استعلامات DNS المتوازية لعدة خوادم لحل أسرع"
|
||||
"strategy" = "استراتيجية الاستعلام"
|
||||
"strategyDesc" = "الاستراتيجية العامة لحل أسماء الدومين"
|
||||
"add" = "أضف سيرفر"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "بيانات الأدمن"
|
||||
"twoFactor" = "المصادقة الثنائية"
|
||||
"twoFactorEnable" = "تفعيل المصادقة الثنائية"
|
||||
"twoFactorEnableDesc" = "يضيف طبقة إضافية من المصادقة لتعزيز الأمان."
|
||||
"twoFactor" = "المصادقة الثنائية"
|
||||
"twoFactorEnable" = "تفعيل المصادقة الثنائية"
|
||||
"twoFactorEnableDesc" = "يضيف طبقة إضافية من المصادقة لتعزيز الأمان."
|
||||
"twoFactorModalSetTitle" = "تفعيل المصادقة الثنائية"
|
||||
"twoFactorModalDeleteTitle" = "تعطيل المصادقة الثنائية"
|
||||
"twoFactorModalSteps" = "لإعداد المصادقة الثنائية، قم ببعض الخطوات:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 مفعل: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 مفعل: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 حالة الاتصال: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 آخر متصل: {{ .Time }}\r\n"
|
||||
"email" = "📧 الإيميل: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 رفع: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 تنزيل: ↓{{ .Download }}\r\n"
|
||||
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Disables fallback DNS queries"
|
||||
"disableFallbackIfMatch" = "Disable Fallback If Match"
|
||||
"disableFallbackIfMatchDesc" = "Disables fallback DNS queries when the matching domain list of the DNS server is hit"
|
||||
"enableParallelQuery" = "Enable Parallel Query"
|
||||
"enableParallelQueryDesc" = "Enable parallel DNS queries to multiple servers for faster resolution"
|
||||
"strategy" = "Query Strategy"
|
||||
"strategyDesc" = "Overall strategy to resolve domain names"
|
||||
"add" = "Add Server"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Active: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Enabled: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Connection status: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Last online: {{ .Time }}\r\n"
|
||||
"email" = "📧 Email: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Upload: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Download: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!"
|
||||
"inbound_client_data_pass" = "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 Password: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!"
|
||||
"cancel" = "❌ Process Canceled! \n\nYou can /start again anytime. 🔄"
|
||||
"error_add_client" = "⚠️ Error:\n\n {{ .error }}"
|
||||
"using_default_value" = "Okay, I'll stick with the default value. 😊"
|
||||
"incorrect_input" ="Your input is not valid.\nThe phrases should be continuous without spaces.\nCorrect example: aaaaaa\nIncorrect example: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Error:\n\n {{ .error }}"
|
||||
"using_default_value" = "Okay, I'll stick with the default value. 😊"
|
||||
"incorrect_input" = "Your input is not valid.\nThe phrases should be continuous without spaces.\nCorrect example: aaaaaa\nIncorrect example: aaa aaa 🚫"
|
||||
"AreYouSure" = "Are you sure? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success"
|
||||
"FailedResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Desactiva las consultas DNS de respaldo"
|
||||
"disableFallbackIfMatch" = "Desactivar respaldo si coincide"
|
||||
"disableFallbackIfMatchDesc" = "Desactiva las consultas DNS de respaldo cuando se acierta en la lista de dominios coincidentes del servidor DNS"
|
||||
"enableParallelQuery" = "Habilitar consulta paralela"
|
||||
"enableParallelQueryDesc" = "Habilitar consultas DNS paralelas a múltiples servidores para una resolución más rápida"
|
||||
"strategy" = "Estrategia de Consulta"
|
||||
"strategyDesc" = "Estrategia general para resolver nombres de dominio"
|
||||
"add" = "Agregar Servidor"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Activo: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Habilitado: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Estado de conexión: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Última conexión: {{ .Time }}\r\n"
|
||||
"email" = "📧 Email: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Subida: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Bajada: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Correo: {{ .ClientEmail }}\n📊 Tráfico: {{ .ClientTraffic }}\n📅 Fecha de expiración: {{ .ClientExp }}\n🌐 Límite de IP: {{ .IpLimit }}\n💬 Comentario: {{ .ClientComment }}\n\n¡Ahora puedes agregar al cliente a la entrada!"
|
||||
"inbound_client_data_pass" = "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 Contraseña: {{ .ClientPass }}\n📧 Correo: {{ .ClientEmail }}\n📊 Tráfico: {{ .ClientTraffic }}\n📅 Fecha de expiración: {{ .ClientExp }}\n🌐 Límite de IP: {{ .IpLimit }}\n💬 Comentario: {{ .ClientComment }}\n\n¡Ahora puedes agregar al cliente a la entrada!"
|
||||
"cancel" = "❌ ¡Proceso cancelado! \n\nPuedes /start de nuevo en cualquier momento. 🔄"
|
||||
"error_add_client" = "⚠️ Error:\n\n {{ .error }}"
|
||||
"using_default_value" = "Está bien, me quedaré con el valor predeterminado. 😊"
|
||||
"incorrect_input" ="Tu entrada no es válida.\nLas frases deben ser continuas sin espacios.\nEjemplo correcto: aaaaaa\nEjemplo incorrecto: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Error:\n\n {{ .error }}"
|
||||
"using_default_value" = "Está bien, me quedaré con el valor predeterminado. 😊"
|
||||
"incorrect_input" = "Tu entrada no es válida.\nLas frases deben ser continuas sin espacios.\nEjemplo correcto: aaaaaa\nEjemplo incorrecto: aaa aaa 🚫"
|
||||
"AreYouSure" = "¿Estás seguro? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito"
|
||||
"FailedResetTraffic" = "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "اطلاعات بهدرستی وارد نشدهاست"
|
||||
"emptyUsername" = "لطفا یک نامکاربری وارد کنید"
|
||||
"emptyPassword" = "لطفا یک رمزعبور وارد کنید"
|
||||
"wrongUsernameOrPassword" = "نام کاربری، رمز عبور یا کد دو مرحلهای نامعتبر است."
|
||||
"wrongUsernameOrPassword" = "نام کاربری، رمز عبور یا کد دو مرحلهای نامعتبر است."
|
||||
"successLogin" = "شما با موفقیت به حساب کاربری خود وارد شدید."
|
||||
|
||||
[pages.index]
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "درخواستهای DNS Fallback را غیرفعال میکند"
|
||||
"disableFallbackIfMatch" = "غیرفعالسازی Fallback در صورت تطابق"
|
||||
"disableFallbackIfMatchDesc" = "درخواستهای DNS Fallback را زمانی که لیست دامنههای مطابقتیافته سرور DNS فعال است، غیرفعال میکند"
|
||||
"enableParallelQuery" = "فعالسازی پرسوجوی موازی"
|
||||
"enableParallelQueryDesc" = "فعالسازی پرسوجوهای DNS موازی به چندین سرور برای وضوح سریعتر"
|
||||
"strategy" = "استراتژی پرسوجو"
|
||||
"strategyDesc" = "استراتژی کلی برای حل نام دامنه"
|
||||
"add" = "افزودن سرور"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "اعتبارنامههای ادمین"
|
||||
"twoFactor" = "احراز هویت دو مرحلهای"
|
||||
"twoFactorEnable" = "فعالسازی 2FA"
|
||||
"twoFactorEnableDesc" = "یک لایه اضافی امنیتی برای احراز هویت فراهم میکند."
|
||||
"twoFactor" = "احراز هویت دو مرحلهای"
|
||||
"twoFactorEnable" = "فعالسازی 2FA"
|
||||
"twoFactorEnableDesc" = "یک لایه اضافی امنیتی برای احراز هویت فراهم میکند."
|
||||
"twoFactorModalSetTitle" = "فعالسازی احراز هویت دو مرحلهای"
|
||||
"twoFactorModalDeleteTitle" = "غیرفعالسازی احراز هویت دو مرحلهای"
|
||||
"twoFactorModalSteps" = "برای راهاندازی احراز هویت دو مرحلهای، مراحل زیر را انجام دهید:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 فعال: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 وضعیت: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 وضعیت اتصال: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 آخرین فعالیت: {{ .Time }}\r\n"
|
||||
"email" = "📧 ایمیل: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 آپلود↑: {{ .Upload }}\r\n"
|
||||
"download" = "🔽 دانلود↓: {{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 ورودی: {{ .InboundRemark }}\n\n🔑 شناسه: {{ .ClientId }}\n📧 ایمیل: {{ .ClientEmail }}\n📊 ترافیک: {{ .ClientTraffic }}\n📅 تاریخ انقضا: {{ .ClientExp }}\n🌐 محدودیت IP: {{ .IpLimit }}\n💬 توضیح: {{ .ClientComment }}\n\nاکنون میتونی مشتری را به ورودی اضافه کنی!"
|
||||
"inbound_client_data_pass" = "🔄 ورودی: {{ .InboundRemark }}\n\n🔑 رمز عبور: {{ .ClientPass }}\n📧 ایمیل: {{ .ClientEmail }}\n📊 ترافیک: {{ .ClientTraffic }}\n📅 تاریخ انقضا: {{ .ClientExp }}\n🌐 محدودیت IP: {{ .IpLimit }}\n💬 توضیح: {{ .ClientComment }}\n\nاکنون میتونی مشتری را به ورودی اضافه کنی!"
|
||||
"cancel" = "❌ فرآیند لغو شد! \n\nمیتوانید هر زمان که خواستید /start را دوباره اجرا کنید. 🔄"
|
||||
"error_add_client" = "⚠️ خطا:\n\n {{ .error }}"
|
||||
"using_default_value" = "باشه، از مقدار پیشفرض استفاده میکنم. 😊"
|
||||
"incorrect_input" ="ورودی شما معتبر نیست.\nعبارتها باید بدون فاصله باشند.\nمثال صحیح: aaaaaa\nمثال نادرست: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ خطا:\n\n {{ .error }}"
|
||||
"using_default_value" = "باشه، از مقدار پیشفرض استفاده میکنم. 😊"
|
||||
"incorrect_input" = "ورودی شما معتبر نیست.\nعبارتها باید بدون فاصله باشند.\nمثال صحیح: aaaaaa\nمثال نادرست: aaa aaa 🚫"
|
||||
"AreYouSure" = "مطمئنی؟ 🤔"
|
||||
"SuccessResetTraffic" = "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیتآمیز"
|
||||
"FailedResetTraffic" = "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,12 +106,12 @@
|
||||
"invalidFormData" = "Format data input tidak valid."
|
||||
"emptyUsername" = "Nama Pengguna diperlukan"
|
||||
"emptyPassword" = "Kata Sandi diperlukan"
|
||||
"wrongUsernameOrPassword" = "Username, kata sandi, atau kode dua faktor tidak valid."
|
||||
"wrongUsernameOrPassword" = "Username, kata sandi, atau kode dua faktor tidak valid."
|
||||
"successLogin" = "Anda telah berhasil masuk ke akun Anda."
|
||||
|
||||
[pages.index]
|
||||
"title" = "Ikhtisar"
|
||||
"cpu" = "CPU"
|
||||
"cpu" = "CPU"
|
||||
"logicalProcessors" = "Prosesor logis"
|
||||
"frequency" = "Frekuensi"
|
||||
"swap" = "Swap"
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Menonaktifkan kueri DNS fallback"
|
||||
"disableFallbackIfMatch" = "Nonaktifkan Fallback Jika Cocok"
|
||||
"disableFallbackIfMatchDesc" = "Menonaktifkan kueri DNS fallback ketika daftar domain yang cocok dari server DNS terpenuhi"
|
||||
"enableParallelQuery" = "Aktifkan Kueri Paralel"
|
||||
"enableParallelQueryDesc" = "Aktifkan kueri DNS paralel ke beberapa server untuk resolusi yang lebih cepat"
|
||||
"strategy" = "Strategi Kueri"
|
||||
"strategyDesc" = "Strategi keseluruhan untuk menyelesaikan nama domain"
|
||||
"add" = "Tambahkan Server"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Aktif: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Diaktifkan: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Status Koneksi: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Terakhir online: {{ .Time }}\r\n"
|
||||
"email" = "📧 Email: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Unggah: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Unduh: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Masuk: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Lalu lintas: {{ .ClientTraffic }}\n📅 Tanggal Kedaluwarsa: {{ .ClientExp }}\n🌐 Batas IP: {{ .IpLimit }}\n💬 Komentar: {{ .ClientComment }}\n\nSekarang kamu bisa menambahkan klien ke inbound!"
|
||||
"inbound_client_data_pass" = "🔄 Masuk: {{ .InboundRemark }}\n\n🔑 Kata sandi: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Lalu lintas: {{ .ClientTraffic }}\n📅 Tanggal Kedaluwarsa: {{ .ClientExp }}\n🌐 Batas IP: {{ .IpLimit }}\n💬 Komentar: {{ .ClientComment }}\n\nSekarang kamu bisa menambahkan klien ke inbound!"
|
||||
"cancel" = "❌ Proses Dibatalkan! \n\nAnda dapat /start lagi kapan saja. 🔄"
|
||||
"error_add_client" = "⚠️ Kesalahan:\n\n {{ .error }}"
|
||||
"using_default_value" = "Oke, saya akan tetap menggunakan nilai default. 😊"
|
||||
"incorrect_input" ="Masukan Anda tidak valid.\nFrasa harus berlanjut tanpa spasi.\nContoh benar: aaaaaa\nContoh salah: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Kesalahan:\n\n {{ .error }}"
|
||||
"using_default_value" = "Oke, saya akan tetap menggunakan nilai default. 😊"
|
||||
"incorrect_input" = "Masukan Anda tidak valid.\nFrasa harus berlanjut tanpa spasi.\nContoh benar: aaaaaa\nContoh salah: aaa aaa 🚫"
|
||||
"AreYouSure" = "Apakah kamu yakin? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil"
|
||||
"FailedResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "データ形式エラー"
|
||||
"emptyUsername" = "ユーザー名を入力してください"
|
||||
"emptyPassword" = "パスワードを入力してください"
|
||||
"wrongUsernameOrPassword" = "ユーザー名、パスワード、または二段階認証コードが無効です。"
|
||||
"wrongUsernameOrPassword" = "ユーザー名、パスワード、または二段階認証コードが無効です。"
|
||||
"successLogin" = "アカウントに正常にログインしました。"
|
||||
|
||||
[pages.index]
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "フォールバックDNSクエリを無効にします"
|
||||
"disableFallbackIfMatch" = "一致した場合にフォールバックを無効にする"
|
||||
"disableFallbackIfMatchDesc" = "DNSサーバーの一致するドメインリストにヒットした場合、フォールバックDNSクエリを無効にします"
|
||||
"enableParallelQuery" = "並列クエリを有効にする"
|
||||
"enableParallelQueryDesc" = "複数のサーバーへの並列DNSクエリを有効にして、より高速な解決を実現"
|
||||
"strategy" = "クエリ戦略"
|
||||
"strategyDesc" = "ドメイン名解決の全体的な戦略"
|
||||
"add" = "サーバー追加"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "管理者の資格情報"
|
||||
"twoFactor" = "二段階認証"
|
||||
"twoFactorEnable" = "2FAを有効化"
|
||||
"twoFactorEnableDesc" = "セキュリティを強化するために追加の認証層を追加します。"
|
||||
"twoFactor" = "二段階認証"
|
||||
"twoFactorEnable" = "2FAを有効化"
|
||||
"twoFactorEnableDesc" = "セキュリティを強化するために追加の認証層を追加します。"
|
||||
"twoFactorModalSetTitle" = "二段階認証を有効にする"
|
||||
"twoFactorModalDeleteTitle" = "二段階認証を無効にする"
|
||||
"twoFactorModalSteps" = "二段階認証を設定するには、次の手順を実行してください:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 有効:{{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 有効化済み:{{ .Enable }}\r\n"
|
||||
"online" = "🌐 接続ステータス:{{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 最終オンライン: {{ .Time }}\r\n"
|
||||
"email" = "📧 メール:{{ .Email }}\r\n"
|
||||
"upload" = "🔼 アップロード↑:{{ .Upload }}\r\n"
|
||||
"download" = "🔽 ダウンロード↓:{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 インバウンド: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 メール: {{ .ClientEmail }}\n📊 トラフィック: {{ .ClientTraffic }}\n📅 有効期限: {{ .ClientExp }}\n🌐 IP制限: {{ .IpLimit }}\n💬 コメント: {{ .ClientComment }}\n\n今すぐこのクライアントをインバウンドに追加できます!"
|
||||
"inbound_client_data_pass" = "🔄 インバウンド: {{ .InboundRemark }}\n\n🔑 パスワード: {{ .ClientPass }}\n📧 メール: {{ .ClientEmail }}\n📊 トラフィック: {{ .ClientTraffic }}\n📅 有効期限: {{ .ClientExp }}\n🌐 IP制限: {{ .IpLimit }}\n💬 コメント: {{ .ClientComment }}\n\n今すぐこのクライアントをインバウンドに追加できます!"
|
||||
"cancel" = "❌ プロセスがキャンセルされました!\n\nいつでも /start で再開できます。 🔄"
|
||||
"error_add_client" = "⚠️ エラー:\n\n {{ .error }}"
|
||||
"using_default_value" = "わかりました、デフォルト値を使用します。 😊"
|
||||
"incorrect_input" ="入力が無効です。\nフレーズはスペースなしで続けて入力してください。\n正しい例: aaaaaa\n間違った例: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ エラー:\n\n {{ .error }}"
|
||||
"using_default_value" = "わかりました、デフォルト値を使用します。 😊"
|
||||
"incorrect_input" = "入力が無効です。\nフレーズはスペースなしで続けて入力してください。\n正しい例: aaaaaa\n間違った例: aaa aaa 🚫"
|
||||
"AreYouSure" = "本当にいいですか?🤔"
|
||||
"SuccessResetTraffic" = "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功"
|
||||
"FailedResetTraffic" = "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,12 +106,12 @@
|
||||
"invalidFormData" = "O formato dos dados de entrada é inválido."
|
||||
"emptyUsername" = "Nome de usuário é obrigatório"
|
||||
"emptyPassword" = "Senha é obrigatória"
|
||||
"wrongUsernameOrPassword" = "Nome de usuário, senha ou código de dois fatores inválido."
|
||||
"wrongUsernameOrPassword" = "Nome de usuário, senha ou código de dois fatores inválido."
|
||||
"successLogin" = "Você entrou na sua conta com sucesso."
|
||||
|
||||
[pages.index]
|
||||
"title" = "Visão Geral"
|
||||
"cpu" = "CPU"
|
||||
"cpu" = "CPU"
|
||||
"logicalProcessors" = "Processadores lógicos"
|
||||
"frequency" = "Frequência"
|
||||
"swap" = "Swap"
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Desativa consultas DNS de fallback"
|
||||
"disableFallbackIfMatch" = "Desativar Fallback Se Corresponder"
|
||||
"disableFallbackIfMatchDesc" = "Desativa consultas DNS de fallback quando a lista de domínios correspondentes do servidor DNS é atingida"
|
||||
"enableParallelQuery" = "Habilitar Consulta Paralela"
|
||||
"enableParallelQueryDesc" = "Habilitar consultas DNS paralelas para múltiplos servidores para resolução mais rápida"
|
||||
"strategy" = "Estratégia de Consulta"
|
||||
"strategyDesc" = "Estratégia geral para resolver nomes de domínio"
|
||||
"add" = "Adicionar Servidor"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Credenciais de administrador"
|
||||
"twoFactor" = "Autenticação de dois fatores"
|
||||
"twoFactorEnable" = "Ativar 2FA"
|
||||
"twoFactorEnableDesc" = "Adiciona uma camada extra de autenticação para mais segurança."
|
||||
"twoFactor" = "Autenticação de dois fatores"
|
||||
"twoFactorEnable" = "Ativar 2FA"
|
||||
"twoFactorEnableDesc" = "Adiciona uma camada extra de autenticação para mais segurança."
|
||||
"twoFactorModalSetTitle" = "Ativar autenticação de dois fatores"
|
||||
"twoFactorModalDeleteTitle" = "Desativar autenticação de dois fatores"
|
||||
"twoFactorModalSteps" = "Para configurar a autenticação de dois fatores, siga alguns passos:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Ativo: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Ativado: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Status da conexão: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Última vez online: {{ .Time }}\r\n"
|
||||
"email" = "📧 Email: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Upload: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Download: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Tráfego: {{ .ClientTraffic }}\n📅 Data de expiração: {{ .ClientExp }}\n🌐 Limite de IP: {{ .IpLimit }}\n💬 Comentário: {{ .ClientComment }}\n\nAgora você pode adicionar o cliente à entrada!"
|
||||
"inbound_client_data_pass" = "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 Senha: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Tráfego: {{ .ClientTraffic }}\n📅 Data de expiração: {{ .ClientExp }}\n🌐 Limite de IP: {{ .IpLimit }}\n💬 Comentário: {{ .ClientComment }}\n\nAgora você pode adicionar o cliente à entrada!"
|
||||
"cancel" = "❌ Processo Cancelado! \n\nVocê pode iniciar novamente a qualquer momento com /start. 🔄"
|
||||
"error_add_client" = "⚠️ Erro:\n\n {{ .error }}"
|
||||
"using_default_value" = "Tudo bem, vou manter o valor padrão. 😊"
|
||||
"incorrect_input" ="Sua entrada não é válida.\nAs frases devem ser contínuas, sem espaços.\nExemplo correto: aaaaaa\nExemplo incorreto: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Erro:\n\n {{ .error }}"
|
||||
"using_default_value" = "Tudo bem, vou manter o valor padrão. 😊"
|
||||
"incorrect_input" = "Sua entrada não é válida.\nAs frases devem ser contínuas, sem espaços.\nExemplo correto: aaaaaa\nExemplo incorreto: aaa aaa 🚫"
|
||||
"AreYouSure" = "Você tem certeza? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso"
|
||||
"FailedResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"copySuccess" = "Скопировано"
|
||||
"sure" = "Да"
|
||||
"encryption" = "Шифрование"
|
||||
"useIPv4ForHost" = "Использовать IPv4 для хоста"
|
||||
"useIPv4ForHost" = "Использовать IPv4 для подключения к хосту"
|
||||
"transmission" = "Транспорт"
|
||||
"host" = "Хост"
|
||||
"path" = "Путь"
|
||||
@@ -46,8 +46,8 @@
|
||||
"online" = "Онлайн"
|
||||
"domainName" = "Домен"
|
||||
"monitor" = "Мониторинг IP"
|
||||
"certificate" = "SSL сертификат"
|
||||
"fail" = "Ошибка"
|
||||
"certificate" = "SSL-сертификат"
|
||||
"fail" = "Сбой"
|
||||
"comment" = "Комментарий"
|
||||
"success" = "Успешно"
|
||||
"lastOnline" = "Был(а) в сети"
|
||||
@@ -55,17 +55,17 @@
|
||||
"install" = "Установка"
|
||||
"clients" = "Клиенты"
|
||||
"usage" = "Использование"
|
||||
"twoFactorCode" = "Код"
|
||||
"twoFactorCode" = "Код 2FA"
|
||||
"remained" = "Остаток"
|
||||
"security" = "Безопасность"
|
||||
"secAlertTitle" = "Предупреждение системы безопасности"
|
||||
"secAlertSsl" = "Это соединение не защищено. Пожалуйста, не вводите конфиденциальную информацию, пока не установите SSL сертификат для защиты соединения"
|
||||
"secAlertConf" = "Некоторые настройки уязвимы для атак. Чтобы в будущем не было проблем, нужно усилить защиту."
|
||||
"secAlertSSL" = "Ваше подключение к панели не защищено. Установите SSL сертификат для защиты данных."
|
||||
"secAlertPanelPort" = "Порт панели по умолчанию небезопасен. Установите случайный или просто другой порт."
|
||||
"secAlertPanelURI" = "Адрес панели по умолчанию небезопасен. Сделайте адрес сложным."
|
||||
"secAlertSubURI" = "URI-адрес подписки по умолчанию небезопасен. Пожалуйста, настройте сложный URI-адрес."
|
||||
"secAlertSubJsonURI" = "URI-адрес по умолчанию для JSON подписки небезопасен. Пожалуйста, настройте сложный URI-адрес."
|
||||
"secAlertSsl" = "Соединение не защищено. Не вводите конфиденциальные данные до установки SSL-сертификата."
|
||||
"secAlertConf" = "Некоторые настройки уязвимы. Рекомендуется усилить защиту для предотвращения атак."
|
||||
"secAlertSSL" = "Подключение к панели не защищено. Установите SSL-сертификат для защиты данных."
|
||||
"secAlertPanelPort" = "Порт панели по умолчанию небезопасен. Установите нестандартный или случайный порт."
|
||||
"secAlertPanelURI" = "Адрес панели по умолчанию небезопасен. Настройте уникальный и сложный URI."
|
||||
"secAlertSubURI" = "URI подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес."
|
||||
"secAlertSubJsonURI" = "URI JSON-подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес."
|
||||
"emptyDnsDesc" = "Нет добавленных DNS-серверов."
|
||||
"emptyFakeDnsDesc" = "Нет добавленных Fake DNS-серверов."
|
||||
"emptyBalancersDesc" = "Нет добавленных балансировщиков."
|
||||
@@ -83,15 +83,15 @@
|
||||
"individualLinks" = "Индивидуальные ссылки"
|
||||
"active" = "Активна"
|
||||
"inactive" = "Неактивна"
|
||||
"unlimited" = "Безлимит"
|
||||
"noExpiry" = "Без срока"
|
||||
"unlimited" = "Неограниченно"
|
||||
"noExpiry" = "Бессрочно"
|
||||
|
||||
[menu]
|
||||
"theme" = "Тема"
|
||||
"dark" = "Темная"
|
||||
"ultraDark" = "Очень темная"
|
||||
"dashboard" = "Дашборд"
|
||||
"inbounds" = "Инбаунды"
|
||||
"inbounds" = "Подключения"
|
||||
"settings" = "Настройки"
|
||||
"xray" = "Настройки Xray"
|
||||
"logout" = "Выход"
|
||||
@@ -107,7 +107,7 @@
|
||||
"emptyUsername" = "Введите имя пользователя"
|
||||
"emptyPassword" = "Введите пароль"
|
||||
"wrongUsernameOrPassword" = "Неверные данные учетной записи."
|
||||
"successLogin" = "Вы успешно вошли в аккаунт"
|
||||
"successLogin" = "Вход выполнен успешно"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Дашборд"
|
||||
@@ -122,7 +122,7 @@
|
||||
"stopXray" = "Остановить"
|
||||
"restartXray" = "Перезапустить"
|
||||
"xraySwitch" = "Выбор версии"
|
||||
"xraySwitchClick" = "Выберите желаемую версию"
|
||||
"xraySwitchClick" = "Выберите нужную версию"
|
||||
"xraySwitchClickDesk" = "Важно: старые версии могут не поддерживать текущие настройки"
|
||||
"xrayStatusUnknown" = "Неизвестно"
|
||||
"xrayStatusRunning" = "Запущен"
|
||||
@@ -134,7 +134,7 @@
|
||||
"systemLoadDesc" = "Средняя загрузка системы за последние 1, 5 и 15 минут"
|
||||
"connectionCount" = "Количество соединений"
|
||||
"ipAddresses" = "IP-адреса сервера"
|
||||
"toggleIpVisibility" = "Переключить видимость IP-адресов сервера"
|
||||
"toggleIpVisibility" = "Скрыть или показать IP-адреса сервера"
|
||||
"overallSpeed" = "Общая скорость передачи трафика"
|
||||
"upload" = "Отправка"
|
||||
"download" = "Загрузка"
|
||||
@@ -168,10 +168,10 @@
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Общий трафик"
|
||||
"allTimeTrafficUsage" = "Общее использование за все время"
|
||||
"title" = "Инбаунды"
|
||||
"totalDownUp" = "Объем отправленного/полученного трафика"
|
||||
"title" = "Подключения"
|
||||
"totalDownUp" = "Отправлено/получено"
|
||||
"totalUsage" = "Всего трафика"
|
||||
"inboundCount" = "Всего инбаундов"
|
||||
"inboundCount" = "Всего подключений"
|
||||
"operate" = "Меню"
|
||||
"enable" = "Включить"
|
||||
"remark" = "Примечание"
|
||||
@@ -185,13 +185,13 @@
|
||||
"createdAt" = "Создано"
|
||||
"updatedAt" = "Обновлено"
|
||||
"resetTraffic" = "Сброс трафика"
|
||||
"addInbound" = "Создать инбаунд"
|
||||
"addInbound" = "Создать подключение"
|
||||
"generalActions" = "Общие действия"
|
||||
"autoRefresh" = "Автообновление"
|
||||
"autoRefreshInterval" = "Интервал"
|
||||
"modifyInbound" = "Изменить инбаунд"
|
||||
"deleteInbound" = "Удалить инбаунд"
|
||||
"deleteInboundContent" = "Вы уверены, что хотите удалить инбаунд?"
|
||||
"modifyInbound" = "Изменить подключение"
|
||||
"deleteInbound" = "Удалить подключение"
|
||||
"deleteInboundContent" = "Вы уверены, что хотите удалить подключение?"
|
||||
"deleteClient" = "Удалить клиента"
|
||||
"deleteClientContent" = "Вы уверены, что хотите удалить клиента?"
|
||||
"resetTrafficContent" = "Вы уверены, что хотите сбросить трафик?"
|
||||
@@ -214,11 +214,11 @@
|
||||
"export" = "Экспорт ссылок"
|
||||
"clone" = "Клонировать"
|
||||
"cloneInbound" = "Клонировать"
|
||||
"cloneInboundContent" = "Будут клонированы все настройки инбаундов, кроме списка клиентов, порта и IP-адреса прослушивания"
|
||||
"cloneInboundContent" = "Будут клонированы все настройки подключений, кроме списка клиентов, порта и IP-адреса прослушивания"
|
||||
"cloneInboundOk" = "Клонировано"
|
||||
"resetAllTraffic" = "Сброс трафика всех инбаундов"
|
||||
"resetAllTrafficTitle" = "Сброс трафика всех инбаундов"
|
||||
"resetAllTrafficContent" = "Вы уверены, что хотите сбросить трафик всех инбаундов?"
|
||||
"resetAllTraffic" = "Сброс трафика всех подключений"
|
||||
"resetAllTrafficTitle" = "Сброс трафика всех подключений"
|
||||
"resetAllTrafficContent" = "Вы уверены, что хотите сбросить трафик всех подключений?"
|
||||
"resetInboundClientTraffics" = "Сброс трафика клиента"
|
||||
"resetInboundClientTrafficTitle" = "Сброс трафика клиентов"
|
||||
"resetInboundClientTrafficContent" = "Вы уверены, что хотите сбросить трафик для этих клиентов?"
|
||||
@@ -231,7 +231,7 @@
|
||||
"email" = "Email"
|
||||
"emailDesc" = "Пожалуйста, укажите уникальный Email"
|
||||
"IPLimit" = "Лимит по количеству IP"
|
||||
"IPLimitDesc" = "Ограничение количества одновременных подключений с разных IP(0 – отключить)"
|
||||
"IPLimitDesc" = "Ограничение числа одновременных подключений с разных IP (0 – отключить)"
|
||||
"IPLimitlog" = "Лог IP-адресов"
|
||||
"IPLimitlogDesc" = "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)"
|
||||
"IPLimitlogclear" = "Очистить лог"
|
||||
@@ -240,19 +240,19 @@
|
||||
"subscriptionDesc" = "Вы можете найти свою ссылку подписки в разделе 'Подробнее'"
|
||||
"info" = "Информация"
|
||||
"same" = "Тот же"
|
||||
"inboundData" = "Данные инбаундов"
|
||||
"exportInbound" = "Экспорт инбаундов"
|
||||
"inboundData" = "Данные подключений"
|
||||
"exportInbound" = "Экспорт подключений"
|
||||
"import" = "Импортировать"
|
||||
"importInbound" = "Импорт инбаундов"
|
||||
"importInbound" = "Импорт подключений"
|
||||
"periodicTrafficResetTitle" = "Сброс трафика"
|
||||
"periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы"
|
||||
"lastReset" = "Последний сброс"
|
||||
|
||||
[pages.client]
|
||||
"add" = "Создать клиента"
|
||||
"add" = "Добавить клиента"
|
||||
"edit" = "Редактировать клиента"
|
||||
"submitAdd" = "Добавить"
|
||||
"submitEdit" = "Сохранить"
|
||||
"submitEdit" = "Сохранить изменения"
|
||||
"clientCount" = "Количество клиентов"
|
||||
"bulk" = "Добавить несколько"
|
||||
"method" = "Метод"
|
||||
@@ -276,13 +276,13 @@
|
||||
"obtain" = "Получить"
|
||||
"updateSuccess" = "Обновление прошло успешно"
|
||||
"logCleanSuccess" = "Лог был очищен"
|
||||
"inboundsUpdateSuccess" = "Инбаунды успешно обновлены"
|
||||
"inboundUpdateSuccess" = "Инбаунд успешно обновлено"
|
||||
"inboundCreateSuccess" = "Инбаунд успешно создано"
|
||||
"inboundDeleteSuccess" = "Инбаунд успешно удалено"
|
||||
"inboundClientAddSuccess" = "Клиент(ы) инбаунда добавлен(ы)"
|
||||
"inboundClientDeleteSuccess" = "Клиент инбаунда удалён"
|
||||
"inboundClientUpdateSuccess" = "Клиент инбаунда обновлён"
|
||||
"inboundsUpdateSuccess" = "Подключения успешно обновлены"
|
||||
"inboundUpdateSuccess" = "Подключение успешно обновлено"
|
||||
"inboundCreateSuccess" = "Подключение успешно создано"
|
||||
"inboundDeleteSuccess" = "Подключение успешно удалено"
|
||||
"inboundClientAddSuccess" = "Клиент(ы) подключения добавлен(ы)"
|
||||
"inboundClientDeleteSuccess" = "Клиент подключения удалён"
|
||||
"inboundClientUpdateSuccess" = "Клиент подключения обновлён"
|
||||
"delDepletedClientsSuccess" = "Все исчерпанные клиенты удалены"
|
||||
"resetAllClientTrafficSuccess" = "Весь трафик клиента сброшен"
|
||||
"resetAllTrafficSuccess" = "Весь трафик сброшен"
|
||||
@@ -310,7 +310,7 @@
|
||||
[pages.settings]
|
||||
"title" = "Настройки"
|
||||
"save" = "Сохранить"
|
||||
"infoDesc" = "Каждое внесённое изменение должно быть сохранено. Пожалуйста, перезапустите панель, чтобы изменения вступили в силу."
|
||||
"infoDesc" = "Сохраните изменения и перезапустите панель для их применения."
|
||||
"restartPanel" = "Перезапуск панели"
|
||||
"restartPanelDesc" = "Вы уверены, что хотите перезапустить панель? Подтвердите, и перезапуск произойдёт через 3 секунды. Если панель будет недоступна, проверьте лог сервера"
|
||||
"restartPanelSuccess" = "Панель успешно перезапущена"
|
||||
@@ -318,11 +318,11 @@
|
||||
"resetDefaultConfig" = "Восстановить настройки по умолчанию"
|
||||
"panelSettings" = "Панель"
|
||||
"securitySettings" = "Учетная запись"
|
||||
"TGBotSettings" = "Telegram"
|
||||
"TGBotSettings" = "Telegram-Бот"
|
||||
"panelListeningIP" = "IP-адрес для управления панелью"
|
||||
"panelListeningIPDesc" = "Оставьте пустым для подключения с любого IP"
|
||||
"panelListeningDomain" = "Домен панели"
|
||||
"panelListeningDomainDesc" = "По умолчанию оставьте пустым, чтобы подключаться с любых доменов и IP-адресов"
|
||||
"panelListeningDomainDesc" = "Оставьте пустым для подключения с любых доменов и IP."
|
||||
"panelPort" = "Порт панели"
|
||||
"panelPortDesc" = "Порт, на котором работает панель"
|
||||
"publicKeyPath" = "Путь к файлу публичного ключа сертификата панели"
|
||||
@@ -332,11 +332,11 @@
|
||||
"panelUrlPath" = "Корневой путь URL адреса панели"
|
||||
"panelUrlPathDesc" = "Должен начинаться с '/' и заканчиваться '/'"
|
||||
"pageSize" = "Размер нумерации страниц"
|
||||
"pageSizeDesc" = "Определить размер страницы для таблицы инбаундов. Установите 0, чтобы отключить"
|
||||
"pageSizeDesc" = "Определить размер страницы для таблицы подключений. Установите 0, чтобы отключить"
|
||||
"remarkModel" = "Модель примечания и символ разделения"
|
||||
"datepicker" = "Выбор даты"
|
||||
"datepicker" = "Тип календаря"
|
||||
"datepickerPlaceholder" = "Выберите дату"
|
||||
"datepickerDescription" = "Запланированные задачи будут выполняться в выбранное время"
|
||||
"datepickerDescription" = "Запланированные задачи будут выполняться в соответствии с этим календарем."
|
||||
"sampleRemark" = "Пример примечания"
|
||||
"oldUsername" = "Текущий логин"
|
||||
"currentPassword" = "Текущий пароль"
|
||||
@@ -346,7 +346,7 @@
|
||||
"telegramBotEnableDesc" = "Доступ к функциям панели через Telegram-бота"
|
||||
"telegramToken" = "Токен Telegram бота"
|
||||
"telegramTokenDesc" = "Необходимо получить токен у менеджера ботов Telegram @botfather"
|
||||
"telegramProxy" = "Прокси Socks5"
|
||||
"telegramProxy" = "Прокси-сервер Socks5"
|
||||
"telegramProxyDesc" = "Если для подключения к Telegram вам нужен прокси Socks5, настройте его параметры согласно руководству."
|
||||
"telegramAPIServer" = "API-сервер Telegram"
|
||||
"telegramAPIServerDesc" = "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию."
|
||||
@@ -451,11 +451,11 @@
|
||||
"RoutingStrategy" = "Настройка маршрутизации доменов"
|
||||
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
|
||||
"Torrent" = "Заблокировать BitTorrent"
|
||||
"Inbounds" = "Инбаунды"
|
||||
"Inbounds" = "Входящие подключения"
|
||||
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"
|
||||
"Outbounds" = "Аутбаунды"
|
||||
"Outbounds" = "Исходящие подключения"
|
||||
"Balancers" = "Балансировщик"
|
||||
"OutboundsDesc" = "Изменение шаблона конфигурации, чтобы определить аутбаунды для этого сервера"
|
||||
"OutboundsDesc" = "Изменение шаблона конфигурации, чтобы определить исходящие подключения для этого сервера"
|
||||
"Routings" = "Маршрутизация"
|
||||
"RoutingsDesc" = "Важен приоритет каждого правила!"
|
||||
"completeTemplate" = "Все"
|
||||
@@ -486,8 +486,8 @@
|
||||
"down" = "Опустить вниз"
|
||||
"source" = "Источник"
|
||||
"dest" = "Пункт назначения"
|
||||
"inbound" = "Инбаунд"
|
||||
"outbound" = "Аутбаунд"
|
||||
"inbound" = "Входящее подключение"
|
||||
"outbound" = "Исходящее подключение"
|
||||
"balancer" = "Балансировщик"
|
||||
"info" = "Информация"
|
||||
"add" = "Создать правило"
|
||||
@@ -495,9 +495,9 @@
|
||||
"useComma" = "Элементы, разделённые запятыми"
|
||||
|
||||
[pages.xray.outbound]
|
||||
"addOutbound" = "Создать аутбаунд"
|
||||
"addOutbound" = "Создать исходящее подключение"
|
||||
"addReverse" = "Создать реверс-прокси"
|
||||
"editOutbound" = "Изменить аутбаунд"
|
||||
"editOutbound" = "Изменить исходящее подключение"
|
||||
"editReverse" = "Редактировать реверс-прокси"
|
||||
"tag" = "Тег"
|
||||
"tagDesc" = "Уникальный тег"
|
||||
@@ -511,7 +511,7 @@
|
||||
"intercon" = "Соединение"
|
||||
"settings" = "Настройки"
|
||||
"accountInfo" = "Информация об учетной записи"
|
||||
"outboundStatus" = "Статус аутбаунда"
|
||||
"outboundStatus" = "Статус исходящего подключения"
|
||||
"sendThrough" = "Отправить через"
|
||||
|
||||
[pages.xray.balancer]
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Отключает резервные DNS-запросы"
|
||||
"disableFallbackIfMatch" = "Отключить резервный DNS при совпадении"
|
||||
"disableFallbackIfMatchDesc" = "Отключает резервные DNS-запросы при совпадении списка доменов DNS-сервера"
|
||||
"enableParallelQuery" = "Включить параллельные запросы"
|
||||
"enableParallelQueryDesc" = "Включить параллельные DNS-запросы к нескольким серверам для более быстрого разрешения"
|
||||
"strategy" = "Стратегия запроса"
|
||||
"strategyDesc" = "Общая стратегия разрешения доменных имен"
|
||||
"add" = "Создать DNS"
|
||||
@@ -587,8 +589,8 @@
|
||||
"modifyUser" = "Вы успешно изменили учетные данные администратора."
|
||||
"originalUserPassIncorrect" = "Неверное имя пользователя или пароль"
|
||||
"userPassMustBeNotEmpty" = "Новое имя пользователя и новый пароль должны быть заполнены"
|
||||
"getOutboundTrafficError" = "Ошибка получения трафика аутбаунда"
|
||||
"resetOutboundTrafficError" = "Ошибка сброса трафика аутбаунда"
|
||||
"getOutboundTrafficError" = "Ошибка получения трафика исходящего подключения"
|
||||
"resetOutboundTrafficError" = "Ошибка сброса трафика исходящего подключения"
|
||||
|
||||
[tgbot]
|
||||
"keyboardClosed" = "❌ Клавиатура закрыта."
|
||||
@@ -596,7 +598,7 @@
|
||||
"noQuery" = "❌ Запрос не найден. Пожалуйста, повторите команду."
|
||||
"wentWrong" = "❌ Что-то пошло не так..."
|
||||
"noIpRecord" = "❗ Нет записей об IP-адресе."
|
||||
"noInbounds" = "❗ У вас не настроено ни одного инбаунда."
|
||||
"noInbounds" = "❗ У вас не настроено ни одного входящего подключения."
|
||||
"unlimited" = "♾ Безлимит"
|
||||
"add" = "Добавить"
|
||||
"month" = "Месяц"
|
||||
@@ -606,7 +608,7 @@
|
||||
"hours" = "Часов"
|
||||
"minutes" = "Минуты"
|
||||
"unknown" = "Неизвестно"
|
||||
"inbounds" = "Инбаунды"
|
||||
"inbounds" = "Входящие подключения"
|
||||
"clients" = "Клиенты"
|
||||
"offline" = "🔴 Офлайн"
|
||||
"online" = "🟢 Онлайн"
|
||||
@@ -620,7 +622,7 @@
|
||||
"status" = "✅ Бот функционирует нормально."
|
||||
"usage" = "❗ Пожалуйста, укажите email для поиска."
|
||||
"getID" = "🆔 Ваш User ID: <code>{{ .ID }}</code>"
|
||||
"helpAdminCommands" = "🔃 Для перезапуска Xray Core:\r\n<code>/restart</code>\r\n\r\n🔎 Для поиска клиента по email:\r\n<code>/usage [Email]</code>\r\n\r\n📊 Для поиска инбаундов (со статистикой клиентов):\r\n<code>/inbound [имя подключения]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>"
|
||||
"helpAdminCommands" = "🔃 Для перезапуска Xray Core:\r\n<code>/restart</code>\r\n\r\n🔎 Для поиска клиента по email:\r\n<code>/usage [Email]</code>\r\n\r\n📊 Для поиска входящих подключений (со статистикой клиентов):\r\n<code>/inbound [имя подключения]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>"
|
||||
"helpClientCommands" = "💲 Для просмотра информации о вашей подписке используйте команду:\r\n<code>/usage [Email]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>"
|
||||
"restartUsage" = "\r\n\r\n<code>/restart</code>"
|
||||
"restartSuccess" = "✅ Ядро Xray успешно перезапущено."
|
||||
@@ -656,13 +658,14 @@
|
||||
"username" = "👤 Имя пользователя: {{ .Username }}\r\n"
|
||||
"password" = "👤 Пароль: {{ .Password }}\r\n"
|
||||
"time" = "⏰ Время: {{ .Time }}\r\n"
|
||||
"inbound" = "📍 Входящий поток: {{ .Remark }}\r\n"
|
||||
"inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
|
||||
"port" = "🔌 Порт: {{ .Port }}\r\n"
|
||||
"expire" = "📅 Дата окончания: {{ .Time }}\r\n"
|
||||
"expireIn" = "📅 Окончание через: {{ .Time }}\r\n"
|
||||
"active" = "💡 Активен: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Активен: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Статус соединения: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Был(а) в сети: {{ .Time }}\r\n"
|
||||
"email" = "📧 Email: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Исходящий трафик: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Входящий трафик: ↓{{ .Download }}\r\n"
|
||||
@@ -685,12 +688,12 @@
|
||||
"pass_prompt" = "🔑 Стандартный пароль: {{ .ClientPassword }}\n\nВведите ваш пароль."
|
||||
"email_prompt" = "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email."
|
||||
"comment_prompt" = "💬 Стандартный комментарий: {{ .ClientComment }}\n\nВведите ваш комментарий."
|
||||
"inbound_client_data_id" = "🔄 Инбаунды: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Дата исчерпания: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в инбаунд!"
|
||||
"inbound_client_data_pass" = "🔄 Инбаунды: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Дата исчерпания: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в инбаунд!"
|
||||
"inbound_client_data_id" = "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!"
|
||||
"inbound_client_data_pass" = "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!"
|
||||
"cancel" = "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄"
|
||||
"error_add_client" = "⚠️ Ошибка:\n\n {{ .error }}"
|
||||
"using_default_value" = "Используется значение по умолчанию👌"
|
||||
"incorrect_input" ="Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Ошибка:\n\n {{ .error }}"
|
||||
"using_default_value" = "Используется значение по умолчанию👌"
|
||||
"incorrect_input" = "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫"
|
||||
"AreYouSure" = "Вы уверены? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно"
|
||||
"FailedResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]"
|
||||
@@ -707,7 +710,7 @@
|
||||
"confirmToggle" = "✅ Подтвердить вкл/выкл пользователя?"
|
||||
"dbBackup" = "📂 Бэкап БД"
|
||||
"serverUsage" = "💻 Состояние сервера"
|
||||
"getInbounds" = "🔌 Инбаунды"
|
||||
"getInbounds" = "🔌 Входящие подключения"
|
||||
"depleteSoon" = "⚠️ Скоро конец"
|
||||
"clientUsage" = "Статистика клиента"
|
||||
"onlines" = "🟢 Онлайн"
|
||||
@@ -731,7 +734,7 @@
|
||||
"allClients" = "👥 Все клиенты"
|
||||
"addClient" = "➕ Новый клиент"
|
||||
"submitDisable" = "Добавить отключенным ☑️"
|
||||
"submitEnable" = "Добавить включенныи ✅"
|
||||
"submitEnable" = "Добавить включенным ✅"
|
||||
"use_default" = "🏷️ Использовать по умолчанию"
|
||||
"change_id" = "⚙️🔑 ID"
|
||||
"change_password" = "⚙️🔑 Пароль"
|
||||
@@ -743,7 +746,7 @@
|
||||
[tgbot.answers]
|
||||
"successfulOperation" = "✅ Успешно!"
|
||||
"errorOperation" = "❗ Ошибка в операции."
|
||||
"getInboundsFailed" = "❌ Не удалось получить инбаунды."
|
||||
"getInboundsFailed" = "❌ Не удалось получить входящие подключения."
|
||||
"getClientsFailed" = "❌ Не удалось получить клиентов."
|
||||
"canceled" = "❌ {{ .Email }}: Операция отменена."
|
||||
"clientRefreshSuccess" = "✅ {{ .Email }}: Клиент успешно обновлен."
|
||||
@@ -760,5 +763,5 @@
|
||||
"enableSuccess" = "✅ {{ .Email }}: Включено успешно."
|
||||
"disableSuccess" = "✅ {{ .Email }}: Отключено успешно."
|
||||
"askToAddUserId" = "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>"
|
||||
"chooseClient" = "Выберите клиента для инбаунда {{ .Inbound }}"
|
||||
"chooseInbound" = "Выберите инбаунд"
|
||||
"chooseClient" = "Выберите клиента для входящего подключения {{ .Inbound }}"
|
||||
"chooseInbound" = "Выберите входящее подключение"
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "Girdi verisi formatı geçersiz."
|
||||
"emptyUsername" = "Kullanıcı adı gerekli"
|
||||
"emptyPassword" = "Şifre gerekli"
|
||||
"wrongUsernameOrPassword" = "Geçersiz kullanıcı adı, şifre veya iki adımlı doğrulama kodu."
|
||||
"wrongUsernameOrPassword" = "Geçersiz kullanıcı adı, şifre veya iki adımlı doğrulama kodu."
|
||||
"successLogin" = "Hesabınıza başarıyla giriş yaptınız."
|
||||
|
||||
[pages.index]
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Yedek DNS sorgularını devre dışı bırakır"
|
||||
"disableFallbackIfMatch" = "Eşleşirse Yedeklemeyi Devre Dışı Bırak"
|
||||
"disableFallbackIfMatchDesc" = "DNS sunucusunun eşleşen alan adı listesi vurulduğunda yedek DNS sorgularını devre dışı bırakır"
|
||||
"enableParallelQuery" = "Paralel Sorguyu Etkinleştir"
|
||||
"enableParallelQueryDesc" = "Daha hızlı çözümleme için birden fazla sunucuya paralel DNS sorgularını etkinleştir"
|
||||
"strategy" = "Sorgu Stratejisi"
|
||||
"strategyDesc" = "Alan adlarını çözmek için genel strateji"
|
||||
"add" = "Sunucu Ekle"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Yönetici kimlik bilgileri"
|
||||
"twoFactor" = "İki adımlı doğrulama"
|
||||
"twoFactorEnable" = "2FA'yı Etkinleştir"
|
||||
"twoFactorEnableDesc" = "Daha fazla güvenlik için ek bir doğrulama katmanı ekler."
|
||||
"twoFactor" = "İki adımlı doğrulama"
|
||||
"twoFactorEnable" = "2FA'yı Etkinleştir"
|
||||
"twoFactorEnableDesc" = "Daha fazla güvenlik için ek bir doğrulama katmanı ekler."
|
||||
"twoFactorModalSetTitle" = "İki adımlı doğrulamayı etkinleştir"
|
||||
"twoFactorModalDeleteTitle" = "İki adımlı doğrulamayı devre dışı bırak"
|
||||
"twoFactorModalSteps" = "İki adımlı doğrulamayı ayarlamak için şu adımları izleyin:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Aktif: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Etkin: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Bağlantı durumu: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Son çevrimiçi: {{ .Time }}\r\n"
|
||||
"email" = "📧 E-posta: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Yükleme: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 İndirme: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Giriş: {{ .InboundRemark }}\n\n🔑 Kimlik: {{ .ClientId }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Trafik: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Yorum: {{ .ClientComment }}\n\nArtık bu müşteriyi girişe ekleyebilirsin!"
|
||||
"inbound_client_data_pass" = "🔄 Giriş: {{ .InboundRemark }}\n\n🔑 Şifre: {{ .ClientPass }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Trafik: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Yorum: {{ .ClientComment }}\n\nArtık bu müşteriyi girişe ekleyebilirsin!"
|
||||
"cancel" = "❌ İşlem iptal edildi! \n\nİstediğiniz zaman /start ile yeniden başlayabilirsiniz. 🔄"
|
||||
"error_add_client" = "⚠️ Hata:\n\n {{ .error }}"
|
||||
"using_default_value" = "Tamam, varsayılan değeri kullanacağım. 😊"
|
||||
"incorrect_input" ="Girdiğiniz değer geçerli değil.\nKelime öbekleri boşluk olmadan devam etmelidir.\nDoğru örnek: aaaaaa\nYanlış örnek: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Hata:\n\n {{ .error }}"
|
||||
"using_default_value" = "Tamam, varsayılan değeri kullanacağım. 😊"
|
||||
"incorrect_input" = "Girdiğiniz değer geçerli değil.\nKelime öbekleri boşluk olmadan devam etmelidir.\nDoğru örnek: aaaaaa\nYanlış örnek: aaa aaa 🚫"
|
||||
"AreYouSure" = "Emin misin? 🤔"
|
||||
"SuccessResetTraffic" = "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı"
|
||||
"FailedResetTraffic" = "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "Формат вхідних даних недійсний."
|
||||
"emptyUsername" = "Потрібне ім'я користувача"
|
||||
"emptyPassword" = "Потрібен пароль"
|
||||
"wrongUsernameOrPassword" = "Невірне ім’я користувача, пароль або код двофакторної аутентифікації."
|
||||
"wrongUsernameOrPassword" = "Невірне ім’я користувача, пароль або код двофакторної аутентифікації."
|
||||
"successLogin" = "Ви успішно увійшли до свого облікового запису."
|
||||
|
||||
[pages.index]
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Вимкнути резервні DNS-запити"
|
||||
"disableFallbackIfMatch" = "Вимкнути резервний DNS при збігу"
|
||||
"disableFallbackIfMatchDesc" = "Вимкнути резервні DNS-запити при збігу списку доменів DNS-сервера"
|
||||
"enableParallelQuery" = "Увімкнути паралельні запити"
|
||||
"enableParallelQueryDesc" = "Увімкнути паралельні DNS-запити до кількох серверів для швидшого вирішення"
|
||||
"strategy" = "Стратегія запиту"
|
||||
"strategyDesc" = "Загальна стратегія вирішення доменних імен"
|
||||
"add" = "Додати сервер"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "Облікові дані адміністратора"
|
||||
"twoFactor" = "Двофакторна аутентифікація"
|
||||
"twoFactorEnable" = "Увімкнути 2FA"
|
||||
"twoFactorEnableDesc" = "Додає додатковий рівень аутентифікації для підвищення безпеки."
|
||||
"twoFactor" = "Двофакторна аутентифікація"
|
||||
"twoFactorEnable" = "Увімкнути 2FA"
|
||||
"twoFactorEnableDesc" = "Додає додатковий рівень аутентифікації для підвищення безпеки."
|
||||
"twoFactorModalSetTitle" = "Увімкнути двофакторну аутентифікацію"
|
||||
"twoFactorModalDeleteTitle" = "Вимкнути двофакторну аутентифікацію"
|
||||
"twoFactorModalSteps" = "Щоб налаштувати двофакторну аутентифікацію, виконайте кілька кроків:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Активний: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Увімкнено: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Стан підключення: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Був(ла) онлайн: {{ .Time }}\r\n"
|
||||
"email" = "📧 Електронна пошта: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Upload: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Download: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Вхід: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Електронна пошта: {{ .ClientEmail }}\n📊 Трафік: {{ .ClientTraffic }}\n📅 Дата завершення: {{ .ClientExp }}\n🌐 Обмеження IP: {{ .IpLimit }}\n💬 Коментар: {{ .ClientComment }}\n\nТепер ви можете додати клієнта до вхідного з'єднання!"
|
||||
"inbound_client_data_pass" = "🔄 Вхід: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Електронна пошта: {{ .ClientEmail }}\n📊 Трафік: {{ .ClientTraffic }}\n📅 Дата завершення: {{ .ClientExp }}\n🌐 Обмеження IP: {{ .IpLimit }}\n💬 Коментар: {{ .ClientComment }}\n\nТепер ви можете додати клієнта до вхідного з'єднання!"
|
||||
"cancel" = "❌ Процес скасовано! \n\nВи можете знову розпочати, використовуючи /start у будь-який час. 🔄"
|
||||
"error_add_client" = "⚠️ Помилка:\n\n {{ .error }}"
|
||||
"using_default_value" = "Гаразд, залишу значення за замовчуванням. 😊"
|
||||
"incorrect_input" ="Ваш ввід невірний.\nФрази повинні бути без пробілів.\nПравильний приклад: aaaaaa\nНеправильний приклад: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Помилка:\n\n {{ .error }}"
|
||||
"using_default_value" = "Гаразд, залишу значення за замовчуванням. 😊"
|
||||
"incorrect_input" = "Ваш ввід невірний.\nФрази повинні бути без пробілів.\nПравильний приклад: aaaaaa\nНеправильний приклад: aaa aaa 🚫"
|
||||
"AreYouSure" = "Ви впевнені? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно"
|
||||
"FailedResetTraffic" = "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "Tắt các truy vấn DNS Fallback"
|
||||
"disableFallbackIfMatch" = "Tắt Fallback Nếu Khớp"
|
||||
"disableFallbackIfMatchDesc" = "Tắt các truy vấn DNS Fallback khi danh sách tên miền khớp của máy chủ DNS được kích hoạt"
|
||||
"enableParallelQuery" = "Bật Truy vấn Song song"
|
||||
"enableParallelQueryDesc" = "Bật truy vấn DNS song song đến nhiều máy chủ để phân giải nhanh hơn"
|
||||
"strategy" = "Chiến lược truy vấn"
|
||||
"strategyDesc" = "Chiến lược tổng thể để phân giải tên miền"
|
||||
"add" = "Thêm máy chủ"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 Đang hoạt động: {{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 Đã bật: {{ .Enable }}\r\n"
|
||||
"online" = "🌐 Trạng thái kết nối: {{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 Lần online gần nhất: {{ .Time }}\r\n"
|
||||
"email" = "📧 Email: {{ .Email }}\r\n"
|
||||
"upload" = "🔼 Tải lên: ↑{{ .Upload }}\r\n"
|
||||
"download" = "🔽 Tải xuống: ↓{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 Kết nối vào: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Dung lượng: {{ .ClientTraffic }}\n📅 Ngày hết hạn: {{ .ClientExp }}\n🌐 Giới hạn IP: {{ .IpLimit }}\n💬 Ghi chú: {{ .ClientComment }}\n\nBây giờ bạn có thể thêm khách hàng vào inbound!"
|
||||
"inbound_client_data_pass" = "🔄 Kết nối vào: {{ .InboundRemark }}\n\n🔑 Mật khẩu: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Dung lượng: {{ .ClientTraffic }}\n📅 Ngày hết hạn: {{ .ClientExp }}\n🌐 Giới hạn IP: {{ .IpLimit }}\n💬 Ghi chú: {{ .ClientComment }}\n\nBây giờ bạn có thể thêm khách hàng vào inbound!"
|
||||
"cancel" = "❌ Quá trình đã bị hủy! \n\nBạn có thể bắt đầu lại bất cứ lúc nào bằng cách nhập /start. 🔄"
|
||||
"error_add_client" = "⚠️ Lỗi:\n\n {{ .error }}"
|
||||
"using_default_value" = "Được rồi, tôi sẽ sử dụng giá trị mặc định. 😊"
|
||||
"incorrect_input" ="Dữ liệu bạn nhập không hợp lệ.\nCác chuỗi phải liền mạch và không có dấu cách.\nVí dụ đúng: aaaaaa\nVí dụ sai: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ Lỗi:\n\n {{ .error }}"
|
||||
"using_default_value" = "Được rồi, tôi sẽ sử dụng giá trị mặc định. 😊"
|
||||
"incorrect_input" = "Dữ liệu bạn nhập không hợp lệ.\nCác chuỗi phải liền mạch và không có dấu cách.\nVí dụ đúng: aaaaaa\nVí dụ sai: aaa aaa 🚫"
|
||||
"AreYouSure" = "Bạn có chắc không? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công"
|
||||
"FailedResetTraffic" = "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "数据格式错误"
|
||||
"emptyUsername" = "请输入用户名"
|
||||
"emptyPassword" = "请输入密码"
|
||||
"wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。"
|
||||
"wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。"
|
||||
"successLogin" = "您已成功登录您的账户。"
|
||||
|
||||
[pages.index]
|
||||
@@ -242,7 +242,7 @@
|
||||
"same" = "相同"
|
||||
"inboundData" = "入站数据"
|
||||
"exportInbound" = "导出入站规则"
|
||||
"import"="导入"
|
||||
"import" = "导入"
|
||||
"importInbound" = "导入入站规则"
|
||||
"periodicTrafficResetTitle" = "流量重置"
|
||||
"periodicTrafficResetDesc" = "按指定间隔自动重置流量计数器"
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "禁用回退DNS查询"
|
||||
"disableFallbackIfMatch" = "匹配时禁用回退"
|
||||
"disableFallbackIfMatchDesc" = "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询"
|
||||
"enableParallelQuery" = "启用并行查询"
|
||||
"enableParallelQueryDesc" = "启用并行DNS查询到多个服务器以实现更快的解析"
|
||||
"strategy" = "查询策略"
|
||||
"strategyDesc" = "解析域名的总体策略"
|
||||
"add" = "添加服务器"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "管理员凭据"
|
||||
"twoFactor" = "双重验证"
|
||||
"twoFactorEnable" = "启用2FA"
|
||||
"twoFactorEnableDesc" = "增加额外的验证层以提高安全性。"
|
||||
"twoFactor" = "双重验证"
|
||||
"twoFactorEnable" = "启用2FA"
|
||||
"twoFactorEnableDesc" = "增加额外的验证层以提高安全性。"
|
||||
"twoFactorModalSetTitle" = "启用双重认证"
|
||||
"twoFactorModalDeleteTitle" = "停用双重认证"
|
||||
"twoFactorModalSteps" = "要设定双重认证,请执行以下步骤:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 激活:{{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 已启用:{{ .Enable }}\r\n"
|
||||
"online" = "🌐 连接状态:{{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 上次在线: {{ .Time }}\r\n"
|
||||
"email" = "📧 邮箱:{{ .Email }}\r\n"
|
||||
"upload" = "🔼 上传↑:{{ .Upload }}\r\n"
|
||||
"download" = "🔽 下载↓:{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 入站: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 邮箱: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日期: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 备注: {{ .ClientComment }}\n\n你现在可以将客户添加到入站了!"
|
||||
"inbound_client_data_pass" = "🔄 入站: {{ .InboundRemark }}\n\n🔑 密码: {{ .ClientPass }}\n📧 邮箱: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日期: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 备注: {{ .ClientComment }}\n\n你现在可以将客户添加到入站了!"
|
||||
"cancel" = "❌ 进程已取消!\n\n您可以随时使用 /start 重新开始。 🔄"
|
||||
"error_add_client" = "⚠️ 错误:\n\n {{ .error }}"
|
||||
"using_default_value" = "好的,我会使用默认值。 😊"
|
||||
"incorrect_input" ="您的输入无效。\n短语应连续输入,不能有空格。\n正确示例: aaaaaa\n错误示例: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ 错误:\n\n {{ .error }}"
|
||||
"using_default_value" = "好的,我会使用默认值。 😊"
|
||||
"incorrect_input" = "您的输入无效。\n短语应连续输入,不能有空格。\n正确示例: aaaaaa\n错误示例: aaa aaa 🚫"
|
||||
"AreYouSure" = "你确定吗?🤔"
|
||||
"SuccessResetTraffic" = "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功"
|
||||
"FailedResetTraffic" = "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"invalidFormData" = "資料格式錯誤"
|
||||
"emptyUsername" = "請輸入使用者名稱"
|
||||
"emptyPassword" = "請輸入密碼"
|
||||
"wrongUsernameOrPassword" = "用戶名、密碼或雙重驗證碼無效。"
|
||||
"wrongUsernameOrPassword" = "用戶名、密碼或雙重驗證碼無效。"
|
||||
"successLogin" = "您已成功登入您的帳戶。"
|
||||
|
||||
[pages.index]
|
||||
@@ -242,7 +242,7 @@
|
||||
"same" = "相同"
|
||||
"inboundData" = "入站資料"
|
||||
"exportInbound" = "匯出入站規則"
|
||||
"import"="匯入"
|
||||
"import" = "匯入"
|
||||
"importInbound" = "匯入入站規則"
|
||||
"periodicTrafficResetTitle" = "流量重置"
|
||||
"periodicTrafficResetDesc" = "按指定間隔自動重置流量計數器"
|
||||
@@ -544,6 +544,8 @@
|
||||
"disableFallbackDesc" = "禁用回退DNS查詢"
|
||||
"disableFallbackIfMatch" = "匹配時禁用回退"
|
||||
"disableFallbackIfMatchDesc" = "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢"
|
||||
"enableParallelQuery" = "啟用並行查詢"
|
||||
"enableParallelQueryDesc" = "啟用並行DNS查詢到多個伺服器以實現更快的解析"
|
||||
"strategy" = "查詢策略"
|
||||
"strategyDesc" = "解析域名的總體策略"
|
||||
"add" = "新增伺服器"
|
||||
@@ -565,9 +567,9 @@
|
||||
|
||||
[pages.settings.security]
|
||||
"admin" = "管理員憑證"
|
||||
"twoFactor" = "雙重驗證"
|
||||
"twoFactorEnable" = "啟用2FA"
|
||||
"twoFactorEnableDesc" = "增加額外的驗證層以提高安全性。"
|
||||
"twoFactor" = "雙重驗證"
|
||||
"twoFactorEnable" = "啟用2FA"
|
||||
"twoFactorEnableDesc" = "增加額外的驗證層以提高安全性。"
|
||||
"twoFactorModalSetTitle" = "啟用雙重認證"
|
||||
"twoFactorModalDeleteTitle" = "停用雙重認證"
|
||||
"twoFactorModalSteps" = "要設定雙重認證,請執行以下步驟:"
|
||||
@@ -663,6 +665,7 @@
|
||||
"active" = "💡 啟用:{{ .Enable }}\r\n"
|
||||
"enabled" = "🚨 已啟用:{{ .Enable }}\r\n"
|
||||
"online" = "🌐 連線狀態:{{ .Status }}\r\n"
|
||||
"lastOnline" = "🔙 上次上線: {{ .Time }}\r\n"
|
||||
"email" = "📧 郵箱:{{ .Email }}\r\n"
|
||||
"upload" = "🔼 上傳↑:{{ .Upload }}\r\n"
|
||||
"download" = "🔽 下載↓:{{ .Download }}\r\n"
|
||||
@@ -688,9 +691,9 @@
|
||||
"inbound_client_data_id" = "🔄 入站: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 電子郵件: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 備註: {{ .ClientComment }}\n\n你現在可以將客戶加入入站了!"
|
||||
"inbound_client_data_pass" = "🔄 入站: {{ .InboundRemark }}\n\n🔑 密碼: {{ .ClientPass }}\n📧 電子郵件: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 備註: {{ .ClientComment }}\n\n你現在可以將客戶加入入站了!"
|
||||
"cancel" = "❌ 程序已取消!\n\n您可以隨時使用 /start 重新開始。 🔄"
|
||||
"error_add_client" = "⚠️ 錯誤:\n\n {{ .error }}"
|
||||
"using_default_value" = "好的,我會使用預設值。 😊"
|
||||
"incorrect_input" ="您的輸入無效。\n短語應連續輸入,不能有空格。\n正確示例: aaaaaa\n錯誤示例: aaa aaa 🚫"
|
||||
"error_add_client" = "⚠️ 錯誤:\n\n {{ .error }}"
|
||||
"using_default_value" = "好的,我會使用預設值。 😊"
|
||||
"incorrect_input" = "您的輸入無效。\n短語應連續輸入,不能有空格。\n正確示例: aaaaaa\n錯誤示例: aaa aaa 🚫"
|
||||
"AreYouSure" = "你確定嗎?🤔"
|
||||
"SuccessResetTraffic" = "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功"
|
||||
"FailedResetTraffic" = "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]"
|
||||
|
||||
22
web/web.go
22
web/web.go
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/network"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -98,11 +99,14 @@ type Server struct {
|
||||
index *controller.IndexController
|
||||
panel *controller.XUIController
|
||||
api *controller.APIController
|
||||
ws *controller.WebSocketController
|
||||
|
||||
xrayService service.XrayService
|
||||
settingService service.SettingService
|
||||
tgbotService service.Tgbot
|
||||
|
||||
wsHub *websocket.Hub
|
||||
|
||||
cron *cron.Cron
|
||||
|
||||
ctx context.Context
|
||||
@@ -266,6 +270,15 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
s.panel = controller.NewXUIController(g)
|
||||
s.api = controller.NewAPIController(g)
|
||||
|
||||
// Initialize WebSocket hub
|
||||
s.wsHub = websocket.NewHub()
|
||||
go s.wsHub.Run()
|
||||
|
||||
// Initialize WebSocket controller
|
||||
s.ws = controller.NewWebSocketController(s.wsHub)
|
||||
// Register WebSocket route with basePath (g already has basePath prefix)
|
||||
g.GET("/ws", s.ws.HandleWebSocket)
|
||||
|
||||
// Chrome DevTools endpoint for debugging web apps
|
||||
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
@@ -448,6 +461,10 @@ func (s *Server) Stop() error {
|
||||
if s.tgbotService.IsRunning() {
|
||||
s.tgbotService.Stop()
|
||||
}
|
||||
// Gracefully stop WebSocket hub
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.Stop()
|
||||
}
|
||||
var err1 error
|
||||
var err2 error
|
||||
if s.httpServer != nil {
|
||||
@@ -468,3 +485,8 @@ func (s *Server) GetCtx() context.Context {
|
||||
func (s *Server) GetCron() *cron.Cron {
|
||||
return s.cron
|
||||
}
|
||||
|
||||
// GetWSHub returns the WebSocket hub instance.
|
||||
func (s *Server) GetWSHub() any {
|
||||
return s.wsHub
|
||||
}
|
||||
|
||||
380
web/websocket/hub.go
Normal file
380
web/websocket/hub.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Package websocket provides WebSocket hub for real-time updates and notifications.
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
)
|
||||
|
||||
// MessageType represents the type of WebSocket message
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeStatus MessageType = "status" // Server status update
|
||||
MessageTypeTraffic MessageType = "traffic" // Traffic statistics update
|
||||
MessageTypeInbounds MessageType = "inbounds" // Inbounds list update
|
||||
MessageTypeNotification MessageType = "notification" // System notification
|
||||
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
||||
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
||||
)
|
||||
|
||||
// Message represents a WebSocket message
|
||||
type Message struct {
|
||||
Type MessageType `json:"type"`
|
||||
Payload any `json:"payload"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
// Client represents a WebSocket client connection
|
||||
type Client struct {
|
||||
ID string
|
||||
Send chan []byte
|
||||
Hub *Hub
|
||||
Topics map[MessageType]bool // Subscribed topics
|
||||
}
|
||||
|
||||
// Hub maintains the set of active clients and broadcasts messages to them
|
||||
type Hub struct {
|
||||
// Registered clients
|
||||
clients map[*Client]bool
|
||||
|
||||
// Inbound messages from clients
|
||||
broadcast chan []byte
|
||||
|
||||
// Register requests from clients
|
||||
register chan *Client
|
||||
|
||||
// Unregister requests from clients
|
||||
unregister chan *Client
|
||||
|
||||
// Mutex for thread-safe operations
|
||||
mu sync.RWMutex
|
||||
|
||||
// Context for graceful shutdown
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// Worker pool for parallel broadcasting
|
||||
workerPoolSize int
|
||||
broadcastWg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub
|
||||
func NewHub() *Hub {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Calculate optimal worker pool size (CPU cores * 2, but max 100)
|
||||
workerPoolSize := runtime.NumCPU() * 2
|
||||
if workerPoolSize > 100 {
|
||||
workerPoolSize = 100
|
||||
}
|
||||
if workerPoolSize < 10 {
|
||||
workerPoolSize = 10
|
||||
}
|
||||
|
||||
return &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
broadcast: make(chan []byte, 2048), // Increased from 256 to 2048 for high load
|
||||
register: make(chan *Client, 100), // Buffered channel for fast registration
|
||||
unregister: make(chan *Client, 100), // Buffered channel for fast unregistration
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
workerPoolSize: workerPoolSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub's main loop
|
||||
func (h *Hub) Run() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("WebSocket hub panic recovered:", r)
|
||||
// Restart the hub loop
|
||||
go h.Run()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-h.ctx.Done():
|
||||
// Graceful shutdown: close all clients
|
||||
h.mu.Lock()
|
||||
for client := range h.clients {
|
||||
// Safely close channel (avoid double close panic)
|
||||
select {
|
||||
case _, stillOpen := <-client.Send:
|
||||
if stillOpen {
|
||||
close(client.Send)
|
||||
}
|
||||
default:
|
||||
close(client.Send)
|
||||
}
|
||||
}
|
||||
h.clients = make(map[*Client]bool)
|
||||
h.mu.Unlock()
|
||||
// Wait for all broadcast workers to finish
|
||||
h.broadcastWg.Wait()
|
||||
logger.Info("WebSocket hub stopped gracefully")
|
||||
return
|
||||
|
||||
case client := <-h.register:
|
||||
if client == nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
count := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
logger.Debugf("WebSocket client connected: %s (total: %d)", client.ID, count)
|
||||
|
||||
case client := <-h.unregister:
|
||||
if client == nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
// Safely close channel (avoid double close panic)
|
||||
// Check if channel is already closed by trying to read from it
|
||||
select {
|
||||
case _, stillOpen := <-client.Send:
|
||||
if stillOpen {
|
||||
// Channel was open and had data, now it's empty, safe to close
|
||||
close(client.Send)
|
||||
}
|
||||
// If stillOpen is false, channel was already closed, do nothing
|
||||
default:
|
||||
// Channel is empty and open, safe to close
|
||||
close(client.Send)
|
||||
}
|
||||
}
|
||||
count := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
logger.Debugf("WebSocket client disconnected: %s (total: %d)", client.ID, count)
|
||||
|
||||
case message := <-h.broadcast:
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
// Optimization: quickly copy client list and release lock
|
||||
h.mu.RLock()
|
||||
clientCount := len(h.clients)
|
||||
if clientCount == 0 {
|
||||
h.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Pre-allocate memory for client list
|
||||
clients := make([]*Client, 0, clientCount)
|
||||
for client := range h.clients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Parallel broadcast using worker pool
|
||||
h.broadcastParallel(clients, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastParallel sends message to all clients in parallel for maximum performance
|
||||
func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
|
||||
if len(clients) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// For small number of clients, use simple parallel sending
|
||||
if len(clients) < h.workerPoolSize {
|
||||
var wg sync.WaitGroup
|
||||
for _, client := range clients {
|
||||
wg.Add(1)
|
||||
go func(c *Client) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Channel may be closed, safely ignore
|
||||
logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", c.ID, r)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case c.Send <- message:
|
||||
default:
|
||||
// Client's send buffer is full, disconnect
|
||||
logger.Debugf("WebSocket client %s send buffer full, disconnecting", c.ID)
|
||||
h.Unregister(c)
|
||||
}
|
||||
}(client)
|
||||
}
|
||||
wg.Wait()
|
||||
return
|
||||
}
|
||||
|
||||
// For large number of clients, use worker pool for optimal performance
|
||||
clientChan := make(chan *Client, len(clients))
|
||||
for _, client := range clients {
|
||||
clientChan <- client
|
||||
}
|
||||
close(clientChan)
|
||||
|
||||
// Start workers for parallel processing
|
||||
h.broadcastWg.Add(h.workerPoolSize)
|
||||
for i := 0; i < h.workerPoolSize; i++ {
|
||||
go func() {
|
||||
defer h.broadcastWg.Done()
|
||||
for client := range clientChan {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Channel may be closed, safely ignore
|
||||
logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", client.ID, r)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case client.Send <- message:
|
||||
default:
|
||||
// Client's send buffer is full, disconnect
|
||||
logger.Debugf("WebSocket client %s send buffer full, disconnecting", client.ID)
|
||||
h.Unregister(client)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all workers to finish
|
||||
h.broadcastWg.Wait()
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients
|
||||
func (h *Hub) Broadcast(messageType MessageType, payload any) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if payload == nil {
|
||||
logger.Warning("Attempted to broadcast nil payload")
|
||||
return
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: messageType,
|
||||
Payload: payload,
|
||||
Time: getCurrentTimestamp(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Error("Failed to marshal WebSocket message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Limit message size to prevent memory issues
|
||||
const maxMessageSize = 1024 * 1024 // 1MB
|
||||
if len(data) > maxMessageSize {
|
||||
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
|
||||
return
|
||||
}
|
||||
|
||||
// Non-blocking send with timeout to prevent delays
|
||||
select {
|
||||
case h.broadcast <- data:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
logger.Warning("WebSocket broadcast channel is full, dropping message")
|
||||
case <-h.ctx.Done():
|
||||
// Hub is shutting down
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastToTopic sends a message only to clients subscribed to the specific topic
|
||||
func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if payload == nil {
|
||||
logger.Warning("Attempted to broadcast nil payload to topic")
|
||||
return
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: messageType,
|
||||
Payload: payload,
|
||||
Time: getCurrentTimestamp(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Error("Failed to marshal WebSocket message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Limit message size to prevent memory issues
|
||||
const maxMessageSize = 1024 * 1024 // 1MB
|
||||
if len(data) > maxMessageSize {
|
||||
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
// Filter clients by topics and quickly release lock
|
||||
subscribedClients := make([]*Client, 0)
|
||||
for client := range h.clients {
|
||||
if len(client.Topics) == 0 || client.Topics[messageType] {
|
||||
subscribedClients = append(subscribedClients, client)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Parallel send to subscribed clients
|
||||
if len(subscribedClients) > 0 {
|
||||
h.broadcastParallel(subscribedClients, data)
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientCount returns the number of connected clients
|
||||
func (h *Hub) GetClientCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// Register registers a new client with the hub
|
||||
func (h *Hub) Register(client *Client) {
|
||||
if h == nil || client == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.register <- client:
|
||||
case <-h.ctx.Done():
|
||||
// Hub is shutting down
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister unregisters a client from the hub
|
||||
func (h *Hub) Unregister(client *Client) {
|
||||
if h == nil || client == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.unregister <- client:
|
||||
case <-h.ctx.Done():
|
||||
// Hub is shutting down
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully stops the hub and closes all connections
|
||||
func (h *Hub) Stop() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if h.cancel != nil {
|
||||
h.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// getCurrentTimestamp returns current Unix timestamp in milliseconds
|
||||
func getCurrentTimestamp() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
82
web/websocket/notifier.go
Normal file
82
web/websocket/notifier.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package websocket provides WebSocket hub for real-time updates and notifications.
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||
)
|
||||
|
||||
// GetHub returns the global WebSocket hub instance
|
||||
func GetHub() *Hub {
|
||||
webServer := global.GetWebServer()
|
||||
if webServer == nil {
|
||||
return nil
|
||||
}
|
||||
hub := webServer.GetWSHub()
|
||||
if hub == nil {
|
||||
return nil
|
||||
}
|
||||
wsHub, ok := hub.(*Hub)
|
||||
if !ok {
|
||||
logger.Warning("WebSocket hub type assertion failed")
|
||||
return nil
|
||||
}
|
||||
return wsHub
|
||||
}
|
||||
|
||||
// BroadcastStatus broadcasts server status update to all connected clients
|
||||
func BroadcastStatus(status any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTraffic broadcasts traffic statistics update to all connected clients
|
||||
func BroadcastTraffic(traffic any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeTraffic, traffic)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastInbounds broadcasts inbounds list update to all connected clients
|
||||
func BroadcastInbounds(inbounds any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeInbounds, inbounds)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastOutbounds broadcasts outbounds list update to all connected clients
|
||||
func BroadcastOutbounds(outbounds interface{}) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeOutbounds, outbounds)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastNotification broadcasts a system notification to all connected clients
|
||||
func BroadcastNotification(title, message, level string) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
notification := map[string]string{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"level": level, // info, warning, error, success
|
||||
}
|
||||
hub.Broadcast(MessageTypeNotification, notification)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastXrayState broadcasts Xray state change to all connected clients
|
||||
func BroadcastXrayState(state string, errorMsg string) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
stateUpdate := map[string]string{
|
||||
"state": state,
|
||||
"errorMsg": errorMsg,
|
||||
}
|
||||
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
BIN
windows_files/SSL/Win64OpenSSL_Light-3_6_0.exe
Normal file
BIN
windows_files/SSL/Win64OpenSSL_Light-3_6_0.exe
Normal file
Binary file not shown.
@@ -4,6 +4,7 @@ After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/default/x-ui
|
||||
Environment="XRAY_VMESS_AEAD_FORCED=false"
|
||||
Type=simple
|
||||
WorkingDirectory=/usr/local/x-ui/
|
||||
16
x-ui.service.rhel
Normal file
16
x-ui.service.rhel
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=x-ui Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/sysconfig/x-ui
|
||||
Environment="XRAY_VMESS_AEAD_FORCED=false"
|
||||
Type=simple
|
||||
WorkingDirectory=/usr/local/x-ui/
|
||||
ExecStart=/usr/local/x-ui/x-ui
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
448
x-ui.sh
448
x-ui.sh
@@ -19,6 +19,20 @@ function LOGI() {
|
||||
echo -e "${green}[INF] $* ${plain}"
|
||||
}
|
||||
|
||||
# Simple helpers for domain/IP validation
|
||||
is_ipv4() {
|
||||
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1
|
||||
}
|
||||
is_ipv6() {
|
||||
[[ "$1" =~ : ]] && return 0 || return 1
|
||||
}
|
||||
is_ip() {
|
||||
is_ipv4 "$1" || is_ipv6 "$1"
|
||||
}
|
||||
is_domain() {
|
||||
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+[A-Za-z]{2,}$ ]] && return 0 || return 1
|
||||
}
|
||||
|
||||
# check root
|
||||
[[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1
|
||||
|
||||
@@ -39,7 +53,10 @@ os_version=""
|
||||
os_version=$(grep "^VERSION_ID" /etc/os-release | cut -d '=' -f2 | tr -d '"' | tr -d '.')
|
||||
|
||||
# Declare Variables
|
||||
log_folder="${XUI_LOG_FOLDER:=/var/log}"
|
||||
xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
|
||||
xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
||||
log_folder="${XUI_LOG_FOLDER:=/var/log/x-ui}"
|
||||
mkdir -p "${log_folder}"
|
||||
iplimit_log_path="${log_folder}/3xipl.log"
|
||||
iplimit_banned_log_path="${log_folder}/3xipl-banned.log"
|
||||
|
||||
@@ -111,8 +128,8 @@ update_menu() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
wget -O /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
|
||||
chmod +x /usr/local/x-ui/x-ui.sh
|
||||
curl -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
|
||||
chmod +x ${xui_folder}/x-ui.sh
|
||||
chmod +x /usr/bin/x-ui
|
||||
|
||||
if [[ $? == 0 ]]; then
|
||||
@@ -161,13 +178,13 @@ uninstall() {
|
||||
else
|
||||
systemctl stop x-ui
|
||||
systemctl disable x-ui
|
||||
rm /etc/systemd/system/x-ui.service -f
|
||||
rm ${xui_service}/x-ui.service -f
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed
|
||||
fi
|
||||
|
||||
rm /etc/x-ui/ -rf
|
||||
rm /usr/local/x-ui/ -rf
|
||||
rm ${xui_folder}/ -rf
|
||||
|
||||
echo ""
|
||||
echo -e "Uninstalled Successfully.\n"
|
||||
@@ -195,9 +212,9 @@ reset_user() {
|
||||
|
||||
read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm
|
||||
if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then
|
||||
/usr/local/x-ui/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor false >/dev/null 2>&1
|
||||
${xui_folder}/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor false >/dev/null 2>&1
|
||||
else
|
||||
/usr/local/x-ui/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor true >/dev/null 2>&1
|
||||
${xui_folder}/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor true >/dev/null 2>&1
|
||||
echo -e "Two factor authentication has been disabled."
|
||||
fi
|
||||
|
||||
@@ -225,7 +242,7 @@ reset_webbasepath() {
|
||||
config_webBasePath=$(gen_random_string 18)
|
||||
|
||||
# Apply the new web base path setting
|
||||
/usr/local/x-ui/x-ui setting -webBasePath "${config_webBasePath}" >/dev/null 2>&1
|
||||
${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" >/dev/null 2>&1
|
||||
|
||||
echo -e "Web base path has been reset to: ${green}${config_webBasePath}${plain}"
|
||||
echo -e "${green}Please use the new web base path to access the panel.${plain}"
|
||||
@@ -240,13 +257,13 @@ reset_config() {
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
/usr/local/x-ui/x-ui setting -reset
|
||||
${xui_folder}/x-ui setting -reset
|
||||
echo -e "All panel settings have been reset to default."
|
||||
restart
|
||||
}
|
||||
|
||||
check_config() {
|
||||
local info=$(/usr/local/x-ui/x-ui setting -show true)
|
||||
local info=$(${xui_folder}/x-ui setting -show true)
|
||||
if [[ $? != 0 ]]; then
|
||||
LOGE "get current settings error, please check logs"
|
||||
show_menu
|
||||
@@ -256,7 +273,7 @@ check_config() {
|
||||
|
||||
local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}')
|
||||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||
local server_ip=$(curl -s --max-time 3 https://api.ipify.org)
|
||||
if [ -z "$server_ip" ]; then
|
||||
server_ip=$(curl -s --max-time 3 https://4.ident.me)
|
||||
@@ -271,7 +288,25 @@ check_config() {
|
||||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||||
fi
|
||||
else
|
||||
echo -e "${green}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||||
echo -e "${red}⚠ WARNING: No SSL certificate configured!${plain}"
|
||||
echo -e "${yellow}You can get a Let's Encrypt certificate for your IP address (valid ~6 days, auto-renews).${plain}"
|
||||
read -rp "Generate SSL certificate for IP now? [y/N]: " gen_ssl
|
||||
if [[ "$gen_ssl" == "y" || "$gen_ssl" == "Y" ]]; then
|
||||
stop >/dev/null 2>&1
|
||||
ssl_cert_issue_for_ip
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||||
# ssl_cert_issue_for_ip already restarts the panel, but ensure it's running
|
||||
start >/dev/null 2>&1
|
||||
else
|
||||
LOGE "IP certificate setup failed."
|
||||
echo -e "${yellow}You can try again via option 18 (SSL Certificate Management).${plain}"
|
||||
start >/dev/null 2>&1
|
||||
fi
|
||||
else
|
||||
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||||
echo -e "${yellow}For security, please configure SSL certificate using option 18 (SSL Certificate Management)${plain}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -282,7 +317,7 @@ set_port() {
|
||||
LOGD "Cancelled"
|
||||
before_show_menu
|
||||
else
|
||||
/usr/local/x-ui/x-ui setting -port ${port}
|
||||
${xui_folder}/x-ui setting -port ${port}
|
||||
echo -e "The port is set, Please restart the panel now, and use the new port ${green}${port}${plain} to access web panel"
|
||||
confirm_restart
|
||||
fi
|
||||
@@ -509,12 +544,16 @@ enable_bbr() {
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install -yqq --no-install-recommends ca-certificates
|
||||
;;
|
||||
centos | rhel | almalinux | rocky | ol)
|
||||
yum -y update && yum -y install ca-certificates
|
||||
;;
|
||||
fedora | amzn | virtuozzo)
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update && dnf -y install ca-certificates
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update && yum -y install ca-certificates
|
||||
else
|
||||
dnf -y update && dnf -y install ca-certificates
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Sy --noconfirm ca-certificates
|
||||
;;
|
||||
@@ -546,7 +585,7 @@ enable_bbr() {
|
||||
}
|
||||
|
||||
update_shell() {
|
||||
wget -O /usr/bin/x-ui -N https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh
|
||||
curl -fLRo /usr/bin/x-ui -z /usr/bin/x-ui https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh
|
||||
if [[ $? != 0 ]]; then
|
||||
echo ""
|
||||
LOGE "Failed to download script, Please check whether the machine can connect Github"
|
||||
@@ -570,7 +609,7 @@ check_status() {
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
|
||||
if [[ ! -f ${xui_service}/x-ui.service ]]; then
|
||||
return 2
|
||||
fi
|
||||
temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
|
||||
@@ -863,55 +902,61 @@ delete_ports() {
|
||||
fi
|
||||
}
|
||||
|
||||
update_all_geofiles() {
|
||||
update_main_geofiles
|
||||
update_ir_geofiles
|
||||
update_ru_geofiles
|
||||
}
|
||||
|
||||
update_main_geofiles() {
|
||||
curl -fLRo geoip.dat https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -fLRo geosite.dat https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
}
|
||||
|
||||
update_ir_geofiles() {
|
||||
curl -fLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
curl -fLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
}
|
||||
|
||||
update_ru_geofiles() {
|
||||
curl -fLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -fLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
}
|
||||
|
||||
update_geo() {
|
||||
echo -e "${green}\t1.${plain} Loyalsoldier (geoip.dat, geosite.dat)"
|
||||
echo -e "${green}\t2.${plain} chocolate4u (geoip_IR.dat, geosite_IR.dat)"
|
||||
echo -e "${green}\t3.${plain} runetfreedom (geoip_RU.dat, geosite_RU.dat)"
|
||||
echo -e "${green}\t4.${plain} All"
|
||||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||||
read -rp "Choose an option: " choice
|
||||
|
||||
cd /usr/local/x-ui/bin
|
||||
cd ${xui_folder}/bin
|
||||
|
||||
case "$choice" in
|
||||
0)
|
||||
show_menu
|
||||
;;
|
||||
1)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop
|
||||
else
|
||||
systemctl stop x-ui
|
||||
fi
|
||||
rm -f geoip.dat geosite.dat
|
||||
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
update_main_geofiles
|
||||
echo -e "${green}Loyalsoldier datasets have been updated successfully!${plain}"
|
||||
restart
|
||||
;;
|
||||
2)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop
|
||||
else
|
||||
systemctl stop x-ui
|
||||
fi
|
||||
rm -f geoip_IR.dat geosite_IR.dat
|
||||
wget -O geoip_IR.dat -N https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
wget -O geosite_IR.dat -N https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
update_ir_geofiles
|
||||
echo -e "${green}chocolate4u datasets have been updated successfully!${plain}"
|
||||
restart
|
||||
;;
|
||||
3)
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui stop
|
||||
else
|
||||
systemctl stop x-ui
|
||||
fi
|
||||
rm -f geoip_RU.dat geosite_RU.dat
|
||||
wget -O geoip_RU.dat -N https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -O geosite_RU.dat -N https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
update_ru_geofiles
|
||||
echo -e "${green}runetfreedom datasets have been updated successfully!${plain}"
|
||||
restart
|
||||
;;
|
||||
4)
|
||||
update_all_geofiles
|
||||
echo -e "${green}All geo files have been updated successfully!${plain}"
|
||||
restart
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||||
update_geo
|
||||
@@ -943,11 +988,12 @@ install_acme() {
|
||||
}
|
||||
|
||||
ssl_cert_issue_main() {
|
||||
echo -e "${green}\t1.${plain} Get SSL"
|
||||
echo -e "${green}\t1.${plain} Get SSL (Domain)"
|
||||
echo -e "${green}\t2.${plain} Revoke"
|
||||
echo -e "${green}\t3.${plain} Force Renew"
|
||||
echo -e "${green}\t4.${plain} Show Existing Domains"
|
||||
echo -e "${green}\t5.${plain} Set Cert paths for the panel"
|
||||
echo -e "${green}\t6.${plain} Get SSL for IP Address (6-day cert, auto-renews)"
|
||||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||||
|
||||
read -rp "Choose an option: " choice
|
||||
@@ -1027,7 +1073,7 @@ ssl_cert_issue_main() {
|
||||
local webKeyFile="/root/cert/${domain}/privkey.pem"
|
||||
|
||||
if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
|
||||
/usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
echo "Panel paths set for domain: $domain"
|
||||
echo " - Certificate File: $webCertFile"
|
||||
echo " - Private Key File: $webKeyFile"
|
||||
@@ -1041,6 +1087,17 @@ ssl_cert_issue_main() {
|
||||
fi
|
||||
ssl_cert_issue_main
|
||||
;;
|
||||
6)
|
||||
echo -e "${yellow}Let's Encrypt SSL Certificate for IP Address${plain}"
|
||||
echo -e "This will obtain a certificate for your server's IP using the shortlived profile."
|
||||
echo -e "${yellow}Certificate valid for ~6 days, auto-renews via acme.sh cron job.${plain}"
|
||||
echo -e "${yellow}Port 80 must be open and accessible from the internet.${plain}"
|
||||
confirm "Do you want to proceed?" "y"
|
||||
if [[ $? == 0 ]]; then
|
||||
ssl_cert_issue_for_ip
|
||||
fi
|
||||
ssl_cert_issue_main
|
||||
;;
|
||||
|
||||
*)
|
||||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||||
@@ -1049,9 +1106,160 @@ ssl_cert_issue_main() {
|
||||
esac
|
||||
}
|
||||
|
||||
ssl_cert_issue_for_ip() {
|
||||
LOGI "Starting automatic SSL certificate generation for server IP..."
|
||||
LOGI "Using Let's Encrypt shortlived profile (~6 days validity, auto-renews)"
|
||||
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
|
||||
# Get server IP
|
||||
local server_ip=$(curl -s --max-time 3 https://api.ipify.org)
|
||||
if [ -z "$server_ip" ]; then
|
||||
server_ip=$(curl -s --max-time 3 https://4.ident.me)
|
||||
fi
|
||||
|
||||
if [ -z "$server_ip" ]; then
|
||||
LOGE "Failed to get server IP address"
|
||||
return 1
|
||||
fi
|
||||
|
||||
LOGI "Server IP detected: ${server_ip}"
|
||||
|
||||
# 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
|
||||
|
||||
# check for acme.sh first
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
LOGI "acme.sh not found, installing..."
|
||||
install_acme
|
||||
if [ $? -ne 0 ]; then
|
||||
LOGE "Failed to install acme.sh"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# install socat
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update >/dev/null 2>&1 && apt-get install socat -y >/dev/null 2>&1
|
||||
;;
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update >/dev/null 2>&1 && dnf -y install socat >/dev/null 2>&1
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update >/dev/null 2>&1 && yum -y install socat >/dev/null 2>&1
|
||||
else
|
||||
dnf -y update >/dev/null 2>&1 && dnf -y install socat >/dev/null 2>&1
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Sy --noconfirm socat >/dev/null 2>&1
|
||||
;;
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh >/dev/null 2>&1 && zypper -q install -y socat >/dev/null 2>&1
|
||||
;;
|
||||
alpine)
|
||||
apk add socat curl openssl >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
LOGW "Unsupported OS for automatic socat installation"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Create certificate directory
|
||||
certPath="/root/cert/ip"
|
||||
mkdir -p "$certPath"
|
||||
|
||||
# Build domain arguments
|
||||
local domain_args="-d ${server_ip}"
|
||||
if [[ -n "$ipv6_addr" ]] && is_ipv6 "$ipv6_addr"; then
|
||||
domain_args="${domain_args} -d ${ipv6_addr}"
|
||||
LOGI "Including IPv6 address: ${ipv6_addr}"
|
||||
fi
|
||||
|
||||
# Use port 80 for certificate issuance
|
||||
local WebPort=80
|
||||
LOGI "Using port ${WebPort} to issue certificate for IP: ${server_ip}"
|
||||
LOGI "Make sure port ${WebPort} is open and not in use..."
|
||||
|
||||
# Reload command - restarts panel after renewal
|
||||
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null"
|
||||
|
||||
# issue the certificate for IP with shortlived profile
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
|
||||
~/.acme.sh/acme.sh --issue \
|
||||
${domain_args} \
|
||||
--standalone \
|
||||
--server letsencrypt \
|
||||
--certificate-profile shortlived \
|
||||
--days 6 \
|
||||
--httpport ${WebPort} \
|
||||
--force
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
LOGE "Failed to issue certificate for IP: ${server_ip}"
|
||||
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${server_ip} 2>/dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2>/dev/null
|
||||
rm -rf ${certPath} 2>/dev/null
|
||||
return 1
|
||||
else
|
||||
LOGI "Certificate issued successfully for IP: ${server_ip}"
|
||||
fi
|
||||
|
||||
# Install the certificate
|
||||
# Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
|
||||
# but the cert files are still installed. We check for files instead of exit code.
|
||||
~/.acme.sh/acme.sh --installcert -d ${server_ip} \
|
||||
--key-file "${certPath}/privkey.pem" \
|
||||
--fullchain-file "${certPath}/fullchain.pem" \
|
||||
--reloadcmd "${reloadCmd}" 2>&1 || true
|
||||
|
||||
# Verify certificate files exist (don't rely on exit code - reloadcmd failure causes non-zero)
|
||||
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
||||
LOGE "Certificate files not found after installation"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${server_ip} 2>/dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2>/dev/null
|
||||
rm -rf ${certPath} 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
LOGI "Certificate files installed successfully"
|
||||
|
||||
# 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
|
||||
|
||||
# Set certificate paths for the panel
|
||||
local webCertFile="${certPath}/fullchain.pem"
|
||||
local webKeyFile="${certPath}/privkey.pem"
|
||||
|
||||
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
LOGI "Certificate configured for panel"
|
||||
LOGI " - Certificate File: $webCertFile"
|
||||
LOGI " - Private Key File: $webKeyFile"
|
||||
LOGI " - Validity: ~6 days (auto-renews via acme.sh cron)"
|
||||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||||
LOGI "Panel will restart to apply SSL certificate..."
|
||||
restart
|
||||
return 0
|
||||
else
|
||||
LOGE "Certificate files not found after installation"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ssl_cert_issue() {
|
||||
local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(/usr/local/x-ui/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}')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
# check for acme.sh first
|
||||
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
|
||||
echo "acme.sh could not be found. we will install it"
|
||||
@@ -1062,29 +1270,32 @@ ssl_cert_issue() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# install socat second
|
||||
# install socat
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install socat -y
|
||||
apt-get update >/dev/null 2>&1 && apt-get install socat -y >/dev/null 2>&1
|
||||
;;
|
||||
centos | rhel | almalinux | rocky | ol)
|
||||
yum -y update && yum -y install socat
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update >/dev/null 2>&1 && dnf -y install socat >/dev/null 2>&1
|
||||
;;
|
||||
fedora | amzn | virtuozzo)
|
||||
dnf -y update && dnf -y install socat
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update >/dev/null 2>&1 && yum -y install socat >/dev/null 2>&1
|
||||
else
|
||||
dnf -y update >/dev/null 2>&1 && dnf -y install socat >/dev/null 2>&1
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Sy --noconfirm socat
|
||||
pacman -Sy --noconfirm socat >/dev/null 2>&1
|
||||
;;
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh && zypper -q install -y socat
|
||||
opensuse-tumbleweed | opensuse-leap)
|
||||
zypper refresh >/dev/null 2>&1 && zypper -q install -y socat >/dev/null 2>&1
|
||||
;;
|
||||
alpine)
|
||||
apk add socat
|
||||
apk add socat curl openssl >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
|
||||
exit 1
|
||||
LOGW "Unsupported OS for automatic socat installation"
|
||||
;;
|
||||
esac
|
||||
if [ $? -ne 0 ]; then
|
||||
@@ -1096,7 +1307,22 @@ ssl_cert_issue() {
|
||||
|
||||
# get the domain here, and we need to verify it
|
||||
local domain=""
|
||||
read -rp "Please enter your domain name: " domain
|
||||
while true; do
|
||||
read -rp "Please enter your domain name: " domain
|
||||
domain="${domain// /}" # Trim whitespace
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
LOGE "Domain name cannot be empty. Please try again."
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! is_domain "$domain"; then
|
||||
LOGE "Invalid domain format: ${domain}. Please enter a valid domain name."
|
||||
continue
|
||||
fi
|
||||
|
||||
break
|
||||
done
|
||||
LOGD "Your domain is: ${domain}, checking it..."
|
||||
|
||||
# check if there already exists a certificate
|
||||
@@ -1183,12 +1409,14 @@ ssl_cert_issue() {
|
||||
if [ $? -ne 0 ]; then
|
||||
LOGE "Auto renew failed, certificate details:"
|
||||
ls -lah cert/*
|
||||
chmod 755 $certPath/*
|
||||
chmod 600 $certPath/privkey.pem
|
||||
chmod 644 $certPath/fullchain.pem
|
||||
exit 1
|
||||
else
|
||||
LOGI "Auto renew succeeded, certificate details:"
|
||||
ls -lah cert/*
|
||||
chmod 755 $certPath/*
|
||||
chmod 600 $certPath/privkey.pem
|
||||
chmod 644 $certPath/fullchain.pem
|
||||
fi
|
||||
|
||||
# Prompt user to set panel paths after successful certificate installation
|
||||
@@ -1198,7 +1426,7 @@ ssl_cert_issue() {
|
||||
local webKeyFile="/root/cert/${domain}/privkey.pem"
|
||||
|
||||
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
||||
/usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
LOGI "Panel paths set for domain: $domain"
|
||||
LOGI " - Certificate File: $webCertFile"
|
||||
LOGI " - Private Key File: $webKeyFile"
|
||||
@@ -1213,8 +1441,8 @@ ssl_cert_issue() {
|
||||
}
|
||||
|
||||
ssl_cert_issue_CF() {
|
||||
local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(/usr/local/x-ui/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}')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
LOGI "****** Instructions for Use ******"
|
||||
LOGI "Follow the steps below to complete the process:"
|
||||
LOGI "1. Cloudflare Registered E-mail."
|
||||
@@ -1328,7 +1556,8 @@ ssl_cert_issue_CF() {
|
||||
else
|
||||
LOGI "The certificate is installed and auto-renewal is turned on. Specific information is as follows:"
|
||||
ls -lah ${certPath}/*
|
||||
chmod 755 ${certPath}/*
|
||||
chmod 600 ${certPath}/privkey.pem
|
||||
chmod 644 ${certPath}/fullchain.pem
|
||||
fi
|
||||
|
||||
# Prompt user to set panel paths after successful certificate installation
|
||||
@@ -1338,7 +1567,7 @@ ssl_cert_issue_CF() {
|
||||
local webKeyFile="${certPath}/privkey.pem"
|
||||
|
||||
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
||||
/usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
||||
LOGI "Panel paths set for domain: $CF_Domain"
|
||||
LOGI " - Certificate File: $webCertFile"
|
||||
LOGI " - Private Key File: $webKeyFile"
|
||||
@@ -1531,13 +1760,17 @@ install_iplimit() {
|
||||
armbian)
|
||||
apt-get update && apt-get install fail2ban -y
|
||||
;;
|
||||
centos | rhel | almalinux | rocky | ol)
|
||||
yum update -y && yum install epel-release -y
|
||||
yum -y install fail2ban
|
||||
;;
|
||||
fedora | amzn | virtuozzo)
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf -y update && dnf -y install fail2ban
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum update -y && yum install epel-release -y
|
||||
yum -y install fail2ban
|
||||
else
|
||||
dnf -y update && dnf -y install fail2ban
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Syu --noconfirm fail2ban
|
||||
;;
|
||||
@@ -1631,14 +1864,19 @@ remove_iplimit() {
|
||||
apt-get purge -y fail2ban -y
|
||||
apt-get autoremove -y
|
||||
;;
|
||||
centos | rhel | almalinux | rocky | ol)
|
||||
yum remove fail2ban -y
|
||||
yum autoremove -y
|
||||
;;
|
||||
fedora | amzn | virtuozzo)
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||
dnf remove fail2ban -y
|
||||
dnf autoremove -y
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum remove fail2ban -y
|
||||
yum autoremove -y
|
||||
else
|
||||
dnf remove fail2ban -y
|
||||
dnf autoremove -y
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Rns --noconfirm fail2ban
|
||||
;;
|
||||
@@ -1793,11 +2031,11 @@ SSH_port_forwarding() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local existing_listenIP=$(/usr/local/x-ui/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')
|
||||
local existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}')
|
||||
local existing_key=$(/usr/local/x-ui/x-ui setting -getCert true | grep -Eo 'key: .+' | awk '{print $2}')
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')
|
||||
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}')
|
||||
local existing_key=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'key: .+' | awk '{print $2}')
|
||||
|
||||
local config_listenIP=""
|
||||
local listen_choice=""
|
||||
@@ -1838,7 +2076,7 @@ SSH_port_forwarding() {
|
||||
config_listenIP="127.0.0.1"
|
||||
[[ "$listen_choice" == "2" ]] && read -rp "Enter custom IP to listen on: " config_listenIP
|
||||
|
||||
/usr/local/x-ui/x-ui setting -listenIP "${config_listenIP}" >/dev/null 2>&1
|
||||
${xui_folder}/x-ui setting -listenIP "${config_listenIP}" >/dev/null 2>&1
|
||||
echo -e "${green}listen IP has been set to ${config_listenIP}.${plain}"
|
||||
echo -e "\n${green}SSH Port Forwarding Configuration:${plain}"
|
||||
echo -e "Standard SSH command:"
|
||||
@@ -1854,7 +2092,7 @@ SSH_port_forwarding() {
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
/usr/local/x-ui/x-ui setting -listenIP 0.0.0.0 >/dev/null 2>&1
|
||||
${xui_folder}/x-ui setting -listenIP 0.0.0.0 >/dev/null 2>&1
|
||||
echo -e "${green}Listen IP has been cleared.${plain}"
|
||||
restart
|
||||
;;
|
||||
@@ -1869,24 +2107,25 @@ SSH_port_forwarding() {
|
||||
}
|
||||
|
||||
show_usage() {
|
||||
echo -e "┌───────────────────────────────────────────────────────┐
|
||||
│ ${blue}x-ui control menu usages (subcommands):${plain} │
|
||||
│ │
|
||||
│ ${blue}x-ui${plain} - Admin Management Script │
|
||||
│ ${blue}x-ui start${plain} - Start │
|
||||
│ ${blue}x-ui stop${plain} - Stop │
|
||||
│ ${blue}x-ui restart${plain} - Restart │
|
||||
│ ${blue}x-ui status${plain} - Current Status │
|
||||
│ ${blue}x-ui settings${plain} - Current Settings │
|
||||
│ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
|
||||
│ ${blue}x-ui disable${plain} - Disable Autostart on OS Startup │
|
||||
│ ${blue}x-ui log${plain} - Check logs │
|
||||
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
||||
│ ${blue}x-ui update${plain} - Update │
|
||||
│ ${blue}x-ui legacy${plain} - legacy version │
|
||||
│ ${blue}x-ui install${plain} - Install │
|
||||
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
||||
└───────────────────────────────────────────────────────┘"
|
||||
echo -e "┌────────────────────────────────────────────────────────────────┐
|
||||
│ ${blue}x-ui control menu usages (subcommands):${plain} │
|
||||
│ │
|
||||
│ ${blue}x-ui${plain} - Admin Management Script │
|
||||
│ ${blue}x-ui start${plain} - Start │
|
||||
│ ${blue}x-ui stop${plain} - Stop │
|
||||
│ ${blue}x-ui restart${plain} - Restart │
|
||||
│ ${blue}x-ui status${plain} - Current Status │
|
||||
│ ${blue}x-ui settings${plain} - Current Settings │
|
||||
│ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
|
||||
│ ${blue}x-ui disable${plain} - Disable Autostart on OS Startup │
|
||||
│ ${blue}x-ui log${plain} - Check logs │
|
||||
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
||||
│ ${blue}x-ui update${plain} - Update │
|
||||
│ ${blue}x-ui update-all-geofiles${plain} - Update all geo files │
|
||||
│ ${blue}x-ui legacy${plain} - Legacy version │
|
||||
│ ${blue}x-ui install${plain} - Install │
|
||||
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
||||
└────────────────────────────────────────────────────────────────┘"
|
||||
}
|
||||
|
||||
show_menu() {
|
||||
@@ -2056,6 +2295,9 @@ if [[ $# > 0 ]]; then
|
||||
"uninstall")
|
||||
check_install 0 && uninstall 0
|
||||
;;
|
||||
"update-all-geofiles")
|
||||
check_install 0 && update_all_geofiles 0 && restart 0
|
||||
;;
|
||||
*) show_usage ;;
|
||||
esac
|
||||
else
|
||||
|
||||
27
xray/api.go
27
xray/api.go
@@ -110,10 +110,33 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
||||
Id: user["id"].(string),
|
||||
})
|
||||
case "vless":
|
||||
account = serial.ToTypedMessage(&vless.Account{
|
||||
vlessAccount := &vless.Account{
|
||||
Id: user["id"].(string),
|
||||
Flow: user["flow"].(string),
|
||||
})
|
||||
}
|
||||
// Add testseed if provided
|
||||
if testseedVal, ok := user["testseed"]; ok {
|
||||
if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
|
||||
testseed := make([]uint32, len(testseedArr))
|
||||
for i, v := range testseedArr {
|
||||
if num, ok := v.(float64); ok {
|
||||
testseed[i] = uint32(num)
|
||||
}
|
||||
}
|
||||
vlessAccount.Testseed = testseed
|
||||
} else if testseedArr, ok := testseedVal.([]uint32); ok && len(testseedArr) >= 4 {
|
||||
vlessAccount.Testseed = testseedArr
|
||||
}
|
||||
}
|
||||
// Add testpre if provided (for outbound, but can be in user for compatibility)
|
||||
if testpreVal, ok := user["testpre"]; ok {
|
||||
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
|
||||
vlessAccount.Testpre = uint32(testpre)
|
||||
} else if testpre, ok := testpreVal.(uint32); ok && testpre > 0 {
|
||||
vlessAccount.Testpre = testpre
|
||||
}
|
||||
}
|
||||
account = serial.ToTypedMessage(vlessAccount)
|
||||
case "trojan":
|
||||
account = serial.ToTypedMessage(&trojan.Account{
|
||||
Password: user["password"].(string),
|
||||
|
||||
Reference in New Issue
Block a user