10 Commits
Author SHA1 Message Date
Muse 5ca69dd9c1 精简P2: go mod tidy: xray-core 及其独占依赖树(quic-go/gvisor/reality/sing/pion/miekg-dns 等)全部移出 go.mod/go.sum; 连带清掉第一轮裁剪后孤儿化的 ldap/telego/qrcode/gotp/fasthttp 直接依赖
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
2026-10-20 11:08:20 +00:00
Muse 284040e994 精简P2: 下线 infra/conf 交叉验证类测试: golden fixtures 改为面板侧契约(全语料可解析+inbound-full 经 GenXrayInboundConfig 生成后身份保持), 文件更名 golden_fixtures_test.go; XMC 完整性测试由与核心对拍改为面板谓词自身规则表(用户名正则/uuid 可解析/textures 非空); healed-config 测试改为断言面板 heal 结果(version 钉 2/空 packet 被清). 核心接受性今后由节点核心启动时把关 2026-10-20 11:07:10 +00:00
Muse 70d24d2f15 精简P2: geodata 测试夹具改 protowire 手工编码(geodat.proto 字段号+proto3 零值省略语义原样复刻), 不再依赖 xray-core 生成类型写 .dat 夹具 2026-10-20 11:04:59 +00:00
Muse cb8981158f 精简P2: 剥离 xray-core API 客户端: StatsService 改 protowire 线缆级实现(QueryStats/GetUsersStats 字段号按核心 proto 复刻, agent 流量上报与本地流量任务不受影响); Handler/Routing 变更类 RPC 无法脱离核心类型化配置构建, 统一降级 ErrHotApplyUnavailable(调用方既有兜底: 热更新->重启核心, balancer 视图报未运行, 节点桥探测放弃, IP 限连临时断开仅记日志); ValidateOutboundConfig 改纯 JSON 实现, 精确复刻核心 v26.7.11 未加密 vless/trojan 公网禁令(含私有 IP/域名匹配表与原文报错, 且同核心一样只查扁平 address 形式); 删除 buildUserAccount 及 SS/WG/e2e 旧测试, 流量增量测试改线缆级假服务器并新增 stock protobuf 服务器互操作测试 2026-10-20 11:03:45 +00:00
Muse c56a02c948 精简P2: geodata token 常量内联, 去掉对 xray-core common/geodata 的常量引用 2026-10-20 10:59:03 +00:00
Muse 4149a162b8 修复: mixed 协议可部署到节点: 后端 nodeEligibleProtocols 放开 Mixed(inbound.go 两处校验点), 前端 NODE_ELIGIBLE_PROTOCOLS 同步; 重建前端 dist(含 Swagger 页/分组页裁剪) 2026-10-20 10:24:28 +00:00
Muse b9e1345b23 修复: Reality x25519 密钥改面板内置生成(crypto/ecdh), 不再 exec xray 二进制; 编码与 xray x25519 输出一致(base64url raw 32B), 附曲线自验单测 2026-10-20 10:18:37 +00:00
Muse 7d42b91ed6 精简: 订阅极简输出: 去掉分流规则注入(Happ/Clash/Incy 远程规则源+remote_routing 拉取缓存+RemoteRoutingJob、Clash 远程规则合并簇、subJsonRules 注入); 保留多格式转换与默认极简模板 2026-10-20 10:17:27 +00:00
Muse 3cbf9164a4 精简: 去掉分组管理功能: GroupController 路由与 client_groups 管理函数(ListGroups/Create/Rename/Delete/AddToGroup/ResetGroupTraffic 等); 保留 group 字段/表与删除流量基线 helper 2026-10-20 10:13:06 +00:00
Muse e42b5d770d 精简: 去掉 Telegram 机器人+通知: tgbot 包/stats-notify 与 hash 清理 job/登录通知/backuptotgbot/testTgBot/全部 tg* 设置/locale Bot 本地化/SecretClears 机制; 连带清理 Swagger/外部流量/邮件/2FA 的残留测试 2026-10-20 10:11:35 +00:00
86 changed files with 1352 additions and 10723 deletions
+5 -1
View File
@@ -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,
};
+2 -31
View File
@@ -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
)
-94
View File
@@ -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=
+3 -357
View File
@@ -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
View File
@@ -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")
}
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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)
}
+6 -6
View File
@@ -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)
+3 -3
View File
@@ -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)
+1 -11
View File
@@ -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,
+18 -18
View File
@@ -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)
}
}
+9 -29
View File
@@ -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")
-623
View File
@@ -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)
}
}
-750
View File
@@ -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())
}
}
-42
View File
@@ -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)
+11 -12
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
-2
View File
@@ -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 {
+8 -8
View File
@@ -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)
-9
View File
@@ -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()
}
-35
View File
@@ -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 {
+45 -20
View File
@@ -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)
}
-172
View File
@@ -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()
}
-23
View File
@@ -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)
+3 -3
View File
@@ -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
}
+1 -48
View File
@@ -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 }
-83
View File
@@ -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)
}
})
}
-25
View File
@@ -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: ""}
-20
View File
@@ -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"`
-22
View File
@@ -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()
}
-31
View File
@@ -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)
}
-33
View File
@@ -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")
}
}
+2 -27
View File
@@ -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 -384
View File
@@ -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
+3
View File
@@ -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))
}
}
-70
View File
@@ -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
+15 -8
View File
@@ -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
}
+3 -189
View File
@@ -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 {
+4 -12
View File
@@ -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
}
-537
View File
@@ -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)
}
}
}
-782
View File
@@ -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")
}
}
-162
View File
@@ -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 ""
}
-306
View File
@@ -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())
}
}
}
-509
View File
@@ -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
-296
View File
@@ -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, &params)
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(), &params); 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(), &params); 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(), &params); 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(), &params); 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)
}
}
-125
View File
@@ -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")
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-257
View File
@@ -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)
}
-149
View File
@@ -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")
}
}
+248
View File
@@ -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
View File
@@ -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)
}
}
+52 -15
View File
@@ -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),
-446
View File
@@ -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)
}
}
-129
View File
@@ -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")
}
}
+103 -45
View File
@@ -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))
+4 -5
View File
@@ -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 (
-79
View File
@@ -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 {