fix(sub): multi-inbound traffic + trojan/hysteria userinfo + utf-8 vmess remark

Three bugs surfaced by the new SubPage and the recent client-record
refactor:

- xray.ClientTraffic.Email is globally unique, so a multi-inbound
  client has exactly one traffic row attached to whichever inbound
  claimed it. Iterating inbound.ClientStats per inbound dedup-locked
  the first lookup to zero for clients that lived under any other
  inbound, so the SubPage info table read 0 B for all the multi-
  inbound subs. Replaced appendUniqueTraffic with a single
  AggregateTrafficByEmails(emails) helper that runs one WHERE email
  IN (?) over xray.ClientTraffic and folds the rows. GetSubs /
  SubClashService.GetClash / SubJsonService.GetJson all share it.

- Trojan and Hysteria share-links embedded the raw password/auth into
  the userinfo (scheme://<value>@host) without percent-encoding, so
  passwords containing `/` or `=` (e.g., base64-with-padding) broke
  popular trojan clients with parse errors. Added encodeUserinfo()
  that wraps url.QueryEscape and rewrites the `+` (space) back to
  `%20` for parity with encodeURIComponent on the frontend; applied
  to trojan.password and hysteria.auth. Same fix on the frontend's
  genTrojanLink.

- VMess link remarks ride inside a base64-encoded JSON payload, but
  the SubPage / ClientInfoModal parser used JSON.parse(atob(body)),
  which treats the binary string as Latin-1 and shreds any multi-byte
  UTF-8 sequence. Most visible on the emoji decorations
  (genRemark appends 📊/), so a remark like `test-1.00GB📊` rendered
  as `test-1.00GBð…`. Routed through Uint8Array +
  TextDecoder('utf-8') so multi-byte codepoints survive.
This commit is contained in:
MHSanaei 2026-05-27 04:22:51 +02:00
parent 6c279d48fd
commit a038ad6135
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 101 additions and 105 deletions

View file

@ -481,7 +481,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
params.set('security', 'none'); params.set('security', 'none');
} }
const url = new URL(`trojan://${clientPassword}@${address}:${port}`); const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value); for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark); url.hash = encodeURIComponent(remark);
return url.toString(); return url.toString();

View file

@ -61,6 +61,17 @@ function trimEmail(remark: string, email: string): string {
.trim(); .trim();
} }
// Decode a base64 string as UTF-8. atob() returns a binary string where
// each char holds one raw byte (Latin-1 interpretation), which mangles
// any multi-byte UTF-8 sequence in the payload — most commonly the
// emoji decorations the panel embeds in remarks (📊, ⏳).
function base64DecodeUtf8(b64: string): string {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder('utf-8').decode(bytes);
}
function parseLinkMeta(link: string): { protocol: string; remark: string } { function parseLinkMeta(link: string): { protocol: string; remark: string } {
const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link); const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
const scheme = schemeMatch?.[1]?.toLowerCase() ?? ''; const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
@ -79,7 +90,7 @@ function parseLinkMeta(link: string): { protocol: string; remark: string } {
if (scheme === 'vmess') { if (scheme === 'vmess') {
try { try {
const body = link.slice('vmess://'.length).split('#')[0]; const body = link.slice('vmess://'.length).split('#')[0];
const json = JSON.parse(atob(body)) as { ps?: unknown }; const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown };
if (typeof json?.ps === 'string') remark = json.ps; if (typeof json?.ps === 'string') remark = json.ps;
} catch { /* fall through to fragment parsing */ } } catch { /* fall through to fragment parsing */ }
} }

View file

@ -102,6 +102,17 @@ function isPostQuantumLink(link: string): boolean {
return false; return false;
} }
// Decode a base64 string as UTF-8. atob() returns a binary string where
// each char holds one raw byte (Latin-1 interpretation), which mangles
// any multi-byte UTF-8 sequence in the payload — most commonly the
// emoji decorations the panel embeds in remarks (📊, ⏳).
function base64DecodeUtf8(b64: string): string {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder('utf-8').decode(bytes);
}
function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } { function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } {
const fallback = `Link ${idx + 1}`; const fallback = `Link ${idx + 1}`;
if (!link) return { protocol: 'LINK', remark: fallback }; if (!link) return { protocol: 'LINK', remark: fallback };
@ -122,7 +133,7 @@ function parseLinkMeta(link: string, idx: number): { protocol: string; remark: s
if (scheme === 'vmess') { if (scheme === 'vmess') {
try { try {
const body = link.slice('vmess://'.length).split('#')[0]; const body = link.slice('vmess://'.length).split('#')[0];
const json = JSON.parse(atob(body)) as { ps?: unknown }; const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown };
if (typeof json?.ps === 'string') remark = json.ps; if (typeof json?.ps === 'string') remark = json.ps;
} catch { /* fall through */ } } catch { /* fall through */ }
} }

View file

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"maps" "maps"
"strings" "strings"
"time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
yaml "github.com/goccy/go-yaml" yaml "github.com/goccy/go-yaml"
@ -12,7 +11,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/xray"
) )
type SubClashService struct { type SubClashService struct {
@ -38,8 +36,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
return "", "", err return "", "", err
} }
var traffic xray.ClientTraffic
var clientTraffics []xray.ClientTraffic
var proxies []map[string]any var proxies []map[string]any
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
@ -54,7 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
s.SubService.projectThroughFallbackMaster(inbound) s.SubService.projectThroughFallbackMaster(inbound)
for _, client := range clients { for _, client := range clients {
if client.SubID == subId { if client.SubID == subId {
_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) seenEmails[client.Email] = struct{}{}
proxies = append(proxies, s.getProxies(inbound, client, host)...) proxies = append(proxies, s.getProxies(inbound, client, host)...)
} }
} }
@ -64,27 +60,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
return "", "", nil return "", "", nil
} }
now := time.Now().UnixMilli() emails := make([]string, 0, len(seenEmails))
for index, clientTraffic := range clientTraffics { for e := range seenEmails {
if index == 0 { emails = append(emails, e)
traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total
traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime)
} else {
traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down
if traffic.Total == 0 || clientTraffic.Total == 0 {
traffic.Total = 0
} else {
traffic.Total += clientTraffic.Total
}
normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime)
if normalized != traffic.ExpiryTime {
traffic.ExpiryTime = 0
}
}
} }
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
proxyNames := make([]string, 0, len(proxies)+1) proxyNames := make([]string, 0, len(proxies)+1)
for _, proxy := range proxies { for _, proxy := range proxies {

View file

@ -6,14 +6,12 @@ import (
"fmt" "fmt"
"maps" "maps"
"strings" "strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/json_util" "github.com/mhsanaei/3x-ui/v3/util/json_util"
"github.com/mhsanaei/3x-ui/v3/util/random" "github.com/mhsanaei/3x-ui/v3/util/random"
"github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/xray"
) )
//go:embed default.json //go:embed default.json
@ -97,8 +95,6 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
} }
var header string var header string
var traffic xray.ClientTraffic
var clientTraffics []xray.ClientTraffic
var configArray []json_util.RawMessage var configArray []json_util.RawMessage
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
@ -115,7 +111,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
for _, client := range clients { for _, client := range clients {
if client.SubID == subId { if client.SubID == subId {
_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) seenEmails[client.Email] = struct{}{}
configArray = append(configArray, s.getConfig(inbound, client, host)...) configArray = append(configArray, s.getConfig(inbound, client, host)...)
} }
} }
@ -125,28 +121,11 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
return "", "", nil return "", "", nil
} }
// Prepare statistics emails := make([]string, 0, len(seenEmails))
now := time.Now().UnixMilli() for e := range seenEmails {
for index, clientTraffic := range clientTraffics { emails = append(emails, e)
if index == 0 {
traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total
traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime)
} else {
traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down
if traffic.Total == 0 || clientTraffic.Total == 0 {
traffic.Total = 0
} else {
traffic.Total += clientTraffic.Total
}
normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime)
if normalized != traffic.ExpiryTime {
traffic.ExpiryTime = 0
}
}
} }
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
// Combile outbounds // Combile outbounds
var finalJson []byte var finalJson []byte

View file

@ -62,9 +62,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
var result []string var result []string
var emails []string var emails []string
var traffic xray.ClientTraffic var traffic xray.ClientTraffic
var lastOnline int64
var hasEnabledClient bool var hasEnabledClient bool
var clientTraffics []xray.ClientTraffic
inbounds, err := s.getInboundsBySubId(subId) inbounds, err := s.getInboundsBySubId(subId)
if err != nil { if err != nil {
return nil, nil, 0, traffic, err return nil, nil, 0, traffic, err
@ -101,40 +99,68 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
} }
result = append(result, s.GetLink(inbound, client.Email)) result = append(result, s.GetLink(inbound, client.Email))
emails = append(emails, client.Email) emails = append(emails, client.Email)
var ct xray.ClientTraffic seenEmails[client.Email] = struct{}{}
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
}
} }
} }
} }
now := time.Now().UnixMilli() uniqueEmails := make([]string, 0, len(seenEmails))
for index, clientTraffic := range clientTraffics { for e := range seenEmails {
if index == 0 { uniqueEmails = append(uniqueEmails, e)
traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total
traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime)
} else {
traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down
if traffic.Total == 0 || clientTraffic.Total == 0 {
traffic.Total = 0
} else {
traffic.Total += clientTraffic.Total
}
normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime)
if normalized != traffic.ExpiryTime {
traffic.ExpiryTime = 0
}
}
} }
traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
traffic.Enable = hasEnabledClient traffic.Enable = hasEnabledClient
return result, emails, lastOnline, traffic, nil return result, emails, lastOnline, traffic, nil
} }
// AggregateTrafficByEmails resolves traffic for every email in one
// query and folds the rows into a single ClientTraffic + lastOnline.
// xray.ClientTraffic.Email is globally unique, so a multi-inbound
// client's single row is attached to exactly one inbound — iterating
// per-inbound ClientStats would miss it on the others. Used by GetSubs,
// SubClashService.GetClash, and SubJsonService.GetJson to keep the
// sub-info header consistent across all three formats.
func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraffic, int64) {
var agg xray.ClientTraffic
var lastOnline int64
if len(emails) == 0 {
return agg, 0
}
var rows []xray.ClientTraffic
if err := database.GetDB().
Model(&xray.ClientTraffic{}).
Where("email IN ?", emails).
Find(&rows).Error; err != nil {
logger.Warning("SubService - AggregateTrafficByEmails: load by email:", err)
return agg, 0
}
now := time.Now().UnixMilli()
for i, ct := range rows {
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
}
if i == 0 {
agg.Up = ct.Up
agg.Down = ct.Down
agg.Total = ct.Total
agg.ExpiryTime = subscriptionExpiryFromClient(now, ct.ExpiryTime)
continue
}
agg.Up += ct.Up
agg.Down += ct.Down
if agg.Total == 0 || ct.Total == 0 {
agg.Total = 0
} else {
agg.Total += ct.Total
}
normalized := subscriptionExpiryFromClient(now, ct.ExpiryTime)
if normalized != agg.ExpiryTime {
agg.ExpiryTime = 0
}
}
return agg, lastOnline
}
func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
if expiryTime > 0 { if expiryTime > 0 {
return expiryTime return expiryTime
@ -163,28 +189,6 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
return inbounds, nil return inbounds, nil
} }
// appendUniqueTraffic resolves the traffic stats for email and appends them
// to acc only the first time email is seen. Shared-email mode lets one
// client_traffics row underpin several inbounds, so without dedupe its
// quota and usage would be counted once per inbound.
func (s *SubService) appendUniqueTraffic(seen map[string]struct{}, acc []xray.ClientTraffic, stats []xray.ClientTraffic, email string) (xray.ClientTraffic, []xray.ClientTraffic) {
ct := s.getClientTraffics(stats, email)
if _, dup := seen[email]; !dup {
seen[email] = struct{}{}
acc = append(acc, ct)
}
return ct, acc
}
func (s *SubService) getClientTraffics(traffics []xray.ClientTraffic, email string) xray.ClientTraffic {
for _, traffic := range traffics {
if traffic.Email == email {
return traffic
}
}
return xray.ClientTraffic{}
}
// projectThroughFallbackMaster mutates the inbound in place so its // projectThroughFallbackMaster mutates the inbound in place so its
// Listen/Port/StreamSettings reflect the externally reachable master // Listen/Port/StreamSettings reflect the externally reachable master
// when applicable. Covers both fallback mechanisms: // when applicable. Covers both fallback mechanisms:
@ -396,7 +400,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
stream := unmarshalStreamSettings(inbound.StreamSettings) stream := unmarshalStreamSettings(inbound.StreamSettings)
clients, _ := s.inboundService.GetClients(inbound) clients, _ := s.inboundService.GetClients(inbound)
clientIndex := findClientIndex(clients, email) clientIndex := findClientIndex(clients, email)
password := clients[clientIndex].Password password := encodeUserinfo(clients[clientIndex].Password)
port := inbound.Port port := inbound.Port
streamNetwork := stream["network"].(string) streamNetwork := stream["network"].(string)
params := make(map[string]string) params := make(map[string]string)
@ -439,6 +443,17 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
} }
// encodeUserinfo percent-encodes a userinfo (password/auth) value so it
// can be safely embedded in a `scheme://<value>@host:port` URL. RFC 3986
// allows `=` in userinfo as a sub-delim, but several Trojan and Hysteria
// clients reject share-links where the password contains literal `/`
// or `=` (notably the common base64-with-padding shape produced by the
// panel). Encode them too — this matches encodeURIComponent() on the
// frontend and round-trips cleanly through net/url's parser.
func encodeUserinfo(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.Shadowsocks { if inbound.Protocol != model.Shadowsocks {
return "" return ""
@ -507,7 +522,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
break break
} }
} }
auth := clients[clientIndex].Auth auth := encodeUserinfo(clients[clientIndex].Auth)
params := make(map[string]string) params := make(map[string]string)
params["security"] = "tls" params["security"] = "tls"