feat(sub): add dynamic remark variables with Jalali date, transport, and status tokens (#5430)

* feat(sub): implement dynamic single-bracket remark variables with timezone-aware inline Jalali conversion

* Update .gitignore

* Update .gitignore

* merge: bring in origin/main commits to resolve conflict base

* fix(sub): address review issues in dynamic remark variables

- Add TIME_LEFT to unlimitedDropTokens so segments containing only
  {TIME_LEFT} are dropped for unlimited clients (same as DAYS_LEFT)
- Remove dead uiSingleBraceRe variable (translateUISingleBrackets uses
  a character scanner, not this regex)
- Change expireDateLabel to use time.Local instead of UTC, consistent
  with jalaliExpireDateLabel

Co-authored-by: Sanaei <MHSanaei@users.noreply.github.com>

* fix

* fix

---------

Co-authored-by: MHSanaei <MHSanaei@users.noreply.github.com>
This commit is contained in:
wahh3b-lgtm
2026-06-21 03:30:27 +03:30
committed by GitHub
parent ce1d348ece
commit 605e90dbf0
11 changed files with 447 additions and 56 deletions
+2 -1
View File
@@ -43,4 +43,5 @@ system_metrics.gob
docker-compose.override.yml
# Ignore .env (Environment Variables) file
.env
.env
+10
View File
@@ -222,6 +222,10 @@
"description": "Encrypt subscription responses",
"type": "boolean"
},
"subHideSettings": {
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean"
},
"subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -439,6 +443,7 @@
"subEnable",
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -698,6 +703,10 @@
"description": "Encrypt subscription responses",
"type": "boolean"
},
"subHideSettings": {
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean"
},
"subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -922,6 +931,7 @@
"subEnable",
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
+6 -5
View File
@@ -163,13 +163,14 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
}}
}
delete(stream, "externalProxy")
network, _ := stream["network"].(string)
proxies := make([]map[string]any, 0, len(externalProxies))
for _, ep := range externalProxies {
extPrxy := ep.(map[string]any)
// Expand the host's {{VAR}} remark template for this client (no-op for
// the synthetic/legacy entry) before it becomes the proxy name.
subReq.renderHostRemark(inbound, client, extPrxy)
subReq.renderHostRemark(inbound, client, extPrxy, network)
workingInbound := *inbound
workingInbound.Listen = extPrxy["dest"].(string)
workingInbound.Port = int(extPrxy["port"].(float64))
@@ -214,14 +215,14 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
return s.buildHysteriaProxy(subReq, inbound, client, ep)
}
network, _ := stream["network"].(string)
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep),
"name": subReq.endpointRemark(inbound, client.Email, ep, network),
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
}
network, _ := stream["network"].(string)
if !s.applyTransport(proxy, network, stream) {
return nil
}
@@ -298,7 +299,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
}
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep),
"name": subReq.endpointRemark(inbound, client.Email, ep, "quic"),
"type": proxyType,
"server": inbound.Listen,
"port": inbound.Port,
+2 -2
View File
@@ -103,7 +103,7 @@ func (s *SubService) buildEndpointLinks(
}
// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string) string {
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
var links strings.Builder
for index, e := range eps {
securityToApply, _ := baseObj["tls"].(string)
@@ -111,7 +111,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
securityToApply = e.ForceTls
}
newObj := cloneVmessShareObj(baseObj, e.ForceTls)
newObj["ps"] = s.endpointRemark(inbound, email, e.ep)
newObj["ps"] = s.endpointRemark(inbound, email, e.ep, transport)
newObj["add"] = e.Address
newObj["port"] = e.Port
if e.ForceTls != "same" {
+2 -2
View File
@@ -81,7 +81,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
}
got := s.buildEndpointLinks(eps, params, "tls",
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark) },
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
)
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B"
@@ -104,7 +104,7 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
}
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user")
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
if got != want {
+7 -5
View File
@@ -197,13 +197,15 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
if len(eps) == 0 {
return ""
}
stream := unmarshalStreamSettings(inbound.StreamSettings)
transport, _ := stream["network"].(string)
// Clone each ep before expanding its remark template: the eps slice is
// shared across all clients of this inbound, so the rendered (per-client)
// remark must not leak into the next client's links.
rendered := make([]map[string]any, len(eps))
for i, ep := range eps {
cp := maps.Clone(ep)
s.renderHostRemark(inbound, client, cp)
s.renderHostRemark(inbound, client, cp, transport)
rendered[i] = cp
}
clone := *inbound
@@ -216,12 +218,12 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
// their output stays byte-identical.
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any) {
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any, transport string) {
if !isHostEndpoint(ep) {
return
}
tmpl, _ := ep["remark"].(string)
ep["remark"] = s.genHostRemark(inbound, client, tmpl)
ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
ep["remarkFinal"] = true
}
@@ -229,7 +231,7 @@ func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Clien
// entry. A host endpoint whose template was pre-expanded by renderHostRemark
// carries remarkFinal and is used verbatim; every other entry flows through the
// standard genRemark composition unchanged.
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any) string {
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any, transport string) string {
if ep != nil {
if final, _ := ep["remarkFinal"].(bool); final {
r, _ := ep["remark"].(string)
@@ -240,7 +242,7 @@ func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map
if ep != nil {
extra, _ = ep["remark"].(string)
}
return s.genRemark(inbound, email, extra)
return s.genRemark(inbound, email, extra, transport)
}
// applyEndpointHostPath overrides the transport host header / path for a host
+4 -2
View File
@@ -174,12 +174,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
}
delete(stream, "externalProxy")
network, _ := stream["network"].(string)
for _, ep := range externalProxies {
extPrxy := ep.(map[string]any)
// Expand the host's {{VAR}} remark template for this client (no-op for
// the synthetic/legacy entry) before it's used as the config remark.
subReq.renderHostRemark(inbound, client, extPrxy)
subReq.renderHostRemark(inbound, client, extPrxy, network)
inbound.Listen = extPrxy["dest"].(string)
inbound.Port = int(extPrxy["port"].(float64))
newStream := cloneStreamForExternalProxy(stream)
@@ -220,8 +221,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
transport, _ := newStream["network"].(string)
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
newJsonArray = append(newJsonArray, newConfig)
+216 -8
View File
@@ -1,6 +1,7 @@
package sub
import (
"fmt"
"regexp"
"strconv"
"strings"
@@ -23,6 +24,7 @@ type remarkContext struct {
stats xray.ClientTraffic
inbound *model.Inbound
hostRemark string
transport string
}
// configName is the display name for a link: always the inbound's own remark.
@@ -50,6 +52,54 @@ var unlimitedDropTokens = map[string]bool{
"TRAFFIC_LEFT": true,
"TRAFFIC_TOTAL": true,
"DAYS_LEFT": true,
"TIME_LEFT": true,
}
// uiTokenMap translates user-friendly single-brace tokens (used in the frontend
// Remark/Host Name fields) to their internal double-brace equivalents. Tokens
// not present in this map are left untouched.
var uiTokenMap = map[string]string{
"EMAIL": "EMAIL",
"DATA_USAGE": "TRAFFIC_USED",
"DATA_LEFT": "TRAFFIC_LEFT",
"DATA_LIMIT": "TRAFFIC_TOTAL",
"DAYS_LEFT": "DAYS_LEFT",
"EXPIRE_DATE": "EXPIRE_DATE",
"JALALI_EXPIRE_DATE": "JALALI_EXPIRE_DATE",
"TIME_LEFT": "TIME_LEFT",
"STATUS_EMOJI": "STATUS_EMOJI",
"USAGE_PERCENTAGE": "USAGE_PERCENTAGE",
"PROTOCOL": "PROTOCOL",
"TRANSPORT": "TRANSPORT",
}
// translateUISingleBrackets converts user-friendly single-brace tokens to the
// internal double-brace format before regex expansion. Only {TOKEN} patterns
// that are NOT part of {{TOKEN}} are translated. Unknown tokens stay as-is.
func translateUISingleBrackets(template string) string {
var result strings.Builder
i := 0
for i < len(template) {
if template[i] == '{' && (i == 0 || template[i-1] != '{') {
j := i + 1
for j < len(template) && template[j] != '}' {
j++
}
if j < len(template) && template[j] == '}' {
token := template[i+1 : j]
if internal, ok := uiTokenMap[token]; ok {
result.WriteString("{{")
result.WriteString(internal)
result.WriteString("}}")
i = j + 1
continue
}
}
}
result.WriteByte(template[i])
i++
}
return result.String()
}
// expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
@@ -58,6 +108,7 @@ var unlimitedDropTokens = map[string]bool{
// or expiry (∞) drops out whole — decoration and separator included — so an
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
func expandRemarkVars(template string, ctx remarkContext) string {
template = translateUISingleBrackets(template)
if !strings.Contains(template, "{{") {
return template
}
@@ -164,6 +215,21 @@ func remarkVarValue(token string, ctx remarkContext) string {
return strconv.Itoa(c.Reset)
}
return ""
case "STATUS_EMOJI":
return statusEmoji(st)
case "USAGE_PERCENTAGE":
return usagePercentage(st)
case "PROTOCOL":
if ctx.inbound != nil {
return strings.ToUpper(string(ctx.inbound.Protocol))
}
return ""
case "TRANSPORT":
return ctx.transport
case "TIME_LEFT":
return timeLeftLabel(st.ExpiryTime)
case "JALALI_EXPIRE_DATE":
return jalaliExpireDateLabel(st.ExpiryTime)
}
return ""
}
@@ -202,13 +268,13 @@ func daysLeftLabel(expiryMs int64) string {
return strconv.FormatInt(days, 10)
}
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
// delayed-start (no fixed calendar date yet) expiries yield "".
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (local time). Unlimited
// and delayed-start (no fixed calendar date yet) expiries yield "".
func expireDateLabel(expiryMs int64) string {
if expiryMs <= 0 {
return ""
}
return time.Unix(expiryMs/1000, 0).UTC().Format("2006-01-02")
return time.Unix(expiryMs/1000, 0).In(time.Local).Format("2006-01-02")
}
func max64(a, b int64) int64 {
@@ -218,6 +284,145 @@ func max64(a, b int64) int64 {
return b
}
// statusEmoji maps clientStatus to a single emoji character.
func statusEmoji(st xray.ClientTraffic) string {
switch clientStatus(st) {
case "active":
return "✅"
case "expired":
return "⏳"
case "depleted":
return "🚫"
case "disabled":
return "🚫"
default:
return ""
}
}
// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
// Returns "" when the client has no traffic limit.
func usagePercentage(st xray.ClientTraffic) string {
if st.Total <= 0 {
return ""
}
used := st.Up + st.Down
pct := float64(used) / float64(st.Total) * 100
if pct > 100 {
pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
}
return fmt.Sprintf("%.1f%%", pct)
}
// timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
// are zero). Returns "∞" for unlimited and "0" when past expiry.
func timeLeftLabel(expiryMs int64) string {
if expiryMs == 0 {
return unlimitedMark
}
exp := expiryMs / 1000
var secs int64
if exp > 0 {
secs = exp - time.Now().Unix()
} else {
secs = -exp
}
if secs <= 0 {
return "0"
}
days := secs / 86400
hours := (secs % 86400) / 3600
mins := (secs % 3600) / 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
// jalaliExpireDateLabel converts a Gregorian expiry timestamp to Jalali
// (Persian/Solar Hijri) date format "YYYY/MM/DD". Returns "" for unlimited
// or delayed-start expiries.
func jalaliExpireDateLabel(expiryMs int64) string {
if expiryMs <= 0 {
return ""
}
t := time.Unix(expiryMs/1000, 0).In(time.Local)
y, m, d := gregorianToJalali(t.Year(), int(t.Month()), t.Day())
return fmt.Sprintf("%d/%02d/%02d", y, m, d)
}
// gregorianToJalali converts a Gregorian date to Jalali (Solar Hijri) date.
// Uses a reference-date approach: counts days from a known reference point
// (2024-01-01 = 1402-10-11 JAL) and walks the Jalali calendar forward/backward.
func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
// Compute Julian Day Number for the input Gregorian date
a := (14 - gm) / 12
y := gy + 4800 - a
m := gm + 12*a - 3
jdn := gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
// Reference: 2024-01-01 = JDN 2460311 = 1402-10-11 JAL
refJDN := 2460311
days := int64(jdn - refJDN)
jy, jm, jd = 1402, 10, 11
// Walk forward
for days > 0 {
remaining := int64(jalaliMonthDays(jy, jm) - jd + 1)
if days < remaining {
jd += int(days)
return
}
days -= remaining
jm++
if jm > 12 {
jm = 1
jy++
}
jd = 1
}
// Walk backward
for days < 0 {
jd += int(days)
for jd < 1 {
jm--
if jm < 1 {
jm = 12
jy--
}
jd += jalaliMonthDays(jy, jm)
}
days = 0
}
return
}
func jalaliMonthDays(y, m int) int {
if m <= 6 {
return 31
}
if m <= 11 {
return 30
}
if isJalaliLeap(y) {
return 30
}
return 29
}
// isJalaliLeap reports whether the given Jalali year is a leap year.
// The leap pattern repeats every 33 years with 8 leap years.
func isJalaliLeap(y int) bool {
switch y % 33 {
case 1, 5, 9, 13, 17, 22, 26, 30:
return true
}
return false
}
// statsForClient returns the client's live traffic record, or a minimal one
// synthesized from the client (enable/expiry/total) when no live stats exist —
// so expiry/total/status tokens still resolve on links that have no counters yet.
@@ -261,6 +466,7 @@ var usageInfoTokens = []string{
"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
}
// nameOnlyTemplate returns template with the trailing per-client info part
@@ -287,25 +493,27 @@ func nameOnlyTemplate(template string) string {
// name-only template for every link thereafter — so the info shows once. Only
// called in the subscription-body context (displays bypass the template).
func (s *SubService) effectiveTemplate(email string) string {
translated := translateUISingleBrackets(s.remarkTemplate)
if s.usageShown == nil {
s.usageShown = map[string]bool{}
}
if s.usageShown[email] {
return nameOnlyTemplate(s.remarkTemplate)
return nameOnlyTemplate(translated)
}
s.usageShown[email] = true
return s.remarkTemplate
return translated
}
// genTemplatedRemark expands the remark template for one client. hostRemark is
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
// token only and never substitutes the inbound remark as the config name.
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
ctx := remarkContext{
client: client,
stats: s.statsForClient(inbound, client),
inbound: inbound,
hostRemark: hostRemark,
transport: transport,
}
tmpl := s.effectiveTemplate(client.Email)
// Fall back to the config name when the template is empty or expands to
@@ -320,9 +528,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
// config name is always the inbound's own remark; the host's remark is surfaced
// only through the {{HOST}} token. In the subscription body the rest of the
// remark template still applies; displays show just the config name.
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if !s.subscriptionBody {
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
}
return s.genTemplatedRemark(inbound, client, hostRemark)
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
}
+181 -15
View File
@@ -37,7 +37,6 @@ func TestExpandRemarkVars(t *testing.T) {
cases := []struct{ tmpl, want string }{
{"{{EMAIL}}", "john@example.com"},
{"{{USERNAME}}", "john@example.com"},
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
{"{{HOST}}", ""}, // no host remark in ctx → empty
{"{{ID}}", client.ID},
@@ -169,10 +168,10 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
// never substitutes it (it is reachable only through {{HOST}}).
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
s, inbound, client := hostRemarkService("") // no template → config name only
if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
}
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
}
}
@@ -182,17 +181,17 @@ func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
}
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
}
// {{HOST}} is the host's own remark even when the inbound has one of its own.
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
t.Fatalf("{{HOST}} token = %q, want CDN", got)
}
}
@@ -201,7 +200,7 @@ func TestGenHostRemark_GlobalTemplate(t *testing.T) {
// legacy externalProxy remark passed as extra.
func TestGenRemark_GlobalTemplate(t *testing.T) {
s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
got := s.genRemark(inbound, "john@example.com", "")
got := s.genRemark(inbound, "john@example.com", "", "")
if got != "john@example.com | 80.00GB" {
t.Fatalf("global template (non-host) = %q", got)
}
@@ -210,7 +209,7 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
// With no template, genRemark composes the fallback model and adds no suffix.
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay")
got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay" {
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
}
@@ -220,8 +219,8 @@ func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
// link of the request; later links show the name-only template.
func TestUsageOnFirstLinkOnly(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
first := s.genHostRemark(inbound, client, "")
second := s.genHostRemark(inbound, client, "")
first := s.genHostRemark(inbound, client, "", "")
second := s.genHostRemark(inbound, client, "", "")
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
t.Fatalf("first link should carry usage: %q", first)
}
@@ -241,15 +240,15 @@ func TestRemarkInDisplayContext(t *testing.T) {
s.subscriptionBody = false
// A host link in a display shows only the config name — the inbound's remark,
// with no per-client email or usage info and the host remark ignored.
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
t.Fatalf("display host link = %q, want config name %q", got, "DE")
}
// With no host remark, the config name is likewise the inbound's own remark.
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
}
// genRemark (non-host) likewise drops the template in display context.
if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
t.Fatalf("display genRemark = %q, want %q", got, "DE")
}
}
@@ -294,9 +293,176 @@ func TestStatsForClient_CrossInboundFallback(t *testing.T) {
func TestGenHostRemark_PerClient(t *testing.T) {
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
inbound := &model.Inbound{}
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
if a != "alice@x" || b != "bob@x" {
t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
}
}
func TestStatusEmoji(t *testing.T) {
cases := []struct {
stats xray.ClientTraffic
want string
}{
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
{xray.ClientTraffic{Enable: false}, "🚫"},
{xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
}
for _, c := range cases {
if got := statusEmoji(c.stats); got != c.want {
t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
}
}
}
func TestUsagePercentage(t *testing.T) {
if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
t.Errorf("usagePercentage 50%% = %q", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
t.Errorf("usagePercentage unlimited = %q, want empty", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
t.Errorf("usagePercentage 100%% = %q", got)
}
// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
}
}
func TestTimeLeftLabel(t *testing.T) {
if got := timeLeftLabel(0); got != "∞" {
t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
}
// Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
if got := timeLeftLabel(-1000); got != "0m" {
t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
}
}
func TestGregorianToJalali(t *testing.T) {
cases := []struct {
gy, gm, gd int
jy, jm, jd int
}{
{2024, 1, 1, 1402, 10, 11},
{2000, 3, 20, 1379, 1, 1},
{1979, 2, 11, 1357, 11, 22},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
if jy != c.jy || jm != c.jm || jd != c.jd {
t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
}
}
}
func TestJalaliExpireDateLabel(t *testing.T) {
if got := jalaliExpireDateLabel(0); got != "" {
t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
}
if got := jalaliExpireDateLabel(-1000); got != "" {
t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
}
}
func TestExpandNewTokensInTemplate(t *testing.T) {
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
client := model.Client{Email: "alice@test.com", ID: "abc-123"}
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
ctx := remarkContext{
client: client,
stats: stats,
inbound: inbound,
transport: "ws",
}
cases := []struct{ tmpl, want string }{
{"{{STATUS_EMOJI}}", "✅"},
{"{{USAGE_PERCENTAGE}}", "50.0%"},
{"{{PROTOCOL}}", "VLESS"},
{"{{TRANSPORT}}", "ws"},
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
}
func TestTranslateUISingleBrackets(t *testing.T) {
cases := []struct{ in, want string }{
{"{EMAIL}", "{{EMAIL}}"},
{"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
{"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
{"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
{"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
{"no braces", "no braces"},
{"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
{"{username}", "{username}"},
}
for _, c := range cases {
if got := translateUISingleBrackets(c.in); got != c.want {
t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
ctx := remarkContext{
client: model.Client{Email: "alice@test.com"},
stats: stats,
inbound: inbound,
transport: "ws",
}
cases := []struct{ tmpl, want string }{
{"{EMAIL}", "alice@test.com"},
{"{DATA_LEFT}", "50.00GB"},
{"{DATA_USAGE}", "50.00GB"},
{"{DATA_LIMIT}", "100.00GB"},
{"{STATUS_EMOJI}", "✅"},
{"{USAGE_PERCENTAGE}", "50.0%"},
{"{PROTOCOL}", "VLESS"},
{"{TRANSPORT}", "ws"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
}
func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
s := &SubService{
remarkTemplate: "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
Up: 20 * gb,
Down: 10 * gb,
}},
}
client := model.Client{Email: "alice@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
s.usageShown["alice@x"] = true
second := s.genTemplatedRemark(inbound, client, "", "ws")
if !strings.Contains(first, "📊") {
t.Fatalf("first link should carry usage: %q", first)
}
if strings.Contains(second, "📊") {
t.Fatalf("second link must not carry usage: %q", second)
}
}
+16 -15
View File
@@ -532,10 +532,10 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
externalProxies, _ := stream["externalProxy"].([]any)
if len(externalProxies) > 0 {
return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email)
return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email, network)
}
obj["ps"] = s.genRemark(inbound, email, "")
obj["ps"] = s.genRemark(inbound, email, "", network)
return buildVmessLink(obj)
}
@@ -619,13 +619,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
},
func(ep map[string]any) string {
return s.endpointRemark(inbound, email, ep)
return s.endpointRemark(inbound, email, ep, streamNetwork)
},
)
}
link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
}
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
@@ -670,13 +670,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
},
func(ep map[string]any) string {
return s.endpointRemark(inbound, email, ep)
return s.endpointRemark(inbound, email, ep, streamNetwork)
},
)
}
link := fmt.Sprintf("trojan://%s@%s", password, joinHostPort(address, port))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
}
// encodeUserinfo percent-encodes a userinfo (password/auth) value so it
@@ -763,13 +763,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
},
func(ep map[string]any) string {
return s.endpointRemark(inbound, email, ep)
return s.endpointRemark(inbound, email, ep, streamNetwork)
},
)
}
link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
}
func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
@@ -872,7 +872,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
applyExternalProxyHysteriaParams(ep, epParams)
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep)))
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic")))
}
return strings.Join(links, "\n")
}
@@ -883,7 +883,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
params["mport"] = hopPorts
}
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
}
// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range
@@ -1494,14 +1494,15 @@ func joinAnyStrings(items []any) string {
// buildVmessExternalProxyLinks is a thin adapter: it maps the legacy
// externalProxy entries to []ShareEndpoint and renders them through the unified
// endpoint path. Kept so genVmessLink's call site is unchanged.
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
// endpoint path. Kept as a thin shim over the unified endpoint builder so
// genVmessLink keeps calling one helper (now threading transport through).
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
eps := make([]ShareEndpoint, 0, len(externalProxies))
for _, externalProxy := range externalProxies {
ep, _ := externalProxy.(map[string]any)
eps = append(eps, externalProxyToEndpoint(ep))
}
return s.buildEndpointVmessLinks(eps, baseObj, inbound, email)
return s.buildEndpointVmessLinks(eps, baseObj, inbound, email, transport)
}
// buildLinkWithParams appends ?query and #fragment to a pre-built
@@ -1588,9 +1589,9 @@ func cloneStringMap(source map[string]string) map[string]string {
// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
// remark template takes over; otherwise (and in every display context) the
// remark is just the config name (inbound remark, then extra).
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
if s.remarkTemplate != "" && s.subscriptionBody {
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra)
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
}
// Sub info page + panel link/QR displays: just the config name (no template,
// so no per-client email/usage leaks into the shown remark).
+1 -1
View File
@@ -35,7 +35,7 @@ func TestGenRemarkOmitsNodeName(t *testing.T) {
nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
}
ib := &model.Inbound{Remark: "vless-tcp", NodeID: &nodeID}
if got := s.genRemark(ib, "", ""); got != "vless-tcp" {
if got := s.genRemark(ib, "", "", ""); got != "vless-tcp" {
t.Fatalf("remark = %q, want %q (node name must not leak into client-visible remarks)", got, "vless-tcp")
}
}