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
+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
}