mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
6c279d48fd
commit
a038ad6135
6 changed files with 101 additions and 105 deletions
|
|
@ -481,7 +481,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||
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);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,17 @@ function trimEmail(remark: string, email: string): string {
|
|||
.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 } {
|
||||
const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
|
||||
const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
|
||||
|
|
@ -79,7 +90,7 @@ function parseLinkMeta(link: string): { protocol: string; remark: string } {
|
|||
if (scheme === 'vmess') {
|
||||
try {
|
||||
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;
|
||||
} catch { /* fall through to fragment parsing */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,17 @@ function isPostQuantumLink(link: string): boolean {
|
|||
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 } {
|
||||
const fallback = `Link ${idx + 1}`;
|
||||
if (!link) return { protocol: 'LINK', remark: fallback };
|
||||
|
|
@ -122,7 +133,7 @@ function parseLinkMeta(link: string, idx: number): { protocol: string; remark: s
|
|||
if (scheme === 'vmess') {
|
||||
try {
|
||||
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;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
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/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
type SubClashService struct {
|
||||
|
|
@ -38,8 +36,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||
return "", "", err
|
||||
}
|
||||
|
||||
var traffic xray.ClientTraffic
|
||||
var clientTraffics []xray.ClientTraffic
|
||||
var proxies []map[string]any
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
|
|
@ -54,7 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||
s.SubService.projectThroughFallbackMaster(inbound)
|
||||
for _, client := range clients {
|
||||
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)...)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,27 +60,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||
return "", "", nil
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for index, clientTraffic := range clientTraffics {
|
||||
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
|
||||
}
|
||||
}
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
||||
|
||||
proxyNames := make([]string, 0, len(proxies)+1)
|
||||
for _, proxy := range proxies {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,12 @@ import (
|
|||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
//go:embed default.json
|
||||
|
|
@ -97,8 +95,6 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
|||
}
|
||||
|
||||
var header string
|
||||
var traffic xray.ClientTraffic
|
||||
var clientTraffics []xray.ClientTraffic
|
||||
var configArray []json_util.RawMessage
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
|
|
@ -115,7 +111,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
|||
|
||||
for _, client := range clients {
|
||||
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)...)
|
||||
}
|
||||
}
|
||||
|
|
@ -125,28 +121,11 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
|||
return "", "", nil
|
||||
}
|
||||
|
||||
// Prepare statistics
|
||||
now := time.Now().UnixMilli()
|
||||
for index, clientTraffic := range clientTraffics {
|
||||
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
|
||||
}
|
||||
}
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
||||
|
||||
// Combile outbounds
|
||||
var finalJson []byte
|
||||
|
|
|
|||
|
|
@ -62,9 +62,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
|
|||
var result []string
|
||||
var emails []string
|
||||
var traffic xray.ClientTraffic
|
||||
var lastOnline int64
|
||||
var hasEnabledClient bool
|
||||
var clientTraffics []xray.ClientTraffic
|
||||
inbounds, err := s.getInboundsBySubId(subId)
|
||||
if err != nil {
|
||||
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))
|
||||
emails = append(emails, client.Email)
|
||||
var ct xray.ClientTraffic
|
||||
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
||||
if ct.LastOnline > lastOnline {
|
||||
lastOnline = ct.LastOnline
|
||||
}
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for index, clientTraffic := range clientTraffics {
|
||||
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
|
||||
}
|
||||
}
|
||||
uniqueEmails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
uniqueEmails = append(uniqueEmails, e)
|
||||
}
|
||||
traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
|
||||
traffic.Enable = hasEnabledClient
|
||||
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 {
|
||||
if expiryTime > 0 {
|
||||
return expiryTime
|
||||
|
|
@ -163,28 +189,6 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
|||
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
|
||||
// Listen/Port/StreamSettings reflect the externally reachable master
|
||||
// when applicable. Covers both fallback mechanisms:
|
||||
|
|
@ -396,7 +400,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
|||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
clientIndex := findClientIndex(clients, email)
|
||||
password := clients[clientIndex].Password
|
||||
password := encodeUserinfo(clients[clientIndex].Password)
|
||||
port := inbound.Port
|
||||
streamNetwork := stream["network"].(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, ""))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if inbound.Protocol != model.Shadowsocks {
|
||||
return ""
|
||||
|
|
@ -507,7 +522,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
|||
break
|
||||
}
|
||||
}
|
||||
auth := clients[clientIndex].Auth
|
||||
auth := encodeUserinfo(clients[clientIndex].Auth)
|
||||
params := make(map[string]string)
|
||||
|
||||
params["security"] = "tls"
|
||||
|
|
|
|||
Loading…
Reference in a new issue