feat(sub): add dummy info node and status configs for subscriptions (#6412)

* feat(settings): add subInfoNodeEnable and status template settings

* feat(sub): add dummy info node and status configs for raw links

* feat(sub): support dummy info node in clash and json subscriptions

* feat(ui): add subscription info node switch and status templates to settings

* style: apply gofumpt formatting

* fix(sub): address review feedback on subscription info node

- Restore GetSubs contract to avoid unintended remark expansions on non-subscription-body calls.
- Exclude dummy info node from Clash PROXY select group when active server nodes exist.
- Track hasEnabledClient and set traffic.Enable in JSON and Clash paths so status tokens evaluate correctly.
- Deterministically sort client emails across subscriptions before selecting primaryEmail.
- Consolidate duplicated info-node evaluation logic into resolveInfoNodeRemark helper.
- Remove redundant pure-getter test from setting_sub_info_node_test.go.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Rouzbeh†
2026-09-08 18:44:01 +03:30
committed by GitHub
parent b8597314f8
commit 9f76a66dcf
33 changed files with 1374 additions and 35 deletions
+248
View File
@@ -0,0 +1,248 @@
package sub
import (
"fmt"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestSubClash_InfoNode_Active(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "user1@test.com",
SubID: "sub-clash",
UUID: "c1-uuid",
Enable: true,
TotalGB: 10737418240,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "user1@test.com",
Up: 1073741824,
Down: 1073741824,
Total: 10737418240,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
sub.subInfoNodeEnable = true
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
}
if !strings.Contains(out, "user1@test.com|📊8.00GB") {
t.Fatalf("expected expanded remark on dummy proxy, got:\n%s", out)
}
}
func TestSubClash_InfoNode_Expired(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-clash-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "exp@test.com",
SubID: "sub-clash-exp",
UUID: "c1-uuid",
Enable: true,
ExpiryTime: expiredTime,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "exp@test.com",
ExpiryTime: expiredTime,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
}
if !strings.Contains(out, "Expired") || !strings.Contains(out, "exp@test.com") {
t.Fatalf("expected expired remark in clash YAML, got:\n%s", out)
}
if strings.Contains(out, "Germany-VLESS") {
t.Fatalf("expired subscription must NOT contain working inbound, got:\n%s", out)
}
}
func TestSubClash_InfoNode_Depleted(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-clash-dep","enable":true,"totalGB":5368709120}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "dep@test.com",
SubID: "sub-clash-dep",
UUID: "c1-uuid",
Enable: true,
TotalGB: 5368709120,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "dep@test.com",
Up: 3221225472,
Down: 2147483648,
Total: 5368709120,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
}
if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "dep@test.com") {
t.Fatalf("expected depleted remark in clash YAML, got:\n%s", out)
}
if strings.Contains(out, "Germany-VLESS") {
t.Fatalf("depleted subscription must NOT contain working inbound, got:\n%s", out)
}
}
func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientRecord{Id: 1, Email: "user1@test.com", SubID: "sub-clash", UUID: "c1-uuid", Enable: true}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "user1@test.com", Enable: true}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}")
sub.subInfoNodeEnable = true
clash := NewSubClashService(false, "", sub)
out, _, err := clash.GetClash("sub-clash", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
// Status token must be rendered as active
if !strings.Contains(out, "active") {
t.Fatalf("expected active status in clash dummy remark, got:\n%s", out)
}
// In proxy-groups, PROXY select group must NOT have the dummy node as first member
// PROXY group proxies should begin with real proxy
if strings.Contains(out, "user1@test.com|active") && strings.Contains(out, "proxies:\n - user1@test.com|active") {
t.Fatalf("PROXY group must NOT contain dummy info node as member, got:\n%s", out)
}
if !strings.Contains(out, "proxies:\n - Germany-VLESS\n - DIRECT") {
t.Fatalf("expected real proxy Germany-VLESS in PROXY group, got:\n%s", out)
}
}
+41
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"maps"
"slices"
"strings"
"github.com/goccy/go-json"
@@ -40,6 +41,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
var proxies []map[string]any
var hasInactiveExternal bool
var hasEnabledClient bool
seenEmails := make(map[string]struct{})
for _, inbound := range inbounds {
@@ -52,11 +54,17 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
injectExternalProxy(inbound, hostEps)
}
for _, client := range clients {
if client.Enable {
hasEnabledClient = true
}
seenEmails[client.Email] = struct{}{}
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
}
}
for _, ext := range externalLinks {
if ext.Enable {
hasEnabledClient = true
}
if !ext.Active {
seenEmails[ext.Email] = struct{}{}
hasInactiveExternal = true
@@ -82,8 +90,25 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
for e := range seenEmails {
emails = append(emails, e)
}
slices.Sort(emails)
traffic, _ := subReq.AggregateTrafficByEmails(emails)
traffic.Enable = hasEnabledClient
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone {
dummyProxy := map[string]any{
"name": remark,
"type": "socks5",
"server": "127.0.0.1",
"port": 1080,
}
if mode == infoNodeExpired || mode == infoNodeDepleted {
proxies = []map[string]any{dummyProxy}
} else {
proxies = append([]map[string]any{dummyProxy}, proxies...)
}
}
if len(proxies) == 0 {
return "", header, nil
}
@@ -92,6 +117,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
proxyNames := make([]string, 0, len(proxies)+1)
for _, proxy := range proxies {
if isDummyProxy(proxy) && len(proxies) > 1 {
continue
}
if name, ok := proxy["name"].(string); ok && name != "" {
proxyNames = append(proxyNames, name)
}
@@ -152,6 +180,19 @@ func ensureUniqueProxyNames(proxies []map[string]any) {
}
}
func isDummyProxy(proxy map[string]any) bool {
typ, _ := proxy["type"].(string)
server, _ := proxy["server"].(string)
var port int
switch p := proxy["port"].(type) {
case int:
port = p
case float64:
port = int(p)
}
return typ == "socks5" && server == "127.0.0.1" && port == 1080
}
func fallbackProxyName(proxy map[string]any, idx int) string {
typ, _ := proxy["type"].(string)
server, _ := proxy["server"].(string)
+247
View File
@@ -0,0 +1,247 @@
package sub
import (
"fmt"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestSubJson_InfoNode_Active(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-json","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "user1@test.com",
SubID: "sub-json",
UUID: "c1-uuid",
Enable: true,
TotalGB: 10737418240,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "user1@test.com",
Up: 1073741824,
Down: 1073741824,
Total: 10737418240,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
sub.subInfoNodeEnable = true
jsonSvc := NewSubJsonService("", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "user1@test.com|📊8.00GB") {
t.Fatalf("expected dummy remark in JSON remarks, got:\n%s", out)
}
if !strings.Contains(out, `"protocol": "socks"`) && !strings.Contains(out, `"protocol":"socks"`) {
t.Fatalf("expected socks outbound in JSON, got:\n%s", out)
}
}
func TestSubJson_InfoNode_Expired(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-json-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "exp@test.com",
SubID: "sub-json-exp",
UUID: "c1-uuid",
Enable: true,
ExpiryTime: expiredTime,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "exp@test.com",
ExpiryTime: expiredTime,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
jsonSvc := NewSubJsonService("", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "Expired") || !strings.Contains(out, "exp@test.com") {
t.Fatalf("expected expired remark in JSON remarks, got:\n%s", out)
}
if strings.Contains(out, "Germany-VLESS") {
t.Fatalf("expired subscription must NOT contain working inbound in JSON, got:\n%s", out)
}
}
func TestSubJson_InfoNode_Depleted(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-json-dep","enable":true,"totalGB":5368709120}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "dep@test.com",
SubID: "sub-json-dep",
UUID: "c1-uuid",
Enable: true,
TotalGB: 5368709120,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "dep@test.com",
Up: 3221225472,
Down: 2147483648,
Total: 5368709120,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
jsonSvc := NewSubJsonService("", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "dep@test.com") {
t.Fatalf("expected depleted remark in JSON remarks, got:\n%s", out)
}
if strings.Contains(out, "Germany-VLESS") {
t.Fatalf("depleted subscription must NOT contain working inbound in JSON, got:\n%s", out)
}
}
func TestSubJson_InfoNode_StatusActive(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-json-status","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "user1@test.com",
SubID: "sub-json-status",
UUID: "c1-uuid",
Enable: true,
TotalGB: 10737418240,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "user1@test.com",
Up: 100,
Down: 100,
Total: 10737418240,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
sub.subInfoNodeEnable = true
jsonSvc := NewSubJsonService("", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "user1@test.com|✅ active") {
t.Fatalf("expected 'user1@test.com|✅ active' in JSON remarks, got:\n%s", out)
}
}
+45
View File
@@ -83,6 +83,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
var header string
var hasInactiveExternal bool
var hasEnabledClient bool
seenEmails := make(map[string]struct{})
entries := make([]subConfigEntry, 0, len(inbounds))
@@ -99,6 +100,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
var inboundConfigs []json_util.RawMessage
for _, client := range clients {
if client.Enable {
hasEnabledClient = true
}
seenEmails[client.Email] = struct{}{}
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
}
@@ -128,6 +132,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
configArray = append(configArray, entry.configs...)
}
for _, ext := range externalLinks {
if ext.Enable {
hasEnabledClient = true
}
if !ext.Active {
seenEmails[ext.Email] = struct{}{}
hasInactiveExternal = true
@@ -162,8 +169,20 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
for e := range seenEmails {
emails = append(emails, e)
}
slices.Sort(emails)
traffic, _ := subReq.AggregateTrafficByEmails(emails)
traffic.Enable = hasEnabledClient
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(configArray) > 0); mode != infoNodeNone {
dummyConfig := s.genDummySocksConfig(remark)
if mode == infoNodeExpired || mode == infoNodeDepleted {
configArray = []json_util.RawMessage{dummyConfig}
} else {
configArray = append([]json_util.RawMessage{dummyConfig}, configArray...)
}
}
if len(configArray) == 0 {
return "", header, nil
}
@@ -962,6 +981,32 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien
return result
}
func (s *SubJsonService) genDummySocksConfig(remark string) json_util.RawMessage {
outbound := map[string]any{
"protocol": "socks",
"tag": "proxy",
"settings": map[string]any{
"servers": []any{
map[string]any{
"address": "127.0.0.1",
"port": 1080,
},
},
},
}
rawOutbound, _ := json.Marshal(outbound)
newOutbounds := []json_util.RawMessage{rawOutbound}
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = remark
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
return newConfig
}
func mergeFinalMask(base any, extra map[string]any) map[string]any {
merged := map[string]any{}
if baseMap, ok := base.(map[string]any); ok {
+1 -1
View File
@@ -625,7 +625,7 @@ func (s *SubService) effectiveTemplate(client model.Client) string {
s.usageShown = map[string]bool{}
}
key := templateInfoKey(client)
if s.usageShown[key] {
if s.subInfoNodeEnable || s.usageShown[key] {
remove := firstLinkOnlyBodyTokens
if s.showIdentityOnAllLinks {
remove = usageInfoTokens
+102 -10
View File
@@ -44,10 +44,13 @@ type SubService struct {
subscriptionBody bool
// usageShown emits info once per subscription identity, including twins.
// PrepareForRequest resets this per-request state.
usageShown map[string]bool
showIdentityOnAllLinks bool
inboundService service.InboundService
settingService service.SettingService
usageShown map[string]bool
showIdentityOnAllLinks bool
subInfoNodeEnable bool
subExpiredTemplate string
subTrafficDepletedTemplate string
inboundService service.InboundService
settingService service.SettingService
// nodesByID is populated per request from the Node table so
// resolveInboundAddress can return the node's address for any
// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -197,14 +200,29 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
// (the date formatter reads datepicker). Loading it only in getSubs left
// JSON/Clash with the zero value.
func (s *SubService) loadRemarkSettings() {
var err error
s.datepicker, err = s.settingService.GetDatepicker()
if err != nil {
if s.datepicker == "" {
s.datepicker = "gregorian"
}
s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
if err != nil {
s.showIdentityOnAllLinks = false
if s.subExpiredTemplate == "" {
s.subExpiredTemplate = service.DefaultSubExpiredTemplate
}
if s.subTrafficDepletedTemplate == "" {
s.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
}
if datepicker, err := s.settingService.GetDatepicker(); err == nil && datepicker != "" {
s.datepicker = datepicker
}
if enabled, err := s.settingService.GetSubShowIdentityOnAllLinks(); err == nil && enabled {
s.showIdentityOnAllLinks = enabled
}
if enabled, err := s.settingService.GetSubInfoNodeEnable(); err == nil && enabled {
s.subInfoNodeEnable = enabled
}
if tmpl, err := s.settingService.GetSubExpiredTemplate(); err == nil && tmpl != "" {
s.subExpiredTemplate = tmpl
}
if tmpl, err := s.settingService.GetSubTrafficDepletedTemplate(); err == nil && tmpl != "" {
s.subTrafficDepletedTemplate = tmpl
}
}
@@ -293,6 +311,70 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
return s.ForRequest(host).getSubs(subId)
}
type infoNodeMode int
const (
infoNodeNone infoNodeMode = iota
infoNodeActive
infoNodeExpired
infoNodeDepleted
)
func (s *SubService) resolveInfoNodeRemark(subId string, uniqueEmails []string, traffic xray.ClientTraffic, hasEntries bool) (infoNodeMode, string) {
if !s.subInfoNodeEnable || !s.subscriptionBody {
return infoNodeNone, ""
}
nowSec := time.Now().Unix()
isExpired := traffic.ExpiryTime > 0 && traffic.ExpiryTime/1000 <= nowSec
isDepleted := traffic.Total > 0 && (traffic.Up+traffic.Down) >= traffic.Total
primaryEmail := ""
if len(uniqueEmails) > 0 {
primaryEmail = uniqueEmails[0]
}
ctx := remarkContext{
client: model.Client{Email: primaryEmail, SubID: subId},
stats: traffic,
}
if isExpired {
tmpl := s.subExpiredTemplate
if tmpl == "" {
tmpl = service.DefaultSubExpiredTemplate
}
remark := expandRemarkVars(tmpl, ctx)
if strings.TrimSpace(remark) == "" {
remark = "Expired"
}
return infoNodeExpired, remark
}
if isDepleted {
tmpl := s.subTrafficDepletedTemplate
if tmpl == "" {
tmpl = service.DefaultSubTrafficDepletedTemplate
}
remark := expandRemarkVars(tmpl, ctx)
if strings.TrimSpace(remark) == "" {
remark = "Traffic Depleted"
}
return infoNodeDepleted, remark
}
if hasEntries {
tmpl := s.remarkTemplate
if tmpl == "" {
tmpl = service.DefaultRemarkTemplate
}
remark := expandRemarkVars(tmpl, ctx)
if strings.TrimSpace(remark) != "" {
return infoNodeActive, remark
}
}
return infoNodeNone, ""
}
func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
var result []string
var emails []string
@@ -360,8 +442,18 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
for e := range seenEmails {
uniqueEmails = append(uniqueEmails, e)
}
slices.Sort(uniqueEmails)
traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
traffic.Enable = hasEnabledClient
if mode, remark := s.resolveInfoNodeRemark(subId, uniqueEmails, traffic, len(result) > 0); mode != infoNodeNone {
dummyLink := fmt.Sprintf("socks://127.0.0.1:1080#%s", strings.ReplaceAll(url.QueryEscape(remark), "+", "%20"))
if mode == infoNodeExpired || mode == infoNodeDepleted {
return []string{dummyLink}, emails, lastOnline, traffic, nil
}
result = append([]string{dummyLink}, result...)
}
return result, emails, lastOnline, traffic, nil
}
+365
View File
@@ -0,0 +1,365 @@
package sub
import (
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func setupInfoNodeTestDB(t *testing.T) {
t.Helper()
if err := database.InitDB(t.TempDir() + "/test_infonode.db"); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() {
_ = database.CloseDB()
})
db := database.GetDB()
if err := db.AutoMigrate(
&model.Inbound{},
&model.ClientRecord{},
&model.ClientInbound{},
&xray.ClientTraffic{},
&model.Setting{},
); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
}
func TestSubService_InfoNode_Active(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Up: 0,
Down: 0,
Total: 0,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-active","enable":true,"totalGB":10737418240,"expiryTime":0}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "user1@test.com",
SubID: "sub-active",
UUID: "c1-uuid",
Enable: true,
TotalGB: 10737418240, // 10 GB
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "user1@test.com",
Up: 1073741824, // 1 GB
Down: 1073741824, // 1 GB
Total: 10737418240,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
svc := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
svc.subInfoNodeEnable = true
svc.subscriptionBody = true
links, emails, _, traffic, err := svc.GetSubs("sub-active", "sub.example.com")
if err != nil {
t.Fatalf("GetSubs error: %v", err)
}
if len(links) != 2 {
t.Fatalf("expected 2 links (dummy info node + 1 vless link), got %d: %v", len(links), links)
}
if len(emails) != 1 || emails[0] != "user1@test.com" {
t.Fatalf("emails = %v, want [user1@test.com]", emails)
}
if !traffic.Enable {
t.Fatalf("traffic should be enabled")
}
// First link must be the dummy socks node
if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
t.Fatalf("first link must be dummy socks node, got: %q", links[0])
}
decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
if !strings.Contains(decodedRemark, "user1@test.com") || !strings.Contains(decodedRemark, "8.00GB") {
t.Fatalf("expected dummy remark to contain user1@test.com and 8.00GB, got: %q", decodedRemark)
}
// Second link must be the clean vless link without traffic left tokens
if !strings.HasPrefix(links[1], "vless://") {
t.Fatalf("second link must be vless, got: %q", links[1])
}
if strings.Contains(links[1], "8.00GB") {
t.Fatalf("server link must have clean remark without usage stats, got: %q", links[1])
}
}
func TestSubService_InfoNode_Expired(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "exp@test.com",
SubID: "sub-exp",
UUID: "c1-uuid",
Enable: true,
ExpiryTime: expiredTime,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "exp@test.com",
ExpiryTime: expiredTime,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
svc := NewSubService("{{INBOUND}}|{{EMAIL}}")
svc.subInfoNodeEnable = true
svc.subExpiredTemplate = service.DefaultSubExpiredTemplate
svc.subscriptionBody = true
links, _, _, _, err := svc.GetSubs("sub-exp", "sub.example.com")
if err != nil {
t.Fatalf("GetSubs error: %v", err)
}
if len(links) != 1 {
t.Fatalf("expected ONLY 1 link (dummy expired node), got %d: %v", len(links), links)
}
if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
t.Fatalf("expired link must be dummy socks node, got: %q", links[0])
}
decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
if !strings.Contains(decodedRemark, "Expired") || !strings.Contains(decodedRemark, "exp@test.com") {
t.Fatalf("expected expired remark, got: %q", decodedRemark)
}
}
func TestSubService_InfoNode_TrafficDepleted(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-dep","enable":true,"totalGB":5368709120}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "dep@test.com",
SubID: "sub-dep",
UUID: "c1-uuid",
Enable: true,
TotalGB: 5368709120, // 5 GB
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "dep@test.com",
Up: 3221225472, // 3 GB
Down: 2147483648, // 2 GB (total 5 GB used = depleted)
Total: 5368709120,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
svc := NewSubService("{{INBOUND}}|{{EMAIL}}")
svc.subInfoNodeEnable = true
svc.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
svc.subscriptionBody = true
links, _, _, _, err := svc.GetSubs("sub-dep", "sub.example.com")
if err != nil {
t.Fatalf("GetSubs error: %v", err)
}
if len(links) != 1 {
t.Fatalf("expected ONLY 1 link (dummy depleted node), got %d: %v", len(links), links)
}
if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
t.Fatalf("depleted link must be dummy socks node, got: %q", links[0])
}
decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
if !strings.Contains(decodedRemark, "Traffic Depleted") || !strings.Contains(decodedRemark, "dep@test.com") {
t.Fatalf("expected depleted remark, got: %q", decodedRemark)
}
}
func TestSubService_GetSubs_NonSubscriptionBody_NoInfoNode(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "US-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-panel","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
rec := &model.ClientRecord{
Id: 1,
Email: "user1@test.com",
SubID: "sub-panel",
UUID: "c1-uuid",
Enable: true,
TotalGB: 10737418240,
}
if err := db.Create(rec).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1,
Email: "user1@test.com",
Up: 1073741824,
Down: 1073741824,
Total: 10737418240,
Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
// When called via GetSubs (e.g. from LinkProvider for admin QR / copy modals),
// subscriptionBody is false, so it should render the clean link and not add dummy nodes.
svc := NewSubService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
svc.subInfoNodeEnable = true
links, _, _, _, err := svc.GetSubs("sub-panel", "sub.example.com")
if err != nil {
t.Fatalf("GetSubs error: %v", err)
}
if len(links) != 1 {
t.Fatalf("expected 1 clean link, got %d: %v", len(links), links)
}
if strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
t.Fatalf("GetSubs must NOT return dummy socks node when subscriptionBody is false: %v", links[0])
}
if strings.Contains(links[0], "8.00GB") {
t.Fatalf("GetSubs must NOT contain snapshot usage stats in non-subscriptionBody context: %v", links[0])
}
}
func TestSubService_InfoNode_MultiClient_DeterministicPrimaryEmail(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1,
UserId: 1,
Remark: "Germany-VLESS",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"zeta@test.com","subId":"sub-multi","enable":true,"totalGB":10737418240},{"id":"c2-uuid","email":"alpha@test.com","subId":"sub-multi","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientRecord{
Id: 1, Email: "zeta@test.com", SubID: "sub-multi", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientRecord{
Id: 2, Email: "alpha@test.com", SubID: "sub-multi", UUID: "c2-uuid", Enable: true, TotalGB: 10737418240,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 2}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1, Email: "zeta@test.com", Up: 100, Down: 100, Total: 10737418240, Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: 1, Email: "alpha@test.com", Up: 100, Down: 100, Total: 10737418240, Enable: true,
}).Error; err != nil {
t.Fatal(err)
}
svc := NewSubService("{{EMAIL}}")
svc.subInfoNodeEnable = true
svc.subscriptionBody = true
for i := 0; i < 5; i++ {
links, _, _, _, err := svc.GetSubs("sub-multi", "sub.example.com")
if err != nil {
t.Fatalf("GetSubs error: %v", err)
}
if len(links) < 1 || !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
t.Fatalf("expected dummy socks node, got: %v", links)
}
decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
if decodedRemark != "alpha@test.com" {
t.Fatalf("expected primaryEmail 'alpha@test.com' (sorted alphabetically), got %q", decodedRemark)
}
}
}