Compare commits
10
Commits
1498d58f14
...
5ca69dd9c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ca69dd9c1 | ||
|
|
284040e994 | ||
|
|
70d24d2f15 | ||
|
|
cb8981158f | ||
|
|
c56a02c948 | ||
|
|
4149a162b8 | ||
|
|
b9e1345b23 | ||
|
|
7d42b91ed6 | ||
|
|
3cbf9164a4 | ||
|
|
e42b5d770d |
@@ -2,7 +2,10 @@ import { Protocols } from '@/schemas/primitives';
|
||||
|
||||
/*
|
||||
* Protocols whose inbounds can live on a sub-node (the "Deploy To" set).
|
||||
* Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only.
|
||||
* Everything else (http, tunnel, tun, mtproto) is panel-local only. Mixed
|
||||
* is eligible: it is a plain xray inbound, and node-local mixed inbounds
|
||||
* already sync back to the panel — hiding the picker only blocked creating
|
||||
* them from the master.
|
||||
* Shared by the inbound form's Deploy To selector and the clone dialog's
|
||||
* target picker so the two surfaces can never drift apart.
|
||||
*/
|
||||
@@ -13,4 +16,5 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
|
||||
[Protocols.SHADOWSOCKS]: true,
|
||||
[Protocols.HYSTERIA]: true,
|
||||
[Protocols.WIREGUARD]: true,
|
||||
[Protocols.MIXED]: true,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ require (
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
github.com/gin-contrib/sessions v1.1.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
@@ -16,16 +15,11 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.19.2
|
||||
github.com/mattn/go-sqlite3 v1.14.50
|
||||
github.com/mymmrac/telego v1.11.2
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.73.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/net v0.58.0
|
||||
@@ -42,18 +36,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.5 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // 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
|
||||
@@ -61,7 +51,6 @@ require (
|
||||
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/grbit/go-json v0.11.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
@@ -69,46 +58,28 @@ require (
|
||||
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/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/miekg/dns v1.1.73 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.5 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/stun/v3 v3.1.7 // indirect
|
||||
github.com/pion/transport/v4 v4.1.0 // indirect
|
||||
github.com/pires/go-proxyproto v0.15.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/sagernet/sing v0.8.14 // indirect
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/arch v0.30.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814 h1:l2AhBD+sFycU8Im81n/bZORMxW7fWtlZJEuJ4Hh0+z0=
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
|
||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
@@ -18,8 +12,6 @@ github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj
|
||||
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -29,8 +21,6 @@ github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
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.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
@@ -39,10 +29,6 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -62,8 +48,6 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/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=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
@@ -83,10 +67,6 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -95,18 +75,6 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -115,16 +83,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||
@@ -133,33 +95,17 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
|
||||
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
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.11.2 h1:f/CCSKHsXEHCEQPixOj6/l8WEns9YlXKvh9zi/Iinww=
|
||||
github.com/mymmrac/telego v1.11.2/go.mod h1:wo7Y5Ux7xUZs04xzmP0SUFGvWVDJDcMDV9aJ1jwqFl0=
|
||||
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=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
|
||||
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
|
||||
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
|
||||
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
|
||||
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
@@ -173,16 +119,8 @@ github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er
|
||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/sagernet/sing v0.8.14 h1:S6Netv4F61uNAuD/sUbHnGuNUEPwtL08Ouk0//CVYgM=
|
||||
github.com/sagernet/sing v0.8.14/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
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=
|
||||
@@ -203,24 +141,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
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.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
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/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
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-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
|
||||
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=
|
||||
@@ -245,8 +165,6 @@ 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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
|
||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
||||
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
@@ -260,8 +178,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
@@ -270,10 +186,6 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
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-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
|
||||
@@ -283,12 +195,8 @@ google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4J
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/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=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
|
||||
@@ -299,7 +207,5 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
||||
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
|
||||
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
type SubClashService struct {
|
||||
enableRouting bool
|
||||
clashRules string
|
||||
SubService *SubService
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
|
||||
func NewSubClashService(subService *SubService) *SubClashService {
|
||||
return &SubClashService{SubService: subService}
|
||||
}
|
||||
|
||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||
@@ -98,19 +94,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
|
||||
if s.enableRouting {
|
||||
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
|
||||
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
|
||||
if remote {
|
||||
if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
} else if err := mergeClashRulesYAML(config, resolved); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalYAML, err := marshalClashYAML(config)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -789,340 +772,3 @@ func cloneMap(src map[string]any) map[string]any {
|
||||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func mergeClashRulesYAML(base map[string]any, raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var custom any
|
||||
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
switch typed := custom.(type) {
|
||||
case []any:
|
||||
mergeClashRules(base, typed)
|
||||
case map[string]any:
|
||||
for key, value := range typed {
|
||||
if key == "rules" {
|
||||
if ruleList, ok := asAnySlice(value); ok {
|
||||
mergeClashRules(base, ruleList)
|
||||
}
|
||||
continue
|
||||
}
|
||||
base[key] = value
|
||||
}
|
||||
default:
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeRemoteClashRules lets remote update only the route graph (see
|
||||
// remoteClashAllowedKey) and never mutates remote: cached documents are shared.
|
||||
func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
|
||||
if len(remote) == 0 {
|
||||
return fmt.Errorf("remote Clash routing source must be a YAML map")
|
||||
}
|
||||
|
||||
for key, value := range remote {
|
||||
if !remoteClashAllowedKey(key) {
|
||||
continue
|
||||
}
|
||||
if err := validateRemoteClashValue(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
switch key {
|
||||
case "rules":
|
||||
rules, _ := asAnySlice(value)
|
||||
mergeClashRules(base, rules)
|
||||
case "proxy-groups":
|
||||
groups, _ := asAnySlice(value)
|
||||
base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups)
|
||||
default:
|
||||
base[key] = value
|
||||
}
|
||||
}
|
||||
return validateClashRouteGraph(base)
|
||||
}
|
||||
|
||||
func validateRemoteClashValue(key string, value any) error {
|
||||
switch key {
|
||||
case "rules":
|
||||
rules, ok := asAnySlice(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("remote Clash rules must be a list")
|
||||
}
|
||||
for _, rule := range rules {
|
||||
text, ok := rule.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return fmt.Errorf("remote Clash rules must contain non-empty strings")
|
||||
}
|
||||
}
|
||||
case "proxy-groups":
|
||||
groups, ok := asAnySlice(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("remote Clash proxy-groups must be a list")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(groups))
|
||||
for _, groupValue := range groups {
|
||||
group, ok := groupValue.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
|
||||
}
|
||||
name, nameOK := group["name"].(string)
|
||||
groupType, typeOK := group["type"].(string)
|
||||
if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" {
|
||||
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if useValue, exists := group["use"]; exists {
|
||||
use, ok := asAnySlice(useValue)
|
||||
if !ok || len(use) > 0 {
|
||||
return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "rule-providers":
|
||||
providers, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("remote Clash rule-providers must be a map")
|
||||
}
|
||||
for name, provider := range providers {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("remote Clash rule-provider name must not be empty")
|
||||
}
|
||||
if _, ok := provider.(map[string]any); !ok {
|
||||
return fmt.Errorf("remote Clash rule-provider %q must be a map", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remoteClashAllowedKey(key string) bool {
|
||||
switch key {
|
||||
case "proxy-groups", "rule-providers", "rules":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateClashRouteGraph(config map[string]any) error {
|
||||
known := map[string]struct{}{
|
||||
"DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
|
||||
}
|
||||
if proxies, ok := asAnySlice(config["proxies"]); ok {
|
||||
for _, value := range proxies {
|
||||
proxy, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||
known[strings.TrimSpace(name)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups, _ := asAnySlice(config["proxy-groups"])
|
||||
for _, value := range groups {
|
||||
if name := clashProxyGroupName(value); name != "" {
|
||||
known[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, value := range groups {
|
||||
group, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := clashProxyGroupName(group)
|
||||
refs, exists := group["proxies"]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
proxies, ok := asAnySlice(refs)
|
||||
if !ok {
|
||||
return fmt.Errorf("Clash proxy-group %q proxies must be a list", name)
|
||||
}
|
||||
for _, refValue := range proxies {
|
||||
ref, ok := refValue.(string)
|
||||
if !ok || strings.TrimSpace(ref) == "" {
|
||||
return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name)
|
||||
}
|
||||
ref = strings.TrimSpace(ref)
|
||||
if _, exists := known[ref]; !exists {
|
||||
return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
providers, _ := config["rule-providers"].(map[string]any)
|
||||
for providerName, value := range providers {
|
||||
provider, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
via, ok := provider["proxy"].(string)
|
||||
if !ok || strings.TrimSpace(via) == "" {
|
||||
continue
|
||||
}
|
||||
via = strings.TrimSpace(via)
|
||||
if _, exists := known[via]; !exists {
|
||||
return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via)
|
||||
}
|
||||
}
|
||||
|
||||
rules, _ := asAnySlice(config["rules"])
|
||||
for _, value := range rules {
|
||||
rule, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(rule) == "" {
|
||||
return errors.New("Clash rules must contain non-empty strings")
|
||||
}
|
||||
parts := strings.Split(rule, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid Clash rule %q", rule)
|
||||
}
|
||||
if strings.EqualFold(parts[0], "RULE-SET") {
|
||||
if len(parts) < 3 {
|
||||
return fmt.Errorf("invalid Clash RULE-SET rule %q", rule)
|
||||
}
|
||||
if _, exists := providers[parts[1]]; !exists {
|
||||
return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1])
|
||||
}
|
||||
}
|
||||
targetIndex := len(parts) - 1
|
||||
// Mihomo IP rules may carry trailing no-resolve / src option flags.
|
||||
for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) {
|
||||
targetIndex--
|
||||
}
|
||||
if targetIndex < 1 {
|
||||
return fmt.Errorf("invalid Clash rule target in %q", rule)
|
||||
}
|
||||
target := parts[targetIndex]
|
||||
if _, exists := known[target]; !exists {
|
||||
return fmt.Errorf("Clash rule references unknown proxy or group %q", target)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any {
|
||||
baseGroups, _ := asAnySlice(baseValue)
|
||||
baseByName := make(map[string]any, len(baseGroups))
|
||||
baseOrder := make([]string, 0, len(baseGroups))
|
||||
for _, group := range baseGroups {
|
||||
name := clashProxyGroupName(group)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
baseByName[name] = group
|
||||
baseOrder = append(baseOrder, name)
|
||||
}
|
||||
|
||||
merged := make([]any, 0, len(remoteGroups)+len(baseGroups))
|
||||
seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups))
|
||||
for _, group := range remoteGroups {
|
||||
name := clashProxyGroupName(group)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
merged = append(merged, group)
|
||||
}
|
||||
for _, name := range baseOrder {
|
||||
if _, replaced := seen[name]; replaced {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, baseByName[name])
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func clashProxyGroupName(value any) string {
|
||||
group, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
name, _ := group["name"].(string)
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func mergeClashRules(base map[string]any, customRules []any) {
|
||||
if len(customRules) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
baseRules, _ := asAnySlice(base["rules"])
|
||||
if hasClashMatchRule(customRules) {
|
||||
base["rules"] = customRules
|
||||
return
|
||||
}
|
||||
|
||||
merged := make([]any, 0, len(customRules)+len(baseRules))
|
||||
merged = append(merged, customRules...)
|
||||
merged = append(merged, baseRules...)
|
||||
base["rules"] = merged
|
||||
}
|
||||
|
||||
func asAnySlice(value any) ([]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return typed, true
|
||||
case []string:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func hasClashMatchRule(rules []any) bool {
|
||||
for _, rule := range rules {
|
||||
ruleText, ok := rule.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(ruleText, ",", 2)
|
||||
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func linesToClashRules(raw string) []any {
|
||||
lines := strings.Split(raw, "\n")
|
||||
rules := make([]any, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, line)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
+23
-88
@@ -45,16 +45,11 @@ type cachedSubTemplate struct {
|
||||
|
||||
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||
type SUBController struct {
|
||||
subTitle string
|
||||
subSupportUrl string
|
||||
subProfileUrl string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subHideSettings bool
|
||||
|
||||
subIncyEnableRouting bool
|
||||
subIncyRoutingRules string
|
||||
subTitle string
|
||||
subSupportUrl string
|
||||
subProfileUrl string
|
||||
subAnnounce string
|
||||
subHideSettings bool
|
||||
|
||||
subPath string
|
||||
subJsonPath string
|
||||
@@ -96,23 +91,15 @@ type subControllerConfig struct {
|
||||
remarkTemplate string
|
||||
updateInterval string
|
||||
|
||||
subJsonMux string
|
||||
subJsonRules string
|
||||
subJsonFinalMask string
|
||||
subJsonObservatory string
|
||||
subClashEnableRouting bool
|
||||
subClashRules string
|
||||
subJsonMux string
|
||||
subJsonFinalMask string
|
||||
subJsonObservatory string
|
||||
|
||||
subTitle string
|
||||
subSupportURL string
|
||||
subProfileURL string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subHideSettings bool
|
||||
|
||||
subIncyEnableRouting bool
|
||||
subIncyRoutingRules string
|
||||
subTitle string
|
||||
subSupportURL string
|
||||
subProfileURL string
|
||||
subAnnounce string
|
||||
subHideSettings bool
|
||||
}
|
||||
|
||||
type SUBControllerOption func(*subControllerConfig)
|
||||
@@ -173,10 +160,6 @@ func WithSUBJsonMux(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonMux = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonRules = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonFinalMask(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
|
||||
}
|
||||
@@ -185,14 +168,6 @@ func WithSUBJsonObservatory(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonObservatory = value }
|
||||
}
|
||||
|
||||
func WithSUBClashEnableRouting(value bool) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subClashEnableRouting = value }
|
||||
}
|
||||
|
||||
func WithSUBClashRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subClashRules = value }
|
||||
}
|
||||
|
||||
func WithSUBTitle(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subTitle = value }
|
||||
}
|
||||
@@ -209,26 +184,10 @@ func WithSUBAnnounce(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subAnnounce = value }
|
||||
}
|
||||
|
||||
func WithSUBEnableRouting(value bool) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subEnableRouting = value }
|
||||
}
|
||||
|
||||
func WithSUBRoutingRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subRoutingRules = value }
|
||||
}
|
||||
|
||||
func WithSUBHideSettings(value bool) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subHideSettings = value }
|
||||
}
|
||||
|
||||
func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
|
||||
}
|
||||
|
||||
func WithSUBIncyRoutingRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
|
||||
}
|
||||
|
||||
func defaultSUBControllerConfig() subControllerConfig {
|
||||
return subControllerConfig{
|
||||
subPath: "/sub/",
|
||||
@@ -248,19 +207,14 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
}
|
||||
|
||||
sub := NewSubService(config.remarkTemplate)
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonFinalMask, sub)
|
||||
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
|
||||
a := &SUBController{
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
subProfileUrl: config.subProfileURL,
|
||||
subAnnounce: config.subAnnounce,
|
||||
subEnableRouting: config.subEnableRouting,
|
||||
subRoutingRules: config.subRoutingRules,
|
||||
subHideSettings: config.subHideSettings,
|
||||
|
||||
subIncyEnableRouting: config.subIncyEnableRouting,
|
||||
subIncyRoutingRules: config.subIncyRoutingRules,
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
subProfileUrl: config.subProfileURL,
|
||||
subAnnounce: config.subAnnounce,
|
||||
subHideSettings: config.subHideSettings,
|
||||
|
||||
subPath: config.subPath,
|
||||
subJsonPath: config.subJsonPath,
|
||||
@@ -277,7 +231,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
|
||||
subService: sub,
|
||||
subJsonService: subJsonSvc,
|
||||
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
|
||||
subClashService: NewSubClashService(sub),
|
||||
|
||||
subTemplateCache: map[string]*cachedSubTemplate{},
|
||||
}
|
||||
@@ -426,15 +380,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
|
||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
|
||||
if err == nil && strings.TrimSpace(incyRules) != "" {
|
||||
result.WriteString(incyRules)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
|
||||
|
||||
if a.subEncrypt {
|
||||
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
|
||||
@@ -749,7 +695,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
||||
}
|
||||
@@ -797,7 +743,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||
} else if metadata.Title != "" {
|
||||
@@ -817,8 +763,6 @@ func (a *SUBController) ApplyCommonHeaders(
|
||||
profileSupportUrl string,
|
||||
profileUrl string,
|
||||
profileAnnounce string,
|
||||
profileEnableRouting bool,
|
||||
profileRoutingRules string,
|
||||
profileHideSettings bool,
|
||||
) {
|
||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||
@@ -838,15 +782,6 @@ func (a *SUBController) ApplyCommonHeaders(
|
||||
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
||||
}
|
||||
|
||||
// Advanced (Happ). Routing stays independent of the enable flag; remote
|
||||
// values come only from the validated cache and never delay this response.
|
||||
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
|
||||
if profileEnableRouting {
|
||||
c.Writer.Header().Set("Routing-Enable", "true")
|
||||
}
|
||||
if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
|
||||
c.Writer.Header().Set("Routing", rules)
|
||||
}
|
||||
if profileHideSettings {
|
||||
c.Writer.Header().Set("Hide-Settings", "1")
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestLinkDisplayName(t *testing.T) {
|
||||
|
||||
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
|
||||
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
|
||||
svc := NewSubClashService(false, "", NewSubService(""))
|
||||
svc := NewSubClashService(NewSubService(""))
|
||||
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
|
||||
if proxy == nil {
|
||||
t.Fatal("expected a clash proxy, got nil")
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
|
||||
|
||||
base := NewSubService("")
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", base)
|
||||
jsonService := NewSubJsonService("", "", base)
|
||||
jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson err = %v", err)
|
||||
@@ -56,7 +56,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
|
||||
t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
|
||||
}
|
||||
|
||||
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
|
||||
clashOut, _, err := NewSubClashService(base).GetClash("ext-only", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash err = %v", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestSub_HostAllowInsecure(t *testing.T) {
|
||||
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
|
||||
}
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
@@ -282,7 +282,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
|
||||
HostHeader: "cdn.example.com", Path: "/ws-path",
|
||||
})
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
@@ -294,7 +294,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
|
||||
t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
|
||||
}
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -336,7 +336,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
|
||||
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
|
||||
SockoptParams: `{"tcpFastOpen":true}`,
|
||||
})
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -354,7 +354,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
|
||||
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
|
||||
MuxParams: `{"enabled":true,"concurrency":8}`,
|
||||
})
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -409,7 +409,7 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) {
|
||||
t.Fatalf("host not excluded from raw should appear in GetSubs")
|
||||
}
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
|
||||
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
|
||||
}
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
@@ -60,7 +60,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
|
||||
t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
|
||||
}
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
|
||||
seedFlowInbound(t, "s1", "tcpflow", 4602,
|
||||
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
|
||||
@@ -34,7 +34,7 @@ type SubJsonService struct {
|
||||
}
|
||||
|
||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
||||
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
|
||||
func NewSubJsonService(mux string, finalMask string, subService *SubService) *SubJsonService {
|
||||
var configJson map[string]any
|
||||
var defaultOutbounds []json_util.RawMessage
|
||||
_ = json.Unmarshal([]byte(defaultJson), &configJson)
|
||||
@@ -45,16 +45,6 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
|
||||
}
|
||||
}
|
||||
|
||||
if rules != "" {
|
||||
var newRules []any
|
||||
routing, _ := configJson["routing"].(map[string]any)
|
||||
defaultRules, _ := routing["rules"].([]any)
|
||||
_ = json.Unmarshal([]byte(rules), &newRules)
|
||||
defaultRules = append(newRules, defaultRules...)
|
||||
routing["rules"] = defaultRules
|
||||
configJson["routing"] = routing
|
||||
}
|
||||
|
||||
return &SubJsonService{
|
||||
configJson: configJson,
|
||||
defaultOutbounds: defaultOutbounds,
|
||||
|
||||
@@ -37,7 +37,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
||||
|
||||
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
svc := NewSubJsonService("", finalMask, nil)
|
||||
|
||||
if hasDirectOutOutbound(svc) {
|
||||
t.Fatal("direct_out outbound must never be emitted")
|
||||
@@ -74,7 +74,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
|
||||
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
svc := NewSubJsonService("", finalMask, nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||
@@ -94,7 +94,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
||||
@@ -108,7 +108,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
// the JSON subscription must emit that form, not an array, or v2ray clients fail
|
||||
// to import the config (#5401).
|
||||
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
|
||||
|
||||
tls, _ := stream["tlsSettings"].(map[string]any)
|
||||
@@ -121,7 +121,7 @@ func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
|
||||
|
||||
tls, _ := stream["tlsSettings"].(map[string]any)
|
||||
@@ -140,7 +140,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vless outbound must not use vnext")
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
||||
if _, ok := settings["flow"]; ok {
|
||||
t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
||||
client := model.Client{ID: "uuid-2"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, ""))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVnext(inbound, nil, client, ""))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vmess outbound must not use vnext")
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
||||
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
||||
client := model.Client{Password: "p4ss"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
|
||||
server := firstServer(settings)
|
||||
if server == nil {
|
||||
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
|
||||
@@ -192,7 +192,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
||||
}
|
||||
|
||||
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, ss, nil, client, ""))
|
||||
ssServer := firstServer(ssSettings)
|
||||
if ssServer == nil {
|
||||
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
|
||||
@@ -204,7 +204,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
||||
|
||||
func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
|
||||
globalMux := `{"enabled":true,"concurrency":8}`
|
||||
svc := NewSubJsonService(globalMux, "", "", nil)
|
||||
svc := NewSubJsonService(globalMux, "", nil)
|
||||
|
||||
// When xmux is present in xhttpSettings, the per-inbound xmux handles
|
||||
// multiplexing and the legacy outbound.Mux must NOT be set.
|
||||
@@ -251,7 +251,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
|
||||
|
||||
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
||||
globalMux := `{"enabled":true,"concurrency":8}`
|
||||
svc := NewSubJsonService(globalMux, "", "", nil)
|
||||
svc := NewSubJsonService(globalMux, "", nil)
|
||||
|
||||
// When no xmux is present, the global subJsonMux should be used.
|
||||
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
|
||||
@@ -305,7 +305,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin
|
||||
}
|
||||
|
||||
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
|
||||
alice := realitySpiderXFromStream(t, svc, "subAlice")
|
||||
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
|
||||
@@ -321,13 +321,13 @@ func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
|
||||
// security whose settings key is missing or null previously panicked the
|
||||
// subscription request.
|
||||
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
|
||||
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
withMask := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
stream := withMask.streamData("not-json", "clientKey")
|
||||
if _, ok := stream["finalmask"]; !ok {
|
||||
t.Fatal("finalMask must still apply when stream settings fail to parse")
|
||||
}
|
||||
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
|
||||
if v, ok := noReality["realitySettings"]; ok {
|
||||
t.Fatalf("missing realitySettings must stay absent, got %v", v)
|
||||
@@ -339,7 +339,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
|
||||
@@ -384,7 +384,7 @@ func TestSubJsonServiceWireguard(t *testing.T) {
|
||||
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
|
||||
}
|
||||
|
||||
raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client)
|
||||
raw := NewSubJsonService("", "", nil).genWireguard(inbound, client)
|
||||
if raw == nil {
|
||||
t.Fatal("genWireguard returned nil for a valid wireguard client")
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
|
||||
client := model.Client{Email: "user"}
|
||||
|
||||
if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil {
|
||||
if raw := NewSubJsonService("", "", nil).genWireguard(inbound, client); raw != nil {
|
||||
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,29 +25,9 @@ func initMutDB(t *testing.T) {
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
// --- json_service.go:40 — rules are merged into routing only when non-empty ---
|
||||
|
||||
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
|
||||
rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
|
||||
svc := NewSubJsonService("", rules, "", nil)
|
||||
|
||||
routing, ok := svc.configJson["routing"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("routing missing: %#v", svc.configJson["routing"])
|
||||
}
|
||||
got, _ := routing["rules"].([]any)
|
||||
// default.json ships exactly 1 rule; the custom rule must be prepended.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
|
||||
}
|
||||
first, _ := got[0].(map[string]any)
|
||||
if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
|
||||
t.Fatalf("custom rule must come first, got %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
routing, _ := svc.configJson["routing"].(map[string]any)
|
||||
got, _ := routing["rules"].([]any)
|
||||
if len(got) != 1 {
|
||||
@@ -67,12 +47,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
|
||||
wantMux bool
|
||||
protocol model.Protocol
|
||||
}{
|
||||
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
|
||||
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
|
||||
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
|
||||
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
|
||||
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
|
||||
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
|
||||
{"vmess mux", NewSubJsonService(mux, "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
|
||||
{"vless mux", NewSubJsonService(mux, "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
|
||||
{"server mux", NewSubJsonService(mux, "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
|
||||
{"vmess no mux", NewSubJsonService("", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
|
||||
{"vless no mux", NewSubJsonService("", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
|
||||
{"server no mux", NewSubJsonService("", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -103,7 +83,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
|
||||
// finalMask is non-empty (passes the len(fm)==0 early return) but its only
|
||||
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
|
||||
// so applyGlobalFinalMask must NOT set finalmask.
|
||||
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
|
||||
svc := NewSubJsonService("", `{"tcp":[]}`, nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
|
||||
@@ -111,7 +91,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
|
||||
|
||||
// Sanity: a finalMask that DOES merge to something still gets set, so the
|
||||
// guard is the only distinguishing factor.
|
||||
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
svc2 := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream2["finalmask"]; !ok {
|
||||
t.Fatal("non-empty finalMask must be set")
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
// Remote sources reuse the existing settings fields (one HTTPS URL = remote,
|
||||
// else inline) so no second mode toggle can disagree with the field contents.
|
||||
|
||||
type remoteRoutingKind string
|
||||
|
||||
const (
|
||||
remoteRoutingHapp remoteRoutingKind = "happ"
|
||||
remoteRoutingClash remoteRoutingKind = "clash"
|
||||
|
||||
remoteRoutingCacheTTL = 10 * time.Minute
|
||||
remoteRoutingRetryDelay = 30 * time.Second
|
||||
remoteRoutingHTTPTimeout = 6 * time.Second
|
||||
remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header
|
||||
remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value
|
||||
remoteRoutingClashMaxBody = 2 << 20 // 2 MiB
|
||||
)
|
||||
|
||||
var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
|
||||
|
||||
type remoteRoutingKey struct {
|
||||
kind remoteRoutingKind
|
||||
source string
|
||||
}
|
||||
|
||||
type remoteRoutingCacheEntry struct {
|
||||
Source string `json:"source"`
|
||||
Content string `json:"content"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
Clash map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
func (e remoteRoutingCacheEntry) fetchedTime() time.Time {
|
||||
return time.Unix(e.FetchedAt, 0)
|
||||
}
|
||||
|
||||
type remoteRoutingFetch struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
type remoteRoutingResolver struct {
|
||||
mu sync.Mutex
|
||||
loadMu sync.Mutex
|
||||
loaded bool
|
||||
loadInFlight bool
|
||||
entries map[remoteRoutingKey]remoteRoutingCacheEntry
|
||||
inflight map[remoteRoutingKey]*remoteRoutingFetch
|
||||
lastAttempt map[remoteRoutingKey]time.Time
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
persist bool
|
||||
}
|
||||
|
||||
func newRemoteRoutingResolver(client *http.Client, persist bool) *remoteRoutingResolver {
|
||||
return &remoteRoutingResolver{
|
||||
entries: make(map[remoteRoutingKey]remoteRoutingCacheEntry),
|
||||
inflight: make(map[remoteRoutingKey]*remoteRoutingFetch),
|
||||
lastAttempt: make(map[remoteRoutingKey]time.Time),
|
||||
client: client,
|
||||
now: time.Now,
|
||||
persist: persist,
|
||||
}
|
||||
}
|
||||
|
||||
var routingSourceResolver = newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), true)
|
||||
|
||||
// resolveRoutingSource serves a remote source from the validated cache without
|
||||
// ever blocking on network; inline values pass through (bool reports remote).
|
||||
func resolveRoutingSource(kind remoteRoutingKind, raw string) (string, bool, error) {
|
||||
return routingSourceResolver.resolve(kind, raw)
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) resolve(kind remoteRoutingKind, raw string) (string, bool, error) {
|
||||
entry, remote, err := r.resolveEntry(kind, raw)
|
||||
if !remote {
|
||||
return raw, false, err
|
||||
}
|
||||
return entry.Content, true, err
|
||||
}
|
||||
|
||||
func resolveClashRoutingSource(raw string) (string, map[string]any, bool, error) {
|
||||
entry, remote, err := routingSourceResolver.resolveEntry(remoteRoutingClash, raw)
|
||||
if !remote {
|
||||
return raw, nil, false, err
|
||||
}
|
||||
return entry.Content, entry.Clash, true, err
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string) (remoteRoutingCacheEntry, bool, error) {
|
||||
source, remote, err := common.ParseRemoteRoutingURL(raw)
|
||||
if err != nil {
|
||||
return remoteRoutingCacheEntry{}, true, err
|
||||
}
|
||||
if !remote {
|
||||
return remoteRoutingCacheEntry{}, false, nil
|
||||
}
|
||||
|
||||
r.triggerPersistedLoad()
|
||||
|
||||
key := remoteRoutingKey{kind: kind, source: source}
|
||||
now := r.now()
|
||||
|
||||
r.mu.Lock()
|
||||
cached, hasCached := r.entries[key]
|
||||
if hasCached && now.Sub(cached.fetchedTime()) < remoteRoutingCacheTTL {
|
||||
r.mu.Unlock()
|
||||
return cached, true, nil
|
||||
}
|
||||
|
||||
if _, ok := r.inflight[key]; ok {
|
||||
r.mu.Unlock()
|
||||
if hasCached {
|
||||
return cached, true, nil
|
||||
}
|
||||
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
|
||||
}
|
||||
|
||||
if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
|
||||
r.mu.Unlock()
|
||||
if hasCached {
|
||||
return cached, true, nil
|
||||
}
|
||||
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
|
||||
}
|
||||
|
||||
fetch := &remoteRoutingFetch{done: make(chan struct{})}
|
||||
r.inflight[key] = fetch
|
||||
r.mu.Unlock()
|
||||
|
||||
common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
|
||||
if hasCached {
|
||||
return cached, true, nil
|
||||
}
|
||||
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
|
||||
}
|
||||
|
||||
// RefreshRemoteRoutingSources warms and refreshes configured remote sources
|
||||
// from the cron job. Concurrent resolver reads are safe; fetches coalesce.
|
||||
func RefreshRemoteRoutingSources(happ, clash string) {
|
||||
for kind, raw := range map[remoteRoutingKind]string{
|
||||
remoteRoutingHapp: happ,
|
||||
remoteRoutingClash: clash,
|
||||
} {
|
||||
_, remote, parseErr := common.ParseRemoteRoutingURL(raw)
|
||||
if parseErr != nil {
|
||||
logger.Warningf("Remote %s routing source is invalid", kind)
|
||||
continue
|
||||
}
|
||||
if remote {
|
||||
_ = routingSourceResolver.refreshSource(kind, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) refreshSource(kind remoteRoutingKind, raw string) error {
|
||||
source, remote, err := common.ParseRemoteRoutingURL(raw)
|
||||
if err != nil || !remote {
|
||||
return err
|
||||
}
|
||||
r.ensurePersistedLoaded()
|
||||
|
||||
key := remoteRoutingKey{kind: kind, source: source}
|
||||
now := r.now()
|
||||
r.mu.Lock()
|
||||
previous, hasPrevious := r.entries[key]
|
||||
if hasPrevious && now.Sub(previous.fetchedTime()) < remoteRoutingCacheTTL {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if fetch, ok := r.inflight[key]; ok {
|
||||
done := fetch.done
|
||||
r.mu.Unlock()
|
||||
<-done
|
||||
return fetch.err
|
||||
}
|
||||
if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
|
||||
r.mu.Unlock()
|
||||
return errRemoteRoutingUnavailable
|
||||
}
|
||||
fetch := &remoteRoutingFetch{done: make(chan struct{})}
|
||||
r.inflight[key] = fetch
|
||||
r.mu.Unlock()
|
||||
|
||||
r.refresh(key, previous, hasPrevious, fetch)
|
||||
return fetch.err
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) refresh(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool, fetch *remoteRoutingFetch) {
|
||||
entry, err := r.fetch(key, previous, hasPrevious)
|
||||
now := r.now()
|
||||
|
||||
r.mu.Lock()
|
||||
r.lastAttempt[key] = now
|
||||
if err == nil {
|
||||
r.entries[key] = entry
|
||||
}
|
||||
fetch.err = err
|
||||
delete(r.inflight, key)
|
||||
close(fetch.done)
|
||||
r.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if hasPrevious {
|
||||
logger.Warningf("Remote %s routing refresh from %s failed; keeping the last valid value", key.kind, remoteRoutingHost(key.source))
|
||||
} else {
|
||||
logger.Warningf("Remote %s routing refresh from %s failed; no validated value is cached", key.kind, remoteRoutingHost(key.source))
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.persist {
|
||||
r.persistEntry(key.kind, entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool) (entry remoteRoutingCacheEntry, err error) {
|
||||
// Remote bytes reach the YAML/JSON parsers below; a parser panic must
|
||||
// degrade to a failed refresh (keeping last-good), not crash the panel.
|
||||
defer func() {
|
||||
if panicValue := recover(); panicValue != nil {
|
||||
entry, err = remoteRoutingCacheEntry{}, fmt.Errorf("remote routing fetch panicked: %v", panicValue)
|
||||
}
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, key.source, nil)
|
||||
if err != nil {
|
||||
return remoteRoutingCacheEntry{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "3x-ui-remote-routing/1.0")
|
||||
if hasPrevious {
|
||||
if previous.ETag != "" {
|
||||
req.Header.Set("If-None-Match", previous.ETag)
|
||||
}
|
||||
if previous.LastModified != "" {
|
||||
req.Header.Set("If-Modified-Since", previous.LastModified)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return remoteRoutingCacheEntry{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
if !hasPrevious {
|
||||
return remoteRoutingCacheEntry{}, errors.New("remote source returned 304 without a cached value")
|
||||
}
|
||||
previous.FetchedAt = r.now().Unix()
|
||||
if etag := strings.TrimSpace(resp.Header.Get("ETag")); etag != "" {
|
||||
previous.ETag = etag
|
||||
}
|
||||
if modified := strings.TrimSpace(resp.Header.Get("Last-Modified")); modified != "" {
|
||||
previous.LastModified = modified
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
|
||||
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||
content, locationErr := normalizeHappRouting([]byte(location))
|
||||
if locationErr != nil {
|
||||
return remoteRoutingCacheEntry{}, fmt.Errorf("invalid Happ redirect target: %w", locationErr)
|
||||
}
|
||||
if len(content) > remoteRoutingHappMaxValue {
|
||||
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
|
||||
}
|
||||
return remoteRoutingCacheEntry{
|
||||
Source: key.source,
|
||||
Content: content,
|
||||
FetchedAt: r.now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return remoteRoutingCacheEntry{}, fmt.Errorf("remote source returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
limit := int64(remoteRoutingHappMaxBody)
|
||||
if key.kind == remoteRoutingClash {
|
||||
limit = remoteRoutingClashMaxBody
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return remoteRoutingCacheEntry{}, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return remoteRoutingCacheEntry{}, errors.New("remote routing response exceeds the size limit")
|
||||
}
|
||||
|
||||
content, clash, err := normalizeRemoteRoutingContent(key.kind, body)
|
||||
if err != nil {
|
||||
return remoteRoutingCacheEntry{}, err
|
||||
}
|
||||
if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
|
||||
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
|
||||
}
|
||||
return remoteRoutingCacheEntry{
|
||||
Source: key.source,
|
||||
Content: content,
|
||||
FetchedAt: r.now().Unix(),
|
||||
ETag: strings.TrimSpace(resp.Header.Get("ETag")),
|
||||
LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")),
|
||||
Clash: clash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isRemoteHappRedirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
|
||||
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
|
||||
switch kind {
|
||||
case remoteRoutingHapp:
|
||||
content, err := normalizeHappRouting(body)
|
||||
return content, nil, err
|
||||
case remoteRoutingClash:
|
||||
return normalizeClashRouting(body)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported remote routing kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHappRouting(body []byte) (string, error) {
|
||||
text := strings.TrimSpace(string(body))
|
||||
if text == "" {
|
||||
return "", errors.New("empty Happ routing response")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "{") {
|
||||
compact, err := validateAndCompactJSONObject([]byte(text))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Happ routing JSON: %w", err)
|
||||
}
|
||||
return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
|
||||
}
|
||||
if strings.ContainsAny(text, "\r\n") {
|
||||
return "", errors.New("Happ deeplink must be a single line")
|
||||
}
|
||||
|
||||
payload := ""
|
||||
for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/"} {
|
||||
if after, ok := strings.CutPrefix(text, prefix); ok {
|
||||
payload = after
|
||||
break
|
||||
}
|
||||
}
|
||||
if payload == "" {
|
||||
return "", errors.New("Happ response is neither routing JSON nor a routing deeplink")
|
||||
}
|
||||
decoded, err := decodeRoutingBase64(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Happ routing payload: %w", err)
|
||||
}
|
||||
if _, err := validateAndCompactJSONObject(decoded); err != nil {
|
||||
return "", fmt.Errorf("invalid Happ routing payload JSON: %w", err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func validateAndCompactJSONObject(raw []byte) ([]byte, error) {
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object == nil {
|
||||
return nil, errors.New("expected a JSON object")
|
||||
}
|
||||
return json.Marshal(object)
|
||||
}
|
||||
|
||||
func decodeRoutingBase64(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
encodings := []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
var lastErr error
|
||||
for _, encoding := range encodings {
|
||||
decoded, err := encoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func normalizeClashRouting(body []byte) (string, map[string]any, error) {
|
||||
text := strings.TrimSpace(string(body))
|
||||
if text == "" {
|
||||
return "", nil, errors.New("empty Clash routing response")
|
||||
}
|
||||
var document map[string]any
|
||||
if err := yaml.Unmarshal([]byte(text), &document); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid Clash routing YAML: %w", err)
|
||||
}
|
||||
if len(document) == 0 {
|
||||
return "", nil, errors.New("Clash routing response must be a YAML map")
|
||||
}
|
||||
hasSupportedKey := false
|
||||
for key := range document {
|
||||
if remoteClashAllowedKey(key) {
|
||||
hasSupportedKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSupportedKey {
|
||||
return "", nil, errors.New("Clash routing response has no supported routing keys")
|
||||
}
|
||||
base := map[string]any{
|
||||
"proxies": []map[string]any{{"name": "validation-node", "type": "vless"}},
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY", "type": "select", "proxies": []string{"validation-node", "DIRECT"},
|
||||
}},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
if err := mergeRemoteClashRules(base, document); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err)
|
||||
}
|
||||
return text, document, nil
|
||||
}
|
||||
|
||||
func resolveIncyRoutingSource(raw string) (string, bool, error) {
|
||||
source, remote, err := common.ParseRemoteRoutingURL(raw)
|
||||
if err != nil || !remote {
|
||||
return raw, remote, err
|
||||
}
|
||||
return "incy://autorouting/onadd/" + source, true, nil
|
||||
}
|
||||
|
||||
func newRemoteRoutingHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 4 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: remoteRoutingHTTPTimeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: checkRemoteRoutingRedirect,
|
||||
}
|
||||
}
|
||||
|
||||
func checkRemoteRoutingRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("stopped after 5 redirects")
|
||||
}
|
||||
if strings.EqualFold(req.URL.Scheme, "happ") {
|
||||
// routing.help-style services publish the deeplink as the final Location;
|
||||
// hand the 3xx back to fetch(), which validates it without a request.
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if !strings.EqualFold(req.URL.Scheme, "https") || req.URL.Hostname() == "" || req.URL.User != nil {
|
||||
return errors.New("remote routing redirect must stay on an absolute HTTPS URL")
|
||||
}
|
||||
// The guarded dialer re-resolves, validates and connects to the same public
|
||||
// address, including on every HTTPS redirect hop.
|
||||
return nil
|
||||
}
|
||||
|
||||
func remoteRoutingHost(source string) string {
|
||||
u, err := url.Parse(source)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "unknown host"
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func remoteRoutingSettingKey(kind remoteRoutingKind) string {
|
||||
return "_subRemoteRoutingCache_" + string(kind)
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) ensurePersistedLoaded() {
|
||||
if !r.persist {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
loaded := r.loaded
|
||||
r.mu.Unlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
|
||||
r.loadMu.Lock()
|
||||
defer r.loadMu.Unlock()
|
||||
r.mu.Lock()
|
||||
loaded = r.loaded
|
||||
r.mu.Unlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil || sqlDB.Ping() != nil {
|
||||
return
|
||||
}
|
||||
r.loadPersisted()
|
||||
r.mu.Lock()
|
||||
r.loaded = true
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// triggerPersistedLoad keeps SQLite off the subscription request path: requests
|
||||
// schedule at most one background load; the startup job loads synchronously.
|
||||
func (r *remoteRoutingResolver) triggerPersistedLoad() {
|
||||
if !r.persist {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.loaded || r.loadInFlight {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.loadInFlight = true
|
||||
r.mu.Unlock()
|
||||
|
||||
common.GoRecover("remote-routing-cache-load", func() {
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
r.loadInFlight = false
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
r.ensurePersistedLoaded()
|
||||
})
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) loadPersisted() {
|
||||
loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
|
||||
for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
|
||||
var setting model.Setting
|
||||
err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var entry remoteRoutingCacheEntry
|
||||
if json.Unmarshal([]byte(setting.Value), &entry) != nil || entry.Source == "" || entry.Content == "" || entry.FetchedAt <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, remote, err := common.ParseRemoteRoutingURL(entry.Source); err != nil || !remote {
|
||||
continue
|
||||
}
|
||||
normalized, clash, err := normalizeRemoteRoutingContent(kind, []byte(entry.Content))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
|
||||
continue
|
||||
}
|
||||
entry.Content = normalized
|
||||
entry.Clash = clash
|
||||
loaded[remoteRoutingKey{kind: kind, source: entry.Source}] = entry
|
||||
}
|
||||
r.mu.Lock()
|
||||
for key, entry := range loaded {
|
||||
current, exists := r.entries[key]
|
||||
if !exists || entry.FetchedAt > current.FetchedAt {
|
||||
r.entries[key] = entry
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *remoteRoutingResolver) persistEntry(kind remoteRoutingKind, entry remoteRoutingCacheEntry) {
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
key := remoteRoutingSettingKey(kind)
|
||||
var setting model.Setting
|
||||
err = db.Where("key = ?", key).First(&setting).Error
|
||||
if database.IsNotFound(err) {
|
||||
err = db.Create(&model.Setting{Key: key, Value: string(encoded)}).Error
|
||||
} else if err == nil {
|
||||
setting.Value = string(encoded)
|
||||
err = db.Save(&setting).Error
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warningf("Could not persist the last valid %s remote routing value", kind)
|
||||
}
|
||||
}
|
||||
@@ -1,750 +0,0 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
|
||||
var remote map[string]any
|
||||
if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil {
|
||||
return err
|
||||
}
|
||||
return mergeRemoteClashRules(base, remote)
|
||||
}
|
||||
|
||||
type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn remoteRoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func remoteRoutingTestClient(fn remoteRoutingRoundTripper) *http.Client {
|
||||
return &http.Client{Transport: fn}
|
||||
}
|
||||
|
||||
func remoteRoutingResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
resolver.mu.Lock()
|
||||
inflight := len(resolver.inflight)
|
||||
resolver.mu.Unlock()
|
||||
if inflight == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("remote routing refresh did not finish")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func waitRemoteRoutingLoadIdle(t *testing.T, resolver *remoteRoutingResolver) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
resolver.mu.Lock()
|
||||
loading := resolver.loadInFlight
|
||||
resolver.mu.Unlock()
|
||||
if !loading {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("persisted routing cache load did not finish")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func primeRemoteRouting(t *testing.T, resolver *remoteRoutingResolver, kind remoteRoutingKind, source string) string {
|
||||
t.Helper()
|
||||
if err := resolver.refreshSource(kind, source); err != nil {
|
||||
t.Fatalf("prime remote routing: %v", err)
|
||||
}
|
||||
value, remote, err := resolver.resolve(kind, source)
|
||||
if err != nil || !remote || value == "" {
|
||||
t.Fatalf("primed resolve got=%q remote=%v err=%v", value, remote, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestNormalizeHappRoutingAcceptsJSONAndDeeplink(t *testing.T) {
|
||||
deeplink, err := normalizeHappRouting([]byte(`{"Name":"RoscomVPN","GlobalProxy":"true"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize JSON: %v", err)
|
||||
}
|
||||
const prefix = "happ://routing/onadd/"
|
||||
if !strings.HasPrefix(deeplink, prefix) {
|
||||
t.Fatalf("deeplink = %q", deeplink)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(deeplink, prefix))
|
||||
if err != nil || !strings.Contains(string(decoded), `"Name":"RoscomVPN"`) {
|
||||
t.Fatalf("decoded payload = %q, err=%v", decoded, err)
|
||||
}
|
||||
|
||||
if got, err := normalizeHappRouting([]byte(deeplink + "\n")); err != nil || got != deeplink {
|
||||
t.Fatalf("ready deeplink got=%q err=%v", got, err)
|
||||
}
|
||||
if _, err := normalizeHappRouting([]byte("routing.help")); err == nil {
|
||||
t.Fatal("invalid Happ response was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverAcceptsHappRedirect(t *testing.T) {
|
||||
deeplink, err := normalizeHappRouting([]byte(`{"Name":"redirected"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
var requests atomic.Int32
|
||||
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
response := remoteRoutingResponse(http.StatusFound, "")
|
||||
response.Header.Set("Location", deeplink)
|
||||
response.Request = req
|
||||
return response, nil
|
||||
})
|
||||
client.CheckRedirect = checkRemoteRoutingRedirect
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
|
||||
const source = "https://routing.example/"
|
||||
if err := resolver.refreshSource(remoteRoutingHapp, source); err != nil {
|
||||
t.Fatalf("refresh redirect: %v", err)
|
||||
}
|
||||
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if err != nil || !remote || got != deeplink {
|
||||
t.Fatalf("redirect resolve got=%q remote=%v err=%v", got, remote, err)
|
||||
}
|
||||
if requests.Load() != 1 {
|
||||
t.Fatalf("network requests = %d, want 1", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverHandlesHappNotModified(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
|
||||
if requests.Add(1) == 1 {
|
||||
response := remoteRoutingResponse(http.StatusOK, `{"Name":"etagged"}`)
|
||||
response.Header.Set("ETag", `"v1"`)
|
||||
return response, nil
|
||||
}
|
||||
if req.Header.Get("If-None-Match") != `"v1"` {
|
||||
t.Errorf("If-None-Match = %q", req.Header.Get("If-None-Match"))
|
||||
}
|
||||
return remoteRoutingResponse(http.StatusNotModified, ""), nil
|
||||
})
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
resolver.now = func() time.Time { return now }
|
||||
const source = "https://example.com/default.json"
|
||||
|
||||
first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
|
||||
now = now.Add(remoteRoutingCacheTTL + time.Second)
|
||||
second, _, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if err != nil || second != first {
|
||||
t.Fatalf("stale resolve got=%q err=%v", second, err)
|
||||
}
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
now = now.Add(time.Minute)
|
||||
third, _, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if err != nil || third != first {
|
||||
t.Fatalf("refreshed cache got=%q err=%v", third, err)
|
||||
}
|
||||
if requests.Load() != 2 {
|
||||
t.Fatalf("requests = %d, want 2", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverDoesNotBlockAndCoalescesColdFetch(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var startOnce sync.Once
|
||||
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
startOnce.Do(func() { close(started) })
|
||||
<-release
|
||||
return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
|
||||
})
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
const source = "https://example.com/default.json"
|
||||
|
||||
results := make(chan error, 8)
|
||||
for range 8 {
|
||||
go func() {
|
||||
_, remote, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if !remote {
|
||||
results <- errors.New("source was not classified as remote")
|
||||
return
|
||||
}
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
<-started
|
||||
for range 8 {
|
||||
select {
|
||||
case err := <-results:
|
||||
if !errors.Is(err, errRemoteRoutingUnavailable) {
|
||||
t.Fatalf("cold resolve err=%v", err)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("cold resolve blocked on the remote fetch")
|
||||
}
|
||||
}
|
||||
if got := requests.Load(); got != 1 {
|
||||
t.Fatalf("requests = %d, want 1", got)
|
||||
}
|
||||
close(release)
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || !strings.HasPrefix(got, "happ://routing/onadd/") {
|
||||
t.Fatalf("cached resolve got=%q err=%v", got, err)
|
||||
}
|
||||
if got := requests.Load(); got != 1 {
|
||||
t.Fatalf("cached request count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverServesStaleAfterFailedRefresh(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
refreshStarted := make(chan struct{})
|
||||
releaseRefresh := make(chan struct{})
|
||||
var startOnce sync.Once
|
||||
fail := atomic.Bool{}
|
||||
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
if fail.Load() {
|
||||
startOnce.Do(func() { close(refreshStarted) })
|
||||
<-releaseRefresh
|
||||
return remoteRoutingResponse(http.StatusBadGateway, "bad gateway"), nil
|
||||
}
|
||||
return remoteRoutingResponse(http.StatusOK, `{"Name":"last-good"}`), nil
|
||||
})
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
resolver.now = func() time.Time { return now }
|
||||
const source = "https://example.com/default.json"
|
||||
|
||||
first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
|
||||
fail.Store(true)
|
||||
now = now.Add(remoteRoutingCacheTTL + time.Second)
|
||||
startedAt := time.Now()
|
||||
stale, remote, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if err != nil || !remote || stale != first {
|
||||
t.Fatalf("stale resolve got=%q remote=%v err=%v", stale, remote, err)
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("stale resolve blocked for %v", elapsed)
|
||||
}
|
||||
select {
|
||||
case <-refreshStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("refresh did not run")
|
||||
}
|
||||
close(releaseRefresh)
|
||||
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
|
||||
if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || got != first {
|
||||
t.Fatalf("negative-cache resolve got=%q err=%v", got, err)
|
||||
}
|
||||
if got := requests.Load(); got != 2 {
|
||||
t.Fatalf("requests = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverLoadsPersistedLastGood(t *testing.T) {
|
||||
initSubDB(t)
|
||||
|
||||
deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
const source = "https://example.com/default.json"
|
||||
entry := remoteRoutingCacheEntry{
|
||||
Source: source, Content: deeplink, FetchedAt: time.Now().Add(-time.Hour).Unix(), ETag: `"v1"`,
|
||||
}
|
||||
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, entry)
|
||||
|
||||
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
|
||||
}), true)
|
||||
resolver.ensurePersistedLoaded()
|
||||
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if err != nil || !remote || got != deeplink {
|
||||
t.Fatalf("persisted resolve got=%q remote=%v err=%v", got, remote, err)
|
||||
}
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverDoesNotBlockOnPersistedLoad(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var startOnce sync.Once
|
||||
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
startOnce.Do(func() { close(started) })
|
||||
<-release
|
||||
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
|
||||
}), true)
|
||||
|
||||
resolver.loadMu.Lock()
|
||||
loadLocked := true
|
||||
t.Cleanup(func() {
|
||||
if loadLocked {
|
||||
resolver.loadMu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
startedAt := time.Now()
|
||||
_, remote, err := resolver.resolve(remoteRoutingHapp, "https://example.com/default.json")
|
||||
if !remote || !errors.Is(err, errRemoteRoutingUnavailable) {
|
||||
t.Fatalf("resolve remote=%v err=%v", remote, err)
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("resolve blocked on persisted cache load for %v", elapsed)
|
||||
}
|
||||
|
||||
resolver.loadMu.Unlock()
|
||||
loadLocked = false
|
||||
close(release)
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background refresh did not start")
|
||||
}
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
waitRemoteRoutingLoadIdle(t, resolver)
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverRejectsOversizedPersistedHappValue(t *testing.T) {
|
||||
initSubDB(t)
|
||||
|
||||
deeplink, err := normalizeHappRouting([]byte(`{"Name":"` + strings.Repeat("x", remoteRoutingHappMaxValue) + `"}`))
|
||||
if err != nil || len(deeplink) <= remoteRoutingHappMaxValue {
|
||||
t.Fatalf("oversized fixture length=%d err=%v", len(deeplink), err)
|
||||
}
|
||||
const source = "https://example.com/oversized.json"
|
||||
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
|
||||
Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
resolver := newRemoteRoutingResolver(nil, true)
|
||||
resolver.ensurePersistedLoaded()
|
||||
resolver.mu.Lock()
|
||||
_, exists := resolver.entries[remoteRoutingKey{kind: remoteRoutingHapp, source: source}]
|
||||
resolver.mu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("oversized persisted Happ routing value was loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingResolverDoesNotReplaceClashCacheWithInvalidSchema(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
if requests.Add(1) == 1 {
|
||||
return remoteRoutingResponse(http.StatusOK, "rules:\n - MATCH,PROXY\n"), nil
|
||||
}
|
||||
return remoteRoutingResponse(http.StatusOK, "rules: not-a-list\n"), nil
|
||||
})
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
resolver.now = func() time.Time { return now }
|
||||
const source = "https://example.com/routing.yaml"
|
||||
|
||||
first := primeRemoteRouting(t, resolver, remoteRoutingClash, source)
|
||||
now = now.Add(remoteRoutingCacheTTL + time.Second)
|
||||
second, _, err := resolver.resolve(remoteRoutingClash, source)
|
||||
if err != nil || second != first {
|
||||
t.Fatalf("invalid refresh replaced last-good: got=%q err=%v", second, err)
|
||||
}
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
second, _, err = resolver.resolve(remoteRoutingClash, source)
|
||||
if err != nil || second != first {
|
||||
t.Fatalf("invalid refresh replaced last-good after completion: got=%q err=%v", second, err)
|
||||
}
|
||||
if requests.Load() != 2 {
|
||||
t.Fatalf("requests = %d, want 2", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCommonHeadersResolvesRemoteHappAndFailsClosed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldResolver := routingSourceResolver
|
||||
t.Cleanup(func() { routingSourceResolver = oldResolver })
|
||||
|
||||
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
|
||||
}), false)
|
||||
const source = "https://example.com/default.json"
|
||||
primeRemoteRouting(t, routingSourceResolver, remoteRoutingHapp, source)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, source, false)
|
||||
if recorder.Header().Get("Routing-Enable") != "true" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
|
||||
t.Fatalf("headers = %#v", recorder.Header())
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
ctx, _ = gin.CreateTestContext(recorder)
|
||||
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, source, false)
|
||||
if recorder.Header().Get("Routing-Enable") != "" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
|
||||
t.Fatalf("independent routing headers = %#v", recorder.Header())
|
||||
}
|
||||
|
||||
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
return remoteRoutingResponse(http.StatusOK, "routing.help"), nil
|
||||
}), false)
|
||||
recorder = httptest.NewRecorder()
|
||||
ctx, _ = gin.CreateTestContext(recorder)
|
||||
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, "https://example.com/bad", false)
|
||||
if recorder.Header().Get("Routing-Enable") != "true" || recorder.Header().Get("Routing") != "" {
|
||||
t.Fatalf("invalid remote source leaked routing headers: %#v", recorder.Header())
|
||||
}
|
||||
waitRemoteRoutingIdle(t, routingSourceResolver)
|
||||
}
|
||||
|
||||
func TestResolveIncyRemoteSourceUsesAutorouting(t *testing.T) {
|
||||
got, remote, err := resolveIncyRoutingSource("https://example.com/DEFAULT.JSON")
|
||||
if err != nil || !remote || got != "incy://autorouting/onadd/https://example.com/DEFAULT.JSON" {
|
||||
t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
|
||||
}
|
||||
inline := "incy://routing/onadd/abc"
|
||||
if got, remote, err := resolveIncyRoutingSource(inline); err != nil || remote || got != inline {
|
||||
t.Fatalf("inline got=%q remote=%v err=%v", got, remote, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRemoteClashRulesPreservesGeneratedProxies(t *testing.T) {
|
||||
originalProxy := map[string]any{"name": "vpn-node", "type": "vless"}
|
||||
base := map[string]any{
|
||||
"proxies": []map[string]any{originalProxy},
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
|
||||
}},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
remote := `
|
||||
proxies:
|
||||
- name: attacker-controlled
|
||||
proxy-providers:
|
||||
prov:
|
||||
url: <SUBSCRIPTION PLACEHOLDER>
|
||||
external-controller: 0.0.0.0:9090
|
||||
allow-lan: true
|
||||
mixed-port: 7890
|
||||
dns:
|
||||
enable: true
|
||||
tun:
|
||||
enable: true
|
||||
proxy-groups:
|
||||
- name: VPN
|
||||
type: select
|
||||
include-all: true
|
||||
- name: PROXY
|
||||
type: select
|
||||
proxies: [VPN]
|
||||
rule-providers:
|
||||
roscom:
|
||||
type: http
|
||||
url: https://example.com/rules.mrs
|
||||
rules:
|
||||
- RULE-SET,roscom,PROXY
|
||||
- MATCH,PROXY
|
||||
`
|
||||
if err := mergeRemoteClashRulesYAML(base, remote); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
proxies, ok := base["proxies"].([]map[string]any)
|
||||
if !ok || len(proxies) != 1 || proxies[0]["name"] != "vpn-node" {
|
||||
t.Fatalf("generated proxies were replaced: %#v", base["proxies"])
|
||||
}
|
||||
if _, exists := base["proxy-providers"]; exists {
|
||||
t.Fatal("remote proxy-providers were imported")
|
||||
}
|
||||
if _, exists := base["external-controller"]; exists {
|
||||
t.Fatal("unsafe top-level key was imported")
|
||||
}
|
||||
for _, key := range []string{"allow-lan", "mixed-port", "dns", "tun"} {
|
||||
if _, exists := base[key]; exists {
|
||||
t.Fatalf("client-local key %q was imported", key)
|
||||
}
|
||||
}
|
||||
if _, exists := base["rule-providers"]; !exists {
|
||||
t.Fatal("rule-providers were not imported")
|
||||
}
|
||||
groups, ok := asAnySlice(base["proxy-groups"])
|
||||
if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "VPN" || clashProxyGroupName(groups[1]) != "PROXY" {
|
||||
t.Fatalf("proxy groups = %#v", base["proxy-groups"])
|
||||
}
|
||||
rules, ok := asAnySlice(base["rules"])
|
||||
if !ok || len(rules) != 2 || rules[1] != "MATCH,PROXY" {
|
||||
t.Fatalf("rules = %#v", base["rules"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
|
||||
}},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
if err := mergeRemoteClashRulesYAML(base, `proxy-groups:
|
||||
- name: Extra
|
||||
type: select
|
||||
proxies: [PROXY]
|
||||
rules:
|
||||
- MATCH,PROXY
|
||||
`); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
groups, ok := asAnySlice(base["proxy-groups"])
|
||||
if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "Extra" || clashProxyGroupName(groups[1]) != "PROXY" {
|
||||
t.Fatalf("proxy groups = %#v", base["proxy-groups"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingRejectsOversizedHappValues(t *testing.T) {
|
||||
largeJSON := `{"Name":"large","Rules":"` + strings.Repeat("a", remoteRoutingHappMaxValue) + `"}`
|
||||
largeDeeplink, err := normalizeHappRouting([]byte(largeJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare large deeplink: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response func(*http.Request) *http.Response
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "response body",
|
||||
response: func(*http.Request) *http.Response {
|
||||
return remoteRoutingResponse(http.StatusOK, strings.Repeat("x", remoteRoutingHappMaxBody+1))
|
||||
},
|
||||
wantErr: "response exceeds the size limit",
|
||||
},
|
||||
{
|
||||
name: "normalized header",
|
||||
response: func(*http.Request) *http.Response {
|
||||
return remoteRoutingResponse(http.StatusOK, largeJSON)
|
||||
},
|
||||
wantErr: "header exceeds the size limit",
|
||||
},
|
||||
{
|
||||
name: "redirect header",
|
||||
response: func(req *http.Request) *http.Response {
|
||||
response := remoteRoutingResponse(http.StatusFound, "")
|
||||
response.Header.Set("Location", largeDeeplink)
|
||||
response.Request = req
|
||||
return response
|
||||
},
|
||||
wantErr: "header exceeds the size limit",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
|
||||
return tt.response(req), nil
|
||||
})
|
||||
client.CheckRedirect = checkRemoteRoutingRedirect
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err=%v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingRefreshTurnsPanicsIntoErrors(t *testing.T) {
|
||||
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
panic("transport exploded")
|
||||
})
|
||||
resolver := newRemoteRoutingResolver(client, false)
|
||||
err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
|
||||
if err == nil || !strings.Contains(err.Error(), "panicked") {
|
||||
t.Fatalf("err=%v, want the panic converted into an error", err)
|
||||
}
|
||||
// The inflight slot must be released so later refreshes are not wedged.
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
}
|
||||
|
||||
func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) {
|
||||
resolver := newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), false)
|
||||
startedAt := time.Now()
|
||||
err := resolver.refreshSource(remoteRoutingHapp, "https://127.0.0.1:1/rules")
|
||||
if err == nil {
|
||||
t.Fatal("loopback remote source was accepted")
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed > 2*time.Second {
|
||||
t.Fatalf("loopback rejection took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRoutingPersistedLoadRetriesAfterDatabaseBecomesReady(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
const source = "https://example.com/default.json"
|
||||
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
|
||||
Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
|
||||
})
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Fatalf("close db: %v", err)
|
||||
}
|
||||
|
||||
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
|
||||
}), true)
|
||||
if _, _, err := resolver.resolve(remoteRoutingHapp, source); !errors.Is(err, errRemoteRoutingUnavailable) {
|
||||
t.Fatalf("closed-db resolve err=%v", err)
|
||||
}
|
||||
waitRemoteRoutingIdle(t, resolver)
|
||||
waitRemoteRoutingLoadIdle(t, resolver)
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("reopen db: %v", err)
|
||||
}
|
||||
resolver.triggerPersistedLoad()
|
||||
waitRemoteRoutingLoadIdle(t, resolver)
|
||||
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
|
||||
if err != nil || !remote || got != deeplink {
|
||||
t.Fatalf("reloaded resolve got=%q remote=%v err=%v", got, remote, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteClashRouteGraphValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remote string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing group name",
|
||||
remote: "proxy-groups:\n - type: select\n proxies: [vpn-node]\nrules:\n - MATCH,PROXY\n",
|
||||
wantErr: "named group maps",
|
||||
},
|
||||
{
|
||||
name: "duplicate group name",
|
||||
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,A\n",
|
||||
wantErr: "duplicated",
|
||||
},
|
||||
{
|
||||
name: "unknown group reference",
|
||||
remote: "proxy-groups:\n - {name: A, type: select, proxies: [missing]}\nrules:\n - MATCH,A\n",
|
||||
wantErr: "unknown proxy or group",
|
||||
},
|
||||
{
|
||||
name: "remote proxy provider use",
|
||||
remote: "proxy-groups:\n - name: A\n type: select\n use: [manual-provider]\nrules:\n - MATCH,A\n",
|
||||
wantErr: "cannot use proxy-providers",
|
||||
},
|
||||
{
|
||||
name: "unknown rule provider",
|
||||
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - RULE-SET,missing,A\n - MATCH,A\n",
|
||||
wantErr: "unknown rule-provider",
|
||||
},
|
||||
{
|
||||
name: "unknown rule target",
|
||||
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,missing\n",
|
||||
wantErr: "unknown proxy or group",
|
||||
},
|
||||
{
|
||||
name: "unknown provider download proxy",
|
||||
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrule-providers:\n p: {type: http, url: https://example.com/p.mrs, proxy: missing}\nrules:\n - RULE-SET,p,A\n - MATCH,A\n",
|
||||
wantErr: "rule-provider \"p\" references unknown",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
|
||||
}},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
err := mergeRemoteClashRulesYAML(base, tt.remote)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err=%v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteClashRouteGraphAcceptsLogicalRulesAndCachedDocument(t *testing.T) {
|
||||
const remote = `
|
||||
proxy-groups:
|
||||
- name: Auto
|
||||
type: url-test
|
||||
include-all: true
|
||||
- name: Video
|
||||
type: select
|
||||
proxies: [Auto, DIRECT]
|
||||
rule-providers:
|
||||
video:
|
||||
type: http
|
||||
url: https://example.com/video.mrs
|
||||
proxy: Auto
|
||||
rules:
|
||||
- RULE-SET,video,Video
|
||||
- AND,((NETWORK,TCP),(DST-PORT,443)),Video
|
||||
- GEOIP,private,DIRECT,no-resolve
|
||||
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve,src
|
||||
- MATCH,Auto
|
||||
`
|
||||
var requests atomic.Int32
|
||||
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
return remoteRoutingResponse(http.StatusOK, remote), nil
|
||||
}), false)
|
||||
const source = "https://example.com/routing.yaml"
|
||||
if err := resolver.refreshSource(remoteRoutingClash, source); err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
entry, remoteSource, err := resolver.resolveEntry(remoteRoutingClash, source)
|
||||
if err != nil || !remoteSource || entry.Clash == nil {
|
||||
t.Fatalf("entry remote=%v parsed=%v err=%v", remoteSource, entry.Clash != nil, err)
|
||||
}
|
||||
base := map[string]any{
|
||||
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
|
||||
}},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
if err := mergeRemoteClashRules(base, entry.Clash); err != nil {
|
||||
t.Fatalf("merge cached document: %v", err)
|
||||
}
|
||||
if requests.Load() != 1 {
|
||||
t.Fatalf("requests=%d, want 1", requests.Load())
|
||||
}
|
||||
}
|
||||
@@ -145,11 +145,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubJsonMux = ""
|
||||
}
|
||||
|
||||
SubJsonRules, err := s.settingService.GetSubJsonRules()
|
||||
if err != nil {
|
||||
SubJsonRules = ""
|
||||
}
|
||||
|
||||
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||
if err != nil {
|
||||
SubJsonFinalMask = ""
|
||||
@@ -160,16 +155,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubJsonObservatory = ""
|
||||
}
|
||||
|
||||
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
||||
if err != nil {
|
||||
SubClashEnableRouting = false
|
||||
}
|
||||
|
||||
SubClashRules, err := s.settingService.GetSubClashRules()
|
||||
if err != nil {
|
||||
SubClashRules = ""
|
||||
}
|
||||
|
||||
SubTitle, err := s.settingService.GetSubTitle()
|
||||
if err != nil {
|
||||
SubTitle = ""
|
||||
@@ -190,31 +175,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubAnnounce = ""
|
||||
}
|
||||
|
||||
SubEnableRouting, err := s.settingService.GetSubEnableRouting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
SubRoutingRules, err := s.settingService.GetSubRoutingRules()
|
||||
if err != nil {
|
||||
SubRoutingRules = ""
|
||||
}
|
||||
|
||||
SubHideSettings, err := s.settingService.GetSubHideSettings()
|
||||
if err != nil {
|
||||
SubHideSettings = false
|
||||
}
|
||||
|
||||
SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
|
||||
if err != nil {
|
||||
SubIncyEnableRouting = false
|
||||
}
|
||||
|
||||
SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
|
||||
if err != nil {
|
||||
SubIncyRoutingRules = ""
|
||||
}
|
||||
|
||||
// set per-request localizer from headers/cookies
|
||||
engine.Use(locale.LocalizerMiddleware())
|
||||
|
||||
@@ -284,20 +249,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
WithSUBRemarkTemplate(RemarkTemplate),
|
||||
WithSUBUpdateInterval(SubUpdates),
|
||||
WithSUBJsonMux(SubJsonMux),
|
||||
WithSUBJsonRules(SubJsonRules),
|
||||
WithSUBJsonFinalMask(SubJsonFinalMask),
|
||||
WithSUBJsonObservatory(SubJsonObservatory),
|
||||
WithSUBClashEnableRouting(SubClashEnableRouting),
|
||||
WithSUBClashRules(SubClashRules),
|
||||
WithSUBTitle(SubTitle),
|
||||
WithSUBSupportURL(SubSupportUrl),
|
||||
WithSUBProfileURL(SubProfileUrl),
|
||||
WithSUBAnnounce(SubAnnounce),
|
||||
WithSUBEnableRouting(SubEnableRouting),
|
||||
WithSUBRoutingRules(SubRoutingRules),
|
||||
WithSUBHideSettings(SubHideSettings),
|
||||
WithSUBIncyEnableRouting(SubIncyEnableRouting),
|
||||
WithSUBIncyRoutingRules(SubIncyRoutingRules),
|
||||
)
|
||||
|
||||
return engine, nil
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
|
||||
Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
|
||||
@@ -56,8 +56,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
|
||||
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
|
||||
js := NewSubJsonService("", rules, "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -103,8 +102,8 @@ func TestSubJson_BalancerDocument(t *testing.T) {
|
||||
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
|
||||
}
|
||||
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
|
||||
if proxyRules != 2 { // custom rule + default catch-all
|
||||
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
|
||||
if proxyRules != 1 { // default catch-all only (custom sub rules were removed)
|
||||
t.Fatalf("balancerTag rules = %d, want 1: %s", proxyRules, ruleJSON)
|
||||
}
|
||||
|
||||
observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
|
||||
@@ -155,7 +154,7 @@ func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
|
||||
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -189,7 +188,7 @@ func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
|
||||
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -213,7 +212,7 @@ func TestSubJson_BalancerTagDedup(t *testing.T) {
|
||||
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -241,7 +240,7 @@ func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
@@ -287,7 +286,7 @@ func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
|
||||
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -322,7 +321,7 @@ func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
|
||||
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -345,7 +344,7 @@ func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -386,7 +385,7 @@ func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(`{"enabled":false}`)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(tc.cfg)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
|
||||
t.Fatalf("seed client_inbound: %v", err)
|
||||
}
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonService := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -83,7 +83,7 @@ func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
|
||||
stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
|
||||
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonService := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -99,7 +99,7 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
|
||||
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
|
||||
seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
|
||||
|
||||
out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com")
|
||||
out, _, err := NewSubClashService(NewSubService("")).GetClash("pin1", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
|
||||
stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
|
||||
seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonService := NewSubJsonService("", "", NewSubService(""))
|
||||
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -122,7 +122,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
|
||||
t.Fatal("GetJson returned empty for an externalProxy entry missing port")
|
||||
}
|
||||
|
||||
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
|
||||
clashOut, _, err := NewSubClashService(NewSubService("")).GetClash("extp1", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestGetSubsScale(t *testing.T) {
|
||||
t.Fatalf("GetSubs links = %d, want 3", len(links))
|
||||
}
|
||||
|
||||
jsonSvc := NewSubJsonService("", "", "", &SubService{})
|
||||
jsonSvc := NewSubJsonService("", "", &SubService{})
|
||||
start = time.Now()
|
||||
for range reps {
|
||||
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) {
|
||||
ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) {
|
||||
ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
|
||||
@@ -23,8 +23,6 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) {
|
||||
"cadenceNodeHeartbeat": cadenceNodeHeartbeat,
|
||||
"cadenceNodeTraffic": cadenceNodeTraffic,
|
||||
"cadenceOutboundSub": cadenceOutboundSub,
|
||||
"cadenceRemoteRouting": cadenceRemoteRouting,
|
||||
"cadenceCheckHash": cadenceCheckHash,
|
||||
"cadenceCPUAlarm": cadenceCPUAlarm,
|
||||
}
|
||||
for name, spec := range cadences {
|
||||
|
||||
@@ -2,10 +2,10 @@ package controller
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -289,13 +289,13 @@ func (a *AgentController) listTokens(c *gin.Context) {
|
||||
out := make([]gin.H, 0, len(agents))
|
||||
for _, ag := range agents {
|
||||
out = append(out, gin.H{
|
||||
"id": ag.Id,
|
||||
"token_prefix": ag.TokenPrefix,
|
||||
"name": ag.Name,
|
||||
"node_id": ag.NodeID,
|
||||
"last_seen": ag.LastSeen,
|
||||
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
|
||||
"created_at": ag.CreatedAt,
|
||||
"id": ag.Id,
|
||||
"token_prefix": ag.TokenPrefix,
|
||||
"name": ag.Name,
|
||||
"node_id": ag.NodeID,
|
||||
"last_seen": ag.LastSeen,
|
||||
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
|
||||
"created_at": ag.CreatedAt,
|
||||
})
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -25,7 +24,6 @@ type APIController struct {
|
||||
xraySettingController *XraySettingController
|
||||
userService panel.UserService
|
||||
apiTokenService panel.ApiTokenService
|
||||
Tgbot tgbot.Tgbot
|
||||
}
|
||||
|
||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||
@@ -180,7 +178,6 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
clients := api.Group("/clients")
|
||||
NewClientController(clients)
|
||||
NewGroupController(clients)
|
||||
|
||||
// Server API
|
||||
server := api.Group("/server")
|
||||
@@ -219,10 +216,4 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
NewSubBalancerController(api)
|
||||
|
||||
// Extra routes
|
||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
||||
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
||||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
||||
a.Tgbot.SendBackupToAdmins()
|
||||
}
|
||||
|
||||
@@ -1,46 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithServerBasePath(t *testing.T) {
|
||||
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
|
||||
|
||||
out, err := withServerBasePath(spec, "/test/")
|
||||
if err != nil {
|
||||
t.Fatalf("withServerBasePath: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out, &doc); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
servers, ok := doc["servers"].([]any)
|
||||
if !ok || len(servers) != 1 {
|
||||
t.Fatalf("servers = %v, want one entry", doc["servers"])
|
||||
}
|
||||
srv, _ := servers[0].(map[string]any)
|
||||
if srv["url"] != "/test" {
|
||||
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
|
||||
}
|
||||
|
||||
if doc["openapi"] != "3.0.3" {
|
||||
t.Errorf("openapi field not preserved: %v", doc["openapi"])
|
||||
}
|
||||
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
|
||||
t.Errorf("paths content not preserved verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithServerBasePathInvalidJSON(t *testing.T) {
|
||||
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
|
||||
t.Errorf("expected error on invalid spec, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -13,8 +13,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/op/go-logging"
|
||||
xraygeodata "github.com/xtls/xray-core/common/geodata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@@ -36,20 +35,47 @@ func newGeodataEngine(t *testing.T) *gin.Engine {
|
||||
return engine
|
||||
}
|
||||
|
||||
// The fixture databases are encoded with protowire: the wire layout
|
||||
// (geodat.proto field numbers) is the contract under test, and spelling it
|
||||
// out keeps the test independent of xray-core's generated types.
|
||||
|
||||
func geoWireBytes(num protowire.Number, payload []byte) []byte {
|
||||
out := protowire.AppendTag(nil, num, protowire.BytesType)
|
||||
return protowire.AppendBytes(out, payload)
|
||||
}
|
||||
|
||||
func geoWireString(num protowire.Number, value string) []byte {
|
||||
out := protowire.AppendTag(nil, num, protowire.BytesType)
|
||||
return protowire.AppendString(out, value)
|
||||
}
|
||||
|
||||
func geoWireVarint(num protowire.Number, value uint64) []byte {
|
||||
out := protowire.AppendTag(nil, num, protowire.VarintType)
|
||||
return protowire.AppendVarint(out, value)
|
||||
}
|
||||
|
||||
func geoFixtureDomain(typ uint64, value string, attrs ...string) []byte {
|
||||
var msg []byte
|
||||
if typ != 0 {
|
||||
msg = append(msg, geoWireVarint(1, typ)...)
|
||||
}
|
||||
msg = append(msg, geoWireString(2, value)...)
|
||||
for _, attr := range attrs {
|
||||
attrMsg := geoWireString(1, attr)
|
||||
attrMsg = append(attrMsg, geoWireVarint(2, 1)...) // bool_value = true
|
||||
msg = append(msg, geoWireBytes(3, attrMsg)...)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func writeGeositeDB(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: []*xraygeodata.GeoSite{
|
||||
{Code: "google", Domain: []*xraygeodata.Domain{
|
||||
{Type: xraygeodata.Domain_Domain, Value: "google.com"},
|
||||
{Type: xraygeodata.Domain_Full, Value: "ads.google.com", Attribute: []*xraygeodata.Domain_Attribute{
|
||||
{Key: "ads", TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true}},
|
||||
}},
|
||||
}},
|
||||
{Code: "cn", Domain: []*xraygeodata.Domain{{Type: xraygeodata.Domain_Domain, Value: "baidu.com"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal geosite: %v", err)
|
||||
}
|
||||
google := geoWireString(1, "google")
|
||||
google = append(google, geoWireBytes(2, geoFixtureDomain(2, "google.com"))...)
|
||||
google = append(google, geoWireBytes(2, geoFixtureDomain(3, "ads.google.com", "ads"))...)
|
||||
cn := geoWireString(1, "cn")
|
||||
cn = append(cn, geoWireBytes(2, geoFixtureDomain(2, "baidu.com"))...)
|
||||
data := append(geoWireBytes(1, google), geoWireBytes(1, cn)...)
|
||||
if err := os.WriteFile(filepath.Join(dir, "geosite.dat"), data, 0o644); err != nil {
|
||||
t.Fatalf("write geosite.dat: %v", err)
|
||||
}
|
||||
@@ -58,12 +84,11 @@ func writeGeositeDB(t *testing.T, dir string) {
|
||||
func writeGeoipDB(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
prefix := netip.MustParsePrefix("10.0.0.0/8")
|
||||
data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: []*xraygeodata.GeoIP{
|
||||
{Code: "private", Cidr: []*xraygeodata.CIDR{{Ip: prefix.Addr().AsSlice(), Prefix: uint32(prefix.Bits())}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal geoip: %v", err)
|
||||
}
|
||||
cidr := geoWireBytes(1, prefix.Addr().AsSlice())
|
||||
cidr = append(cidr, geoWireVarint(2, uint64(prefix.Bits()))...)
|
||||
entry := geoWireString(1, "private")
|
||||
entry = append(entry, geoWireBytes(2, cidr)...)
|
||||
data := geoWireBytes(1, entry)
|
||||
if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), data, 0o644); err != nil {
|
||||
t.Fatalf("write geoip.dat: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GroupController struct {
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
}
|
||||
|
||||
func NewGroupController(g *gin.RouterGroup) *GroupController {
|
||||
a := &GroupController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *GroupController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/groups", a.list)
|
||||
g.GET("/groups/:name/emails", a.emails)
|
||||
g.POST("/groups/create", a.create)
|
||||
g.POST("/groups/rename", a.rename)
|
||||
g.POST("/groups/delete", a.delete)
|
||||
g.POST("/groups/resetTraffic", a.resetTraffic)
|
||||
g.POST("/groups/bulkAdd", a.bulkAdd)
|
||||
g.POST("/groups/bulkRemove", a.bulkRemove)
|
||||
}
|
||||
|
||||
func (a *GroupController) list(c *gin.Context) {
|
||||
rows, err := a.clientService.ListGroups()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
func (a *GroupController) emails(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
emails, err := a.clientService.EmailsByGroup(name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, emails, nil)
|
||||
}
|
||||
|
||||
type groupCreateBody struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *GroupController) create(c *gin.Context) {
|
||||
var body groupCreateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.clientService.CreateGroup(body.Name); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"name": body.Name}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type groupRenameBody struct {
|
||||
OldName string `json:"oldName"`
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
func (a *GroupController) rename(c *gin.Context) {
|
||||
var body groupRenameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
a.xrayService.SetToNeedRestart()
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type groupDeleteBody struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *GroupController) delete(c *gin.Context) {
|
||||
var body groupDeleteBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.DeleteGroup(body.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
a.xrayService.SetToNeedRestart()
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type groupResetTrafficBody struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *GroupController) resetTraffic(c *gin.Context) {
|
||||
var body groupResetTrafficBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.clientService.ResetGroupTraffic(body.Name); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"name": body.Name}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkAddToGroupRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
func (a *GroupController) bulkAdd(c *gin.Context) {
|
||||
var req bulkAddToGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Group) == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkRemoveFromGroupRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
}
|
||||
|
||||
func (a *GroupController) bulkRemove(c *gin.Context) {
|
||||
var req bulkRemoveFromGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.RemoveFromGroup(req.Emails)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
a.xrayService.SetToNeedRestart()
|
||||
notifyClientsChanged()
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,7 +26,6 @@ type IndexController struct {
|
||||
|
||||
settingService service.SettingService
|
||||
userService panel.UserService
|
||||
tgbot tgbot.Tgbot
|
||||
}
|
||||
|
||||
// NewIndexController creates a new IndexController and initializes its routes.
|
||||
@@ -75,17 +73,9 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
|
||||
remoteIP := getRemoteIp(c)
|
||||
safeUser := template.HTMLEscapeString(form.Username)
|
||||
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
||||
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
|
||||
reason := "too many failed attempts"
|
||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
||||
Username: safeUser,
|
||||
IP: remoteIP,
|
||||
Time: timeStr,
|
||||
Status: tgbot.LoginFail,
|
||||
Reason: reason,
|
||||
})
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||
return
|
||||
}
|
||||
@@ -99,25 +89,12 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
} else {
|
||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
|
||||
}
|
||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
||||
Username: safeUser,
|
||||
IP: remoteIP,
|
||||
Time: timeStr,
|
||||
Status: tgbot.LoginFail,
|
||||
Reason: reason,
|
||||
})
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||
return
|
||||
}
|
||||
|
||||
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
|
||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
|
||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
||||
Username: safeUser,
|
||||
IP: remoteIP,
|
||||
Time: timeStr,
|
||||
Status: tgbot.LoginSuccess,
|
||||
})
|
||||
|
||||
if err := session.SetLoginUser(c, user); err != nil {
|
||||
logger.Warning("Unable to save session:", err)
|
||||
|
||||
@@ -176,9 +176,9 @@ func (a *NodeController) add(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/apiTokens/create", a.createApiToken)
|
||||
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
|
||||
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
||||
g.POST("/testTgBot", a.testTgBot)
|
||||
}
|
||||
|
||||
func (a *SettingController) validateRegex(c *gin.Context) {
|
||||
@@ -119,13 +118,7 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
}
|
||||
allSetting := &form.AllSetting
|
||||
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
|
||||
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
|
||||
oldTgToken, _ := a.settingService.GetTgBotToken()
|
||||
oldTgChatId, _ := a.settingService.GetTgBotChatId()
|
||||
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
|
||||
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
|
||||
TgBotToken: form.ClearTgBotToken,
|
||||
})
|
||||
err := a.settingService.UpdateAllSetting(allSetting)
|
||||
if err == nil && form.PanelOutbound != oldPanelOutbound {
|
||||
// The egress bridge lives in the generated config; reconcile the
|
||||
// running core. One SOCKS inbound plus one routing rule — both
|
||||
@@ -134,16 +127,6 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
logger.Warning("apply panel outbound change failed:", applyErr)
|
||||
}
|
||||
}
|
||||
// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
|
||||
if err == nil && reloadTgbotFunc != nil {
|
||||
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
|
||||
(allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken ||
|
||||
oldTgChatId != allSetting.TgBotChatId ||
|
||||
oldTgAPIServer != allSetting.TgBotAPIServer))
|
||||
if tgChanged {
|
||||
reloadTgbotFunc()
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
}
|
||||
|
||||
@@ -256,33 +239,3 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
|
||||
}
|
||||
|
||||
func (a *SettingController) testTgBot(c *gin.Context) {
|
||||
enabled, err := a.settingService.GetTgbotEnabled()
|
||||
if err != nil || !enabled {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled"))
|
||||
return
|
||||
}
|
||||
// Import tgbot package would create a circular dependency, so we call
|
||||
// the test through the global function registered at startup.
|
||||
if testTgFunc != nil {
|
||||
if err := testTgFunc(); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
|
||||
}
|
||||
|
||||
// testTgFunc is set from web layer to test Telegram sending without circular imports.
|
||||
var testTgFunc func() error
|
||||
|
||||
// SetTestTgFunc registers the function used to test Telegram sending.
|
||||
func SetTestTgFunc(fn func() error) { testTgFunc = fn }
|
||||
|
||||
// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency.
|
||||
var reloadTgbotFunc func()
|
||||
|
||||
func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestValidateRegex(t *testing.T) {
|
||||
@@ -89,84 +87,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// GHSA-xqqw-jqqv-99h6: a save that keeps 2FA enabled must not be able to
|
||||
// rebind the authenticator without presenting a current code.
|
||||
func TestUpdateSettingRequiresCodeToReplaceTwoFactorToken(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
settingService := service.SettingService{}
|
||||
if err := settingService.SetTwoFactorToken("ORIGINALSECRET234567"); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
if err := settingService.SetTwoFactorEnable(true); err != nil {
|
||||
t.Fatalf("seed enable: %v", err)
|
||||
}
|
||||
|
||||
post := func(t *testing.T, mutate func(map[string]any)) string {
|
||||
t.Helper()
|
||||
base, err := settingService.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllSetting: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
body := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
mutate(body)
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
NewSettingController(router.Group("/panel/api"))
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/update", strings.NewReader(string(payload)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
return resp.Body.String()
|
||||
}
|
||||
|
||||
t.Run("rebind without code is rejected", func(t *testing.T) {
|
||||
got := post(t, func(body map[string]any) {
|
||||
body["twoFactorEnable"] = true
|
||||
body["twoFactorToken"] = "ATTACKERSECRET567890"
|
||||
})
|
||||
if !strings.Contains(got, `"success":false`) {
|
||||
t.Fatalf("rebind without a 2FA code was accepted: %s", got)
|
||||
}
|
||||
stored, err := settingService.GetTwoFactorToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GetTwoFactorToken: %v", err)
|
||||
}
|
||||
if stored != "ORIGINALSECRET234567" {
|
||||
t.Fatalf("stored 2FA secret = %q, want it unchanged", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary save with redacted token still succeeds", func(t *testing.T) {
|
||||
got := post(t, func(body map[string]any) {
|
||||
body["twoFactorEnable"] = true
|
||||
body["twoFactorToken"] = ""
|
||||
})
|
||||
if !strings.Contains(got, `"success":true`) {
|
||||
t.Fatalf("normal settings save was rejected: %s", got)
|
||||
}
|
||||
stored, err := settingService.GetTwoFactorToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GetTwoFactorToken: %v", err)
|
||||
}
|
||||
if stored != "ORIGINALSECRET234567" {
|
||||
t.Fatalf("stored 2FA secret = %q, want it preserved", stored)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,31 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckValidSmtpFrom(t *testing.T) {
|
||||
base := func() *AllSetting {
|
||||
return &AllSetting{WebPort: 2053, SubPort: 2096}
|
||||
}
|
||||
|
||||
for _, v := range []string{"", "panel@example.com"} {
|
||||
s := base()
|
||||
s.SmtpFrom = v
|
||||
if err := s.CheckValid(); err != nil {
|
||||
t.Errorf("CheckValid with smtpFrom=%q: unexpected error %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range []string{
|
||||
"not-an-address",
|
||||
"panel@example.com\r\nBcc: evil@example.com",
|
||||
"a@b\nSubject: injected",
|
||||
} {
|
||||
s := base()
|
||||
s.SmtpFrom = v
|
||||
if err := s.CheckValid(); err == nil {
|
||||
t.Errorf("CheckValid with smtpFrom=%q: want error, got nil", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckValidWildcardListenPortConflict(t *testing.T) {
|
||||
s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""}
|
||||
|
||||
@@ -36,18 +36,6 @@ type AllSetting struct {
|
||||
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
|
||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
|
||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
|
||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
|
||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
|
||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
|
||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
|
||||
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`
|
||||
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"`
|
||||
TgLang string `json:"tgLang" form:"tgLang"`
|
||||
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
|
||||
|
||||
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
|
||||
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||
@@ -63,10 +51,6 @@ type AllSetting struct {
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
||||
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
|
||||
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
|
||||
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
|
||||
SubListen string `json:"subListen" form:"subListen"`
|
||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
|
||||
SubPath string `json:"subPath" form:"subPath"`
|
||||
@@ -82,10 +66,7 @@ type AllSetting struct {
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"`
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"`
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"`
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
@@ -97,7 +78,6 @@ type AllSetting struct {
|
||||
type AllSettingView struct {
|
||||
AllSetting
|
||||
|
||||
HasTgBotToken bool `json:"hasTgBotToken"`
|
||||
HasApiToken bool `json:"hasApiToken"`
|
||||
HasWarpSecret bool `json:"hasWarpSecret"`
|
||||
HasNordSecret bool `json:"hasNordSecret"`
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package job
|
||||
|
||||
import "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
|
||||
// CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage.
|
||||
type CheckHashStorageJob struct {
|
||||
tgbotService tgbot.Tgbot
|
||||
}
|
||||
|
||||
// NewCheckHashStorageJob creates a new hash storage cleanup job instance.
|
||||
func NewCheckHashStorageJob() *CheckHashStorageJob {
|
||||
return new(CheckHashStorageJob)
|
||||
}
|
||||
|
||||
// Run removes expired hash entries from the Telegram bot's hash storage.
|
||||
func (j *CheckHashStorageJob) Run() {
|
||||
storage := j.tgbotService.GetHashStorage()
|
||||
if storage == nil {
|
||||
return
|
||||
}
|
||||
storage.RemoveExpiredHashes()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package job
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckHashStorageJob_RunWithoutPanicWhenStorageNil(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("CheckHashStorageJob.Run panicked when storage is nil: %v", r)
|
||||
}
|
||||
}()
|
||||
NewCheckHashStorageJob().Run()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// RemoteRoutingJob keeps remote Happ and Clash/Mihomo routing URLs warm: all
|
||||
// network work runs here (cron + startup warm), never in a request handler.
|
||||
type RemoteRoutingJob struct {
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
func NewRemoteRoutingJob() *RemoteRoutingJob {
|
||||
return &RemoteRoutingJob{}
|
||||
}
|
||||
|
||||
func (j *RemoteRoutingJob) Run() {
|
||||
happ, err := j.settingService.GetSubRoutingRules()
|
||||
if err != nil {
|
||||
logger.Warning("Could not read Happ routing source:", err)
|
||||
return
|
||||
}
|
||||
clash, err := j.settingService.GetSubClashRules()
|
||||
if err != nil {
|
||||
logger.Warning("Could not read Clash routing source:", err)
|
||||
return
|
||||
}
|
||||
sub.RefreshRemoteRoutingSources(happ, clash)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
)
|
||||
|
||||
// LoginStatus represents the status of a login attempt.
|
||||
type LoginStatus byte
|
||||
|
||||
const (
|
||||
LoginSuccess LoginStatus = 1 // Successful login
|
||||
LoginFail LoginStatus = 0 // Failed login attempt
|
||||
)
|
||||
|
||||
// StatsNotifyJob sends periodic statistics reports via Telegram bot.
|
||||
type StatsNotifyJob struct {
|
||||
xrayService service.XrayService
|
||||
tgbotService tgbot.Tgbot
|
||||
}
|
||||
|
||||
// NewStatsNotifyJob creates a new statistics notification job instance.
|
||||
func NewStatsNotifyJob() *StatsNotifyJob {
|
||||
return new(StatsNotifyJob)
|
||||
}
|
||||
|
||||
// Run sends a statistics report via Telegram bot if Xray is running.
|
||||
func (j *StatsNotifyJob) Run() {
|
||||
if !j.xrayService.IsXrayRunning() {
|
||||
return
|
||||
}
|
||||
j.tgbotService.SendReport()
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func stallingListener(t *testing.T) (addr string, release func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
<-done
|
||||
_ = c.Close()
|
||||
}(conn)
|
||||
go func(c net.Conn) { _, _ = io.Copy(io.Discard, c) }(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
_ = ln.Close()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalInformClient_StalledReceiverTimesOut(t *testing.T) {
|
||||
addr, release := stallingListener(t)
|
||||
defer release()
|
||||
|
||||
request := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(request)
|
||||
request.Header.SetMethod("POST")
|
||||
request.Header.SetContentType("application/json; charset=UTF-8")
|
||||
request.SetBody([]byte(`{"clientTraffics":[],"inboundTraffics":[]}`))
|
||||
request.SetRequestURI("http://" + addr + "/inform")
|
||||
request.Header.SetConnectionClose()
|
||||
|
||||
response := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(response)
|
||||
|
||||
start := time.Now()
|
||||
err := externalInformClient.DoTimeout(request, response, externalInformTimeout)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a receiver that never answers must produce an error, not a completed request")
|
||||
}
|
||||
if !errors.Is(err, fasthttp.ErrTimeout) {
|
||||
t.Errorf("want a timeout error, got %v", err)
|
||||
}
|
||||
if elapsed > externalInformTimeout+2*time.Second {
|
||||
t.Errorf("call took %v, must be bounded by %v", elapsed, externalInformTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalInformClient_HasDeadlines(t *testing.T) {
|
||||
if externalInformClient.ReadTimeout <= 0 || externalInformClient.WriteTimeout <= 0 {
|
||||
t.Fatal("the traffic-notify client must carry read and write deadlines")
|
||||
}
|
||||
if externalInformTimeout >= 5*time.Second {
|
||||
t.Errorf("the call budget (%v) must stay under the 5s traffic poll cadence", externalInformTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInformSkippedWhenNothingToReport(t *testing.T) {
|
||||
j := &XrayTrafficJob{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
j.informTrafficToExternalAPI(nil, nil)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("an empty payload must return before touching settings or the network")
|
||||
}
|
||||
}
|
||||
@@ -19,24 +19,17 @@ import (
|
||||
var (
|
||||
i18nBundle *i18n.Bundle
|
||||
LocalizerWeb *i18n.Localizer
|
||||
LocalizerBot *i18n.Localizer
|
||||
)
|
||||
|
||||
// I18nType represents the type of interface for internationalization.
|
||||
type I18nType string
|
||||
|
||||
const (
|
||||
Bot I18nType = "bot" // Bot interface type
|
||||
Web I18nType = "web" // Web interface type
|
||||
)
|
||||
|
||||
// SettingService interface defines methods for accessing locale settings.
|
||||
type SettingService interface {
|
||||
GetTgLang() (string, error)
|
||||
}
|
||||
|
||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
||||
func InitLocalizer(i18nFS embed.FS) error {
|
||||
// set default bundle to English
|
||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
@@ -46,11 +39,6 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// setup bot locale
|
||||
if err := initTGBotLocalizer(settingService); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,14 +59,12 @@ func createTemplateData(params []string, separator ...string) map[string]any {
|
||||
}
|
||||
|
||||
// I18n retrieves a localized message for the given key and type.
|
||||
// It supports both bot and web contexts, with optional template parameters.
|
||||
// It supports web contexts, with optional template parameters.
|
||||
// Returns the localized message or an empty string if localization fails.
|
||||
func I18n(i18nType I18nType, key string, params ...string) string {
|
||||
var localizer *i18n.Localizer
|
||||
|
||||
switch i18nType {
|
||||
case "bot":
|
||||
localizer = LocalizerBot
|
||||
case "web":
|
||||
localizer = LocalizerWeb
|
||||
default:
|
||||
@@ -105,17 +91,6 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
// initTGBotLocalizer initializes the bot localizer with the configured language.
|
||||
func initTGBotLocalizer(settingService SettingService) error {
|
||||
botLang, err := settingService.GetTgLang()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LocalizerMiddleware returns a Gin middleware that sets up localization for web requests.
|
||||
// It determines the user's language from cookies or Accept-Language header,
|
||||
// creates a localizer instance, and stores it in the Gin context for use in handlers.
|
||||
|
||||
@@ -99,12 +99,6 @@ func TestAllAPIsPostgresScale(t *testing.T) {
|
||||
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
|
||||
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
|
||||
|
||||
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
|
||||
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
|
||||
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
|
||||
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
|
||||
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
|
||||
|
||||
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
|
||||
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
|
||||
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestAddToGroupReportsOnlyChangedRecordsIncludingNull(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
if err := db.Create(&model.ClientGroup{Name: "paid"}).Error; err != nil {
|
||||
t.Fatalf("create group: %v", err)
|
||||
}
|
||||
rows := []model.ClientRecord{
|
||||
{Email: "same@example", UUID: "same", Group: "paid"},
|
||||
{Email: "other@example", UUID: "other", Group: "free"},
|
||||
{Email: "null@example", UUID: "null"},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("create clients: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "null@example").UpdateColumn("group_name", nil).Error; err != nil {
|
||||
t.Fatalf("set NULL group: %v", err)
|
||||
}
|
||||
|
||||
got, err := (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example", "missing@example"}, "paid")
|
||||
if err != nil {
|
||||
t.Fatalf("AddToGroup: %v", err)
|
||||
}
|
||||
if got != 2 {
|
||||
t.Fatalf("affected = %d, want 2 changed records", got)
|
||||
}
|
||||
got, err = (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example"}, "paid")
|
||||
if err != nil {
|
||||
t.Fatalf("second AddToGroup: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Fatalf("second affected = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func groupByName(t *testing.T, svc *ClientService, name string) GroupSummary {
|
||||
t.Helper()
|
||||
rows, err := svc.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups: %v", err)
|
||||
}
|
||||
for _, g := range rows {
|
||||
if g.Name == name {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatalf("group %q not found in %v", name, rows)
|
||||
return GroupSummary{}
|
||||
}
|
||||
|
||||
func seedGroupedClient(t *testing.T, email, group string, up, down int64) {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Create(&model.ClientRecord{Email: email, Enable: true, Group: group}).Error; err != nil {
|
||||
t.Fatalf("seed client record %q: %v", email, err)
|
||||
}
|
||||
seedClientRow(t, email, 1, up, down, 0)
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_ZeroesGroupButKeepsClients(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedGroupedClient(t, "alice", "vip", 100, 200)
|
||||
seedGroupedClient(t, "bob", "vip", 50, 50)
|
||||
|
||||
before := groupByName(t, svc, "vip")
|
||||
if before.Up != 150 || before.Down != 250 || before.TrafficUsed != 400 || before.ClientCount != 2 {
|
||||
t.Fatalf("before reset: got %+v, want up=150 down=250 used=400 count=2", before)
|
||||
}
|
||||
|
||||
if err := svc.ResetGroupTraffic("vip"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
|
||||
after := groupByName(t, svc, "vip")
|
||||
if after.Up != 0 || after.Down != 0 || after.TrafficUsed != 0 {
|
||||
t.Fatalf("after reset: got %+v, want up=0 down=0 used=0", after)
|
||||
}
|
||||
if after.ClientCount != 2 {
|
||||
t.Fatalf("after reset: client count changed to %d, want 2", after.ClientCount)
|
||||
}
|
||||
|
||||
var alice xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load alice traffic: %v", err)
|
||||
}
|
||||
if alice.Up != 100 || alice.Down != 200 {
|
||||
t.Fatalf("client counter modified by group reset: alice up=%d down=%d, want 100/200", alice.Up, alice.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_NewTrafficAccumulatesAboveBaseline(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedGroupedClient(t, "carol", "team", 100, 100)
|
||||
if err := svc.ResetGroupTraffic("team"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
if g := groupByName(t, svc, "team"); g.Up != 0 || g.Down != 0 {
|
||||
t.Fatalf("after reset: got %+v, want up=0 down=0", g)
|
||||
}
|
||||
|
||||
if err := database.GetDB().Table("client_traffics").
|
||||
Where("email = ?", "carol").
|
||||
Updates(map[string]any{"up": 130, "down": 100}).Error; err != nil {
|
||||
t.Fatalf("bump carol traffic: %v", err)
|
||||
}
|
||||
|
||||
g := groupByName(t, svc, "team")
|
||||
if g.Up != 30 || g.Down != 0 || g.TrafficUsed != 30 {
|
||||
t.Fatalf("post-bump: got %+v, want up=30 down=0 used=30", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_CreatesRowForDerivedGroup(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedGroupedClient(t, "dave", "adhoc", 70, 30)
|
||||
|
||||
var rows int64
|
||||
if err := database.GetDB().Model(&model.ClientGroup{}).Where("name = ?", "adhoc").Count(&rows).Error; err != nil {
|
||||
t.Fatalf("count client_groups: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Fatalf("precondition: derived group should have no client_groups row, got %d", rows)
|
||||
}
|
||||
|
||||
if err := svc.ResetGroupTraffic("adhoc"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
|
||||
var stored model.ClientGroup
|
||||
if err := database.GetDB().Where("name = ?", "adhoc").First(&stored).Error; err != nil {
|
||||
t.Fatalf("client_groups row not created: %v", err)
|
||||
}
|
||||
if stored.ResetUp != 70 || stored.ResetDown != 30 {
|
||||
t.Fatalf("baseline not snapshotted: got up=%d down=%d, want 70/30", stored.ResetUp, stored.ResetDown)
|
||||
}
|
||||
if g := groupByName(t, svc, "adhoc"); g.Up != 0 || g.Down != 0 {
|
||||
t.Fatalf("after reset: got %+v, want up=0 down=0", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
if err := svc.ResetGroupTraffic(" "); err == nil {
|
||||
t.Fatal("ResetGroupTraffic(blank) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupTotalsSurviveSingleClientReset(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "erin", "gold", 100, 200)
|
||||
seedGroupedClient(t, "frank", "gold", 40, 60)
|
||||
|
||||
if err := isvc.ResetClientTrafficByEmail("erin"); err != nil {
|
||||
t.Fatalf("ResetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
|
||||
g := groupByName(t, csvc, "gold")
|
||||
if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 {
|
||||
t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g)
|
||||
}
|
||||
|
||||
var erin xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
|
||||
t.Fatalf("load erin traffic: %v", err)
|
||||
}
|
||||
if erin.Up != 0 || erin.Down != 0 {
|
||||
t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupTotalsSurviveBulkClientReset(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "gina", "silver", 10, 20)
|
||||
seedGroupedClient(t, "hank", "silver", 30, 40)
|
||||
|
||||
affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkResetTraffic: %v", err)
|
||||
}
|
||||
if affected != 2 {
|
||||
t.Fatalf("BulkResetTraffic affected = %d, want 2", affected)
|
||||
}
|
||||
|
||||
g := groupByName(t, csvc, "silver")
|
||||
if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 {
|
||||
t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupTotalsSurviveClientDelete(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "iris", "bronze", 70, 30)
|
||||
seedGroupedClient(t, "jack", "bronze", 5, 5)
|
||||
|
||||
var rec model.ClientRecord
|
||||
if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil {
|
||||
t.Fatalf("load iris record: %v", err)
|
||||
}
|
||||
if _, err := csvc.Delete(isvc, rec.Id, false); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
g := groupByName(t, csvc, "bronze")
|
||||
if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 {
|
||||
t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g)
|
||||
}
|
||||
if g.ClientCount != 1 {
|
||||
t.Fatalf("client count = %d, want 1", g.ClientCount)
|
||||
}
|
||||
|
||||
var trafficRows int64
|
||||
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil {
|
||||
t.Fatalf("count iris traffic rows: %v", err)
|
||||
}
|
||||
if trafficRows != 0 {
|
||||
t.Fatalf("iris traffic row survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "kate", "iron", 100, 100)
|
||||
if err := isvc.ResetClientTrafficByEmail("kate"); err != nil {
|
||||
t.Fatalf("ResetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 {
|
||||
t.Fatalf("pre group-reset: got %+v, want used=200", g)
|
||||
}
|
||||
|
||||
if err := csvc.ResetGroupTraffic("iron"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 {
|
||||
t.Fatalf("group reset did not zero adjusted baselines: got %+v", g)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GroupSummary struct {
|
||||
Name string `json:"name"`
|
||||
ClientCount int `json:"clientCount"`
|
||||
TrafficUsed int64 `json:"trafficUsed"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
||||
db := database.GetDB()
|
||||
// email is unique in both clients and client_traffics, so the LEFT JOIN
|
||||
// never double-counts a client's traffic.
|
||||
var derived []GroupSummary
|
||||
if err := db.Table("clients AS c").
|
||||
Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
|
||||
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
|
||||
Where("c.group_name <> ''").
|
||||
Group("c.group_name").
|
||||
Scan(&derived).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stored []model.ClientGroup
|
||||
if err := db.Find(&stored).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type groupAgg struct {
|
||||
count int
|
||||
up int64
|
||||
down int64
|
||||
}
|
||||
baseUp := make(map[string]int64, len(stored))
|
||||
baseDown := make(map[string]int64, len(stored))
|
||||
merged := make(map[string]groupAgg, len(derived)+len(stored))
|
||||
for _, g := range stored {
|
||||
merged[g.Name] = groupAgg{}
|
||||
baseUp[g.Name] = g.ResetUp
|
||||
baseDown[g.Name] = g.ResetDown
|
||||
}
|
||||
for _, g := range derived {
|
||||
merged[g.Name] = groupAgg{count: g.ClientCount, up: g.Up, down: g.Down}
|
||||
}
|
||||
out := make([]GroupSummary, 0, len(merged))
|
||||
for name, agg := range merged {
|
||||
up := max(agg.up-baseUp[name], 0)
|
||||
down := max(agg.down-baseDown[name], 0)
|
||||
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: up + down, Up: up, Down: down})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
|
||||
// current counters so per-group traffic totals survive a traffic reset or client delete (#5675).
|
||||
// current counters so ListGroups totals survive a traffic reset or client delete (#5675).
|
||||
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
||||
if len(emails) == 0 {
|
||||
@@ -115,328 +57,3 @@ func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("group_name = ?", name).
|
||||
Order("email ASC").
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if emails == nil {
|
||||
emails = []string{}
|
||||
}
|
||||
return emails, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetGroupTraffic(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return common.NewError("group name is required")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var agg struct {
|
||||
Up int64
|
||||
Down int64
|
||||
}
|
||||
if err := db.Table("clients AS c").
|
||||
Select("COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
|
||||
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
|
||||
Where("c.group_name = ?", name).
|
||||
Scan(&agg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.Up, ResetDown: agg.Down}).Error
|
||||
}
|
||||
return db.Model(&model.ClientGroup{}).Where("name = ?", name).
|
||||
Updates(map[string]any{"reset_up": agg.Up, "reset_down": agg.Down}).Error
|
||||
}
|
||||
|
||||
func (s *ClientService) CreateGroup(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return common.NewError("group name is required")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return common.NewError("group already exists")
|
||||
}
|
||||
return db.Create(&model.ClientGroup{Name: name}).Error
|
||||
}
|
||||
|
||||
func (s *ClientService) RenameGroup(oldName, newName string) (int, error) {
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" {
|
||||
return 0, common.NewError("old group name is required")
|
||||
}
|
||||
if newName == "" {
|
||||
return 0, common.NewError("new group name is required")
|
||||
}
|
||||
if oldName == newName {
|
||||
return 0, nil
|
||||
}
|
||||
return s.replaceGroupValue(oldName, newName)
|
||||
}
|
||||
|
||||
func (s *ClientService) DeleteGroup(name string) (int, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0, common.NewError("group name is required")
|
||||
}
|
||||
return s.replaceGroupValue(name, "")
|
||||
}
|
||||
|
||||
func (s *ClientService) RemoveFromGroup(emails []string) (int, error) {
|
||||
return s.AddToGroup(emails, "")
|
||||
}
|
||||
|
||||
func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
|
||||
group = strings.TrimSpace(group)
|
||||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
|
||||
if group != "" {
|
||||
var exists int64
|
||||
if err := db.Model(&model.ClientGroup{}).Where("name = ?", group).Count(&exists).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if exists == 0 {
|
||||
var derived int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("group_name = ?", group).Count(&derived).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if derived == 0 {
|
||||
if err := db.Create(&model.ClientGroup{Name: group}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var records []model.ClientRecord
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Where("email IN ?", batch).
|
||||
Where("group_name IS NULL OR group_name <> ?", group).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
records = append(records, rows...)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
affectedEmails := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
affectedEmails = append(affectedEmails, r.Email)
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
var affected int64
|
||||
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
||||
result := tx.Model(&model.ClientRecord{}).
|
||||
Where("email IN ?", batch).
|
||||
Where("group_name IS NULL OR group_name <> ?", group).
|
||||
UpdateColumn("group_name", group)
|
||||
if result.Error != nil {
|
||||
tx.Rollback()
|
||||
return 0, result.Error
|
||||
}
|
||||
affected += result.RowsAffected
|
||||
}
|
||||
|
||||
var inboundIDs []int
|
||||
inboundIDSeen := make(map[int]struct{})
|
||||
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
||||
var ids []int
|
||||
if err := tx.Table("client_inbounds").
|
||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||
Where("clients.email IN ?", batch).
|
||||
Distinct("client_inbounds.inbound_id").
|
||||
Pluck("inbound_id", &ids).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, ok := inboundIDSeen[id]; !ok {
|
||||
inboundIDSeen[id] = struct{}{}
|
||||
inboundIDs = append(inboundIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emailSet := make(map[string]struct{}, len(affectedEmails))
|
||||
for _, e := range affectedEmails {
|
||||
emailSet[e] = struct{}{}
|
||||
}
|
||||
|
||||
for _, ibID := range inboundIDs {
|
||||
var ib model.Inbound
|
||||
if err := tx.First(&ib, ibID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
modified := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := cm["email"].(string)
|
||||
if _, hit := emailSet[email]; !hit {
|
||||
continue
|
||||
}
|
||||
if group == "" {
|
||||
delete(cm, "group")
|
||||
} else {
|
||||
cm["group"] = group
|
||||
}
|
||||
clients[i] = cm
|
||||
modified = true
|
||||
}
|
||||
if modified {
|
||||
settings["clients"] = clients
|
||||
newSettings, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ib.Settings = string(newSettings)
|
||||
if err := tx.Save(&ib).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) {
|
||||
db := database.GetDB()
|
||||
if newName == "" {
|
||||
if err := db.Where("name = ?", oldName).Delete(&model.ClientGroup{}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else {
|
||||
if err := db.Model(&model.ClientGroup{}).Where("name = ?", oldName).Update("name", newName).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
var records []model.ClientRecord
|
||||
if err := db.Where("group_name = ?", oldName).Find(&records).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
affectedEmails := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
affectedEmails = append(affectedEmails, r.Email)
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("group_name = ?", oldName).
|
||||
UpdateColumn("group_name", newName).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var inboundIDs []int
|
||||
inboundIDSeen := make(map[int]struct{})
|
||||
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
||||
var ids []int
|
||||
if err := tx.Table("client_inbounds").
|
||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||
Where("clients.email IN ?", batch).
|
||||
Distinct("client_inbounds.inbound_id").
|
||||
Pluck("inbound_id", &ids).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, ok := inboundIDSeen[id]; !ok {
|
||||
inboundIDSeen[id] = struct{}{}
|
||||
inboundIDs = append(inboundIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ibID := range inboundIDs {
|
||||
var ib model.Inbound
|
||||
if err := tx.First(&ib, ibID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
modified := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if g, ok := cm["group"].(string); ok && g == oldName {
|
||||
if newName == "" {
|
||||
delete(cm, "group")
|
||||
} else {
|
||||
cm["group"] = newName
|
||||
}
|
||||
clients[i] = cm
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
if modified {
|
||||
settings["clients"] = clients
|
||||
newSettings, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ib.Settings = string(newSettings)
|
||||
if err := tx.Save(&ib).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
@@ -107,9 +107,13 @@ func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
|
||||
// refuses outright must build once the panel has healed them.
|
||||
func TestHealedConfigsBuildInXray(t *testing.T) {
|
||||
// TestHealedConfigsCarryPanelHeals closes the loop on both heals at the
|
||||
// panel level: the generated config must carry the healed shape. Until the
|
||||
// lite peel this test also built the results in xray-core (and asserted the
|
||||
// unhealed rows were refused); with the library gone, the heals themselves
|
||||
// are what's pinned — the core still refuses the unhealed shapes when it
|
||||
// starts on the node, which is exactly why the heals exist.
|
||||
func TestHealedConfigsCarryPanelHeals(t *testing.T) {
|
||||
t.Run("hysteria v1 row", func(t *testing.T) {
|
||||
in := model.Inbound{
|
||||
Protocol: model.Hysteria,
|
||||
@@ -128,33 +132,36 @@ func TestHealedConfigsBuildInXray(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &healed); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
|
||||
|
||||
var unhealed map[string]any
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
|
||||
"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
|
||||
t.Fatalf("decode raw inbound: %v", err)
|
||||
settings, _ := healed["settings"].(map[string]any)
|
||||
if settings["version"] != float64(2) {
|
||||
t.Fatalf("generated settings version = %v, want the heal to pin 2", settings["version"])
|
||||
}
|
||||
if err := buildGoldenInbound(t, unhealed); err == nil {
|
||||
t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
|
||||
stream, _ := healed["streamSettings"].(map[string]any)
|
||||
hysteriaSettings, _ := stream["hysteriaSettings"].(map[string]any)
|
||||
if hysteriaSettings["version"] != float64(2) {
|
||||
t.Fatalf("generated hysteriaSettings version = %v, want the heal to pin 2", hysteriaSettings["version"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("noise item with an empty packet", func(t *testing.T) {
|
||||
item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
|
||||
stream := streamWithNoiseItem(t, item)
|
||||
inbound := map[string]any{
|
||||
"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
||||
"settings": map[string]any{"clients": []any{}, "decryption": "none"},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
if err := buildGoldenInbound(t, inbound); err == nil {
|
||||
t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
|
||||
}
|
||||
|
||||
dropEmptyRandPackets(stream["finalmask"])
|
||||
inbound["streamSettings"] = stream
|
||||
assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
|
||||
// The unhealed item carries the rand+packet combination the core
|
||||
// refuses; the heal must remove the empty packet and keep the rest.
|
||||
before := noiseItem(t, stream)
|
||||
if _, present := before["packet"]; !present {
|
||||
t.Fatal("fixture lost its packet key before the heal")
|
||||
}
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
|
||||
t.Fatalf("cleared = %d, want 1", cleared)
|
||||
}
|
||||
after := noiseItem(t, stream)
|
||||
if _, present := after["packet"]; present {
|
||||
t.Fatalf("packet survived the heal: %#v", after)
|
||||
}
|
||||
if after["rand"] != "1-8192" || after["delay"] != "5" {
|
||||
t.Fatalf("healing changed the mask: %#v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// The frontend's golden fixtures are the panel's model of an xray config: the
|
||||
// Zod snapshots pin what the forms parse and emit. Until the lite peel, every
|
||||
// fixture was also built here through xray-core's config builders
|
||||
// (conf.InboundDetourConfig / conf.RouterConfig / conf.DNSConfig) to prove
|
||||
// the core would accept what the panel produces. That cross-check retired
|
||||
// with the library: this build has no in-process core builder, and config
|
||||
// acceptance is enforced by the core on the node when it starts. What stays
|
||||
// is the panel-side contract — every fixture still parses, and every full
|
||||
// inbound fixture still flows through GenXrayInboundConfig (the conversion
|
||||
// the full-config path uses) with its identity intact.
|
||||
//
|
||||
// mtproto fixtures are excluded from generation: it is served by the
|
||||
// bundled mtg-multi sidecar, not by xray.
|
||||
|
||||
func goldenFixtureDir(t *testing.T, category string) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve fixture dir: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// goldenFixtures returns every fixture in a category as name -> decoded object.
|
||||
func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
|
||||
t.Helper()
|
||||
dir := goldenFixtureDir(t, category)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
out := make(map[string]map[string]any)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
t.Fatalf("no fixtures under %s", dir)
|
||||
}
|
||||
for _, name := range names {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", name, err)
|
||||
}
|
||||
out[strings.TrimSuffix(name, ".json")] = obj
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGoldenFixturesParse keeps the whole corpus exercised: a fixture that
|
||||
// stops being a valid JSON object fails here even in a category whose only
|
||||
// consumer is the frontend.
|
||||
func TestGoldenFixturesParse(t *testing.T) {
|
||||
for _, category := range []string{"inbound", "inbound-full", "stream", "security", "sockopt", "finalmask", "rule", "balancer", "dns", "dns-server"} {
|
||||
t.Run(category, func(t *testing.T) {
|
||||
if fixtures := goldenFixtures(t, category); len(fixtures) == 0 {
|
||||
t.Fatal("no fixtures parsed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenInboundFullFixturesGenerate runs each complete panel Inbound
|
||||
// fixture through GenXrayInboundConfig and checks the generated config keeps
|
||||
// the fixture's identity: protocol, port, tag and a non-empty settings
|
||||
// object. (The full-output equivalence of this path against the previous
|
||||
// build is covered by the phase-2 golden diff at the binary level.)
|
||||
func TestGoldenInboundFullFixturesGenerate(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound-full") {
|
||||
protocol, _ := fixture["protocol"].(string)
|
||||
if protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
section := func(key string) string {
|
||||
value, ok := fixture[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", key, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
port := 0
|
||||
if p, ok := fixture["port"].(float64); ok {
|
||||
port = int(p)
|
||||
}
|
||||
listen, _ := fixture["listen"].(string)
|
||||
tag, _ := fixture["tag"].(string)
|
||||
|
||||
ib := &model.Inbound{
|
||||
Protocol: model.Protocol(protocol),
|
||||
Port: port,
|
||||
Listen: listen,
|
||||
Tag: tag,
|
||||
Settings: section("settings"),
|
||||
StreamSettings: section("streamSettings"),
|
||||
Sniffing: section("sniffing"),
|
||||
}
|
||||
raw, err := json.Marshal(ib.GenXrayInboundConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal generated inbound: %v", err)
|
||||
}
|
||||
var generated map[string]any
|
||||
if err := json.Unmarshal(raw, &generated); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
if generated["protocol"] != protocol {
|
||||
t.Errorf("protocol = %v, want %q", generated["protocol"], protocol)
|
||||
}
|
||||
if port != 0 {
|
||||
if got, ok := generated["port"].(float64); !ok || int(got) != port {
|
||||
t.Errorf("port = %v, want %d", generated["port"], port)
|
||||
}
|
||||
}
|
||||
if tag != "" && generated["tag"] != tag {
|
||||
t.Errorf("tag = %v, want %q", generated["tag"], tag)
|
||||
}
|
||||
settings, ok := generated["settings"].(map[string]any)
|
||||
if !ok || len(settings) == 0 {
|
||||
t.Errorf("settings = %v, want a non-empty object", generated["settings"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"maps"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
)
|
||||
|
||||
// The frontend's golden fixtures are the panel's model of an xray config: the
|
||||
// Zod snapshots pin what the forms parse and emit. Parsing proves the panel
|
||||
// agrees with itself, not that xray-core would accept the result, so every
|
||||
// fixture is also built here through the very config builders the panel hands
|
||||
// its config to — conf.InboundDetourConfig for the full-config and AddInbound
|
||||
// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
|
||||
// section. A fixture xray-core refuses is a config the panel would let an
|
||||
// admin save and then fail to start the core with, taking every inbound down.
|
||||
//
|
||||
// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
|
||||
// xray, so xray-core has no config id for it.
|
||||
|
||||
func goldenFixtureDir(t *testing.T, category string) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve fixture dir: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// goldenFixtures returns every fixture in a category as name -> decoded object.
|
||||
func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
|
||||
t.Helper()
|
||||
dir := goldenFixtureDir(t, category)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
out := make(map[string]map[string]any)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
t.Fatalf("no fixtures under %s", dir)
|
||||
}
|
||||
for _, name := range names {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", name, err)
|
||||
}
|
||||
out[strings.TrimSuffix(name, ".json")] = obj
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTestCertificate writes a self-signed certificate and key, returning both
|
||||
// paths. The TLS fixtures point certificateFile/keyFile at deployment paths
|
||||
// that do not exist here, and xray-core reads them while building.
|
||||
func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "golden-fixture.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"golden-fixture.test"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath = filepath.Join(dir, "fixture.crt")
|
||||
keyPath = filepath.Join(dir, "fixture.key")
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
||||
t.Fatalf("write certificate: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
// repointCertificateFiles rewrites every certificateFile/keyFile reference to
|
||||
// the generated pair, so a fixture is judged on its shape rather than on paths
|
||||
// that only exist on a deployed server.
|
||||
func repointCertificateFiles(node any, certPath, keyPath string) {
|
||||
switch value := node.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range value {
|
||||
switch key {
|
||||
case "certificateFile":
|
||||
if _, ok := child.(string); ok {
|
||||
value[key] = certPath
|
||||
continue
|
||||
}
|
||||
case "keyFile":
|
||||
if _, ok := child.(string); ok {
|
||||
value[key] = keyPath
|
||||
continue
|
||||
}
|
||||
}
|
||||
repointCertificateFiles(child, certPath, keyPath)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range value {
|
||||
repointCertificateFiles(child, certPath, keyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
|
||||
// geoip:/geosite: need the dat files the panel ships next to the xray binary;
|
||||
// where they are absent the loader fails on the missing file rather than on the
|
||||
// fixture, so those are skipped instead of reported as broken.
|
||||
func assertXrayAccepts(t *testing.T, subject string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if isMissingGeoAssetErr(err) {
|
||||
t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
|
||||
}
|
||||
t.Fatalf("xray-core refuses %s: %v", subject, err)
|
||||
}
|
||||
|
||||
func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
|
||||
t.Helper()
|
||||
certPath, keyPath := writeTestCertificate(t)
|
||||
repointCertificateFiles(inbound, certPath, keyPath)
|
||||
|
||||
raw, err := json.Marshal(inbound)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal inbound: %v", err)
|
||||
}
|
||||
detour := new(conf.InboundDetourConfig)
|
||||
if err := json.Unmarshal(raw, detour); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = detour.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
|
||||
// inbound and builds it.
|
||||
func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound") {
|
||||
if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
inbound := map[string]any{
|
||||
"tag": "golden-in",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 8443,
|
||||
"protocol": fixture["protocol"],
|
||||
"settings": fixture["settings"],
|
||||
}
|
||||
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
|
||||
// model through GenXrayInboundConfig, the conversion both the full-config and
|
||||
// the live AddInbound paths use, and builds the result.
|
||||
func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound-full") {
|
||||
protocol, _ := fixture["protocol"].(string)
|
||||
if protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
section := func(key string) string {
|
||||
value, ok := fixture[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", key, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
port := 0
|
||||
if p, ok := fixture["port"].(float64); ok {
|
||||
port = int(p)
|
||||
}
|
||||
listen, _ := fixture["listen"].(string)
|
||||
tag, _ := fixture["tag"].(string)
|
||||
|
||||
ib := &model.Inbound{
|
||||
Protocol: model.Protocol(protocol),
|
||||
Port: port,
|
||||
Listen: listen,
|
||||
Tag: tag,
|
||||
Settings: section("settings"),
|
||||
StreamSettings: section("streamSettings"),
|
||||
Sniffing: section("sniffing"),
|
||||
}
|
||||
raw, err := json.Marshal(ib.GenXrayInboundConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal generated inbound: %v", err)
|
||||
}
|
||||
var generated map[string]any
|
||||
if err := json.Unmarshal(raw, &generated); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
|
||||
// stream sections, the security block, sockopt and finalmask — to an inbound.
|
||||
func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
|
||||
for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
|
||||
for name, fixture := range goldenFixtures(t, category) {
|
||||
t.Run(category+"/"+name, func(t *testing.T) {
|
||||
stream := map[string]any{"network": "tcp"}
|
||||
switch category {
|
||||
case "stream":
|
||||
stream = fixture
|
||||
case "security":
|
||||
maps.Copy(stream, fixture)
|
||||
case "sockopt":
|
||||
stream["sockopt"] = fixture
|
||||
case "finalmask":
|
||||
stream["finalmask"] = fixture
|
||||
}
|
||||
inbound := map[string]any{
|
||||
"tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
||||
"settings": map[string]any{
|
||||
"clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
|
||||
"decryption": "none",
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildGoldenRouting(routing map[string]any) error {
|
||||
raw, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router := new(conf.RouterConfig)
|
||||
if err := json.Unmarshal(raw, router); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = router.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
|
||||
// through the router config ApplyRoutingConfig hands to the running core.
|
||||
func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
|
||||
for name, rule := range goldenFixtures(t, "rule") {
|
||||
t.Run("rule/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{rule},
|
||||
"balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
for name, balancer := range goldenFixtures(t, "balancer") {
|
||||
t.Run("balancer/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"balancers": []any{balancer},
|
||||
"rules": []any{map[string]any{
|
||||
"type": "field", "port": "443", "balancerTag": balancer["tag"],
|
||||
}},
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
|
||||
// fixture inside one.
|
||||
func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
|
||||
build := func(dns map[string]any) error {
|
||||
raw, err := json.Marshal(dns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dnsConf := new(conf.DNSConfig)
|
||||
if err := json.Unmarshal(raw, dnsConf); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dnsConf.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
for name, fixture := range goldenFixtures(t, "dns") {
|
||||
t.Run("dns/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this dns section", build(fixture))
|
||||
})
|
||||
}
|
||||
for name, server := range goldenFixtures(t, "dns-server") {
|
||||
t.Run("dns-server/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isMissingGeoAssetErr(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
)
|
||||
|
||||
const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
|
||||
@@ -77,62 +75,54 @@ func TestValidateFinalMaskXmcProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
|
||||
// to xray-core's own loader instead of a restatement of it: every profile the
|
||||
// panel accepts must build, and every one it rejects must fail to build. A
|
||||
// future core release that tightens or relaxes the rules fails here rather
|
||||
// than silently producing configs the core refuses to start on.
|
||||
func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
|
||||
// TestXmcMaskProfilesCompleteExpectations pins the panel predicate's own
|
||||
// contract. Until the lite peel it was cross-checked against xray-core's
|
||||
// loader (every accepted profile had to build in the core and vice versa);
|
||||
// with the library gone the predicate stands on its own rules — username
|
||||
// ^[A-Za-z0-9_]{3,16}$, a parseable uuid, non-empty textures value and
|
||||
// signature, at least one profile — and this table records them. A core
|
||||
// release that tightens or relaxes its side is no longer detected here;
|
||||
// the strip/validate pair errs on the panel's documented rules instead.
|
||||
func TestXmcMaskProfilesCompleteExpectations(t *testing.T) {
|
||||
profiles := []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{name: "complete", raw: completeXmcProfile},
|
||||
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
|
||||
{name: "complete", raw: completeXmcProfile, want: true},
|
||||
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: true},
|
||||
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: true},
|
||||
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: false},
|
||||
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: false},
|
||||
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: false},
|
||||
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`, want: false},
|
||||
{name: "no profiles at all", raw: ``, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range profiles {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var coreProfile conf.XMCProfile
|
||||
if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
|
||||
t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
|
||||
}
|
||||
_, coreErr := coreProfile.Build()
|
||||
|
||||
mask := map[string]any{"type": "xmc"}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
raw := tt.raw
|
||||
if raw == "" {
|
||||
raw = `{}` // a mask whose settings carry no profiles key at all
|
||||
if err := json.Unmarshal([]byte(raw), &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := json.Unmarshal([]byte(`{"profiles":[`+raw+`]}`), &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
}
|
||||
mask["settings"] = settings
|
||||
|
||||
panelAccepts := xmcMaskProfilesComplete(mask)
|
||||
coreAccepts := coreErr == nil
|
||||
if panelAccepts != coreAccepts {
|
||||
t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
|
||||
if got := xmcMaskProfilesComplete(mask); got != tt.want {
|
||||
t.Errorf("xmcMaskProfilesComplete = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
|
||||
// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
|
||||
// with no profiles at all is now a build failure.
|
||||
func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
|
||||
var core conf.XMC
|
||||
if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
|
||||
t.Fatalf("unmarshal into conf.XMC: %v", err)
|
||||
}
|
||||
if _, err := core.Build(); err == nil {
|
||||
t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripIncompleteXmcMasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -57,6 +57,8 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
|
||||
// sidecar-managed protocols are absent because their reconcile loops only query
|
||||
// NodeID IS NULL rows, so a node-assigned one would never be reconciled at all.
|
||||
// A new protocol defaults to ineligible until added here, as on the frontend.
|
||||
// Mixed is a plain xray inbound (not sidecar-managed): node-local mixed
|
||||
// inbounds already sync back and are managed by the panel, so it is eligible.
|
||||
var nodeEligibleProtocols = map[model.Protocol]bool{
|
||||
model.VLESS: true,
|
||||
model.VMESS: true,
|
||||
@@ -64,6 +66,7 @@ var nodeEligibleProtocols = map[model.Protocol]bool{
|
||||
model.Shadowsocks: true,
|
||||
model.Hysteria: true,
|
||||
model.WireGuard: true,
|
||||
model.Mixed: true,
|
||||
}
|
||||
|
||||
// isNodeEligibleProtocol reports whether protocol may be assigned to a node.
|
||||
|
||||
@@ -93,13 +93,13 @@ func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) {
|
||||
// frontend/src/pages/inbounds/form/InboundFormModal.tsx -- keep both lists
|
||||
// in sync if a protocol's node-eligibility ever changes.
|
||||
func TestIsNodeEligibleProtocol(t *testing.T) {
|
||||
eligible := []model.Protocol{model.VLESS, model.VMESS, model.Trojan, model.Shadowsocks, model.Hysteria, model.WireGuard}
|
||||
eligible := []model.Protocol{model.VLESS, model.VMESS, model.Trojan, model.Shadowsocks, model.Hysteria, model.WireGuard, model.Mixed}
|
||||
for _, p := range eligible {
|
||||
if !isNodeEligibleProtocol(p) {
|
||||
t.Errorf("isNodeEligibleProtocol(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
ineligible := []model.Protocol{model.MTProto, model.AmneziaWG, model.Mixed, model.HTTP, model.Tunnel}
|
||||
ineligible := []model.Protocol{model.MTProto, model.AmneziaWG, model.HTTP, model.Tunnel}
|
||||
for _, p := range ineligible {
|
||||
if isNodeEligibleProtocol(p) {
|
||||
t.Errorf("isNodeEligibleProtocol(%q) = true, want false", p)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the
|
||||
// prefilter regressing into a formatting-sensitive string match (#5805): the
|
||||
// lookup must find clients whether inbounds.settings stores compact JSON
|
||||
// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as
|
||||
// written by the panel's MarshalIndent).
|
||||
func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
const tgId int64 = 123456789
|
||||
cases := []struct {
|
||||
name string
|
||||
settings string
|
||||
email string
|
||||
port int
|
||||
}{
|
||||
{"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001},
|
||||
{"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002},
|
||||
}
|
||||
for _, c := range cases {
|
||||
inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create %s inbound: %v", c.name, err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil {
|
||||
t.Fatalf("create %s client_traffics: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
traffics, err := svc.GetClientTrafficTgBot(tgId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientTrafficTgBot: %v", err)
|
||||
}
|
||||
got := make(map[string]bool, len(traffics))
|
||||
for _, tr := range traffics {
|
||||
got[tr.Email] = true
|
||||
}
|
||||
if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] {
|
||||
t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got)
|
||||
}
|
||||
|
||||
other, err := svc.GetClientTrafficTgBot(42)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientTrafficTgBot(42): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other))
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -998,75 +997,6 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
idQuery := fmt.Sprintf(
|
||||
"SELECT DISTINCT inbounds.id %s WHERE %s = ?",
|
||||
database.JSONClientsFromInbound(),
|
||||
database.JSONFieldText("client.value", "tgId"),
|
||||
)
|
||||
var inboundIds []int
|
||||
if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil {
|
||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var inbounds []*model.Inbound
|
||||
if len(inboundIds) > 0 {
|
||||
err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var emails []string
|
||||
for _, inbound := range inbounds {
|
||||
clients, err := s.GetClients(inbound)
|
||||
if err != nil {
|
||||
logger.Errorf("Error retrieving clients for inbound %d: %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.TgID == tgId {
|
||||
emails = append(emails, client.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chunked to stay under SQLite's bind-variable limit when a single Telegram
|
||||
// account owns thousands of clients across inbounds.
|
||||
uniqEmails := uniqueNonEmptyStrings(emails)
|
||||
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
|
||||
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
|
||||
var page []*xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
|
||||
return nil, err
|
||||
}
|
||||
traffics = append(traffics, page...)
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
logger.Warning("No ClientTraffic records found for emails:", emails)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate UUID and other client data for each traffic record
|
||||
for i := range traffics {
|
||||
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
|
||||
traffics[i].Enable = client.Enable
|
||||
traffics[i].UUID = client.ID
|
||||
traffics[i].SubId = client.SubID
|
||||
}
|
||||
}
|
||||
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
// BumpClientsLastOnline sets client_traffics.last_online to now for the given
|
||||
// emails. Used in online-API mode for clients that hold a live connection but
|
||||
// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
@@ -2305,17 +2308,21 @@ func parseXrayKeyPairOutput(output string) (string, string, error) {
|
||||
return strings.TrimSpace(first[1]), strings.TrimSpace(second[1]), nil
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewX25519Cert() (any, error) {
|
||||
// Run the command
|
||||
cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "x25519")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
err := cmd.Run()
|
||||
// generateX25519KeyPair returns a fresh X25519 key pair in the exact format
|
||||
// `xray x25519` prints (base64 RawURLEncoding of the raw 32-byte keys), but
|
||||
// generated in-process: a control-plane-only panel has no xray binary to shell
|
||||
// out to, and shelling out made Reality inbound creation fail there.
|
||||
func generateX25519KeyPair() (privateKey string, publicKey string, err error) {
|
||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(priv.Bytes()),
|
||||
base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
privateKey, publicKey, err := parseXrayKeyPairOutput(out.String())
|
||||
func (s *ServerService) GetNewX25519Cert() (any, error) {
|
||||
privateKey, publicKey, err := generateX25519KeyPair()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -70,16 +70,6 @@ var defaultValueMap = map[string]string{
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
"tgBotProxy": "",
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotChatId": "",
|
||||
"tgRunTime": "@daily",
|
||||
"tgBotBackup": "false",
|
||||
"tgCpu": "80",
|
||||
"tgMemory": "80",
|
||||
"tgLang": "en-US",
|
||||
"subEnable": "true",
|
||||
"subJsonEnable": "false",
|
||||
"subJsonAutoDetect": "false",
|
||||
@@ -91,11 +81,7 @@ var defaultValueMap = map[string]string{
|
||||
"subSupportUrl": "",
|
||||
"subProfileUrl": "",
|
||||
"subAnnounce": "",
|
||||
"subEnableRouting": "false",
|
||||
"subRoutingRules": "",
|
||||
"subHideSettings": "false",
|
||||
"subIncyEnableRouting": "false",
|
||||
"subIncyRoutingRules": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
"subPath": "/sub/",
|
||||
@@ -110,10 +96,7 @@ var defaultValueMap = map[string]string{
|
||||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subClashEnableRouting": "false",
|
||||
"subClashRules": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonObservatory": "",
|
||||
"subThemeDir": "",
|
||||
@@ -128,7 +111,6 @@ var defaultValueMap = map[string]string{
|
||||
"devChannelEnable": "false",
|
||||
|
||||
// Event bus — per-subscriber event filtering (empty = all disabled)
|
||||
"tgEnabledEvents": "login.attempt,cpu.high",
|
||||
|
||||
// Consecutive failed observatory probes before an outbound.down event fires
|
||||
"outboundDownThreshold": "3",
|
||||
@@ -228,14 +210,12 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
|
||||
return nil, err
|
||||
}
|
||||
view := &entity.AllSettingView{AllSetting: *allSetting}
|
||||
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
|
||||
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
|
||||
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
|
||||
var apiTokenCount int64
|
||||
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
|
||||
view.HasApiToken = apiTokenCount > 0
|
||||
}
|
||||
view.TgBotToken = ""
|
||||
return view, nil
|
||||
}
|
||||
|
||||
@@ -396,22 +376,6 @@ func (s *SettingService) GetWebDomain() (string, error) {
|
||||
return s.getString("webDomain")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgBotToken() (string, error) {
|
||||
return s.getString("tgBotToken")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgBotToken(token string) error {
|
||||
return s.setString("tgBotToken", token)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgBotProxy() (string, error) {
|
||||
return s.getString("tgBotProxy")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgBotProxy(token string) error {
|
||||
return s.setString("tgBotProxy", token)
|
||||
}
|
||||
|
||||
// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
|
||||
// requests (version checks, Telegram, subscription fetches) are routed through.
|
||||
func (s *SettingService) GetPanelOutbound() (string, error) {
|
||||
@@ -483,58 +447,6 @@ func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Clien
|
||||
return client
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgBotAPIServer() (string, error) {
|
||||
return s.getString("tgBotAPIServer")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgBotAPIServer(token string) error {
|
||||
return s.setString("tgBotAPIServer", token)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgBotChatId() (string, error) {
|
||||
return s.getString("tgBotChatId")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgBotChatId(chatIds string) error {
|
||||
return s.setString("tgBotChatId", chatIds)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgbotEnabled() (bool, error) {
|
||||
return s.getBool("tgBotEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgbotEnabled(value bool) error {
|
||||
return s.setBool("tgBotEnable", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgbotRuntime() (string, error) {
|
||||
return s.getString("tgRunTime")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgbotRuntime(time string) error {
|
||||
return s.setString("tgRunTime", time)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgBotBackup() (bool, error) {
|
||||
return s.getBool("tgBotBackup")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgCpu() (int, error) {
|
||||
return s.getInt("tgCpu")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgMemory() (int, error) {
|
||||
return s.getInt("tgMemory")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgMemory(value int) error {
|
||||
return s.setInt("tgMemory", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgLang() (string, error) {
|
||||
return s.getString("tgLang")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetPort() (int, error) {
|
||||
return s.getInt("webPort")
|
||||
}
|
||||
@@ -704,26 +616,10 @@ func (s *SettingService) GetSubAnnounce() (string, error) {
|
||||
return s.getString("subAnnounce")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubEnableRouting() (bool, error) {
|
||||
return s.getBool("subEnableRouting")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubRoutingRules() (string, error) {
|
||||
return s.getString("subRoutingRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubHideSettings() (bool, error) {
|
||||
return s.getBool("subHideSettings")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
|
||||
return s.getBool("subIncyEnableRouting")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubIncyRoutingRules() (string, error) {
|
||||
return s.getString("subIncyRoutingRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubListen() (string, error) {
|
||||
return s.getString("subListen")
|
||||
}
|
||||
@@ -792,22 +688,10 @@ func (s *SettingService) GetSubClashURI() (string, error) {
|
||||
return s.getString("subClashURI")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
|
||||
return s.getBool("subClashEnableRouting")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubClashRules() (string, error) {
|
||||
return s.getString("subClashRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonMux() (string, error) {
|
||||
return s.getString("subJsonMux")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonRules() (string, error) {
|
||||
return s.getString("subJsonRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||
return s.getString("subJsonFinalMask")
|
||||
}
|
||||
@@ -887,14 +771,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) {
|
||||
|
||||
// Event bus — per-subscriber event filtering
|
||||
|
||||
func (s *SettingService) GetTgEnabledEvents() (string, error) {
|
||||
return s.getString("tgEnabledEvents")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgEnabledEvents(events string) error {
|
||||
return s.setString("tgEnabledEvents", events)
|
||||
}
|
||||
|
||||
// GetOutboundDownThreshold returns how many consecutive failed observatory
|
||||
// probes an outbound must accumulate before an outbound.down notification is
|
||||
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
|
||||
@@ -906,17 +782,7 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error {
|
||||
return s.setInt("outboundDownThreshold", value)
|
||||
}
|
||||
|
||||
// SecretClears marks redacted secrets the user explicitly emptied. Without a
|
||||
// flag, a blank submitted secret means "unchanged" (the field is always served
|
||||
// blank to the browser) and the stored value is preserved.
|
||||
type SecretClears struct {
|
||||
TgBotToken bool
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
|
||||
return err
|
||||
}
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||
if err := validateSettingsURLs(allSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1005,63 +871,16 @@ func ValidateRegex(pattern string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
|
||||
value, err := s.GetTgBotToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allSetting.TgBotToken = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSettingsURLs(allSetting *entity.AllSetting) error {
|
||||
if allSetting.TgBotAPIServer != "" {
|
||||
u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer)
|
||||
if err != nil {
|
||||
return common.NewError("telegram API server URL is invalid:", err)
|
||||
}
|
||||
allSetting.TgBotAPIServer = u
|
||||
}
|
||||
// Support/profile links land in subscription headers and page data, where
|
||||
// client apps resolve a scheme-less value against the panel's own domain.
|
||||
// Non-http schemes (tg://, mailto:) are legitimate here, so only default
|
||||
// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
|
||||
allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
|
||||
allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
|
||||
for name, value := range map[string]*string{
|
||||
"Happ routing source": &allSetting.SubRoutingRules,
|
||||
"Clash/Mihomo routing source": &allSetting.SubClashRules,
|
||||
"Incy routing source": &allSetting.SubIncyRoutingRules,
|
||||
} {
|
||||
if err := validateRemoteRoutingURLSetting(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteRoutingURLSetting(name string, value *string) error {
|
||||
canonical, remote, err := common.ParseRemoteRoutingURL(*value)
|
||||
if err != nil {
|
||||
return common.NewError(name, err.Error())
|
||||
}
|
||||
if remote {
|
||||
*value = canonical
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateSecret(key string, value string) error {
|
||||
switch key {
|
||||
case "tgBotToken":
|
||||
return s.saveSetting(key, strings.TrimSpace(value))
|
||||
default:
|
||||
return common.NewError("secret key is not replaceable:", key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
||||
var jsonData any
|
||||
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
||||
@@ -1123,7 +942,6 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
@@ -1187,16 +1005,12 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var factoryDefaultSecretKeys = map[string]bool{
|
||||
"tgBotToken": true,
|
||||
}
|
||||
|
||||
/*
|
||||
GetFactoryDefaults returns the shipped default value per setting, keyed by
|
||||
the AllSetting json field name. Unlike GetDefaultSettings (which reports
|
||||
current effective values), this is defaultValueMap projected through the
|
||||
AllSetting field set: only keys that exist as an AllSetting json tag are
|
||||
returned, minus the credential fields in factoryDefaultSecretKeys. Keys
|
||||
returned. Keys
|
||||
with no AllSetting field (secret, panelGuid, the node mTLS material,
|
||||
xrayTemplateConfig) are excluded structurally rather than by deny-list.
|
||||
*/
|
||||
@@ -1204,7 +1018,7 @@ func (s *SettingService) GetFactoryDefaults() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
||||
key := field.Tag.Get("json")
|
||||
if key == "" || factoryDefaultSecretKeys[key] {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if value, ok := defaultValueMap[key]; ok {
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) {
|
||||
settings.SubJsonUserAgentRegex = " "
|
||||
settings.SubClashUserAgentRegex = ""
|
||||
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
if err := s.UpdateAllSetting(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -128,10 +128,8 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
|
||||
settings.SubClashEnable = true
|
||||
settings.SubClashPath = "/clash-custom/"
|
||||
settings.SubClashURI = "https://subscriptions.example.com/clash-custom/"
|
||||
settings.SubClashEnableRouting = true
|
||||
settings.SubClashRules = "GEOIP,private,DIRECT"
|
||||
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
if err := s.UpdateAllSetting(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -172,12 +170,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
|
||||
if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" {
|
||||
t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/")
|
||||
}
|
||||
if !got.SubClashEnableRouting {
|
||||
t.Fatal("subClashEnableRouting = false, want true")
|
||||
}
|
||||
if got.SubClashRules != "GEOIP,private,DIRECT" {
|
||||
t.Fatalf("subClashRules = %q, want %q", got.SubClashRules, "GEOIP,private,DIRECT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
|
||||
@@ -188,7 +180,7 @@ func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SubClashUserAgentRegex = "["
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
|
||||
if err := s.UpdateAllSetting(settings); err == nil {
|
||||
t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex")
|
||||
}
|
||||
}
|
||||
@@ -201,7 +193,7 @@ func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SubJsonUserAgentRegex = "["
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
|
||||
if err := s.UpdateAllSetting(settings); err == nil {
|
||||
t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,6 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
|
||||
"nodeMtlsClientKeyPem",
|
||||
"nodeMtlsClientCertSha256",
|
||||
"xrayTemplateConfig",
|
||||
"tgBotToken",
|
||||
"twoFactorToken",
|
||||
"smtpPassword",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
if _, ok := defaults[key]; ok {
|
||||
@@ -75,9 +72,6 @@ func TestGetFactoryDefaultsInvariant(t *testing.T) {
|
||||
if !tags[key] {
|
||||
t.Errorf("key %q is not an entity.AllSetting json tag", key)
|
||||
}
|
||||
if factoryDefaultSecretKeys[key] {
|
||||
t.Errorf("key %q is in the credential deny-list and must not be returned", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func TestValidateRemoteRoutingURLSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{name: "valid HTTPS", value: " https://example.com/rules#fragment ", want: "https://example.com/rules"},
|
||||
{name: "credentials", value: "https://user:pass@example.com/rules", wantError: "must not contain URL credentials"},
|
||||
{name: "missing host", value: "https:///rules", wantError: "absolute HTTPS URL"},
|
||||
{name: "legacy HTTP stays inline", value: "http://example.com/rules", want: "http://example.com/rules"},
|
||||
{name: "multiline Clash stays inline", value: "https://example.com/rules\nMATCH,PROXY", want: "https://example.com/rules\nMATCH,PROXY"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
settings := &entity.AllSetting{SubRoutingRules: tt.value}
|
||||
err := validateSettingsURLs(settings)
|
||||
if tt.wantError != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("err=%v, want %q", err, tt.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || settings.SubRoutingRules != tt.want {
|
||||
t.Fatalf("value=%q err=%v", settings.SubRoutingRules, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xlzd/gotp"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func setupSettingTestDB(t *testing.T) {
|
||||
@@ -22,126 +19,6 @@ func setupSettingTestDB(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" {
|
||||
t.Fatalf("settings view leaked secrets: %#v", view)
|
||||
}
|
||||
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword {
|
||||
t.Fatalf("settings view did not report configured secret flags: %#v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := &view.AllSetting
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||
t.Fatalf("tg token = %q, want preserved secret", got)
|
||||
}
|
||||
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
||||
t.Fatalf("ldap password = %q, want preserved secret", got)
|
||||
}
|
||||
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
|
||||
t.Fatalf("2fa token = %q, want preserved secret", got)
|
||||
}
|
||||
if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
|
||||
t.Fatalf("smtp password = %q, want preserved secret", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetSmtpPassword(); got != "" {
|
||||
t.Fatalf("smtp password = %q, want cleared", got)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
|
||||
}
|
||||
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
||||
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
|
||||
}
|
||||
|
||||
view, err = s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.HasSmtpPassword {
|
||||
t.Fatal("hasSmtpPassword must report false after clearing")
|
||||
}
|
||||
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "" {
|
||||
t.Fatalf("tg token = %q, want cleared", got)
|
||||
}
|
||||
if got, _ := s.GetLdapPassword(); got != "" {
|
||||
t.Fatalf("ldap password = %q, want cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
||||
t.Fatal("expected localhost URL to be blocked")
|
||||
@@ -151,25 +28,6 @@ func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTwoFactorCode(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const token = "JBSWY3DPEHPK3PXP"
|
||||
if err := s.saveSetting("twoFactorToken", token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil {
|
||||
t.Fatalf("valid code rejected: %v", err)
|
||||
}
|
||||
if err := s.VerifyTwoFactorCode("000000"); err == nil {
|
||||
t.Fatal("invalid code accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSecret_FallbacksOnEmptyDatabaseSetting(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
||||
}
|
||||
|
||||
settings.SubShowIdentityOnAllLinks = true
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
if err := s.UpdateAllSetting(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
|
||||
@@ -30,7 +30,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SubShowIdentityOnAllLinks = false
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
if err := s.UpdateAllSetting(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
|
||||
@@ -216,58 +216,6 @@ func TestAddDelClientPostgresScale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAndListPostgresScale(t *testing.T) {
|
||||
setupScaleDB(t)
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 100000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
db.Exec("ANALYZE")
|
||||
emails := make([]string, n)
|
||||
for i := range n {
|
||||
emails[i] = clients[i].Email
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
|
||||
t.Fatalf("AddToGroup: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if _, err := svc.RemoveFromGroup(emails); err != nil {
|
||||
t.Fatalf("RemoveFromGroup: %v", err)
|
||||
}
|
||||
rmDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
list, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
listDur := time.Since(start)
|
||||
if len(list) != n {
|
||||
t.Fatalf("List returned %d, want %d", len(list), n)
|
||||
}
|
||||
|
||||
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
|
||||
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelAllClientsPostgresScale(t *testing.T) {
|
||||
setupScaleDB(t)
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
// updateNumericInput applies one number-pad key: -2 clears, -1 backspaces, and 0..9 append.
|
||||
// Callers retain their own validation and keyboard labels.
|
||||
func updateNumericInput(value, key int) int {
|
||||
switch key {
|
||||
case -2:
|
||||
return 0
|
||||
case -1:
|
||||
if value > 0 {
|
||||
return value / 10
|
||||
}
|
||||
return value
|
||||
default:
|
||||
return value*10 + key
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateNumericInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value, key int
|
||||
want int
|
||||
}{
|
||||
{name: "append digit", value: 12, key: 3, want: 123},
|
||||
{name: "append zero", value: 12, key: 0, want: 120},
|
||||
{name: "backspace", value: 123, key: -1, want: 12},
|
||||
{name: "backspace zero", value: 0, key: -1, want: 0},
|
||||
{name: "clear", value: 123, key: -2, want: 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := updateNumericInput(tt.value, tt.key); got != tt.want {
|
||||
t.Fatalf("updateNumericInput(%d, %d) = %d, want %d", tt.value, tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericInputTransitionIsUsedByEveryKeypad(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read tgbot package: %v", err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || filepath.Ext(name) != ".go" || name == "numeric_input.go" || filepath.Ext(strings.TrimSuffix(name, "_test.go")) != ".go" {
|
||||
continue
|
||||
}
|
||||
parsed, err := parser.ParseFile(fset, name, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||
switchStmt, ok := node.(*ast.SwitchStmt)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
hasClear, hasBackspace, hasDefault := false, false, false
|
||||
for _, stmt := range switchStmt.Body.List {
|
||||
clause := stmt.(*ast.CaseClause)
|
||||
if clause.List == nil {
|
||||
hasDefault = true
|
||||
}
|
||||
for _, expr := range clause.List {
|
||||
hasClear = hasClear || numericKeyLiteral(expr, "2")
|
||||
hasBackspace = hasBackspace || numericKeyLiteral(expr, "1")
|
||||
}
|
||||
}
|
||||
if hasClear && hasBackspace && hasDefault {
|
||||
position := fset.Position(switchStmt.Pos())
|
||||
t.Errorf("open-coded numeric keypad transition at %s; use updateNumericInput", position)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func numericKeyLiteral(expr ast.Expr, magnitude string) bool {
|
||||
unary, ok := expr.(*ast.UnaryExpr)
|
||||
if !ok || unary.Op != token.SUB {
|
||||
return false
|
||||
}
|
||||
literal, ok := unary.X.(*ast.BasicLit)
|
||||
return ok && literal.Kind == token.INT && literal.Value == magnitude
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
)
|
||||
|
||||
var (
|
||||
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
|
||||
hostname string
|
||||
hashStorage *global.HashStorage
|
||||
|
||||
// EventBus is set from web layer to publish login/security events.
|
||||
EventBus *eventbus.Bus
|
||||
|
||||
// Performance improvements
|
||||
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
|
||||
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
|
||||
|
||||
// Simple cache for frequently accessed data
|
||||
statusCache struct {
|
||||
data *service.Status
|
||||
timestamp time.Time
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
serverStatsCache struct {
|
||||
data string
|
||||
timestamp time.Time
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// clients data to adding new client. receiver_inbound_IDs is the set of
|
||||
// inbounds the new client will be attached to; receiver_inbound_ID mirrors
|
||||
// the primary pick for the legacy attach-picker entry point. Per-protocol
|
||||
// secrets (UUID, password, flow, method) are filled per-inbound on submit
|
||||
// by ClientService.fillProtocolDefaults, so the bot only tracks universal
|
||||
// client fields here.
|
||||
receiver_inbound_ID int
|
||||
receiver_inbound_IDs []int
|
||||
client_Email string
|
||||
client_LimitIP int
|
||||
client_TotalGB int64
|
||||
client_ExpiryTime int64
|
||||
client_Enable bool
|
||||
client_TgID string
|
||||
client_SubID string
|
||||
client_Comment string
|
||||
client_Reset int
|
||||
)
|
||||
|
||||
// userStateStore guards the per-chat conversation states. The Telegram command
|
||||
// and callback handlers run on a worker-pool goroutine while the message handler
|
||||
// runs on the dispatch goroutine, so a bare map would be a concurrent-map-write
|
||||
// crash. It also expires abandoned conversations so a user who starts a flow and
|
||||
// goes silent doesn't leave an entry forever.
|
||||
type userStateStore struct {
|
||||
mu sync.Mutex
|
||||
states map[int64]userStateEntry
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
type userStateEntry struct {
|
||||
state string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var userStateMgr = &userStateStore{states: make(map[int64]userStateEntry)}
|
||||
|
||||
func (s *userStateStore) set(chatID int64, state string) {
|
||||
s.mu.Lock()
|
||||
s.states[chatID] = userStateEntry{state: state, at: time.Now()}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *userStateStore) get(chatID int64) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.states[chatID]
|
||||
return e.state, ok
|
||||
}
|
||||
|
||||
func (s *userStateStore) clear(chatID int64) {
|
||||
s.mu.Lock()
|
||||
delete(s.states, chatID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *userStateStore) reset() {
|
||||
s.mu.Lock()
|
||||
s.states = make(map[int64]userStateEntry)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// maybePrune drops conversations older than maxAge, at most once per maxAge so a
|
||||
// busy bot doesn't sweep the whole map on every message.
|
||||
func (s *userStateStore) maybePrune(maxAge time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
if now.Sub(s.lastPrune) < maxAge {
|
||||
return
|
||||
}
|
||||
s.lastPrune = now
|
||||
for id, e := range s.states {
|
||||
if now.Sub(e.at) > maxAge {
|
||||
delete(s.states, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoginStatus represents the result of a login attempt.
|
||||
type LoginStatus byte
|
||||
|
||||
// Login status constants
|
||||
const (
|
||||
LoginSuccess LoginStatus = 1 // Login was successful
|
||||
LoginFail LoginStatus = 0 // Login failed
|
||||
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
|
||||
)
|
||||
|
||||
// LoginAttempt contains safe metadata for panel login notifications.
|
||||
// It intentionally does not include attempted passwords.
|
||||
type LoginAttempt struct {
|
||||
Username string
|
||||
IP string
|
||||
Time string
|
||||
Status LoginStatus
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Tgbot provides business logic for Telegram bot integration.
|
||||
// It handles bot commands, user interactions, and status reporting via Telegram.
|
||||
type Tgbot struct {
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
settingService service.SettingService
|
||||
serverService service.ServerService
|
||||
xrayService service.XrayService
|
||||
lastStatus *service.Status
|
||||
}
|
||||
|
||||
// NewTgbot creates a new Tgbot instance.
|
||||
func (t *Tgbot) NewTgbot() *Tgbot {
|
||||
return new(Tgbot)
|
||||
}
|
||||
|
||||
// I18nBot retrieves a localized message for the bot interface.
|
||||
func (t *Tgbot) I18nBot(name string, params ...string) string {
|
||||
return locale.I18n(locale.Bot, name, params...)
|
||||
}
|
||||
|
||||
// GetHashStorage returns the hash storage instance for callback queries.
|
||||
func (t *Tgbot) GetHashStorage() *global.HashStorage {
|
||||
return hashStorage
|
||||
}
|
||||
|
||||
// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old)
|
||||
func (t *Tgbot) getCachedStatus() (*service.Status, bool) {
|
||||
statusCache.mutex.RLock()
|
||||
defer statusCache.mutex.RUnlock()
|
||||
|
||||
if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second {
|
||||
return statusCache.data, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// setCachedStatus updates the status cache
|
||||
func (t *Tgbot) setCachedStatus(status *service.Status) {
|
||||
statusCache.mutex.Lock()
|
||||
defer statusCache.mutex.Unlock()
|
||||
|
||||
statusCache.data = status
|
||||
statusCache.timestamp = time.Now()
|
||||
}
|
||||
|
||||
// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old)
|
||||
func (t *Tgbot) getCachedServerStats() (string, bool) {
|
||||
serverStatsCache.mutex.RLock()
|
||||
defer serverStatsCache.mutex.RUnlock()
|
||||
|
||||
if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second {
|
||||
return serverStatsCache.data, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// setCachedServerStats updates the server stats cache
|
||||
func (t *Tgbot) setCachedServerStats(stats string) {
|
||||
serverStatsCache.mutex.Lock()
|
||||
defer serverStatsCache.mutex.Unlock()
|
||||
|
||||
serverStatsCache.data = stats
|
||||
serverStatsCache.timestamp = time.Now()
|
||||
}
|
||||
|
||||
// Start initializes and starts the Telegram bot with the provided translation files.
|
||||
func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
// Initialize localizer
|
||||
err := locale.InitLocalizer(i18nFS, &t.settingService)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
// Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
|
||||
messageWorkerPool = make(chan struct{}, 10)
|
||||
|
||||
// Initialize optimized HTTP client with connection pooling
|
||||
optimizedHTTPClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.SetHostname()
|
||||
|
||||
// Get Telegram bot token
|
||||
tgBotToken, err := t.settingService.GetTgBotToken()
|
||||
if err != nil || tgBotToken == "" {
|
||||
logger.Warning("Failed to get Telegram bot token:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get Telegram bot chat ID(s)
|
||||
tgBotID, err := t.settingService.GetTgBotChatId()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get Telegram bot chat ID:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAdminIds := make([]int64, 0)
|
||||
// Parse admin IDs from comma-separated string
|
||||
if tgBotID != "" {
|
||||
for adminID := range strings.SplitSeq(tgBotID, ",") {
|
||||
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
|
||||
}
|
||||
parsedAdminIds = append(parsedAdminIds, id)
|
||||
}
|
||||
}
|
||||
tgBotMutex.Lock()
|
||||
adminIds = parsedAdminIds
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
// Get Telegram bot proxy URL
|
||||
tgBotProxy, err := t.settingService.GetTgBotProxy()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get Telegram bot proxy URL:", err)
|
||||
}
|
||||
|
||||
// Fall back to the panel-wide egress bridge when no dedicated bot proxy is
|
||||
// set. Resolved once at bot start: if Xray comes up later, the bot keeps
|
||||
// its direct connection until it is restarted.
|
||||
if tgBotProxy == "" {
|
||||
if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) {
|
||||
tgBotProxy = egress
|
||||
}
|
||||
}
|
||||
|
||||
// Get Telegram bot API server URL
|
||||
tgBotAPIServer, err := t.settingService.GetTgBotAPIServer()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get Telegram bot API server URL:", err)
|
||||
}
|
||||
|
||||
// Create new Telegram bot instance
|
||||
bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize Telegram bot API:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
t.trySetBotCommands(bot)
|
||||
|
||||
// Start receiving Telegram bot messages
|
||||
tgBotMutex.Lock()
|
||||
alreadyRunning := isRunning || botCancel != nil
|
||||
tgBotMutex.Unlock()
|
||||
if !alreadyRunning {
|
||||
logger.Info("Telegram bot receiver started")
|
||||
go t.OnReceive()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
|
||||
Commands: []telego.BotCommand{
|
||||
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
|
||||
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
|
||||
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
|
||||
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
|
||||
{Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")},
|
||||
{Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")},
|
||||
{Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")},
|
||||
{Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to set bot commands:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isSupportedBotProxyScheme(proxyUrl string) bool {
|
||||
return strings.HasPrefix(proxyUrl, "socks5://") ||
|
||||
strings.HasPrefix(proxyUrl, "http://") ||
|
||||
strings.HasPrefix(proxyUrl, "https://")
|
||||
}
|
||||
|
||||
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
|
||||
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
|
||||
client := &fasthttp.Client{
|
||||
// Connection timeouts
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
MaxIdleConnDuration: 60 * time.Second,
|
||||
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
|
||||
MaxIdemponentCallAttempts: 3,
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
MaxConnsPerHost: 100,
|
||||
MaxConnWaitTimeout: 10 * time.Second,
|
||||
DisableHeaderNamesNormalizing: false,
|
||||
DisablePathNormalizing: false,
|
||||
// resetTimeout stays false to keep the pre-RetryIfErr retry timing.
|
||||
RetryIfErr: func(request *fasthttp.Request, _ int, _ error) (bool, bool) {
|
||||
method := string(request.Header.Method())
|
||||
return false, method == "GET" || method == "POST"
|
||||
},
|
||||
}
|
||||
|
||||
if proxyUrl != "" {
|
||||
if strings.HasPrefix(proxyUrl, "socks5://") {
|
||||
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
|
||||
} else {
|
||||
client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
|
||||
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
||||
// Validate proxy URL if provided
|
||||
if proxyUrl != "" {
|
||||
if !isSupportedBotProxyScheme(proxyUrl) {
|
||||
logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy")
|
||||
proxyUrl = "" // Clear invalid proxy
|
||||
} else if _, err := url.Parse(proxyUrl); err != nil {
|
||||
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
|
||||
proxyUrl = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Validate API server URL if provided
|
||||
if apiServerUrl != "" {
|
||||
safeURL, err := service.SanitizePublicHTTPURL(apiServerUrl, false)
|
||||
if err != nil {
|
||||
logger.Warningf("Invalid or blocked API server URL, using default: %v", err)
|
||||
apiServerUrl = ""
|
||||
} else {
|
||||
apiServerUrl = safeURL
|
||||
}
|
||||
}
|
||||
|
||||
// Create robust fasthttp client
|
||||
client := t.createRobustFastHTTPClient(proxyUrl)
|
||||
|
||||
// Build bot options
|
||||
var options []telego.BotOption
|
||||
options = append(options, telego.WithFastHTTPClient(client))
|
||||
|
||||
if apiServerUrl != "" {
|
||||
options = append(options, telego.WithAPIServer(apiServerUrl))
|
||||
}
|
||||
|
||||
return telego.NewBot(token, options...)
|
||||
}
|
||||
|
||||
// IsRunning checks if the Telegram bot is currently running.
|
||||
func (t *Tgbot) IsRunning() bool {
|
||||
tgBotMutex.Lock()
|
||||
defer tgBotMutex.Unlock()
|
||||
return isRunning
|
||||
}
|
||||
|
||||
// SetHostname sets the hostname for the bot.
|
||||
func (t *Tgbot) SetHostname() {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
logger.Error("get hostname error:", err)
|
||||
hostname = ""
|
||||
return
|
||||
}
|
||||
hostname = host
|
||||
}
|
||||
|
||||
// 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() {
|
||||
StopBot()
|
||||
logger.Info("Stop Telegram receiver ...")
|
||||
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()
|
||||
|
||||
userStateMgr.reset()
|
||||
|
||||
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.
|
||||
func (t *Tgbot) encodeQuery(query string) string {
|
||||
// NOTE: we only need to hash for more than 64 chars
|
||||
if len(query) <= 64 {
|
||||
return query
|
||||
}
|
||||
|
||||
return hashStorage.SaveHash(query)
|
||||
}
|
||||
|
||||
// decodeQuery decodes a hashed query string back to its original form.
|
||||
func (t *Tgbot) decodeQuery(query string) (string, error) {
|
||||
if !hashStorage.IsMD5(query) {
|
||||
return query, nil
|
||||
}
|
||||
|
||||
decoded, exists := hashStorage.GetValue(query)
|
||||
if !exists {
|
||||
return "", common.NewError("hash not found in storage!")
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// randomLowerAndNum generates a random string of lowercase letters and numbers.
|
||||
func (t *Tgbot) randomLowerAndNum(length int) string {
|
||||
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
randomIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
bytes[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// int64Contains checks if an int64 slice contains a specific item.
|
||||
func int64Contains(slice []int64, item int64) bool {
|
||||
return slices.Contains(slice, item)
|
||||
}
|
||||
|
||||
// isSingleWord checks if the text contains only a single word.
|
||||
func (t *Tgbot) isSingleWord(text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
re := regexp.MustCompile(`\s+`)
|
||||
return re.MatchString(text)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("a non-admin callback reached a privileged handler: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
tg := &Tgbot{}
|
||||
for _, data := range []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "inbounds"} {
|
||||
q := &telego.CallbackQuery{
|
||||
Data: data,
|
||||
From: telego.User{ID: 999999},
|
||||
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
|
||||
}
|
||||
tg.answerCallback(q, false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsClientSelfCallback(t *testing.T) {
|
||||
allowed := []string{"client_traffic", "client_sub_links", "client_qr_links", "client_sub_links alice@x"}
|
||||
for _, d := range allowed {
|
||||
if !isClientSelfCallback(d) {
|
||||
t.Errorf("%q should be a per-user client callback", d)
|
||||
}
|
||||
}
|
||||
denied := []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "get_banlogs", "get_usage"}
|
||||
for _, d := range denied {
|
||||
if isClientSelfCallback(d) {
|
||||
t.Errorf("%q is an admin-only callback and must not be treated as per-user", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,782 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress
|
||||
// client (email, attached inbounds, traffic limit, expiry, ip limit, comment)
|
||||
// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password,
|
||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
||||
// never has to track them per inbound itself.
|
||||
func (t *Tgbot) BuildClientDraftMessage() string {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
expiry := ""
|
||||
switch {
|
||||
case client_ExpiryTime == 0:
|
||||
expiry = t.I18nBot("tgbot.unlimited")
|
||||
case client_ExpiryTime < 0:
|
||||
expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
||||
default:
|
||||
diff := client_ExpiryTime - now
|
||||
if diff > 172800000 {
|
||||
expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours"))
|
||||
}
|
||||
}
|
||||
|
||||
traffic := "♾️ Unlimited(Reset)"
|
||||
if client_TotalGB > 0 {
|
||||
traffic = common.FormatTraffic(client_TotalGB)
|
||||
}
|
||||
|
||||
ipLimit := "♾️ Unlimited(Reset)"
|
||||
if client_LimitIP > 0 {
|
||||
ipLimit = fmt.Sprint(client_LimitIP)
|
||||
}
|
||||
|
||||
attached := t.describeAttachedInbounds(receiver_inbound_IDs)
|
||||
if attached == "" {
|
||||
attached = "—"
|
||||
}
|
||||
|
||||
comment := client_Comment
|
||||
if comment == "" {
|
||||
comment = "—"
|
||||
}
|
||||
|
||||
tgID := client_TgID
|
||||
if tgID == "" {
|
||||
tgID = "—"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("📝 *New client draft*\r\n")
|
||||
fmt.Fprintf(&b, "📧 Email: `%s`\r\n", client_Email)
|
||||
fmt.Fprintf(&b, "🔗 Attached: %s\r\n", attached)
|
||||
fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
|
||||
fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
|
||||
fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit)
|
||||
fmt.Fprintf(&b, "👤 TG user: %s\r\n", tgID)
|
||||
fmt.Fprintf(&b, "💬 Comment: %s\r\n", comment)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// describeAttachedInbounds returns a short "remark1, remark2" list for the given
|
||||
// inbound ids, falling back to "#id" when an inbound can't be loaded.
|
||||
func (t *Tgbot) describeAttachedInbounds(ids []int) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
ib, err := t.inboundService.GetInbound(id)
|
||||
if err != nil || ib == nil {
|
||||
parts = append(parts, fmt.Sprintf("#%d", id))
|
||||
continue
|
||||
}
|
||||
label := ib.Remark
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("#%d", id)
|
||||
}
|
||||
parts = append(parts, label)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// SubmitAddClient sends the in-progress client to ClientService.Create with
|
||||
// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on
|
||||
// the panel generates UUID/password/auth per protocol, so the bot only
|
||||
// supplies the universal fields it actually collected.
|
||||
func (t *Tgbot) SubmitAddClient() (bool, error) {
|
||||
inboundIDs := receiver_inbound_IDs
|
||||
if len(inboundIDs) == 0 && receiver_inbound_ID > 0 {
|
||||
inboundIDs = []int{receiver_inbound_ID}
|
||||
}
|
||||
if len(inboundIDs) == 0 {
|
||||
return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64)
|
||||
client := model.Client{
|
||||
Email: client_Email,
|
||||
Enable: client_Enable,
|
||||
LimitIP: client_LimitIP,
|
||||
TotalGB: client_TotalGB,
|
||||
ExpiryTime: client_ExpiryTime,
|
||||
SubID: client_SubID,
|
||||
Comment: client_Comment,
|
||||
Reset: client_Reset,
|
||||
TgID: tgIDInt,
|
||||
}
|
||||
|
||||
return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{
|
||||
Client: client,
|
||||
InboundIds: inboundIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
|
||||
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
||||
// Resolve subId from client email
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
_ = traffic
|
||||
if err != nil || client == nil {
|
||||
return "", "", errors.New("client not found")
|
||||
}
|
||||
|
||||
// Gather settings to construct absolute URLs
|
||||
subURI, _ := t.settingService.GetSubURI()
|
||||
subJsonURI, _ := t.settingService.GetSubJsonURI()
|
||||
subDomain, _ := t.settingService.GetSubDomain()
|
||||
subPort, _ := t.settingService.GetSubPort()
|
||||
subPath, _ := t.settingService.GetSubPath()
|
||||
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
||||
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
|
||||
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
||||
subCertFile, _ := t.settingService.GetSubCertFile()
|
||||
|
||||
tls := (subKeyFile != "" && subCertFile != "")
|
||||
scheme := "http"
|
||||
if tls {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
if subDomain == "" {
|
||||
// try panel domain, otherwise OS hostname
|
||||
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
|
||||
subDomain = d
|
||||
} else if hostname != "" {
|
||||
subDomain = hostname
|
||||
} else {
|
||||
subDomain = "localhost"
|
||||
}
|
||||
}
|
||||
|
||||
host := subDomain
|
||||
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
|
||||
// standard ports: no port in host
|
||||
} else {
|
||||
host = fmt.Sprintf("%s:%d", subDomain, subPort)
|
||||
}
|
||||
|
||||
// Ensure paths
|
||||
if !strings.HasPrefix(subPath, "/") {
|
||||
subPath = "/" + subPath
|
||||
}
|
||||
if !strings.HasSuffix(subPath, "/") {
|
||||
subPath = subPath + "/"
|
||||
}
|
||||
if !strings.HasPrefix(subJsonPath, "/") {
|
||||
subJsonPath = "/" + subJsonPath
|
||||
}
|
||||
if !strings.HasSuffix(subJsonPath, "/") {
|
||||
subJsonPath = subJsonPath + "/"
|
||||
}
|
||||
|
||||
var subURL string
|
||||
var subJsonURL string
|
||||
|
||||
// If pre-configured URIs are available, use them directly
|
||||
if subURI != "" {
|
||||
if !strings.HasSuffix(subURI, "/") {
|
||||
subURI = subURI + "/"
|
||||
}
|
||||
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
|
||||
} else {
|
||||
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||
}
|
||||
|
||||
if subJsonURI != "" {
|
||||
if !strings.HasSuffix(subJsonURI, "/") {
|
||||
subJsonURI = subJsonURI + "/"
|
||||
}
|
||||
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
|
||||
} else {
|
||||
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||
}
|
||||
|
||||
if !subJsonEnable {
|
||||
subJsonURL = ""
|
||||
}
|
||||
return subURL, subJsonURL, nil
|
||||
}
|
||||
|
||||
// sendClientSubLinks sends the subscription links for the client to the chat.
|
||||
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
|
||||
if subJsonURL != "" {
|
||||
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||
}
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
|
||||
),
|
||||
)
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
|
||||
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
|
||||
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
||||
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch raw subscription links. Prefer plain text response.
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
// Force plain text to avoid HTML page; controller respects Accept header
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
|
||||
// Use optimized client with connection pooling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := optimizedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If service is configured to encode (Base64), decode it
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
|
||||
if err != nil {
|
||||
// fallback to raw text
|
||||
content = string(bodyBytes)
|
||||
} else {
|
||||
content = string(decoded)
|
||||
}
|
||||
} else {
|
||||
content = string(bodyBytes)
|
||||
}
|
||||
|
||||
// Normalize line endings and trim
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
|
||||
// Send in chunks to respect message length; use monospace formatting
|
||||
const maxPerMessage = 50
|
||||
for i := 0; i < len(cleaned); i += maxPerMessage {
|
||||
j := min(i+maxPerMessage, len(cleaned))
|
||||
chunk := cleaned[i:j]
|
||||
var msg strings.Builder
|
||||
msg.WriteString(t.I18nBot("subscription.individualLinks"))
|
||||
msg.WriteString(":\r\n")
|
||||
for _, link := range chunk {
|
||||
// wrap each link in <code>
|
||||
msg.WriteString("<code>")
|
||||
msg.WriteString(link)
|
||||
msg.WriteString("</code>\r\n")
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg.String())
|
||||
}
|
||||
}
|
||||
|
||||
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
|
||||
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to create QR PNG bytes from content
|
||||
createQR := func(content string, size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
size = 256
|
||||
}
|
||||
return qrcode.Encode(content, qrcode.Medium, size)
|
||||
}
|
||||
|
||||
// Inform user
|
||||
t.SendMsgToTgbot(chatId, "QRCode for client "+email+":")
|
||||
|
||||
// Send sub URL QR (filename: sub.png)
|
||||
if png, err := createQR(subURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "sub.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
|
||||
// Send JSON URL QR (filename: subjson.png) when available
|
||||
if subJsonURL != "" {
|
||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Also generate a few individual links' QRs (first up to 5)
|
||||
subPageURL := subURL
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
if resp, err := optimizedHTTPClient.Do(req); err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||
content = string(dec)
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
max := min(len(cleaned), 5)
|
||||
for i := range max {
|
||||
if png, err := createQR(cleaned[i], 320); err == nil {
|
||||
// Use the email as filename for individual link QR
|
||||
filename := email + ".png"
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
// Reduced delay for better performance
|
||||
if i < max-1 { // Only delay between documents, not after the last one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientInfoMsg formats client information message based on traffic and flags.
|
||||
func (t *Tgbot) clientInfoMsg(
|
||||
traffic *xray.ClientTraffic,
|
||||
printEnabled bool,
|
||||
printOnline bool,
|
||||
printActive bool,
|
||||
printDate bool,
|
||||
printTraffic bool,
|
||||
printRefreshed bool,
|
||||
) string {
|
||||
now := time.Now().Unix()
|
||||
expiryTime := ""
|
||||
flag := false
|
||||
diff := traffic.ExpiryTime/1000 - now
|
||||
if traffic.ExpiryTime == 0 {
|
||||
expiryTime = t.I18nBot("tgbot.unlimited")
|
||||
} else if diff > 172800 || !traffic.Enable {
|
||||
expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
|
||||
if diff > 0 {
|
||||
days := diff / 86400
|
||||
hours := (diff % 86400) / 3600
|
||||
minutes := (diff % 3600) / 60
|
||||
remainingTime := ""
|
||||
if days > 0 {
|
||||
remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days"))
|
||||
}
|
||||
if hours > 0 {
|
||||
remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours"))
|
||||
}
|
||||
if minutes > 0 {
|
||||
remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes"))
|
||||
}
|
||||
expiryTime += fmt.Sprintf(" (%s)", remainingTime)
|
||||
}
|
||||
} else if traffic.ExpiryTime < 0 {
|
||||
expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
||||
flag = true
|
||||
} else {
|
||||
expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours"))
|
||||
flag = true
|
||||
}
|
||||
|
||||
total := ""
|
||||
if traffic.Total == 0 {
|
||||
total = t.I18nBot("tgbot.unlimited")
|
||||
} else {
|
||||
total = common.FormatTraffic(traffic.Total)
|
||||
}
|
||||
|
||||
enabled := ""
|
||||
isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
enabled = t.I18nBot("tgbot.wentWrong")
|
||||
} else if isEnabled {
|
||||
enabled = t.I18nBot("tgbot.messages.yes")
|
||||
} else {
|
||||
enabled = t.I18nBot("tgbot.messages.no")
|
||||
}
|
||||
|
||||
active := ""
|
||||
if traffic.Enable {
|
||||
active = t.I18nBot("tgbot.messages.yes")
|
||||
} else {
|
||||
active = t.I18nBot("tgbot.messages.no")
|
||||
}
|
||||
|
||||
status := t.I18nBot("tgbot.offline")
|
||||
isOnline := false
|
||||
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
||||
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
|
||||
status = t.I18nBot("tgbot.online")
|
||||
isOnline = true
|
||||
}
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
|
||||
if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 {
|
||||
output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds))
|
||||
}
|
||||
if printEnabled {
|
||||
output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if printDate {
|
||||
if flag {
|
||||
output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime)
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime)
|
||||
}
|
||||
}
|
||||
if printTraffic {
|
||||
output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up))
|
||||
output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down))
|
||||
output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total)
|
||||
}
|
||||
if printRefreshed {
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// getClientUsage retrieves and sends client usage information to the chat.
|
||||
func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if len(traffics) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
||||
return
|
||||
}
|
||||
|
||||
output := ""
|
||||
|
||||
if len(traffics) > 0 {
|
||||
if len(email) > 0 {
|
||||
for _, traffic := range traffics {
|
||||
if traffic.Email == email[0] {
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
return
|
||||
}
|
||||
}
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
} else {
|
||||
for _, traffic := range traffics {
|
||||
output += t.clientInfoMsg(traffic, true, true, true, true, true, false)
|
||||
output += "\r\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
output = t.I18nBot("tgbot.commands.pleaseChoose")
|
||||
t.SendAnswer(chatId, output, false)
|
||||
}
|
||||
|
||||
// searchClientIps searches and sends client IP addresses for the given email.
|
||||
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
||||
ips, err := t.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || len(ips) == 0 {
|
||||
ips = t.I18nBot("tgbot.noIpRecord")
|
||||
}
|
||||
|
||||
formattedIps := ips
|
||||
if err == nil && len(ips) > 0 {
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
|
||||
lines := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
|
||||
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, item.IP)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
formattedIps = strings.Join(lines, "\n")
|
||||
}
|
||||
} else {
|
||||
var oldIps []string
|
||||
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
|
||||
formattedIps = strings.Join(oldIps, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)),
|
||||
),
|
||||
)
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// clientTelegramUserInfo retrieves and sends Telegram user info for the client.
|
||||
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if client == nil {
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
tgId := "None"
|
||||
if client.TgID != 0 {
|
||||
tgId = strconv.FormatInt(client.TgID, 10)
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||
output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId)
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)),
|
||||
),
|
||||
)
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
requestUser := telego.KeyboardButtonRequestUsers{
|
||||
RequestID: int32(traffic.Id),
|
||||
UserIsBot: new(bool),
|
||||
}
|
||||
keyboard := tu.Keyboard(
|
||||
tu.KeyboardRow(
|
||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser),
|
||||
),
|
||||
tu.KeyboardRow(
|
||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")),
|
||||
),
|
||||
).WithIsPersistent().WithResizeKeyboard()
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// searchClient searches for a client by email and sends the information.
|
||||
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if traffic == nil {
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),
|
||||
),
|
||||
)
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// getCommonClientButtons returns the shared inline keyboard rows for the
|
||||
// client-first multi-inbound add flow. Per-protocol secrets (UUID, password,
|
||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
||||
// only exposes the universal client fields here.
|
||||
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
|
||||
attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs))
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// addClient renders the draft message + shared client-first keyboard.
|
||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||
inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...)
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
|
||||
userStateMgr.reset()
|
||||
t.Cleanup(userStateMgr.reset)
|
||||
|
||||
const chatID = int64(4242)
|
||||
userStateMgr.set(chatID, "awaiting_comment")
|
||||
|
||||
tg := &Tgbot{}
|
||||
tg.deleteMessageAfterDelay(chatID, 1, 0)
|
||||
|
||||
if st, ok := userStateMgr.get(chatID); !ok || st != "awaiting_comment" {
|
||||
t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment")
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
|
||||
var cachedHostname string
|
||||
|
||||
func getHostname() string {
|
||||
if cachedHostname != "" {
|
||||
return cachedHostname
|
||||
}
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
cachedHostname = "unknown"
|
||||
} else {
|
||||
cachedHostname = h
|
||||
}
|
||||
return cachedHostname
|
||||
}
|
||||
|
||||
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
|
||||
|
||||
// HandleEvent is the eventbus subscriber callback. It formats incoming events
|
||||
// as Telegram messages and sends them to all admin chats.
|
||||
func (t *Tgbot) HandleEvent(e eventbus.Event) {
|
||||
if !t.isEventEnabled(e.Type) {
|
||||
return
|
||||
}
|
||||
if e.Type != eventbus.EventLoginAttempt {
|
||||
if !tgEventLimiter.Allow(e.Type, e.Source) {
|
||||
return
|
||||
}
|
||||
}
|
||||
msg := t.formatEventMessage(e)
|
||||
if msg != "" {
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
|
||||
events, err := t.settingService.GetTgEnabledEvents()
|
||||
if err != nil || events == "" {
|
||||
return false
|
||||
}
|
||||
for e := range strings.SplitSeq(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
|
||||
host := getHostname()
|
||||
header := fmt.Sprintf("<b>📡 %s</b>\n", host)
|
||||
|
||||
switch e.Type {
|
||||
case eventbus.EventOutboundDown:
|
||||
msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
|
||||
"Tag=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
|
||||
if data.Error != "" {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
|
||||
"Error=="+data.Error)
|
||||
}
|
||||
if data.Delay > 0 {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
|
||||
"Delay=="+fmt.Sprintf("%d", data.Delay))
|
||||
}
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventOutboundUp:
|
||||
msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
|
||||
"Tag=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
|
||||
"Delay=="+fmt.Sprintf("%d", data.Delay))
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventXrayCrash:
|
||||
errStr := ""
|
||||
if e.Data != nil {
|
||||
errStr = fmt.Sprint(e.Data)
|
||||
}
|
||||
msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
|
||||
if errStr != "" {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventNodeDown:
|
||||
msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError)
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventNodeUp:
|
||||
msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs))
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventCPUHigh:
|
||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
||||
tgCpu, err := t.settingService.GetTgCpu()
|
||||
if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) {
|
||||
return ""
|
||||
}
|
||||
return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold",
|
||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
||||
"Threshold=="+strconv.Itoa(tgCpu))
|
||||
}
|
||||
return ""
|
||||
|
||||
case eventbus.EventMemoryHigh:
|
||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
||||
tgMemory, err := t.settingService.GetTgMemory()
|
||||
if err != nil || tgMemory <= 0 || data.Percent <= float64(tgMemory) {
|
||||
return ""
|
||||
}
|
||||
return header + "🔴 " + t.I18nBot("tgbot.messages.memoryThreshold",
|
||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
||||
"Threshold=="+strconv.Itoa(tgMemory))
|
||||
}
|
||||
return ""
|
||||
|
||||
case eventbus.EventLoginAttempt:
|
||||
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
|
||||
if data.Status == "success" {
|
||||
msg := t.I18nBot("tgbot.messages.loginSuccess")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
|
||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
|
||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
|
||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
|
||||
return msg
|
||||
}
|
||||
msg := t.I18nBot("tgbot.messages.loginFailed")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
|
||||
if data.Reason != "" {
|
||||
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason)
|
||||
}
|
||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
|
||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
|
||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
|
||||
return msg
|
||||
}
|
||||
return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// getInboundUsages retrieves and formats inbound usage information.
|
||||
func (t *Tgbot) getInboundUsages() string {
|
||||
var info strings.Builder
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
return info.String()
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
|
||||
info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
|
||||
info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
|
||||
|
||||
clients, listErr := t.clientService.ListForInbound(nil, inbound.Id)
|
||||
if listErr == nil {
|
||||
fmt.Fprintf(&info, "👥 Clients: %d\r\n", len(clients))
|
||||
}
|
||||
|
||||
if inbound.ExpiryTime == 0 {
|
||||
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
|
||||
} else {
|
||||
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
info.WriteString("\r\n")
|
||||
}
|
||||
return info.String()
|
||||
}
|
||||
|
||||
// getInbounds creates an inline keyboard with all inbounds.
|
||||
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
|
||||
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
|
||||
func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbound, err := t.inboundService.GetInbound(inboundID)
|
||||
if err != nil {
|
||||
logger.Warning("getInboundClientsFor run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("GetInboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
} else {
|
||||
if len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
}
|
||||
cols := 0
|
||||
if len(buttons) < 6 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
|
||||
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
excludedProtocols := map[model.Protocol]bool{
|
||||
model.Tunnel: true,
|
||||
model.Mixed: true,
|
||||
model.WireGuard: true,
|
||||
model.AmneziaWG: true,
|
||||
model.HTTP: true,
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
if excludedProtocols[inbound.Protocol] {
|
||||
continue
|
||||
}
|
||||
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsAttachPicker builds a toggle picker over multi-client inbounds
|
||||
// for the "attach more inbounds to the new client" step. Each row shows the
|
||||
// current selection state for the inbound; tapping fires
|
||||
// add_client_toggle_attach <id> which flips it and re-renders. A final
|
||||
// "Done" button (add_client_attach_done) returns to the field-edit screen.
|
||||
func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
excludedProtocols := map[model.Protocol]bool{
|
||||
model.Tunnel: true,
|
||||
model.Mixed: true,
|
||||
model.WireGuard: true,
|
||||
model.AmneziaWG: true,
|
||||
model.HTTP: true,
|
||||
}
|
||||
selected := make(map[int]bool, len(receiver_inbound_IDs))
|
||||
for _, id := range receiver_inbound_IDs {
|
||||
selected[id] = true
|
||||
}
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, ib := range inbounds {
|
||||
if excludedProtocols[ib.Protocol] {
|
||||
continue
|
||||
}
|
||||
mark := "☐"
|
||||
if selected[ib.Id] {
|
||||
mark = "✅"
|
||||
}
|
||||
label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol)
|
||||
callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback))
|
||||
}
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
rows := tu.InlineKeyboardCols(cols, buttons...)
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")),
|
||||
))
|
||||
return tu.InlineKeyboardGrid(rows), nil
|
||||
}
|
||||
|
||||
// getInboundClients creates an inline keyboard with clients of a specific inbound.
|
||||
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbound, err := t.inboundService.GetInbound(id)
|
||||
if err != nil {
|
||||
logger.Warning("getIboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("GetInboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
} else {
|
||||
if len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
}
|
||||
cols := 0
|
||||
if len(buttons) < 6 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// searchInbound searches for inbounds by remark and sends the results.
|
||||
func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
||||
inbounds, err := t.inboundService.SearchInbounds(remark)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
msg := t.I18nBot("tgbot.noInbounds")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
info := ""
|
||||
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
||||
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
||||
|
||||
if inbound.ExpiryTime == 0 {
|
||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
||||
} else {
|
||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, info)
|
||||
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
var output strings.Builder
|
||||
for _, traffic := range inbound.ClientStats {
|
||||
output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// SendReport sends a periodic report to admin chats.
|
||||
func (t *Tgbot) SendReport() {
|
||||
runTime, err := t.settingService.GetTgbotRuntime()
|
||||
if err == nil && len(runTime) > 0 {
|
||||
msg := ""
|
||||
msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime)
|
||||
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
|
||||
info := t.sendServerUsage()
|
||||
t.SendMsgToTgbotAdmins(info)
|
||||
|
||||
t.sendExhaustedToAdmins()
|
||||
t.notifyExhausted()
|
||||
|
||||
backupEnable, err := t.settingService.GetTgBotBackup()
|
||||
if err == nil && backupEnable {
|
||||
t.SendBackupToAdmins()
|
||||
}
|
||||
}
|
||||
|
||||
// SendBackupToAdmins sends a database backup to admin chats.
|
||||
func (t *Tgbot) SendBackupToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
dbFilename := t.serverService.BackupFilename("")
|
||||
for i, adminId := range adminIds {
|
||||
t.sendBackupData(adminId, dbData, dbFilename)
|
||||
// Add delay between sends to avoid Telegram rate limits
|
||||
if i < len(adminIds)-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
|
||||
func (t *Tgbot) sendExhaustedToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
for _, adminId := range adminIds {
|
||||
t.getExhausted(adminId)
|
||||
}
|
||||
}
|
||||
|
||||
// getServerUsage retrieves and formats server usage information.
|
||||
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
|
||||
info := t.prepareServerUsageInfo()
|
||||
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh"))))
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], info, keyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, info, keyboard)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// Send server usage without an inline keyboard
|
||||
func (t *Tgbot) sendServerUsage() string {
|
||||
info := t.prepareServerUsageInfo()
|
||||
return info
|
||||
}
|
||||
|
||||
// prepareServerUsageInfo prepares the server usage information string.
|
||||
func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
// Check if we have cached data first
|
||||
if cachedStats, found := t.getCachedServerStats(); found {
|
||||
return cachedStats
|
||||
}
|
||||
|
||||
info, ipv4, ipv6 := "", "", ""
|
||||
|
||||
// get latest status of server with caching
|
||||
if cachedStatus, found := t.getCachedStatus(); found {
|
||||
t.lastStatus = cachedStatus
|
||||
} else {
|
||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
||||
t.setCachedStatus(t.lastStatus)
|
||||
}
|
||||
var onlines []string
|
||||
if process := service.XrayProcess(); process != nil {
|
||||
onlines = process.GetOnlineClients()
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
||||
info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version))
|
||||
|
||||
// get ip address
|
||||
netInterfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
logger.Error("net.Interfaces failed, err: ", err.Error())
|
||||
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
||||
info += "\r\n"
|
||||
} else {
|
||||
for i := range netInterfaces {
|
||||
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
||||
addrs, _ := netInterfaces[i].Addrs()
|
||||
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
ipv4 += ipnet.IP.String() + " "
|
||||
} else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() {
|
||||
ipv6 += ipnet.IP.String() + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.ipv4", "IPv4=="+ipv4)
|
||||
info += t.I18nBot("tgbot.messages.ipv6", "IPv6=="+ipv6)
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.serverUpTime", "UpTime=="+strconv.FormatUint(t.lastStatus.Uptime/86400, 10), "Unit=="+t.I18nBot("tgbot.days"))
|
||||
info += t.I18nBot("tgbot.messages.serverLoad", "Load1=="+strconv.FormatFloat(t.lastStatus.Loads[0], 'f', 2, 64), "Load2=="+strconv.FormatFloat(t.lastStatus.Loads[1], 'f', 2, 64), "Load3=="+strconv.FormatFloat(t.lastStatus.Loads[2], 'f', 2, 64))
|
||||
info += t.I18nBot("tgbot.messages.serverMemory", "Current=="+common.FormatTraffic(int64(t.lastStatus.Mem.Current)), "Total=="+common.FormatTraffic(int64(t.lastStatus.Mem.Total)))
|
||||
info += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(len(onlines)))
|
||||
info += t.I18nBot("tgbot.messages.tcpCount", "Count=="+strconv.Itoa(t.lastStatus.TcpCount))
|
||||
info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount))
|
||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
|
||||
info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State))
|
||||
|
||||
// Cache the complete server stats
|
||||
t.setCachedServerStats(info)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// UserLoginNotify publishes a login event to the event bus.
|
||||
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
|
||||
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
|
||||
logger.Warning("UserLoginNotify failed, invalid info!")
|
||||
return
|
||||
}
|
||||
|
||||
if EventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := "fail"
|
||||
if attempt.Status == LoginSuccess {
|
||||
status = "success"
|
||||
}
|
||||
|
||||
EventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Source: attempt.IP,
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: attempt.Username,
|
||||
IP: attempt.IP,
|
||||
Time: attempt.Time,
|
||||
Status: status,
|
||||
Reason: attempt.Reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// getExhausted retrieves and sends information about exhausted clients.
|
||||
func (t *Tgbot) getExhausted(chatId int64) {
|
||||
trDiff := int64(0)
|
||||
exDiff := int64(0)
|
||||
now := time.Now().Unix() * 1000
|
||||
var exhaustedInbounds []model.Inbound
|
||||
var exhaustedClients []xray.ClientTraffic
|
||||
var disabledInbounds []model.Inbound
|
||||
var disabledClients []xray.ClientTraffic
|
||||
|
||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
||||
if err == nil && TrafficThreshold > 0 {
|
||||
trDiff = int64(TrafficThreshold) * 1073741824
|
||||
}
|
||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
||||
if err == nil && ExpireThreshold > 0 {
|
||||
exDiff = int64(ExpireThreshold) * 86400000
|
||||
}
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load Inbounds", err)
|
||||
}
|
||||
|
||||
seenClients := make(map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Enable {
|
||||
if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
|
||||
(inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) {
|
||||
exhaustedInbounds = append(exhaustedInbounds, *inbound)
|
||||
}
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
for _, client := range inbound.ClientStats {
|
||||
if seenClients[client.Email] {
|
||||
continue
|
||||
}
|
||||
seenClients[client.Email] = true
|
||||
if client.Enable {
|
||||
if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
|
||||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
|
||||
exhaustedClients = append(exhaustedClients, client)
|
||||
}
|
||||
} else {
|
||||
disabledClients = append(disabledClients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disabledInbounds = append(disabledInbounds, *inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// Inbounds
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
|
||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds)))
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds)))
|
||||
|
||||
if len(exhaustedInbounds) > 0 {
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds"))
|
||||
|
||||
for _, inbound := range exhaustedInbounds {
|
||||
output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
||||
output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
||||
output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
||||
if inbound.ExpiryTime == 0 {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
output += "\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Clients
|
||||
exhaustedCC := len(exhaustedClients)
|
||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
|
||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC))
|
||||
|
||||
if exhaustedCC > 0 {
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients"))
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, traffic := range exhaustedClients {
|
||||
output += t.clientInfoMsg(&traffic, true, false, false, true, true, false)
|
||||
output += "\r\n"
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email)))
|
||||
}
|
||||
cols := 0
|
||||
if exhaustedCC < 11 {
|
||||
cols = 1
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyExhausted sends notifications for exhausted clients.
|
||||
func (t *Tgbot) notifyExhausted() {
|
||||
trDiff := int64(0)
|
||||
exDiff := int64(0)
|
||||
now := time.Now().Unix() * 1000
|
||||
|
||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
||||
if err == nil && TrafficThreshold > 0 {
|
||||
trDiff = int64(TrafficThreshold) * 1073741824
|
||||
}
|
||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
||||
if err == nil && ExpireThreshold > 0 {
|
||||
exDiff = int64(ExpireThreshold) * 86400000
|
||||
}
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load Inbounds", err)
|
||||
}
|
||||
|
||||
var chatIDsDone []int64
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Enable {
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
if err == nil {
|
||||
for _, client := range clients {
|
||||
if client.TgID != 0 {
|
||||
chatID := client.TgID
|
||||
if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) {
|
||||
var disabledClients []xray.ClientTraffic
|
||||
var exhaustedClients []xray.ClientTraffic
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID)
|
||||
if err == nil && len(traffics) > 0 {
|
||||
var output strings.Builder
|
||||
output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")))
|
||||
for _, traffic := range traffics {
|
||||
if traffic.Enable {
|
||||
if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) ||
|
||||
(traffic.Total > 0 && (traffic.Total-(traffic.Up+traffic.Down) < trDiff)) {
|
||||
exhaustedClients = append(exhaustedClients, *traffic)
|
||||
}
|
||||
} else {
|
||||
disabledClients = append(disabledClients, *traffic)
|
||||
}
|
||||
}
|
||||
if len(exhaustedClients) > 0 {
|
||||
output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))))
|
||||
if len(disabledClients) > 0 {
|
||||
output.WriteString(t.I18nBot("tgbot.clients"))
|
||||
output.WriteString(":\r\n")
|
||||
for _, traffic := range disabledClients {
|
||||
output.WriteString(" ")
|
||||
output.WriteString(traffic.Email)
|
||||
}
|
||||
output.WriteString("\r\n")
|
||||
}
|
||||
output.WriteString("\r\n")
|
||||
output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients))))
|
||||
for _, traffic := range exhaustedClients {
|
||||
output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false))
|
||||
output.WriteString("\r\n")
|
||||
}
|
||||
t.SendMsgToTgbot(chatID, output.String())
|
||||
}
|
||||
chatIDsDone = append(chatIDsDone, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onlineClients retrieves and sends information about online clients.
|
||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
process := service.XrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
onlines := process.GetOnlineClients()
|
||||
onlinesCount := len(onlines)
|
||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh"))))
|
||||
|
||||
if onlinesCount > 0 {
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, online := range onlines {
|
||||
label := online
|
||||
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
|
||||
label = online + " - " + inbound.Remark
|
||||
}
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
|
||||
}
|
||||
cols := 0
|
||||
if onlinesCount < 21 {
|
||||
cols = 2
|
||||
} else if onlinesCount < 61 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 4
|
||||
}
|
||||
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...)
|
||||
}
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, keyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// sendBackup sends a backup of the database and configuration files.
|
||||
func (t *Tgbot) sendBackup(chatId int64) {
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
|
||||
}
|
||||
|
||||
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
|
||||
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
|
||||
if dbData != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(dbData, dbFilename),
|
||||
)
|
||||
_, err := bot.SendDocument(ctx, document)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between file sends
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Send config.json backup
|
||||
file, err := os.Open(xray.GetConfigPath())
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading config.json: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in opening config.json file for backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendBanLogs sends the ban logs to the specified chat.
|
||||
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
||||
if dt {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
}
|
||||
|
||||
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
|
||||
if err == nil {
|
||||
// Check if the file is non-empty before attempting to upload
|
||||
fileInfo, _ := file.Stat()
|
||||
if fileInfo.Size() > 0 {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.")
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err)
|
||||
}
|
||||
|
||||
file, err = os.Open(xray.GetIPLimitBannedLogPath())
|
||||
if err == nil {
|
||||
// Check if the file is non-empty before attempting to upload
|
||||
fileInfo, _ := file.Stat()
|
||||
if fileInfo.Size() > 0 {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedLog: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("IPLimitBannedLog file is empty, not uploading.")
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
logger.Error("Error in opening IPLimitBannedLog file for backup: ", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// sendResponse sends the response message based on the onlyMessage flag.
|
||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||
if onlyMessage {
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
} else {
|
||||
t.SendAnswer(chatId, msg, isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
// SendAnswer sends a response message with an inline keyboard to the specified chat.
|
||||
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
||||
numericKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
|
||||
),
|
||||
// TODOOOOOOOOOOOOOO: Add restart button here.
|
||||
)
|
||||
numericKeyboardClient := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
|
||||
),
|
||||
)
|
||||
|
||||
var ReplyMarkup telego.ReplyMarkup
|
||||
if isAdmin {
|
||||
ReplyMarkup = numericKeyboard
|
||||
} else {
|
||||
ReplyMarkup = numericKeyboardClient
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
|
||||
}
|
||||
|
||||
const telegramPageLimit = 2000
|
||||
|
||||
func pageMessage(message string, limit int) []string {
|
||||
if len(message) <= limit {
|
||||
return []string{message}
|
||||
}
|
||||
|
||||
pages := make([]string, 0)
|
||||
for _, block := range strings.Split(message, "\r\n\r\n") {
|
||||
for _, page := range splitMessageLines(block, limit) {
|
||||
last := len(pages) - 1
|
||||
if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit {
|
||||
pages[last] += "\r\n\r\n" + page
|
||||
continue
|
||||
}
|
||||
pages = append(pages, page)
|
||||
}
|
||||
}
|
||||
if len(pages) > 0 && strings.TrimSpace(pages[len(pages)-1]) == "" {
|
||||
pages = pages[:len(pages)-1]
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func splitMessageLines(block string, limit int) []string {
|
||||
if len(block) <= limit {
|
||||
return []string{block}
|
||||
}
|
||||
|
||||
lines := strings.Split(block, "\r\n")
|
||||
pages := []string{lines[0]}
|
||||
for _, line := range lines[1:] {
|
||||
last := len(pages) - 1
|
||||
if len(pages[last])+len("\r\n")+len(line) > limit {
|
||||
pages = append(pages, line)
|
||||
continue
|
||||
}
|
||||
pages[last] += "\r\n" + line
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
|
||||
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||
if !isRunning {
|
||||
return
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
logger.Info("[tgbot] message is empty!")
|
||||
return
|
||||
}
|
||||
|
||||
allMessages := pageMessage(msg, telegramPageLimit)
|
||||
for n, message := range allMessages {
|
||||
params := telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
Text: message,
|
||||
ParseMode: "HTML",
|
||||
}
|
||||
// only add replyMarkup to last message
|
||||
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
|
||||
params.ReplyMarkup = replyMarkup[0]
|
||||
}
|
||||
|
||||
// Retry logic with exponential backoff for connection errors
|
||||
maxRetries := 3
|
||||
for attempt := range maxRetries {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_, err := bot.SendMessage(ctx, ¶ms)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
break // Success
|
||||
}
|
||||
|
||||
// Check if error is a connection error
|
||||
errStr := err.Error()
|
||||
isConnectionError := strings.Contains(errStr, "connection") ||
|
||||
strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "closed")
|
||||
|
||||
if isConnectionError && attempt < maxRetries-1 {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
||||
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
|
||||
attempt+1, maxRetries, backoff, err)
|
||||
time.Sleep(backoff)
|
||||
} else {
|
||||
logger.Warning("Error sending telegram message:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced delay to improve performance (only needed for rate limiting)
|
||||
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
|
||||
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||
if len(replyMarkup) > 0 {
|
||||
for _, adminId := range adminIds {
|
||||
t.SendMsgToTgbot(adminId, msg, replyMarkup[0])
|
||||
}
|
||||
} else {
|
||||
for _, adminId := range adminIds {
|
||||
t.SendMsgToTgbot(adminId, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendCallbackAnswerTgBot answers a callback query with a message.
|
||||
func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
||||
params := telego.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: id,
|
||||
Text: message,
|
||||
}
|
||||
if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
// editMessageCallbackTgBot edits the reply markup of a message.
|
||||
func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) {
|
||||
params := telego.EditMessageReplyMarkupParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
ReplyMarkup: inlineKeyboard,
|
||||
}
|
||||
if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
// editMessageTgBot edits the text and reply markup of a message.
|
||||
func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) {
|
||||
params := telego.EditMessageTextParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
Text: text,
|
||||
ParseMode: "HTML",
|
||||
}
|
||||
if len(inlineKeyboard) > 0 {
|
||||
params.ReplyMarkup = inlineKeyboard[0]
|
||||
}
|
||||
if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
|
||||
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
|
||||
// Determine if replyMarkup was passed; otherwise, set it to nil
|
||||
var replyMarkupParam telego.ReplyMarkup
|
||||
if len(replyMarkup) > 0 {
|
||||
replyMarkupParam = replyMarkup[0] // Use the first element
|
||||
}
|
||||
|
||||
// Send the message
|
||||
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
Text: msg,
|
||||
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the sent message after the specified number of seconds.
|
||||
go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
|
||||
}
|
||||
|
||||
// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
|
||||
// deliberately does not touch the conversation state: every caller that ends a
|
||||
// wizard step already clears the state synchronously, and clearing it here — up
|
||||
// to several seconds later — would wipe a state the user set for the next step
|
||||
// in the meantime, silently dropping their following input.
|
||||
func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
|
||||
time.Sleep(time.Duration(delayInSeconds) * time.Second)
|
||||
t.deleteMessageTgBot(chatId, messageID)
|
||||
}
|
||||
|
||||
// deleteMessageTgBot deletes a message from the chat.
|
||||
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||
if bot == nil {
|
||||
return
|
||||
}
|
||||
params := telego.DeleteMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
}
|
||||
if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning("Failed to delete message:", err)
|
||||
} else {
|
||||
logger.Info("Message deleted successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection verifies the bot token is valid and the API is reachable.
|
||||
func (t *Tgbot) TestConnection() error {
|
||||
tgBotMutex.Lock()
|
||||
b := bot
|
||||
tgBotMutex.Unlock()
|
||||
if b == nil {
|
||||
return fmt.Errorf("bot not initialized")
|
||||
}
|
||||
me, err := b.GetMe(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("API unreachable: %w", err)
|
||||
}
|
||||
_ = me
|
||||
return nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) {
|
||||
var message strings.Builder
|
||||
message.WriteString("Individual links:\r\n")
|
||||
for range 50 {
|
||||
message.WriteString("<code>vless://" + strings.Repeat("a", 300) + "</code>\r\n")
|
||||
}
|
||||
|
||||
pages := pageMessage(message.String(), telegramPageLimit)
|
||||
if len(pages) < 2 {
|
||||
t.Fatalf("pageMessage() returned %d page, want multiple", len(pages))
|
||||
}
|
||||
|
||||
links := 0
|
||||
for index, page := range pages {
|
||||
if len(page) > telegramPageLimit {
|
||||
t.Errorf("page %d has %d bytes, want at most %d", index, len(page), telegramPageLimit)
|
||||
}
|
||||
openingTags := strings.Count(page, "<code>")
|
||||
closingTags := strings.Count(page, "</code>")
|
||||
if openingTags != closingTags {
|
||||
t.Errorf("page %d has %d opening tags and %d closing tags", index, openingTags, closingTags)
|
||||
}
|
||||
links += openingTags
|
||||
}
|
||||
if links != 50 {
|
||||
t.Errorf("pages contain %d links, want 50", links)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
||||
typ := reflect.TypeFor[LoginAttempt]()
|
||||
if _, ok := typ.FieldByName("Password"); ok {
|
||||
t.Fatal("LoginAttempt must not carry attempted passwords")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSupportedBotProxyScheme(t *testing.T) {
|
||||
supported := []string{
|
||||
"socks5://127.0.0.1:1080",
|
||||
"http://127.0.0.1:8080",
|
||||
"https://127.0.0.1:8080",
|
||||
}
|
||||
for _, p := range supported {
|
||||
if !isSupportedBotProxyScheme(p) {
|
||||
t.Errorf("expected %q to be supported", p)
|
||||
}
|
||||
}
|
||||
unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"}
|
||||
for _, p := range unsupported {
|
||||
if isSupportedBotProxyScheme(p) {
|
||||
t.Errorf("expected %q to be unsupported", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = make(chan []byte, 1)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, n)
|
||||
m, _ := io.ReadFull(conn, buf)
|
||||
got <- buf[:m]
|
||||
}()
|
||||
return ln.Addr().String(), got
|
||||
}
|
||||
|
||||
func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) {
|
||||
addr, got := recordingDialTarget(t, len("CONNECT "))
|
||||
tg := &Tgbot{}
|
||||
client := tg.createRobustFastHTTPClient("http://" + addr)
|
||||
if client.Dial == nil {
|
||||
t.Fatal("Dial must be set for an http:// proxy")
|
||||
}
|
||||
go func() { _, _ = client.Dial("example.com:443") }()
|
||||
select {
|
||||
case b := <-got:
|
||||
if string(b) != "CONNECT " {
|
||||
t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("proxy never received a connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) {
|
||||
addr, got := recordingDialTarget(t, 1)
|
||||
tg := &Tgbot{}
|
||||
client := tg.createRobustFastHTTPClient("socks5://" + addr)
|
||||
if client.Dial == nil {
|
||||
t.Fatal("Dial must be set for a socks5:// proxy")
|
||||
}
|
||||
go func() { _, _ = client.Dial("example.com:443") }()
|
||||
select {
|
||||
case b := <-got:
|
||||
if len(b) != 1 || b[0] != 0x05 {
|
||||
t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("proxy never received a connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
|
||||
tg := &Tgbot{}
|
||||
client := tg.createRobustFastHTTPClient("")
|
||||
if client.Dial != nil {
|
||||
t.Fatal("Dial must be nil when no proxy is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
|
||||
if !isCommandForBot("/status", "panel_bot") {
|
||||
t.Fatal("untargeted commands must remain accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
|
||||
t.Fatal("commands targeted to this bot must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
|
||||
if isCommandForBot("/status@other_bot", "panel_bot") {
|
||||
t.Fatal("commands targeted to another bot must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "") {
|
||||
t.Fatal("commands must remain accepted when the current bot username is unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The panel generates Reality keys in-process now (no xray binary on a
|
||||
// control-plane-only master). These tests pin the wire format to what
|
||||
// `xray x25519` prints — base64 RawURLEncoding of the raw 32-byte keys —
|
||||
// and prove the pair is a genuine X25519 pair by re-deriving the public
|
||||
// key from the private key and by an ECDH agreement check.
|
||||
func TestGenerateX25519KeyPair_MatchesXrayFormatAndCurve(t *testing.T) {
|
||||
privB64, pubB64, err := generateX25519KeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("generateX25519KeyPair: %v", err)
|
||||
}
|
||||
|
||||
for name, k := range map[string]string{"private": privB64, "public": pubB64} {
|
||||
if len(k) != 43 || strings.ContainsAny(k, "+/=") {
|
||||
t.Fatalf("%s key %q is not 43-char raw base64url", name, k)
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(k)
|
||||
if err != nil {
|
||||
t.Fatalf("%s key does not decode as raw base64url: %v", name, err)
|
||||
}
|
||||
if len(raw) != 32 {
|
||||
t.Fatalf("%s key decodes to %d bytes, want 32", name, len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
privRaw, _ := base64.RawURLEncoding.DecodeString(privB64)
|
||||
pubRaw, _ := base64.RawURLEncoding.DecodeString(pubB64)
|
||||
|
||||
priv, err := ecdh.X25519().NewPrivateKey(privRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPrivateKey: %v", err)
|
||||
}
|
||||
if got := priv.PublicKey().Bytes(); !bytes.Equal(got, pubRaw) {
|
||||
t.Fatalf("public key does not correspond to private key:\n got %x\nwant %x", got, pubRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateX25519KeyPair_ECDHagreement(t *testing.T) {
|
||||
privAB64, pubAB64, err := generateX25519KeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privBB64, pubBB64, err := generateX25519KeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if privAB64 == privBB64 || pubAB64 == pubBB64 {
|
||||
t.Fatal("two generations produced identical keys")
|
||||
}
|
||||
|
||||
decode := func(s string) []byte {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
privA, _ := ecdh.X25519().NewPrivateKey(decode(privAB64))
|
||||
privB, _ := ecdh.X25519().NewPrivateKey(decode(privBB64))
|
||||
pubA, _ := ecdh.X25519().NewPublicKey(decode(pubAB64))
|
||||
pubB, _ := ecdh.X25519().NewPublicKey(decode(pubBB64))
|
||||
|
||||
secretAB, err := privA.ECDH(pubB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secretBA, err := privB.ECDH(pubA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(secretAB, secretBA) {
|
||||
t.Fatal("ECDH shared secrets differ")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (s *XrayService) IsXrayRunning() bool {
|
||||
|
||||
// XrayProcess returns the current Xray process instance (may be nil when Xray
|
||||
// is not running). It exposes the lifecycle snapshot to callers outside this
|
||||
// package (e.g. the tgbot subpackage).
|
||||
// package.
|
||||
func XrayProcess() *xray.Process {
|
||||
return currentXrayProcess()
|
||||
}
|
||||
|
||||
+8
-142
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
@@ -119,7 +118,6 @@ type Server struct {
|
||||
|
||||
xrayService service.XrayService
|
||||
settingService service.SettingService
|
||||
tgbotService tgbot.Tgbot
|
||||
|
||||
wsHub *websocket.Hub
|
||||
|
||||
@@ -220,7 +218,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
|
||||
// init i18n — still used by backend strings (errors, log messages,
|
||||
// SubPage menu entries) even though the Go template engine is gone.
|
||||
err = locale.InitLocalizer(i18nFS, &s.settingService)
|
||||
err = locale.InitLocalizer(i18nFS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,9 +295,7 @@ const (
|
||||
cadenceNodeTraffic = "@every 5s"
|
||||
cadenceOutboundSub = "@every 5m"
|
||||
cadenceReapOrphans = "@every 5m"
|
||||
cadenceRemoteRouting = "@every 5m"
|
||||
cadenceXrayLogPrune = "@every 10m"
|
||||
cadenceCheckHash = "@every 2m"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
|
||||
cadenceCPUAlarm = "@every 1m"
|
||||
@@ -350,11 +346,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
|
||||
_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
|
||||
|
||||
// Warm permanent routing URLs immediately and refresh them outside the
|
||||
// latency-sensitive subscription request path.
|
||||
remoteRoutingJob := job.NewRemoteRoutingJob()
|
||||
_, _ = s.cron.AddJob(cadenceRemoteRouting, remoteRoutingJob)
|
||||
common.GoRecover("remote-routing-warm", remoteRoutingJob.Run)
|
||||
|
||||
// check client ips from log file every day
|
||||
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
@@ -371,36 +362,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
// Check monthly reset days at midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||
|
||||
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && isTgbotenabled {
|
||||
runtime, err := s.settingService.GetTgbotRuntime()
|
||||
if err != nil {
|
||||
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
|
||||
runtime = "@daily"
|
||||
} else if strings.TrimSpace(runtime) == "" {
|
||||
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
|
||||
runtime = "@daily"
|
||||
}
|
||||
logger.Infof("Tg notify enabled,run at %s", runtime)
|
||||
if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
|
||||
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
|
||||
}
|
||||
|
||||
// check for Telegram bot callback query hash storage reset
|
||||
_, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
|
||||
}
|
||||
|
||||
// CPU monitor publishes cpu.high events; register it whenever any notifier
|
||||
// (Telegram or Email) wants them, independent of the Telegram bot being on.
|
||||
if s.cpuAlarmWanted() {
|
||||
_, _ = s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
|
||||
}
|
||||
// Memory monitor publishes memory.high events; register it whenever any notifier wants them.
|
||||
if s.memoryAlarmWanted() {
|
||||
_, _ = s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob())
|
||||
}
|
||||
|
||||
if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
|
||||
_, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
|
||||
go func() {
|
||||
@@ -410,63 +371,16 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
}
|
||||
}
|
||||
|
||||
// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
|
||||
// alerts, so the minute-long blocking CPU sampler only runs when it's needed.
|
||||
func (s *Server) cpuAlarmWanted() bool {
|
||||
wants := func(events string, threshold int) bool {
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
for e := range strings.SplitSeq(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
||||
events, _ := s.settingService.GetTgEnabledEvents()
|
||||
cpu, _ := s.settingService.GetTgCpu()
|
||||
if wants(events, cpu) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// memoryAlarmWanted reports whether any notifier is configured to receive memory.high alerts.
|
||||
func (s *Server) memoryAlarmWanted() bool {
|
||||
wants := func(events string, threshold int) bool {
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
for e := range strings.SplitSeq(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
||||
events, _ := s.settingService.GetTgEnabledEvents()
|
||||
mem, _ := s.settingService.GetTgMemory()
|
||||
if wants(events, mem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
||||
func (s *Server) Start() (err error) {
|
||||
return s.start(true, true)
|
||||
return s.start(true)
|
||||
}
|
||||
|
||||
func (s *Server) StartPanelOnly() (err error) {
|
||||
return s.start(false, true)
|
||||
return s.start(false)
|
||||
}
|
||||
|
||||
func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
func (s *Server) start(restartXray bool) (err error) {
|
||||
// This is an anonymous function, no function name
|
||||
defer func() {
|
||||
if err != nil {
|
||||
@@ -589,7 +503,6 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
||||
service.SetEventBus(s.bus)
|
||||
job.EventBus = s.bus
|
||||
tgbot.EventBus = s.bus
|
||||
|
||||
// Wire xray crash callback BEFORE startTask so it's ready
|
||||
xray.OnCrash = func(err error) {
|
||||
@@ -601,65 +514,21 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire Telegram test function to controller
|
||||
controller.SetTestTgFunc(func() error {
|
||||
if !s.tgbotService.IsRunning() {
|
||||
return fmt.Errorf("telegram bot is not running (check token and chat ID)")
|
||||
}
|
||||
if err := s.tgbotService.TestConnection(); err != nil {
|
||||
return fmt.Errorf("telegram API test failed: %w", err)
|
||||
}
|
||||
s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
|
||||
return nil
|
||||
})
|
||||
|
||||
controller.SetReloadTgbotFunc(func() {
|
||||
enabled, err := s.settingService.GetTgbotEnabled()
|
||||
if err != nil || !enabled {
|
||||
if s.tgbotService.IsRunning() {
|
||||
s.tgbotService.Stop()
|
||||
}
|
||||
if s.bus != nil {
|
||||
s.bus.Unsubscribe("tg-notifier")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Start() stops any previous receiver first, so it is safe whether or not the bot is already running.
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
if startErr := tgBot.Start(i18nFS); startErr != nil {
|
||||
logger.Warning("reload Telegram bot failed:", startErr)
|
||||
return
|
||||
}
|
||||
if s.bus != nil {
|
||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
||||
}
|
||||
})
|
||||
|
||||
s.startTask(restartXray, loc)
|
||||
|
||||
if startTgBot {
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && isTgbotenabled {
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
_ = tgBot.Start(i18nFS)
|
||||
// Subscribe Telegram notifications for event bus
|
||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
|
||||
// Stop gracefully shuts down the web server, stops Xray, and cron jobs.
|
||||
func (s *Server) Stop() error {
|
||||
return s.stop(true, true)
|
||||
return s.stop(true)
|
||||
}
|
||||
|
||||
func (s *Server) StopPanelOnly() error {
|
||||
return s.stop(false, true)
|
||||
return s.stop(false)
|
||||
}
|
||||
|
||||
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
func (s *Server) stop(stopXray bool) error {
|
||||
s.cancel()
|
||||
if stopXray {
|
||||
_ = s.xrayService.StopXray()
|
||||
@@ -678,9 +547,6 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
if stopXray {
|
||||
service.StopTrafficWriter()
|
||||
}
|
||||
if stopTgBot && s.tgbotService.IsRunning() {
|
||||
s.tgbotService.Stop()
|
||||
}
|
||||
// Gracefully stop WebSocket hub
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.Stop()
|
||||
|
||||
+436
-577
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestXrayAPI_E2E exercises the gRPC hot-apply surface (outbounds, inbounds,
|
||||
// routing) against a real xray-core process. It validates the exact error
|
||||
// texts IsMissingHandlerErr/IsExistingTagErr rely on, and that replacing the
|
||||
// routing config keeps the api rule working.
|
||||
//
|
||||
// Skipped unless XRAY_E2E_BINARY points at an xray executable built from the
|
||||
// same xray-core version as go.mod, e.g.:
|
||||
//
|
||||
// go install github.com/xtls/xray-core/main@<version from go.mod>
|
||||
// XRAY_E2E_BINARY=$GOBIN/main go test ./internal/xray -run TestXrayAPI_E2E -v
|
||||
func TestXrayAPI_E2E(t *testing.T) {
|
||||
bin := os.Getenv("XRAY_E2E_BINARY")
|
||||
if bin == "" {
|
||||
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
|
||||
}
|
||||
|
||||
apiPort := freePort(t)
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"api": map[string]any{
|
||||
"services": []string{"HandlerService", "StatsService", "RoutingService"},
|
||||
"tag": "api",
|
||||
},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1",
|
||||
"port": apiPort,
|
||||
"protocol": "tunnel",
|
||||
"settings": map[string]any{"rewriteAddress": "127.0.0.1"},
|
||||
"tag": "api",
|
||||
},
|
||||
},
|
||||
"outbounds": []any{
|
||||
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
|
||||
map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{
|
||||
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
||||
},
|
||||
},
|
||||
"policy": map[string]any{
|
||||
"levels": map[string]any{
|
||||
"0": map[string]any{"statsUserOnline": true},
|
||||
},
|
||||
},
|
||||
"stats": map[string]any{},
|
||||
}
|
||||
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "-c", cfgPath)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start xray: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
|
||||
waitForPort(t, apiPort)
|
||||
|
||||
api := XrayAPI{}
|
||||
if err := api.Init(apiPort); err != nil {
|
||||
t.Fatalf("api init: %v", err)
|
||||
}
|
||||
defer api.Close()
|
||||
|
||||
// --- outbounds ---
|
||||
socksOutbound := []byte(`{"protocol":"socks","settings":{"servers":[{"address":"127.0.0.1","port":10808}]},"tag":"test-out"}`)
|
||||
if err := api.AddOutbound(socksOutbound); err != nil {
|
||||
t.Fatalf("AddOutbound: %v", err)
|
||||
}
|
||||
err = api.AddOutbound(socksOutbound)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate AddOutbound must fail")
|
||||
}
|
||||
if !IsExistingTagErr(err) {
|
||||
t.Fatalf("duplicate AddOutbound error not matched by IsExistingTagErr: %q", err)
|
||||
}
|
||||
if err := api.DelOutbound("test-out"); err != nil {
|
||||
t.Fatalf("DelOutbound: %v", err)
|
||||
}
|
||||
// xray's outbound manager treats removal of an unknown tag as a no-op.
|
||||
if err := api.DelOutbound("test-out"); err != nil && !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("removing a missing outbound: unexpected error %q", err)
|
||||
}
|
||||
|
||||
// --- inbounds ---
|
||||
vlessPort := freePort(t)
|
||||
vlessInbound := fmt.Appendf(nil,
|
||||
`{"listen":"127.0.0.1","port":%d,"protocol":"vless","settings":{"clients":[{"id":"a17e367c-2074-4d3e-aaeb-fbef5dfde7e7","email":"e2e"}],"decryption":"none"},"tag":"test-in"}`,
|
||||
vlessPort)
|
||||
if err := api.AddInbound(vlessInbound); err != nil {
|
||||
t.Fatalf("AddInbound: %v", err)
|
||||
}
|
||||
err = api.AddInbound(vlessInbound)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate AddInbound must fail")
|
||||
}
|
||||
if !IsExistingTagErr(err) {
|
||||
t.Fatalf("duplicate AddInbound error not matched by IsExistingTagErr: %q", err)
|
||||
}
|
||||
if err := api.DelInbound("test-in"); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
err = api.DelInbound("test-in")
|
||||
if err == nil {
|
||||
t.Fatal("removing a missing inbound must fail")
|
||||
}
|
||||
if !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
|
||||
}
|
||||
|
||||
// --- online-stats API ---
|
||||
// statsUserOnline is enabled in the policy above; with no client
|
||||
// connections the call must succeed and return an empty set. This proves
|
||||
// the GetUsersStats plumbing against a real core (an older binary would
|
||||
// return Unimplemented here — see IsUnimplementedErr).
|
||||
online, err := api.GetOnlineUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("GetOnlineUsers: %v", err)
|
||||
}
|
||||
if len(online) != 0 {
|
||||
t.Fatalf("expected no online users on an idle core, got %+v", online)
|
||||
}
|
||||
|
||||
// --- routing (rules + balancers replace) ---
|
||||
newRouting := []byte(`{
|
||||
"domainStrategy": "AsIs",
|
||||
"balancers": [{"tag":"b1","selector":["direct"]}],
|
||||
"rules": [
|
||||
{"type":"field","inboundTag":["api"],"outboundTag":"api"},
|
||||
{"type":"field","port":"6666","outboundTag":"blocked","ruleTag":"e2e-rule"},
|
||||
{"type":"field","port":"7777","balancerTag":"b1","ruleTag":"e2e-balancer-rule"}
|
||||
]
|
||||
}`)
|
||||
if err := api.ApplyRoutingConfig(newRouting); err != nil {
|
||||
t.Fatalf("ApplyRoutingConfig: %v", err)
|
||||
}
|
||||
// The replaced rule set still contains the api rule — the gRPC channel
|
||||
// must keep working after the swap.
|
||||
if err := api.AddOutbound([]byte(`{"protocol":"blackhole","settings":{},"tag":"post-routing"}`)); err != nil {
|
||||
t.Fatalf("api unusable after routing replace (api rule lost?): %v", err)
|
||||
}
|
||||
if err := api.DelOutbound("post-routing"); err != nil {
|
||||
t.Fatalf("DelOutbound after routing replace: %v", err)
|
||||
}
|
||||
|
||||
// --- route testing ---
|
||||
res, err := api.TestRoute(RouteTestRequest{IP: "1.2.3.4", Port: 6666, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(port rule): %v", err)
|
||||
}
|
||||
if !res.Matched || res.OutboundTag != "blocked" {
|
||||
t.Fatalf("TestRoute(port rule) = %+v, want matched blocked", res)
|
||||
}
|
||||
res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(balancer rule): %v", err)
|
||||
}
|
||||
if !res.Matched || res.OutboundTag != "direct" {
|
||||
t.Fatalf("TestRoute(balancer rule) = %+v, want matched direct", res)
|
||||
}
|
||||
// Note: current xray-core never populates OutboundGroupTags in PickRoute,
|
||||
// so GroupTags stays empty even for balancer rules — don't assert on it.
|
||||
res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 9999, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(no match): %v", err)
|
||||
}
|
||||
if res.Matched {
|
||||
t.Fatalf("TestRoute(no match) = %+v, want unmatched (default outbound)", res)
|
||||
}
|
||||
|
||||
// --- balancer info + override ---
|
||||
info, err := api.GetBalancerInfo("b1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalancerInfo: %v", err)
|
||||
}
|
||||
if info.Override != "" {
|
||||
t.Fatalf("fresh balancer must have no override, got %q", info.Override)
|
||||
}
|
||||
if err := api.SetBalancerTarget("b1", "blocked"); err != nil {
|
||||
t.Fatalf("SetBalancerTarget: %v", err)
|
||||
}
|
||||
info, err = api.GetBalancerInfo("b1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalancerInfo after override: %v", err)
|
||||
}
|
||||
if info.Override != "blocked" {
|
||||
t.Fatalf("override = %q, want blocked", info.Override)
|
||||
}
|
||||
res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(overridden balancer): %v", err)
|
||||
}
|
||||
if res.OutboundTag != "blocked" {
|
||||
t.Fatalf("overridden balancer must route to blocked, got %+v", res)
|
||||
}
|
||||
if err := api.SetBalancerTarget("b1", ""); err != nil {
|
||||
t.Fatalf("SetBalancerTarget(clear): %v", err)
|
||||
}
|
||||
info, err = api.GetBalancerInfo("b1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalancerInfo after clear: %v", err)
|
||||
}
|
||||
if info.Override != "" {
|
||||
t.Fatalf("override after clear = %q, want empty", info.Override)
|
||||
}
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func waitForPort(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("xray api port %d did not open in time", port)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/proxy/shadowsocks"
|
||||
"github.com/xtls/xray-core/proxy/shadowsocks_2022"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// decodeSSAccount decodes the typed message buildUserAccount produced for a
|
||||
// shadowsocks user. The type URL is what the running inbound casts on, so the
|
||||
// test asserts on it directly.
|
||||
func decodeSSAccount(t *testing.T, user map[string]any) (typeURL string, legacy *shadowsocks.Account, modern *shadowsocks_2022.Account) {
|
||||
t.Helper()
|
||||
tm, err := buildUserAccount("shadowsocks", user)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUserAccount: %v", err)
|
||||
}
|
||||
if tm == nil {
|
||||
t.Fatal("buildUserAccount returned no account for shadowsocks")
|
||||
}
|
||||
typeURL = tm.Type
|
||||
switch {
|
||||
case strings.Contains(typeURL, "shadowsocks_2022"):
|
||||
modern = new(shadowsocks_2022.Account)
|
||||
if err := proto.Unmarshal(tm.Value, modern); err != nil {
|
||||
t.Fatalf("unmarshal shadowsocks_2022 account: %v", err)
|
||||
}
|
||||
case strings.Contains(typeURL, "shadowsocks"):
|
||||
legacy = new(shadowsocks.Account)
|
||||
if err := proto.Unmarshal(tm.Value, legacy); err != nil {
|
||||
t.Fatalf("unmarshal shadowsocks account: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected account type %q", typeURL)
|
||||
}
|
||||
return typeURL, legacy, modern
|
||||
}
|
||||
|
||||
func TestBuildUserAccountShadowsocksLegacyCiphers(t *testing.T) {
|
||||
tests := []struct {
|
||||
cipher string
|
||||
want shadowsocks.CipherType
|
||||
}{
|
||||
{"aes-128-gcm", shadowsocks.CipherType_AES_128_GCM},
|
||||
{"aead_aes_128_gcm", shadowsocks.CipherType_AES_128_GCM},
|
||||
{"aes-256-gcm", shadowsocks.CipherType_AES_256_GCM},
|
||||
{"AES-256-GCM", shadowsocks.CipherType_AES_256_GCM},
|
||||
{"aead_aes_256_gcm", shadowsocks.CipherType_AES_256_GCM},
|
||||
{"chacha20-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
|
||||
{"chacha20-ietf-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
|
||||
{"aead_chacha20_poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
|
||||
{"xchacha20-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
|
||||
{"xchacha20-ietf-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
|
||||
{"aead_xchacha20_poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.cipher, func(t *testing.T) {
|
||||
user := map[string]any{"email": "a@example.test", "password": "pw", "cipher": tt.cipher}
|
||||
typeURL, legacy, modern := decodeSSAccount(t, user)
|
||||
if modern != nil {
|
||||
t.Fatalf("cipher %q built a shadowsocks-2022 account (%s); the legacy inbound casts to *shadowsocks.MemoryAccount and panics the core", tt.cipher, typeURL)
|
||||
}
|
||||
if legacy.CipherType != tt.want {
|
||||
t.Fatalf("CipherType = %v, want %v", legacy.CipherType, tt.want)
|
||||
}
|
||||
if legacy.Password != "pw" {
|
||||
t.Fatalf("Password = %q, want %q", legacy.Password, "pw")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountShadowsocks2022Ciphers(t *testing.T) {
|
||||
for _, cipher := range []string{
|
||||
"2022-blake3-aes-128-gcm",
|
||||
"2022-blake3-aes-256-gcm",
|
||||
"2022-blake3-chacha20-poly1305",
|
||||
} {
|
||||
t.Run(cipher, func(t *testing.T) {
|
||||
user := map[string]any{"email": "a@example.test", "password": b64Key(7), "cipher": cipher}
|
||||
typeURL, _, modern := decodeSSAccount(t, user)
|
||||
if modern == nil {
|
||||
t.Fatalf("cipher %q built a legacy account (%s), want shadowsocks-2022", cipher, typeURL)
|
||||
}
|
||||
if modern.Key != b64Key(7) {
|
||||
t.Fatalf("Key = %q, want the client password", modern.Key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUserAccountShadowsocksReadsMethodKey covers the client maps taken
|
||||
// verbatim from an inbound's settings (the auto-renew path): they carry the
|
||||
// inbound's cipher under "method", never "cipher".
|
||||
func TestBuildUserAccountShadowsocksReadsMethodKey(t *testing.T) {
|
||||
user := map[string]any{"email": "a@example.test", "password": "pw", "method": "aes-256-gcm"}
|
||||
_, legacy, modern := decodeSSAccount(t, user)
|
||||
if modern != nil {
|
||||
t.Fatal("a client carrying only \"method\" must still build a legacy account")
|
||||
}
|
||||
if legacy.CipherType != shadowsocks.CipherType_AES_256_GCM {
|
||||
t.Fatalf("CipherType = %v, want AES_256_GCM", legacy.CipherType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountShadowsocksCipherKeyWins(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "a@example.test",
|
||||
"password": b64Key(3),
|
||||
"cipher": "2022-blake3-aes-128-gcm",
|
||||
"method": "aes-256-gcm",
|
||||
}
|
||||
_, _, modern := decodeSSAccount(t, user)
|
||||
if modern == nil {
|
||||
t.Fatal("the explicit \"cipher\" must win over a stale \"method\"")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUserAccountShadowsocksUnknownCipherErrors is the regression for the
|
||||
// account-type guess that killed the core: an unrecognized cipher used to fall
|
||||
// through to a shadowsocks-2022 account, which xray's legacy inbound casts
|
||||
// without checking, panicking the whole process.
|
||||
func TestBuildUserAccountShadowsocksUnknownCipherErrors(t *testing.T) {
|
||||
for _, cipher := range []string{"", "rc4-md5", "2022-blake3-future-gcm", "none"} {
|
||||
t.Run("cipher="+cipher, func(t *testing.T) {
|
||||
user := map[string]any{"email": "a@example.test", "password": "pw"}
|
||||
if cipher != "" {
|
||||
user["cipher"] = cipher
|
||||
}
|
||||
account, err := buildUserAccount("shadowsocks", user)
|
||||
if err == nil {
|
||||
t.Fatalf("cipher %q built account %v, want an error instead of an account-type guess", cipher, account)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown cipher") {
|
||||
t.Fatalf("error = %q, want it to name the unknown cipher", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountShadowsocksMissingPassword(t *testing.T) {
|
||||
user := map[string]any{"email": "a@example.test", "cipher": "aes-256-gcm"}
|
||||
if _, err := buildUserAccount("shadowsocks", user); err == nil {
|
||||
t.Fatal("expected an error for a shadowsocks user without a password")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protodesc"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/descriptorpb"
|
||||
"google.golang.org/protobuf/types/dynamicpb"
|
||||
)
|
||||
|
||||
// This file proves the wire-level stats client against a STOCK gRPC server:
|
||||
// the server side runs grpc's default protobuf codec with dynamicpb messages
|
||||
// built from the xray.app.stats.command descriptor (rebuilt here from the
|
||||
// .proto field numbers), so what interops is the real wire contract — field
|
||||
// numbers, message shapes, the "proto" content subtype — not just this
|
||||
// package's own codec talking to itself.
|
||||
|
||||
func fieldDesc(name string, num int32, typ descriptorpb.FieldDescriptorProto_Type, label descriptorpb.FieldDescriptorProto_Label, typeName string) *descriptorpb.FieldDescriptorProto {
|
||||
f := &descriptorpb.FieldDescriptorProto{
|
||||
Name: proto.String(name),
|
||||
Number: proto.Int32(num),
|
||||
Type: typ.Enum(),
|
||||
Label: label.Enum(),
|
||||
}
|
||||
if typeName != "" {
|
||||
f.TypeName = proto.String(typeName)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func statsFileDescriptor(t *testing.T) protoreflect.FileDescriptor {
|
||||
t.Helper()
|
||||
const (
|
||||
strT = descriptorpb.FieldDescriptorProto_TYPE_STRING
|
||||
i64T = descriptorpb.FieldDescriptorProto_TYPE_INT64
|
||||
boolT = descriptorpb.FieldDescriptorProto_TYPE_BOOL
|
||||
msgT = descriptorpb.FieldDescriptorProto_TYPE_MESSAGE
|
||||
opt = descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL
|
||||
rep = descriptorpb.FieldDescriptorProto_LABEL_REPEATED
|
||||
pkg = ".xray.app.stats.command."
|
||||
)
|
||||
msg := func(name string, fields ...*descriptorpb.FieldDescriptorProto) *descriptorpb.DescriptorProto {
|
||||
return &descriptorpb.DescriptorProto{Name: proto.String(name), Field: fields}
|
||||
}
|
||||
fdp := &descriptorpb.FileDescriptorProto{
|
||||
Name: proto.String("stats.proto"),
|
||||
Package: proto.String("xray.app.stats.command"),
|
||||
Syntax: proto.String("proto3"),
|
||||
MessageType: []*descriptorpb.DescriptorProto{
|
||||
msg("Stat",
|
||||
fieldDesc("name", 1, strT, opt, ""),
|
||||
fieldDesc("value", 2, i64T, opt, "")),
|
||||
msg("QueryStatsRequest",
|
||||
fieldDesc("pattern", 1, strT, opt, ""),
|
||||
fieldDesc("reset", 2, boolT, opt, "")),
|
||||
msg("QueryStatsResponse",
|
||||
fieldDesc("stat", 1, msgT, rep, pkg+"Stat")),
|
||||
msg("OnlineIPEntry",
|
||||
fieldDesc("ip", 1, strT, opt, ""),
|
||||
fieldDesc("last_seen", 2, i64T, opt, "")),
|
||||
msg("TrafficUserStat",
|
||||
fieldDesc("uplink", 1, i64T, opt, ""),
|
||||
fieldDesc("downlink", 2, i64T, opt, "")),
|
||||
msg("UserStat",
|
||||
fieldDesc("email", 1, strT, opt, ""),
|
||||
fieldDesc("ips", 2, msgT, rep, pkg+"OnlineIPEntry"),
|
||||
fieldDesc("traffic", 3, msgT, opt, pkg+"TrafficUserStat")),
|
||||
msg("GetUsersStatsRequest",
|
||||
fieldDesc("include_traffic", 1, boolT, opt, ""),
|
||||
fieldDesc("reset", 2, boolT, opt, "")),
|
||||
msg("GetUsersStatsResponse",
|
||||
fieldDesc("users", 1, msgT, rep, pkg+"UserStat")),
|
||||
},
|
||||
Service: []*descriptorpb.ServiceDescriptorProto{
|
||||
{
|
||||
Name: proto.String("StatsService"),
|
||||
Method: []*descriptorpb.MethodDescriptorProto{
|
||||
{Name: proto.String("QueryStats"), InputType: proto.String(pkg + "QueryStatsRequest"), OutputType: proto.String(pkg + "QueryStatsResponse")},
|
||||
{Name: proto.String("GetUsersStats"), InputType: proto.String(pkg + "GetUsersStatsRequest"), OutputType: proto.String(pkg + "GetUsersStatsResponse")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
fd, err := protodesc.NewFile(fdp, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build stats descriptor: %v", err)
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
// stockStats is a StatsService served by grpc's stock codec from dynamicpb
|
||||
// messages, replaying queued responses per method.
|
||||
type stockStats struct {
|
||||
fd protoreflect.FileDescriptor
|
||||
querySeq []*dynamicpb.Message
|
||||
usersSeq []*dynamicpb.Message
|
||||
qCalls int
|
||||
uCalls int
|
||||
}
|
||||
|
||||
func (s *stockStats) newMsg(name protoreflect.Name) *dynamicpb.Message {
|
||||
return dynamicpb.NewMessage(s.fd.Messages().ByName(name))
|
||||
}
|
||||
|
||||
func (s *stockStats) method(name string, reqName protoreflect.Name, pick func() *dynamicpb.Message) grpc.MethodDesc {
|
||||
return grpc.MethodDesc{
|
||||
MethodName: name,
|
||||
Handler: func(_ any, _ context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) {
|
||||
req := s.newMsg(reqName)
|
||||
if err := dec(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pick(), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func startStockStats(t *testing.T, st *stockStats) *XrayAPI {
|
||||
t.Helper()
|
||||
st.fd = statsFileDescriptor(t)
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := grpc.NewServer() // stock codec: the real interop surface
|
||||
srv.RegisterService(&grpc.ServiceDesc{
|
||||
ServiceName: "xray.app.stats.command.StatsService",
|
||||
HandlerType: (*statsServiceIface)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
st.method("QueryStats", "QueryStatsRequest", func() *dynamicpb.Message {
|
||||
i := st.qCalls
|
||||
st.qCalls++
|
||||
if i >= len(st.querySeq) {
|
||||
i = len(st.querySeq) - 1
|
||||
}
|
||||
return st.querySeq[i]
|
||||
}),
|
||||
st.method("GetUsersStats", "GetUsersStatsRequest", func() *dynamicpb.Message {
|
||||
i := st.uCalls
|
||||
st.uCalls++
|
||||
if i >= len(st.usersSeq) {
|
||||
i = len(st.usersSeq) - 1
|
||||
}
|
||||
return st.usersSeq[i]
|
||||
}),
|
||||
},
|
||||
}, st)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
api := &XrayAPI{}
|
||||
if err := api.Init(lis.Addr().(*net.TCPAddr).Port); err != nil {
|
||||
t.Fatalf("api init: %v", err)
|
||||
}
|
||||
t.Cleanup(api.Close)
|
||||
return api
|
||||
}
|
||||
|
||||
func (s *stockStats) statMsg(name string, value int64) *dynamicpb.Message {
|
||||
m := s.newMsg("Stat")
|
||||
d := m.Descriptor()
|
||||
m.Set(d.Fields().ByName("name"), protoreflect.ValueOfString(name))
|
||||
m.Set(d.Fields().ByName("value"), protoreflect.ValueOfInt64(value))
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *stockStats) queryResp(stats ...*dynamicpb.Message) *dynamicpb.Message {
|
||||
m := s.newMsg("QueryStatsResponse")
|
||||
list := m.Mutable(m.Descriptor().Fields().ByName("stat")).List()
|
||||
for _, st := range stats {
|
||||
list.Append(protoreflect.ValueOfMessage(st.ProtoReflect()))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestStatsAgainstStockProtoServer drives GetTraffic and GetOnlineUsers
|
||||
// against the stock-codec server: the same deltas and user shapes the typed
|
||||
// client produced must come out of the wire-level one.
|
||||
func TestStatsAgainstStockProtoServer(t *testing.T) {
|
||||
st := &stockStats{}
|
||||
st.fd = statsFileDescriptor(t)
|
||||
st.querySeq = []*dynamicpb.Message{
|
||||
st.queryResp(st.statMsg("user>>>alice>>>traffic>>>uplink", 100)),
|
||||
st.queryResp(
|
||||
st.statMsg("user>>>alice>>>traffic>>>uplink", 175),
|
||||
st.statMsg("inbound>>>in-443>>>traffic>>>downlink", 2048),
|
||||
),
|
||||
}
|
||||
|
||||
// One online user with two IPs and a traffic block (field 3 — present in
|
||||
// the message, ignored by the panel's OnlineUser shape).
|
||||
user := st.newMsg("UserStat")
|
||||
ud := user.Descriptor()
|
||||
user.Set(ud.Fields().ByName("email"), protoreflect.ValueOfString("alice"))
|
||||
ipList := user.Mutable(ud.Fields().ByName("ips")).List()
|
||||
for _, e := range []struct {
|
||||
ip string
|
||||
seen int64
|
||||
}{{"203.0.113.7", 111}, {"2001:db8::5", 222}} {
|
||||
entry := st.newMsg("OnlineIPEntry")
|
||||
ed := entry.Descriptor()
|
||||
entry.Set(ed.Fields().ByName("ip"), protoreflect.ValueOfString(e.ip))
|
||||
entry.Set(ed.Fields().ByName("last_seen"), protoreflect.ValueOfInt64(e.seen))
|
||||
ipList.Append(protoreflect.ValueOfMessage(entry.ProtoReflect()))
|
||||
}
|
||||
traffic := st.newMsg("TrafficUserStat")
|
||||
td := traffic.Descriptor()
|
||||
traffic.Set(td.Fields().ByName("uplink"), protoreflect.ValueOfInt64(10))
|
||||
traffic.Set(td.Fields().ByName("downlink"), protoreflect.ValueOfInt64(20))
|
||||
user.Set(ud.Fields().ByName("traffic"), protoreflect.ValueOfMessage(traffic.ProtoReflect()))
|
||||
usersResp := st.newMsg("GetUsersStatsResponse")
|
||||
uList := usersResp.Mutable(usersResp.Descriptor().Fields().ByName("users")).List()
|
||||
uList.Append(protoreflect.ValueOfMessage(user.ProtoReflect()))
|
||||
st.usersSeq = []*dynamicpb.Message{usersResp}
|
||||
|
||||
api := startStockStats(t, st)
|
||||
|
||||
if _, _, err := api.GetTraffic(); err != nil {
|
||||
t.Fatalf("GetTraffic (baseline): %v", err)
|
||||
}
|
||||
tags, clients, err := api.GetTraffic()
|
||||
if err != nil {
|
||||
t.Fatalf("GetTraffic: %v", err)
|
||||
}
|
||||
if len(clients) != 1 || clients[0].Email != "alice" || clients[0].Up != 75 {
|
||||
t.Fatalf("clients = %+v, want alice up=75", clients)
|
||||
}
|
||||
if len(tags) != 1 || tags[0].Tag != "in-443" || tags[0].Down != 2048 {
|
||||
t.Fatalf("tags = %+v, want in-443 down=2048", tags)
|
||||
}
|
||||
|
||||
users, err := api.GetOnlineUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("GetOnlineUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 || users[0].Email != "alice" || len(users[0].IPs) != 2 {
|
||||
t.Fatalf("users = %+v, want alice with 2 IPs", users)
|
||||
}
|
||||
if users[0].IPs[0].IP != "203.0.113.7" || users[0].IPs[0].LastSeen != 111 ||
|
||||
users[0].IPs[1].IP != "2001:db8::5" || users[0].IPs[1].LastSeen != 222 {
|
||||
t.Fatalf("ips = %+v", users[0].IPs)
|
||||
}
|
||||
}
|
||||
+26
-74
@@ -1,89 +1,41 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRemoveUserGuardsNilHandlerClient pins that mutation calls on an
|
||||
// uninitialized client fail instead of panicking. In this build they fail
|
||||
// unconditionally — hot-apply is unavailable without xray-core — and every
|
||||
// caller treats that as "fall back to a core restart".
|
||||
func TestRemoveUserGuardsNilHandlerClient(t *testing.T) {
|
||||
err := (&XrayAPI{}).RemoveUser("in-443-tcp", "user@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("RemoveUser with an uninitialized HandlerServiceClient must return an error")
|
||||
t.Fatal("RemoveUser with an uninitialized client must return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequiredUserString_Present(t *testing.T) {
|
||||
user := map[string]any{"email": "alice@example.com"}
|
||||
got, err := getRequiredUserString(user, "email")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
func TestHotApplyMutationsAreUnavailable(t *testing.T) {
|
||||
api := &XrayAPI{}
|
||||
calls := map[string]func() error{
|
||||
"AddInbound": func() error { return api.AddInbound([]byte("{}")) },
|
||||
"DelInbound": func() error { return api.DelInbound("in-443-tcp") },
|
||||
"AddOutbound": func() error { return api.AddOutbound([]byte("{}")) },
|
||||
"DelOutbound": func() error { return api.DelOutbound("direct") },
|
||||
"ApplyRoutingConfig": func() error { return api.ApplyRoutingConfig([]byte("{}")) },
|
||||
"AddUser": func() error { return api.AddUser("vless", "in-443-tcp", map[string]any{"email": "a@b.c"}) },
|
||||
"RemoveUser": func() error { return api.RemoveUser("in-443-tcp", "a@b.c") },
|
||||
"SetBalancerTarget": func() error { return api.SetBalancerTarget("lb", "direct") },
|
||||
}
|
||||
if got != "alice@example.com" {
|
||||
t.Fatalf("got %q, want %q", got, "alice@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequiredUserString_Missing(t *testing.T) {
|
||||
user := map[string]any{}
|
||||
if _, err := getRequiredUserString(user, "email"); err == nil {
|
||||
t.Fatal("expected error for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequiredUserString_NilValue(t *testing.T) {
|
||||
user := map[string]any{"email": nil}
|
||||
if _, err := getRequiredUserString(user, "email"); err == nil {
|
||||
t.Fatal("expected error for nil value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequiredUserString_WrongType(t *testing.T) {
|
||||
user := map[string]any{"email": 42}
|
||||
_, err := getRequiredUserString(user, "email")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-string value")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid type") {
|
||||
t.Fatalf("expected %q in error, got: %v", "invalid type", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOptionalUserString_Present(t *testing.T) {
|
||||
user := map[string]any{"flow": "xtls-rprx-vision"}
|
||||
got, err := getOptionalUserString(user, "flow")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "xtls-rprx-vision" {
|
||||
t.Fatalf("got %q, want %q", got, "xtls-rprx-vision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOptionalUserString_MissingReturnsEmptyNoError(t *testing.T) {
|
||||
user := map[string]any{}
|
||||
got, err := getOptionalUserString(user, "flow")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for missing optional field: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOptionalUserString_NilReturnsEmptyNoError(t *testing.T) {
|
||||
user := map[string]any{"flow": nil}
|
||||
got, err := getOptionalUserString(user, "flow")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for nil optional field: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOptionalUserString_WrongTypeErrors(t *testing.T) {
|
||||
user := map[string]any{"flow": []string{"a", "b"}}
|
||||
if _, err := getOptionalUserString(user, "flow"); err == nil {
|
||||
t.Fatal("expected error for non-string optional value")
|
||||
for name, call := range calls {
|
||||
if err := call(); err != ErrHotApplyUnavailable {
|
||||
t.Fatalf("%s = %v, want ErrHotApplyUnavailable", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := api.GetBalancerInfo("lb"); err != ErrHotApplyUnavailable {
|
||||
t.Fatalf("GetBalancerInfo = %v, want ErrHotApplyUnavailable", err)
|
||||
}
|
||||
if _, err := api.TestRoute(RouteTestRequest{}); err != ErrHotApplyUnavailable {
|
||||
t.Fatalf("TestRoute = %v, want ErrHotApplyUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,41 +5,78 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
statsService "github.com/xtls/xray-core/app/stats/command"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
// fakeStatsServer serves a scripted sequence of QueryStats responses, one per
|
||||
// call, so the delta bookkeeping in GetTraffic can be driven exactly.
|
||||
// call, so the delta bookkeeping in GetTraffic can be driven exactly. It
|
||||
// speaks the stats service's wire format directly: this build has no typed
|
||||
// protobuf client, so the fake encodes responses with protowire and the
|
||||
// server runs with the client's raw codec.
|
||||
type fakeStatsServer struct {
|
||||
statsService.UnimplementedStatsServiceServer
|
||||
rounds [][]*statsService.Stat
|
||||
rounds [][]statEntry
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeStatsServer) QueryStats(context.Context, *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) {
|
||||
func (f *fakeStatsServer) queryStats() rawBytes {
|
||||
round := f.calls
|
||||
f.calls++
|
||||
if round >= len(f.rounds) {
|
||||
round = len(f.rounds) - 1
|
||||
}
|
||||
return &statsService.QueryStatsResponse{Stat: f.rounds[round]}, nil
|
||||
return encodeQueryStatsResponse(f.rounds[round])
|
||||
}
|
||||
|
||||
func stat(name string, value int64) *statsService.Stat {
|
||||
return &statsService.Stat{Name: name, Value: value}
|
||||
// encodeQueryStatsResponse builds a QueryStatsResponse message
|
||||
// (stat = 1, repeated Stat{name = 1, value = 2}).
|
||||
func encodeQueryStatsResponse(stats []statEntry) rawBytes {
|
||||
var out []byte
|
||||
for _, st := range stats {
|
||||
var statMsg []byte
|
||||
statMsg = protowire.AppendTag(statMsg, 1, protowire.BytesType)
|
||||
statMsg = protowire.AppendString(statMsg, st.Name)
|
||||
statMsg = protowire.AppendTag(statMsg, 2, protowire.VarintType)
|
||||
statMsg = protowire.AppendVarint(statMsg, uint64(st.Value))
|
||||
out = protowire.AppendTag(out, 1, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, statMsg)
|
||||
}
|
||||
return rawBytes(out)
|
||||
|
||||
}
|
||||
|
||||
func stat(name string, value int64) statEntry {
|
||||
return statEntry{Name: name, Value: value}
|
||||
}
|
||||
|
||||
type statsServiceIface interface{}
|
||||
|
||||
// startFakeStats runs the scripted stats service and returns an XrayAPI wired
|
||||
// to it.
|
||||
func startFakeStats(t *testing.T, rounds [][]*statsService.Stat) *XrayAPI {
|
||||
func startFakeStats(t *testing.T, rounds [][]statEntry) *XrayAPI {
|
||||
t.Helper()
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := grpc.NewServer()
|
||||
statsService.RegisterStatsServiceServer(srv, &fakeStatsServer{rounds: rounds})
|
||||
fake := &fakeStatsServer{rounds: rounds}
|
||||
srv := grpc.NewServer(grpc.ForceServerCodec(rawCodec{}))
|
||||
srv.RegisterService(&grpc.ServiceDesc{
|
||||
ServiceName: "xray.app.stats.command.StatsService",
|
||||
HandlerType: (*statsServiceIface)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "QueryStats",
|
||||
Handler: func(_ any, _ context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) {
|
||||
var req rawBytes
|
||||
if err := dec(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fake.queryStats(), nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}, fake)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
@@ -64,7 +101,7 @@ func clientTrafficByEmail(t *testing.T, traffics []*ClientTraffic) map[string]*C
|
||||
// the panel may attach to counters that already hold traffic it cannot
|
||||
// attribute, so the first poll of a client only records baselines.
|
||||
func TestGetTrafficFirstPollIsBaselineOnly(t *testing.T) {
|
||||
api := startFakeStats(t, [][]*statsService.Stat{
|
||||
api := startFakeStats(t, [][]statEntry{
|
||||
{stat("user>>>alice>>>traffic>>>uplink", 5000)},
|
||||
})
|
||||
|
||||
@@ -84,7 +121,7 @@ func TestGetTrafficFirstPollIsBaselineOnly(t *testing.T) {
|
||||
// traffic being dropped: xray creates a counter on first use, so a name that
|
||||
// appears after the baseline poll starts at zero and every byte in it is new.
|
||||
func TestGetTrafficCountsNewStatFromZero(t *testing.T) {
|
||||
api := startFakeStats(t, [][]*statsService.Stat{
|
||||
api := startFakeStats(t, [][]statEntry{
|
||||
{stat("user>>>alice>>>traffic>>>uplink", 100)},
|
||||
{
|
||||
stat("user>>>alice>>>traffic>>>uplink", 180),
|
||||
@@ -122,7 +159,7 @@ func TestGetTrafficCountsNewStatFromZero(t *testing.T) {
|
||||
// start over at zero, so a value below the recorded baseline is all new
|
||||
// traffic rather than something to drop.
|
||||
func TestGetTrafficCountsAfterCounterReset(t *testing.T) {
|
||||
api := startFakeStats(t, [][]*statsService.Stat{
|
||||
api := startFakeStats(t, [][]statEntry{
|
||||
{stat("user>>>alice>>>traffic>>>uplink", 100)},
|
||||
{stat("user>>>alice>>>traffic>>>uplink", 900)},
|
||||
{stat("user>>>alice>>>traffic>>>uplink", 250)},
|
||||
@@ -152,7 +189,7 @@ func TestGetTrafficCountsAfterCounterReset(t *testing.T) {
|
||||
// inbound is the panel's own gRPC channel and is never a user-facing inbound,
|
||||
// and baselines for vanished stats are dropped once they outgrow the live set.
|
||||
func TestGetTrafficSkipsAPIInboundAndPrunes(t *testing.T) {
|
||||
api := startFakeStats(t, [][]*statsService.Stat{
|
||||
api := startFakeStats(t, [][]statEntry{
|
||||
{
|
||||
stat("inbound>>>api>>>traffic>>>uplink", 10),
|
||||
stat("inbound>>>in-443>>>traffic>>>uplink", 10),
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// e2eCore is a real xray-core process plus a connected panel API client.
|
||||
type e2eCore struct {
|
||||
api *XrayAPI
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
}
|
||||
|
||||
// alive reports whether the core is still serving its API port. A gRPC call
|
||||
// that hands the core an account type its inbound does not expect takes the
|
||||
// whole process down, so every user probe checks this.
|
||||
func (c *e2eCore) alive() bool {
|
||||
if c.cmd.ProcessState != nil {
|
||||
return false
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", c.port), time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// startE2ECore boots xray-core with the given inbounds plus the api inbound the
|
||||
// panel talks through, and returns a connected client. Skips unless
|
||||
// XRAY_E2E_BINARY points at an xray built from the version go.mod pins.
|
||||
func startE2ECore(t *testing.T, inbounds []any) *e2eCore {
|
||||
t.Helper()
|
||||
bin := os.Getenv("XRAY_E2E_BINARY")
|
||||
if bin == "" {
|
||||
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
|
||||
}
|
||||
|
||||
apiPort := freePort(t)
|
||||
all := []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1",
|
||||
"port": apiPort,
|
||||
"protocol": "tunnel",
|
||||
"settings": map[string]any{"rewriteAddress": "127.0.0.1"},
|
||||
"tag": "api",
|
||||
},
|
||||
}
|
||||
all = append(all, inbounds...)
|
||||
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"api": map[string]any{
|
||||
"services": []string{"HandlerService", "StatsService", "RoutingService"},
|
||||
"tag": "api",
|
||||
},
|
||||
"inbounds": all,
|
||||
"outbounds": []any{
|
||||
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
|
||||
map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{
|
||||
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
||||
},
|
||||
},
|
||||
"policy": map[string]any{
|
||||
"levels": map[string]any{
|
||||
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true},
|
||||
},
|
||||
"system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true},
|
||||
},
|
||||
"stats": map[string]any{},
|
||||
}
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(cfgPath, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "-c", cfgPath)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start xray: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
})
|
||||
waitForPort(t, apiPort)
|
||||
|
||||
api := &XrayAPI{}
|
||||
if err := api.Init(apiPort); err != nil {
|
||||
t.Fatalf("api init: %v", err)
|
||||
}
|
||||
t.Cleanup(api.Close)
|
||||
return &e2eCore{api: api, cmd: cmd, port: apiPort}
|
||||
}
|
||||
|
||||
// ssKey builds a base64 shadowsocks-2022 key of the given byte length.
|
||||
func ssKey(n int, seed byte) string {
|
||||
raw := make([]byte, n)
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// panelUser mirrors the map shape the panel's client-apply paths hand to
|
||||
// AddUser: every field is always present, unused ones are empty.
|
||||
func panelUser(email string, fields map[string]any) map[string]any {
|
||||
user := map[string]any{
|
||||
"email": email,
|
||||
"id": "",
|
||||
"auth": "",
|
||||
"security": "",
|
||||
"flow": "",
|
||||
"password": "",
|
||||
"cipher": "",
|
||||
"publicKey": "",
|
||||
"allowedIPs": nil,
|
||||
"preSharedKey": "",
|
||||
"keepAlive": "",
|
||||
}
|
||||
maps.Copy(user, fields)
|
||||
return user
|
||||
}
|
||||
|
||||
// TestXrayAPI_E2E_Users runs the panel's add/remove user surface against a live
|
||||
// core for every protocol it builds accounts for, pinning the error texts
|
||||
// IsUserExistsErr and IsMissingHandlerErr match on.
|
||||
func TestXrayAPI_E2E_Users(t *testing.T) {
|
||||
c := startE2ECore(t, []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "vmess", "tag": "vmess-in",
|
||||
"settings": map[string]any{"clients": []any{
|
||||
map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "seed-vmess"},
|
||||
}},
|
||||
},
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "vless", "tag": "vless-in",
|
||||
"settings": map[string]any{"clients": []any{
|
||||
map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30812", "email": "seed-vless"},
|
||||
}, "decryption": "none"},
|
||||
},
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "trojan", "tag": "trojan-in",
|
||||
"settings": map[string]any{"clients": []any{
|
||||
map[string]any{"password": "seed-pw", "email": "seed-trojan"},
|
||||
}},
|
||||
},
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
|
||||
"settings": map[string]any{
|
||||
"method": "aes-256-gcm", "network": "tcp,udp",
|
||||
"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
|
||||
"settings": map[string]any{
|
||||
"method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
|
||||
"clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol string
|
||||
tag string
|
||||
user map[string]any
|
||||
// rejectsDuplicateEmail is false for the legacy shadowsocks inbound,
|
||||
// whose validator does not dedupe emails at all.
|
||||
rejectsDuplicateEmail bool
|
||||
}{
|
||||
{
|
||||
name: "vmess", protocol: "vmess", tag: "vmess-in",
|
||||
user: panelUser("e2e-vmess", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30821", "security": "auto"}),
|
||||
rejectsDuplicateEmail: true,
|
||||
},
|
||||
{
|
||||
name: "vless", protocol: "vless", tag: "vless-in",
|
||||
user: panelUser("e2e-vless", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30822", "flow": "xtls-rprx-vision"}),
|
||||
rejectsDuplicateEmail: true,
|
||||
},
|
||||
{
|
||||
name: "trojan", protocol: "trojan", tag: "trojan-in",
|
||||
user: panelUser("e2e-trojan", map[string]any{"password": "e2e-pw"}),
|
||||
rejectsDuplicateEmail: true,
|
||||
},
|
||||
{
|
||||
name: "shadowsocks legacy", protocol: "shadowsocks", tag: "ss-in",
|
||||
user: panelUser("e2e-ss", map[string]any{"password": "e2e-pw", "cipher": "aes-256-gcm"}),
|
||||
rejectsDuplicateEmail: false,
|
||||
},
|
||||
{
|
||||
name: "shadowsocks 2022", protocol: "shadowsocks", tag: "ss2022-in",
|
||||
user: panelUser("e2e-ss2022", map[string]any{"password": ssKey(32, 40), "cipher": "2022-blake3-aes-256-gcm"}),
|
||||
rejectsDuplicateEmail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := tt.user["email"].(string)
|
||||
if err := c.api.AddUser(tt.protocol, tt.tag, tt.user); err != nil {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
if !c.alive() {
|
||||
t.Fatal("core died on AddUser: the account type does not match the running inbound")
|
||||
}
|
||||
|
||||
if tt.rejectsDuplicateEmail {
|
||||
err := c.api.AddUser(tt.protocol, tt.tag, tt.user)
|
||||
if err == nil {
|
||||
t.Fatal("adding a user whose email is taken must fail")
|
||||
}
|
||||
if !IsUserExistsErr(err) {
|
||||
t.Fatalf("duplicate-email error not matched by IsUserExistsErr: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.api.RemoveUser(tt.tag, email); err != nil {
|
||||
t.Fatalf("RemoveUser: %v", err)
|
||||
}
|
||||
err := c.api.RemoveUser(tt.tag, email)
|
||||
if err == nil {
|
||||
t.Fatal("the user is still registered after RemoveUser")
|
||||
}
|
||||
if !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("unknown inbound tag", func(t *testing.T) {
|
||||
err := c.api.AddUser("vmess", "no-such-tag", panelUser("e2e-ghost", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30899"}))
|
||||
if err == nil {
|
||||
t.Fatal("AddUser on an unknown tag must fail")
|
||||
}
|
||||
if !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("unknown-tag error not matched by IsMissingHandlerErr: %q", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestXrayAPI_E2E_ShadowsocksAccountTypeGuess is the regression for the crash
|
||||
// that took the whole core down: a shadowsocks user whose cipher the panel
|
||||
// could not resolve used to be sent as a shadowsocks-2022 account, which the
|
||||
// legacy inbound casts without checking. Every case here must leave the core
|
||||
// running.
|
||||
func TestXrayAPI_E2E_ShadowsocksAccountTypeGuess(t *testing.T) {
|
||||
c := startE2ECore(t, []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
|
||||
"settings": map[string]any{
|
||||
"method": "aes-256-gcm", "network": "tcp,udp",
|
||||
"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
|
||||
"settings": map[string]any{
|
||||
"method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
|
||||
"clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// The auto-renew path hands over the client object straight out of the
|
||||
// inbound's settings, which carries the cipher under "method".
|
||||
t.Run("client object from settings", func(t *testing.T) {
|
||||
user := map[string]any{"email": "renew-ss", "password": "pw", "method": "aes-256-gcm", "enable": true}
|
||||
if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
|
||||
t.Fatalf("AddUser with the settings-shaped client: %v", err)
|
||||
}
|
||||
if !c.alive() {
|
||||
t.Fatal("core died: a legacy shadowsocks client was sent as a 2022 account")
|
||||
}
|
||||
if err := c.api.RemoveUser("ss-in", "renew-ss"); err != nil {
|
||||
t.Fatalf("RemoveUser: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cipher missing entirely", func(t *testing.T) {
|
||||
err := c.api.AddUser("shadowsocks", "ss-in", map[string]any{"email": "nocipher", "password": "pw"})
|
||||
if err == nil {
|
||||
t.Fatal("a shadowsocks user with no resolvable cipher must be refused, not guessed")
|
||||
}
|
||||
if !c.alive() {
|
||||
t.Fatal("core died on a shadowsocks user with no cipher")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy cipher against a 2022 inbound", func(t *testing.T) {
|
||||
// The reverse mismatch is only reachable when the panel's view of the
|
||||
// inbound has drifted from the running one; the account type is still
|
||||
// the one the cipher names, so the panel never guesses here.
|
||||
user := map[string]any{"email": "drift", "password": ssKey(32, 50), "cipher": "2022-blake3-aes-256-gcm"}
|
||||
if err := c.api.AddUser("shadowsocks", "ss2022-in", user); err != nil {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
if !c.alive() {
|
||||
t.Fatal("core died adding a 2022 user to a 2022 inbound")
|
||||
}
|
||||
if err := c.api.RemoveUser("ss2022-in", "drift"); err != nil {
|
||||
t.Fatalf("RemoveUser: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestXrayAPI_E2E_ShadowsocksAddIsIdempotent covers the legacy shadowsocks
|
||||
// inbound accepting a second user under an email it already holds: one removal
|
||||
// then left the client connectable. Adding twice must leave exactly one
|
||||
// registration, so a single removal fully revokes the client.
|
||||
func TestXrayAPI_E2E_ShadowsocksAddIsIdempotent(t *testing.T) {
|
||||
c := startE2ECore(t, []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
|
||||
"settings": map[string]any{
|
||||
"method": "aes-256-gcm", "network": "tcp,udp",
|
||||
"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
user := map[string]any{"email": "dup-ss", "password": "pw", "cipher": "aes-256-gcm"}
|
||||
for i := range 2 {
|
||||
if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
|
||||
t.Fatalf("AddUser #%d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.api.RemoveUser("ss-in", "dup-ss"); err != nil {
|
||||
t.Fatalf("RemoveUser: %v", err)
|
||||
}
|
||||
err := c.api.RemoveUser("ss-in", "dup-ss")
|
||||
if err == nil {
|
||||
t.Fatal("a second registration survived removal: a disabled client would keep connecting")
|
||||
}
|
||||
if !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXrayAPI_E2E_NewClientTrafficIsCounted proves against a live core that a
|
||||
// counter appearing after the baseline poll is reported in full: xray creates a
|
||||
// user's counter on first use, and dropping that first sample lost a new
|
||||
// client's traffic for a whole polling interval.
|
||||
func TestXrayAPI_E2E_NewClientTrafficIsCounted(t *testing.T) {
|
||||
const payload = 64 * 1024
|
||||
|
||||
backendLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(make([]byte, payload))
|
||||
})}
|
||||
go func() { _ = backend.Serve(backendLn) }()
|
||||
t.Cleanup(func() { _ = backend.Close() })
|
||||
|
||||
proxyPort := freePort(t)
|
||||
c := startE2ECore(t, []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1", "port": proxyPort, "protocol": "http", "tag": "http-in",
|
||||
"settings": map[string]any{
|
||||
"accounts": []any{map[string]any{"user": "e2e-fresh", "pass": "e2e-pass"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Baseline poll: the client's counter does not exist yet.
|
||||
if _, _, err := c.api.GetTraffic(); err != nil {
|
||||
t.Fatalf("GetTraffic (baseline): %v", err)
|
||||
}
|
||||
|
||||
proxyURL, err := url.Parse(fmt.Sprintf("http://e2e-fresh:e2e-pass@127.0.0.1:%d", proxyPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
|
||||
resp, err := client.Get(fmt.Sprintf("http://%s/", backendLn.Addr().String()))
|
||||
if err != nil {
|
||||
t.Fatalf("proxied request: %v", err)
|
||||
}
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("reading the proxied response: %v", err)
|
||||
}
|
||||
if n != payload {
|
||||
t.Fatalf("proxied %d bytes, want %d", n, payload)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
_, clients, err := c.api.GetTraffic()
|
||||
if err != nil {
|
||||
t.Fatalf("GetTraffic: %v", err)
|
||||
}
|
||||
var got *ClientTraffic
|
||||
for _, ct := range clients {
|
||||
if ct.Email == "e2e-fresh" {
|
||||
got = ct
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("the new client's traffic was dropped; reported clients: %+v", clients)
|
||||
}
|
||||
if got.Down < payload {
|
||||
t.Fatalf("downlink = %d, want at least the %d bytes that were proxied", got.Down, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXrayAPI_E2E_TestRoutePortRange keeps TestRoute from wrapping an
|
||||
// out-of-range port into the uint32 the core is asked about.
|
||||
func TestXrayAPI_E2E_TestRoutePortRange(t *testing.T) {
|
||||
c := startE2ECore(t, nil)
|
||||
|
||||
for _, port := range []int{-1, 65536, 70000} {
|
||||
if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: port}); err == nil {
|
||||
t.Fatalf("TestRoute accepted the out-of-range port %d", port)
|
||||
}
|
||||
}
|
||||
if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 65535}); err != nil {
|
||||
t.Fatalf("TestRoute rejected the valid port 65535: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
wireguard "github.com/xtls/xray-core/proxy/wireguard"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func b64Key(seed byte) string {
|
||||
raw := make([]byte, 32)
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func decodeWgAccount(t *testing.T, user map[string]any) *wireguard.PeerConfig {
|
||||
t.Helper()
|
||||
tm, err := buildUserAccount("wireguard", user)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUserAccount: %v", err)
|
||||
}
|
||||
if tm == nil {
|
||||
t.Fatal("buildUserAccount returned nil account for wireguard")
|
||||
}
|
||||
var pc wireguard.PeerConfig
|
||||
if err := proto.Unmarshal(tm.Value, &pc); err != nil {
|
||||
t.Fatalf("unmarshal PeerConfig: %v", err)
|
||||
}
|
||||
return &pc
|
||||
}
|
||||
|
||||
func assertHexKey(t *testing.T, label, value string) {
|
||||
t.Helper()
|
||||
if len(value) != 64 {
|
||||
t.Fatalf("%s = %q, want 64-char hex", label, value)
|
||||
}
|
||||
if raw, err := hex.DecodeString(value); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("%s is not a 32-byte hex key: err=%v len=%d", label, err, len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardHexConversion(t *testing.T) {
|
||||
pub := b64Key(1)
|
||||
psk := b64Key(100)
|
||||
user := map[string]any{
|
||||
"email": "alice@example.test",
|
||||
"publicKey": pub,
|
||||
"preSharedKey": psk,
|
||||
"allowedIPs": []any{"10.0.0.2/32", "fd00::2/128"},
|
||||
"keepAlive": "25",
|
||||
}
|
||||
|
||||
pc := decodeWgAccount(t, user)
|
||||
assertHexKey(t, "PublicKey", pc.PublicKey)
|
||||
assertHexKey(t, "PreSharedKey", pc.PreSharedKey)
|
||||
|
||||
wantPubHex, _ := hex.DecodeString(pc.PublicKey)
|
||||
gotPub, _ := base64.StdEncoding.DecodeString(pub)
|
||||
if string(wantPubHex) != string(gotPub) {
|
||||
t.Fatal("PublicKey hex does not match the base64 input bytes")
|
||||
}
|
||||
|
||||
if len(pc.AllowedIps) != 2 || pc.AllowedIps[0] != "10.0.0.2/32" || pc.AllowedIps[1] != "fd00::2/128" {
|
||||
t.Fatalf("AllowedIps = %v, want [10.0.0.2/32 fd00::2/128]", pc.AllowedIps)
|
||||
}
|
||||
if pc.KeepAlive != "25" {
|
||||
t.Fatalf("KeepAlive = %q, want %q", pc.KeepAlive, "25")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardNoPSK(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "bob@example.test",
|
||||
"publicKey": b64Key(2),
|
||||
"allowedIPs": []string{"10.0.0.3/32"},
|
||||
}
|
||||
pc := decodeWgAccount(t, user)
|
||||
if pc.PreSharedKey != "" {
|
||||
t.Fatalf("PreSharedKey = %q, want empty", pc.PreSharedKey)
|
||||
}
|
||||
if pc.KeepAlive != "" {
|
||||
t.Fatalf("KeepAlive = %q, want empty", pc.KeepAlive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardMissingPublicKey(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "c@example.test",
|
||||
"allowedIPs": []any{"10.0.0.4/32"},
|
||||
}
|
||||
if _, err := buildUserAccount("wireguard", user); err == nil {
|
||||
t.Fatal("expected error for missing publicKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardMissingAllowedIPs(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "d@example.test",
|
||||
"publicKey": b64Key(3),
|
||||
}
|
||||
if _, err := buildUserAccount("wireguard", user); err == nil {
|
||||
t.Fatal("expected error for missing allowedIPs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardBadKey(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "e@example.test",
|
||||
"publicKey": "not-a-valid-key",
|
||||
"allowedIPs": []any{"10.0.0.5/32"},
|
||||
}
|
||||
if _, err := buildUserAccount("wireguard", user); err == nil {
|
||||
t.Fatal("expected error for invalid publicKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountUnknownProtocolReturnsNil(t *testing.T) {
|
||||
tm, err := buildUserAccount("mtproto", map[string]any{"email": "x@example.test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tm != nil {
|
||||
t.Fatal("expected nil account for unsupported protocol")
|
||||
}
|
||||
}
|
||||
@@ -12,24 +12,97 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xraygeodata "github.com/xtls/xray-core/common/geodata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func writeSiteDB(t *testing.T, dir, name string, sites ...*xraygeodata.GeoSite) string {
|
||||
// The fixtures are encoded with protowire directly: the databases' wire
|
||||
// layout is the contract this package parses, and spelling it out keeps the
|
||||
// tests independent of xray-core's generated types. Field numbers follow
|
||||
// geodat.proto — list entry 1; entry code 1, payload 2; domain type 1,
|
||||
// value 2, attribute 3; attribute key 1, bool value 2; cidr ip 1, prefix 2.
|
||||
// proto3 zero-value omission is reproduced exactly (a domain of type 0 and
|
||||
// a /0 prefix emit no field), because the reader's behavior depends on it.
|
||||
|
||||
// Domain match types, mirroring xray's Domain_Type enum.
|
||||
const (
|
||||
DomainSubstr = 0
|
||||
DomainRegex = 1
|
||||
DomainDomain = 2
|
||||
DomainFull = 3
|
||||
)
|
||||
|
||||
type fixtureDomain struct {
|
||||
typ uint64
|
||||
value string
|
||||
attrs []string
|
||||
}
|
||||
|
||||
type fixtureSite struct {
|
||||
code string
|
||||
domains []fixtureDomain
|
||||
}
|
||||
|
||||
type fixtureIP struct {
|
||||
code string
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func wireBytes(num protowire.Number, payload []byte) []byte {
|
||||
out := protowire.AppendTag(nil, num, protowire.BytesType)
|
||||
return protowire.AppendBytes(out, payload)
|
||||
}
|
||||
|
||||
func wireString(num protowire.Number, value string) []byte {
|
||||
out := protowire.AppendTag(nil, num, protowire.BytesType)
|
||||
return protowire.AppendString(out, value)
|
||||
}
|
||||
|
||||
func wireVarint(num protowire.Number, value uint64) []byte {
|
||||
out := protowire.AppendTag(nil, num, protowire.VarintType)
|
||||
return protowire.AppendVarint(out, value)
|
||||
}
|
||||
|
||||
func encodeFixtureDomain(d fixtureDomain) []byte {
|
||||
var msg []byte
|
||||
if d.typ != 0 {
|
||||
msg = append(msg, wireVarint(1, d.typ)...)
|
||||
}
|
||||
msg = append(msg, wireString(2, d.value)...)
|
||||
for _, attr := range d.attrs {
|
||||
attrMsg := wireString(1, attr)
|
||||
attrMsg = append(attrMsg, wireVarint(2, 1)...) // bool_value = true
|
||||
msg = append(msg, wireBytes(3, attrMsg)...)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func writeSiteDB(t *testing.T, dir, name string, sites ...fixtureSite) string {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: sites})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal geosite list: %v", err)
|
||||
var data []byte
|
||||
for _, s := range sites {
|
||||
entry := wireString(1, s.code)
|
||||
for _, d := range s.domains {
|
||||
entry = append(entry, wireBytes(2, encodeFixtureDomain(d))...)
|
||||
}
|
||||
data = append(data, wireBytes(1, entry)...)
|
||||
}
|
||||
return writeFile(t, dir, name, data)
|
||||
}
|
||||
|
||||
func writeIPDB(t *testing.T, dir, name string, geoips ...*xraygeodata.GeoIP) string {
|
||||
func writeIPDB(t *testing.T, dir, name string, geoips ...fixtureIP) string {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: geoips})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal geoip list: %v", err)
|
||||
var data []byte
|
||||
for _, g := range geoips {
|
||||
entry := wireString(1, g.code)
|
||||
for _, raw := range g.prefixes {
|
||||
prefix := netip.MustParsePrefix(raw)
|
||||
cidr := wireBytes(1, prefix.Addr().AsSlice())
|
||||
if prefix.Bits() != 0 {
|
||||
cidr = append(cidr, wireVarint(2, uint64(prefix.Bits()))...)
|
||||
}
|
||||
entry = append(entry, wireBytes(2, cidr)...)
|
||||
}
|
||||
data = append(data, wireBytes(1, entry)...)
|
||||
}
|
||||
return writeFile(t, dir, name, data)
|
||||
}
|
||||
@@ -43,45 +116,30 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func site(code string, domains ...*xraygeodata.Domain) *xraygeodata.GeoSite {
|
||||
return &xraygeodata.GeoSite{Code: code, Domain: domains}
|
||||
func site(code string, domains ...fixtureDomain) fixtureSite {
|
||||
return fixtureSite{code: code, domains: domains}
|
||||
}
|
||||
|
||||
func domain(domainType xraygeodata.Domain_Type, value string, attributes ...string) *xraygeodata.Domain {
|
||||
d := &xraygeodata.Domain{Type: domainType, Value: value}
|
||||
for _, attribute := range attributes {
|
||||
d.Attribute = append(d.Attribute, &xraygeodata.Domain_Attribute{
|
||||
Key: attribute,
|
||||
TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true},
|
||||
})
|
||||
}
|
||||
return d
|
||||
func domain(domainType uint64, value string, attributes ...string) fixtureDomain {
|
||||
return fixtureDomain{typ: domainType, value: value, attrs: attributes}
|
||||
}
|
||||
|
||||
func geoip(code string, prefixes ...string) *xraygeodata.GeoIP {
|
||||
entry := &xraygeodata.GeoIP{Code: code}
|
||||
for _, raw := range prefixes {
|
||||
prefix := netip.MustParsePrefix(raw)
|
||||
entry.Cidr = append(entry.Cidr, &xraygeodata.CIDR{
|
||||
Ip: prefix.Addr().AsSlice(),
|
||||
Prefix: uint32(prefix.Bits()),
|
||||
})
|
||||
}
|
||||
return entry
|
||||
func geoip(code string, prefixes ...string) fixtureIP {
|
||||
return fixtureIP{code: code, prefixes: prefixes}
|
||||
}
|
||||
|
||||
func sampleSiteDB(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
writeSiteDB(t, dir, "geosite.dat",
|
||||
site("google",
|
||||
domain(xraygeodata.Domain_Domain, "google.com"),
|
||||
domain(xraygeodata.Domain_Full, "ads.google.com", "ads"),
|
||||
domain(xraygeodata.Domain_Substr, "googlevideo", "cn"),
|
||||
domain(xraygeodata.Domain_Regex, `^g.*\.cn$`),
|
||||
domain(DomainDomain, "google.com"),
|
||||
domain(DomainFull, "ads.google.com", "ads"),
|
||||
domain(DomainSubstr, "googlevideo", "cn"),
|
||||
domain(DomainRegex, `^g.*\.cn$`),
|
||||
),
|
||||
site("CN",
|
||||
domain(xraygeodata.Domain_Domain, "baidu.com"),
|
||||
domain(xraygeodata.Domain_Domain, "qq.com"),
|
||||
domain(DomainDomain, "baidu.com"),
|
||||
domain(DomainDomain, "qq.com"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -126,7 +184,7 @@ func TestListFilesReportsKindAndCategories(t *testing.T) {
|
||||
|
||||
func TestKindDetectedFromContentsNotName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSiteDB(t, dir, "my_ip_rules.dat", site("corp", domain(xraygeodata.Domain_Domain, "intranet.corp.local")))
|
||||
writeSiteDB(t, dir, "my_ip_rules.dat", site("corp", domain(DomainDomain, "intranet.corp.local")))
|
||||
writeIPDB(t, dir, "custom_sites.dat", geoip("office", "192.168.7.0/24"))
|
||||
|
||||
store := NewStore(dir)
|
||||
@@ -241,9 +299,9 @@ func TestSearchFilters(t *testing.T) {
|
||||
|
||||
func TestPagination(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domains := make([]*xraygeodata.Domain, 0, 250)
|
||||
domains := make([]fixtureDomain, 0, 250)
|
||||
for i := range 250 {
|
||||
domains = append(domains, domain(xraygeodata.Domain_Domain, "host"+strconv.Itoa(i)+".example.com"))
|
||||
domains = append(domains, domain(DomainDomain, "host"+strconv.Itoa(i)+".example.com"))
|
||||
}
|
||||
writeSiteDB(t, dir, "geosite.dat", site("bulk", domains...))
|
||||
store := NewStore(dir)
|
||||
@@ -284,9 +342,9 @@ func TestPagination(t *testing.T) {
|
||||
|
||||
func TestCategoriesReturnEverythingWithoutLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sites := make([]*xraygeodata.GeoSite, 0, MaxPageSize+20)
|
||||
sites := make([]fixtureSite, 0, MaxPageSize+20)
|
||||
for i := range MaxPageSize + 20 {
|
||||
sites = append(sites, site("cat"+strconv.Itoa(i), domain(xraygeodata.Domain_Domain, "example.com")))
|
||||
sites = append(sites, site("cat"+strconv.Itoa(i), domain(DomainDomain, "example.com")))
|
||||
}
|
||||
writeSiteDB(t, dir, "geosite.dat", sites...)
|
||||
store := NewStore(dir)
|
||||
@@ -421,9 +479,9 @@ func TestIndexCacheInvalidatedWhenFileChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
path := writeSiteDB(t, dir, "geosite.dat",
|
||||
site("google", domain(xraygeodata.Domain_Domain, "google.com")),
|
||||
site("cn", domain(xraygeodata.Domain_Domain, "baidu.com")),
|
||||
site("telegram", domain(xraygeodata.Domain_Domain, "t.me")),
|
||||
site("google", domain(DomainDomain, "google.com")),
|
||||
site("cn", domain(DomainDomain, "baidu.com")),
|
||||
site("telegram", domain(DomainDomain, "t.me")),
|
||||
)
|
||||
touch(t, path, time.Now().Add(time.Second))
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ package geodata
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
xraygeodata "github.com/xtls/xray-core/common/geodata"
|
||||
)
|
||||
|
||||
// DefaultSiteFile and DefaultIPFile are the databases the geosite: and geoip:
|
||||
// shorthands expand to.
|
||||
// shorthands expand to. They are xray-core's file names, inlined so this
|
||||
// package no longer links the core library just for two constants.
|
||||
const (
|
||||
DefaultSiteFile = xraygeodata.DefaultGeoSiteDat
|
||||
DefaultIPFile = xraygeodata.DefaultGeoIPDat
|
||||
DefaultSiteFile = "geosite.dat"
|
||||
DefaultIPFile = "geoip.dat"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/op/go-logging"
|
||||
@@ -237,10 +236,6 @@ func runWebServer() {
|
||||
stopTunnelHealthMonitor()
|
||||
}
|
||||
|
||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
||||
tgbot.StopBot()
|
||||
// ------------------------------------------------------------
|
||||
|
||||
_ = server.Stop()
|
||||
_ = subServer.Stop()
|
||||
log.Println("Shutting down servers.")
|
||||
@@ -318,66 +313,6 @@ func showSetting(show bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// updateTgbotEnableSts enables or disables the Telegram bot notifications based on the status parameter.
|
||||
func updateTgbotEnableSts(status bool) {
|
||||
settingService := service.SettingService{}
|
||||
currentTgSts, err := settingService.GetTgbotEnabled()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
logger.Infof("current enabletgbot status[%v],need update to status[%v]", currentTgSts, status)
|
||||
if currentTgSts != status {
|
||||
err := settingService.SetTgbotEnabled(status)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
} else {
|
||||
logger.Infof("SetTgbotEnabled[%v] success", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
||||
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
if err != nil {
|
||||
fmt.Println("Error initializing database:", err)
|
||||
return
|
||||
}
|
||||
|
||||
settingService := service.SettingService{}
|
||||
|
||||
if tgBotToken != "" {
|
||||
err := settingService.SetTgBotToken(tgBotToken)
|
||||
if err != nil {
|
||||
fmt.Printf("Error setting Telegram bot token: %v\n", err)
|
||||
return
|
||||
}
|
||||
logger.Info("Successfully updated Telegram bot token.")
|
||||
}
|
||||
|
||||
if tgBotRuntime != "" {
|
||||
err := settingService.SetTgbotRuntime(tgBotRuntime)
|
||||
if err != nil {
|
||||
fmt.Printf("Error setting Telegram bot runtime: %v\n", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("Successfully updated Telegram bot runtime to [%s].", tgBotRuntime)
|
||||
}
|
||||
|
||||
if tgBotChatid != "" {
|
||||
err := settingService.SetTgBotChatId(tgBotChatid)
|
||||
if err != nil {
|
||||
fmt.Printf("Error setting Telegram bot chat ID: %v\n", err)
|
||||
return
|
||||
}
|
||||
logger.Info("Successfully updated Telegram bot chat ID.")
|
||||
}
|
||||
}
|
||||
|
||||
// encryptNodeTokens re-encrypts stored tokens after enablement or rotation.
|
||||
// It requires migration|required mode and a configured key.
|
||||
func encryptNodeTokens() {
|
||||
_ = godotenv.Load()
|
||||
if err := initNodeTokenCrypto(); err != nil {
|
||||
@@ -625,10 +560,6 @@ func main() {
|
||||
var getListen bool
|
||||
var webCertFile string
|
||||
var webKeyFile string
|
||||
var tgbottoken string
|
||||
var tgbotchatid string
|
||||
var enabletgbot bool
|
||||
var tgbotRuntime string
|
||||
var reset bool
|
||||
var show bool
|
||||
var getCert bool
|
||||
@@ -645,10 +576,6 @@ func main() {
|
||||
settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
|
||||
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
|
||||
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
||||
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
|
||||
settingCmd.StringVar(&tgbotRuntime, "tgbotRuntime", "", "Set cron time for Telegram bot notifications")
|
||||
settingCmd.StringVar(&tgbotchatid, "tgbotchatid", "", "Set chat ID for Telegram bot notifications")
|
||||
settingCmd.BoolVar(&enabletgbot, "enabletgbot", false, "Enable notifications via Telegram bot")
|
||||
|
||||
oldUsage := flag.Usage
|
||||
flag.Usage = func() {
|
||||
@@ -740,12 +667,6 @@ func main() {
|
||||
if getApiToken {
|
||||
GetApiToken(getApiToken)
|
||||
}
|
||||
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
|
||||
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
|
||||
}
|
||||
if enabletgbot {
|
||||
updateTgbotEnableSts(enabletgbot)
|
||||
}
|
||||
case "cert":
|
||||
err := settingCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user