mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-20 11:36:06 +00:00
feat(sub): add PROTOCOL, TRANSPORT, SECURITY remark template variables
This commit is contained in:
+90
-31
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
@@ -25,6 +24,7 @@ type remarkContext struct {
|
||||
inbound *model.Inbound
|
||||
hostRemark string
|
||||
transport string
|
||||
security string
|
||||
}
|
||||
|
||||
// configName is the display name for a link: always the inbound's own remark.
|
||||
@@ -71,6 +71,7 @@ var uiTokenMap = map[string]string{
|
||||
"USAGE_PERCENTAGE": "USAGE_PERCENTAGE",
|
||||
"PROTOCOL": "PROTOCOL",
|
||||
"TRANSPORT": "TRANSPORT",
|
||||
"SECURITY": "SECURITY",
|
||||
}
|
||||
|
||||
// translateUISingleBrackets converts user-friendly single-brace tokens to the
|
||||
@@ -226,6 +227,8 @@ func remarkVarValue(token string, ctx remarkContext) string {
|
||||
return ""
|
||||
case "TRANSPORT":
|
||||
return ctx.transport
|
||||
case "SECURITY":
|
||||
return strings.ToUpper(ctx.security)
|
||||
case "TIME_LEFT":
|
||||
return timeLeftLabel(st.ExpiryTime)
|
||||
case "JALALI_EXPIRE_DATE":
|
||||
@@ -458,52 +461,107 @@ func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Cl
|
||||
return model.Client{Email: email}
|
||||
}
|
||||
|
||||
// usageInfoTokens are the per-client status tokens. On every link of a
|
||||
// subscription except the client's first, these (and the decoration leading
|
||||
// into them) are dropped, so the traffic/expiry info shows once instead of on
|
||||
// every server.
|
||||
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",
|
||||
var usageInfoTokens = map[string]bool{
|
||||
"TRAFFIC_USED": true, "TRAFFIC_LEFT": true, "TRAFFIC_TOTAL": true,
|
||||
"TRAFFIC_USED_BYTES": true, "TRAFFIC_LEFT_BYTES": true, "TRAFFIC_TOTAL_BYTES": true,
|
||||
"UP": true, "DOWN": true, "DAYS_LEFT": true, "EXPIRE_DATE": true, "EXPIRE_UNIX": true,
|
||||
"STATUS": true, "STATUS_EMOJI": true, "USAGE_PERCENTAGE": true, "TIME_LEFT": true,
|
||||
"JALALI_EXPIRE_DATE": true,
|
||||
}
|
||||
|
||||
// nameOnlyTemplate returns template with the trailing per-client info part
|
||||
// removed: everything from the first usage token (and the decoration — emojis,
|
||||
// spaces, separators — leading into it) onward is dropped, leaving the config
|
||||
// name. Returns "" when the template is info-only.
|
||||
func nameOnlyTemplate(template string) string {
|
||||
idx := -1
|
||||
for _, tok := range usageInfoTokens {
|
||||
if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
|
||||
idx = i
|
||||
var connectionTokens = map[string]bool{
|
||||
"PROTOCOL": true,
|
||||
"TRANSPORT": true,
|
||||
"SECURITY": true,
|
||||
}
|
||||
|
||||
var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)
|
||||
|
||||
func mergeTokenSets(sets ...map[string]bool) map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, set := range sets {
|
||||
for tok := range set {
|
||||
out[tok] = true
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return template
|
||||
}
|
||||
return strings.TrimRightFunc(template[:idx], func(r rune) bool {
|
||||
return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func filterRemarkTemplate(template string, remove map[string]bool) string {
|
||||
segments := strings.Split(template, "|")
|
||||
kept := make([]string, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if out := filterRemarkSegment(seg, remove); out != "" {
|
||||
kept = append(kept, out)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "|")
|
||||
}
|
||||
|
||||
func filterRemarkSegment(seg string, remove map[string]bool) string {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
|
||||
hasRemove := false
|
||||
for _, loc := range locs {
|
||||
if remove[seg[loc[2]:loc[3]]] {
|
||||
hasRemove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRemove {
|
||||
return strings.TrimSpace(seg)
|
||||
}
|
||||
runs := make([]string, 0, 2)
|
||||
runStart, leftRemoved := 0, false
|
||||
for _, loc := range locs {
|
||||
if !remove[seg[loc[2]:loc[3]]] {
|
||||
continue
|
||||
}
|
||||
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
|
||||
runStart, leftRemoved = loc[1], true
|
||||
}
|
||||
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
|
||||
return strings.Join(runs, " ")
|
||||
}
|
||||
|
||||
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
|
||||
if len(locs) == 0 {
|
||||
return runs
|
||||
}
|
||||
start, end := 0, len(run)
|
||||
if leftRemoved {
|
||||
start = locs[0][0]
|
||||
}
|
||||
if rightRemoved {
|
||||
end = locs[len(locs)-1][1]
|
||||
}
|
||||
if frag := strings.TrimSpace(run[start:end]); frag != "" {
|
||||
runs = append(runs, frag)
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// effectiveTemplate picks which template to expand for one body link: the full
|
||||
// template (with the per-client info) for a client's first link, and the
|
||||
// name-only template for every link thereafter — so the info shows once. Only
|
||||
// called in the subscription-body context (displays render name-only directly).
|
||||
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(translated)
|
||||
return filterRemarkTemplate(translated, usageInfoTokens)
|
||||
}
|
||||
s.usageShown[email] = true
|
||||
return translated
|
||||
}
|
||||
|
||||
func inboundSecurity(inbound *model.Inbound) string {
|
||||
if inbound == nil {
|
||||
return ""
|
||||
}
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
security, _ := stream["security"].(string)
|
||||
return security
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -514,12 +572,13 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
|
||||
inbound: inbound,
|
||||
hostRemark: hostRemark,
|
||||
transport: transport,
|
||||
security: inboundSecurity(inbound),
|
||||
}
|
||||
var tmpl string
|
||||
if s.subscriptionBody {
|
||||
tmpl = s.effectiveTemplate(client.Email)
|
||||
} else {
|
||||
tmpl = nameOnlyTemplate(translateUISingleBrackets(s.remarkTemplate))
|
||||
tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
|
||||
}
|
||||
if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
|
||||
return out
|
||||
|
||||
@@ -251,22 +251,138 @@ func TestRemarkInDisplayContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
|
||||
func TestNameOnlyTemplate(t *testing.T) {
|
||||
func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // usage tail stripped
|
||||
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
|
||||
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
|
||||
"{{TRAFFIC_LEFT}}": "", // info only → empty
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",
|
||||
"{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}": "{{INBOUND}} {{PROTOCOL}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
|
||||
"{{TRAFFIC_LEFT}}|{{SECURITY}}": "{{SECURITY}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{INBOUND}}|{{PROTOCOL}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}": "{{INBOUND}}|{{EMAIL}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
|
||||
"{{EMAIL}} {{TRAFFIC_USED}}5h": "{{EMAIL}}",
|
||||
"{{PROTOCOL}} {{TRAFFIC_LEFT}}GB": "{{PROTOCOL}}",
|
||||
"{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}": "{{EMAIL}} {{HOST}}",
|
||||
"{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{EMAIL}} {{PROTOCOL}}",
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := nameOnlyTemplate(tmpl); got != want {
|
||||
t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
|
||||
if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
|
||||
t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRemarkTemplate_Display(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
|
||||
"{{INBOUND}} {{PROTOCOL}}": "{{INBOUND}}",
|
||||
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}",
|
||||
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
|
||||
"{{TRAFFIC_LEFT}}": "",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}": "{{INBOUND}}|{{HOST}}",
|
||||
"{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}": "{{EMAIL}} {{HOST}}",
|
||||
"{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}": "{{INBOUND}} {{EMAIL}}",
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
|
||||
t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
|
||||
subscriptionBody: true,
|
||||
usageShown: map[string]bool{},
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
client := model.Client{Email: "john@x"}
|
||||
first := s.genTemplatedRemark(inbound, client, "", "ws")
|
||||
second := s.genTemplatedRemark(inbound, client, "", "ws")
|
||||
for _, want := range []string{"VLESS", "ws", "TLS"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Fatalf("first body link %q missing %q", first, want)
|
||||
}
|
||||
if !strings.Contains(second, want) {
|
||||
t.Fatalf("repeat body link %q missing connection token %q", second, want)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
|
||||
t.Fatalf("repeat body link must drop the usage block: %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
|
||||
subscriptionBody: true,
|
||||
usageShown: map[string]bool{},
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"grpc","security":"reality"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
client := model.Client{Email: "john@x"}
|
||||
_ = s.genTemplatedRemark(inbound, client, "", "grpc")
|
||||
second := s.genTemplatedRemark(inbound, client, "", "grpc")
|
||||
for _, want := range []string{"VLESS", "grpc", "REALITY"} {
|
||||
if !strings.Contains(second, want) {
|
||||
t.Fatalf("repeat body link %q missing connection token %q", second, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
|
||||
t.Fatalf("repeat body link must drop the usage block: %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
|
||||
subscriptionBody: false,
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
|
||||
t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityTokensEverywhere(t *testing.T) {
|
||||
const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
client := model.Client{Email: "john@x"}
|
||||
|
||||
body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
|
||||
_ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
|
||||
if second := body.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(second, "john@x") {
|
||||
t.Fatalf("repeat body link %q must keep the identity token", second)
|
||||
}
|
||||
|
||||
display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
|
||||
if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
|
||||
t.Fatalf("display remark %q must keep the identity token", got)
|
||||
}
|
||||
}
|
||||
|
||||
// statsForClient resolves usage from the per-request statsByEmail map when the
|
||||
// link's own inbound doesn't carry the client's (globally unique) traffic row —
|
||||
// the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
|
||||
@@ -377,6 +493,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
stats: stats,
|
||||
inbound: inbound,
|
||||
transport: "ws",
|
||||
security: "reality",
|
||||
}
|
||||
|
||||
cases := []struct{ tmpl, want string }{
|
||||
@@ -384,6 +501,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
{"{{USAGE_PERCENTAGE}}", "50.0%"},
|
||||
{"{{PROTOCOL}}", "VLESS"},
|
||||
{"{{TRANSPORT}}", "ws"},
|
||||
{"{{SECURITY}}", "REALITY"},
|
||||
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -393,6 +511,32 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundSecurity(t *testing.T) {
|
||||
cases := []struct{ stream, want string }{
|
||||
{`{"network":"ws","security":"tls"}`, "tls"},
|
||||
{`{"network":"tcp","security":"reality"}`, "reality"},
|
||||
{`{"network":"tcp","security":"none"}`, "none"},
|
||||
{`{"network":"tcp"}`, ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
|
||||
t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := inboundSecurity(nil); got != "" {
|
||||
t.Errorf("inboundSecurity(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
|
||||
s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
|
||||
inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
|
||||
if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
|
||||
t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateUISingleBrackets(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"{EMAIL}", "{{EMAIL}}"},
|
||||
@@ -419,6 +563,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
|
||||
stats: stats,
|
||||
inbound: inbound,
|
||||
transport: "ws",
|
||||
security: "tls",
|
||||
}
|
||||
cases := []struct{ tmpl, want string }{
|
||||
{"{EMAIL}", "alice@test.com"},
|
||||
@@ -429,6 +574,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
|
||||
{"{USAGE_PERCENTAGE}", "50.0%"},
|
||||
{"{PROTOCOL}", "VLESS"},
|
||||
{"{TRANSPORT}", "ws"},
|
||||
{"{SECURITY}", "TLS"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
|
||||
|
||||
Reference in New Issue
Block a user