diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 8e2e3a7f0..0c684c567 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -149,7 +149,8 @@ func (a *SUBController) subs(c *gin.Context) { } else { var result strings.Builder for _, sub := range subs { - result.WriteString(sub + "\n") + result.WriteString(sub) + result.WriteString("\n") } // If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 66c319fdc..1ba475a7b 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -15,8 +15,9 @@ import ( // remarkContext carries the per-client data a remark template can interpolate. // stats holds the live traffic record when one exists; when it doesn't, the // caller synthesizes a minimal one from the client so expiry/total/status tokens -// still resolve. hostRemark is the host endpoint's own remark: it takes priority -// over the inbound's remark as the config name and backs the {{HOST}} token. +// still resolve. hostRemark is the host endpoint's own remark; it backs the +// {{HOST}} token only — it never substitutes the inbound's remark as the config +// name (use {{INBOUND}} and {{HOST}} side by side to show both). type remarkContext struct { client model.Client stats xray.ClientTraffic @@ -24,12 +25,9 @@ type remarkContext struct { hostRemark string } -// configName is the display name for a link: the host endpoint's own remark when -// it has one, otherwise the inbound's remark. +// configName is the display name for a link: always the inbound's own remark. +// The host endpoint's remark is surfaced only through the {{HOST}} token. func (ctx remarkContext) configName() string { - if ctx.hostRemark != "" { - return ctx.hostRemark - } if ctx.inbound != nil { return ctx.inbound.Remark } @@ -227,6 +225,14 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) if stats, ok := s.findClientStats(inbound, client.Email); ok { return stats } + // client_traffics.email is globally unique, so a client shared across several + // inbounds of one subscription has a single traffic row owned by exactly one + // inbound. On every other inbound's link findClientStats misses; fall back to + // the per-request map built from all the subscription's inbounds so + // {{TRAFFIC_*}} reflect real usage instead of the full quota (#5443). + if stats, ok := s.statsByEmail[client.Email]; ok { + return stats + } return xray.ClientTraffic{ Enable: client.Enable, ExpiryTime: client.ExpiryTime, @@ -292,8 +298,8 @@ func (s *SubService) effectiveTemplate(email string) string { } // genTemplatedRemark expands the remark template for one client. hostRemark is -// the host endpoint's remark (empty for a plain inbound); it takes priority over -// the inbound remark for the config name and backs the {{HOST}} token. +// 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 { ctx := remarkContext{ client: client, @@ -311,9 +317,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli } // genHostRemark builds one host endpoint's remark for a specific client. The -// config name is the host endpoint's own remark when set, otherwise the inbound's -// remark. In the subscription body the rest of the remark template still applies; -// displays show just the config name. +// 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 { if !s.subscriptionBody { return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName() diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index aac964e01..42845f1ad 100644 --- a/internal/sub/remark_vars_test.go +++ b/internal/sub/remark_vars_test.go @@ -165,30 +165,30 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie return s, inbound, client } -// The config name prefers the host endpoint's own remark; the inbound's remark is -// the fallback, used only when the host has none. -func TestGenHostRemark_ConfigNameHostWins(t *testing.T) { +// The config name is always the inbound's own remark; the host endpoint's remark +// 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 != "Relay" { - t.Fatalf("genHostRemark = %q, want %q (host remark wins)", got, "Relay") + 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" { - t.Fatalf("genHostRemark (no host remark) = %q, want %q (inbound fallback)", got, "DE") + t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE") } } -// In the body the template applies: {{INBOUND}} is the config name (host remark -// first, inbound fallback) and {{HOST}} is always the host's own remark. +// In the body the template applies: {{INBOUND}} is always the inbound's remark +// and {{HOST}} the host's own remark, so the two can be shown side by side. func TestGenHostRemark_GlobalTemplate(t *testing.T) { - // Host remark set → {{INBOUND}} resolves to it (host wins over the inbound). + // {{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 != "CDN | 80.00GB | 10d" { - t.Fatalf("global template (host wins) = %q", got) + if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" { + t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got) } - // No host remark → {{INBOUND}} falls back to the inbound's own remark. - s2, inbound2, client2 := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}}") - if got := s2.genHostRemark(inbound2, client2, ""); got != "DE | 80.00GB" { - t.Fatalf("global template (inbound fallback) = %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" { + 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}}") @@ -239,12 +239,12 @@ func TestUsageOnFirstLinkOnly(t *testing.T) { func TestRemarkInDisplayContext(t *testing.T) { s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") s.subscriptionBody = false - // A host link in a display shows only the config name — host remark wins, with - // no per-client email or usage info. - if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN" { - t.Fatalf("display host link = %q, want config name %q (host wins)", got, "CDN") + // 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" { + t.Fatalf("display host link = %q, want config name %q", got, "DE") } - // With no host remark, the config name is the inbound's own remark. + // With no host remark, the config name is likewise the inbound's own remark. if got := s.genHostRemark(inbound, client, ""); got != "DE" { t.Fatalf("display host link (no host) = %q, want %q", got, "DE") } @@ -270,6 +270,26 @@ func TestNameOnlyTemplate(t *testing.T) { } } +// 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). +func TestStatsForClient_CrossInboundFallback(t *testing.T) { + s := &SubService{ + statsByEmail: map[string]xray.ClientTraffic{ + "john@example.com": {Email: "john@example.com", Total: 100 * gb, Up: 15 * gb, Down: 5 * gb}, + }, + } + // Inbound B carries no ClientStats for john (his row is owned by inbound A). + inboundB := &model.Inbound{Remark: "B"} + st := s.statsForClient(inboundB, model.Client{Email: "john@example.com"}) + if used := st.Up + st.Down; used != 20*gb { + t.Fatalf("statsForClient used = %d, want %d (cross-inbound fallback)", used, 20*gb) + } + if got := remarkVarValue("TRAFFIC_LEFT", remarkContext{stats: st}); got != "80.00GB" { + t.Fatalf("TRAFFIC_LEFT = %q, want 80.00GB (remaining, not total)", got) + } +} + // Two clients through the same global template get distinct, per-client remarks. func TestGenHostRemark_PerClient(t *testing.T) { s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true} diff --git a/internal/sub/service.go b/internal/sub/service.go index 2c17ec554..35b155d53 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -47,6 +47,12 @@ type SubService struct { // inbound whose NodeID is set. Keeps the per-link host derivation // O(1) instead of O(N) DB hits. nodesByID map[int]*model.Node + // statsByEmail maps a client email to its traffic row across ALL inbounds + // loaded for the request. client_traffics.email is globally unique, so this + // lets statsForClient resolve usage for a client even on an inbound that + // doesn't own its row (multi-inbound subscriptions). Filled in + // getInboundsBySubId; reset per request in PrepareForRequest. + statsByEmail map[string]xray.ClientTraffic } // NewSubService creates a new subscription service with the given configuration. @@ -78,6 +84,7 @@ func (s *SubService) PrepareForRequest(host string) { } s.address = host s.usageShown = map[string]bool{} + s.statsByEmail = map[string]xray.ClientTraffic{} s.loadNodes() s.loadRemarkSettings() } @@ -335,9 +342,24 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) if err != nil { return nil, err } + s.indexStatsByEmail(inbounds) return inbounds, nil } +// indexStatsByEmail records every loaded inbound's client traffic rows keyed by +// email so statsForClient can resolve a client's usage even on an inbound that +// doesn't own its (globally unique) client_traffics row. See statsByEmail. +func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) { + if s.statsByEmail == nil { + s.statsByEmail = map[string]xray.ClientTraffic{} + } + for _, inbound := range inbounds { + for _, st := range inbound.ClientStats { + s.statsByEmail[st.Email] = st + } + } +} + // projectThroughFallbackMaster mutates the inbound in place so its // Listen/Port/StreamSettings reflect the externally reachable master // when applicable. Covers both fallback mechanisms: diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 5dcf9ba95..3b2b7fbf3 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1761,7 +1761,7 @@ "time": "الوقت والحالة" }, "descEMAIL": "بريد العميل", - "descINBOUND": "اسم الإعداد: ملاحظة المضيف عند تعيينها، وإلا ملاحظة الوارد", + "descINBOUND": "ملاحظة الوارد نفسه (اسم الإعداد)", "descHOST": "ملاحظة المضيف", "descID": "UUID العميل", "descSHORT_ID": "أول 8 أحرف من الـ UUID", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 27050b5e0..7933066a8 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -951,7 +951,7 @@ "time": "Time & status" }, "descEMAIL": "Client email", - "descINBOUND": "Config name: the host's remark when set, otherwise the inbound's remark", + "descINBOUND": "Inbound's own remark (the config name)", "descHOST": "Host remark", "descID": "Client UUID", "descSHORT_ID": "First 8 characters of the UUID", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 24575f4d6..581b6e6d2 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1761,7 +1761,7 @@ "time": "Tiempo y estado" }, "descEMAIL": "Email del cliente", - "descINBOUND": "Nombre de la configuración: las notas del host cuando están definidas, de lo contrario las notas del inbound", + "descINBOUND": "Notas del propio inbound (nombre de la configuración)", "descHOST": "Notas del host", "descID": "UUID del cliente", "descSHORT_ID": "Primeros 8 caracteres del UUID", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4152973b3..24c0c5536 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1761,7 +1761,7 @@ "time": "زمان و وضعیت" }, "descEMAIL": "ایمیل کاربر", - "descINBOUND": "نام کانفیگ: نام میزبان در صورت تنظیم، در غیر این صورت نام اینباند", + "descINBOUND": "نام خود اینباند (نام کانفیگ)", "descHOST": "نام میزبان", "descID": "UUID کاربر", "descSHORT_ID": "۸ کاراکتر اول UUID", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 1db2d5ab3..9624e98a3 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1761,7 +1761,7 @@ "time": "Waktu & status" }, "descEMAIL": "Email klien", - "descINBOUND": "Nama konfigurasi: catatan host bila diatur, jika tidak catatan inbound", + "descINBOUND": "Catatan inbound itu sendiri (nama konfigurasi)", "descHOST": "Catatan host", "descID": "UUID klien", "descSHORT_ID": "8 karakter pertama dari UUID", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index db97ba851..2dcbe57fa 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1761,7 +1761,7 @@ "time": "時刻とステータス" }, "descEMAIL": "クライアントのメール", - "descINBOUND": "設定名: ホストの備考が設定されている場合はそれ、それ以外はインバウンドの備考", + "descINBOUND": "インバウンド自身の備考(設定名)", "descHOST": "ホストの備考", "descID": "クライアント UUID", "descSHORT_ID": "UUID の最初の 8 文字", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index ae5fc0051..94c569148 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1761,7 +1761,7 @@ "time": "Tempo e status" }, "descEMAIL": "Email do cliente", - "descINBOUND": "Nome da configuração: a observação do host quando definida, caso contrário a observação da entrada", + "descINBOUND": "Observação da própria entrada (nome da configuração)", "descHOST": "Observação do host", "descID": "UUID do cliente", "descSHORT_ID": "Primeiros 8 caracteres do UUID", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 0c96bb448..d03b44a5c 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1761,7 +1761,7 @@ "time": "Время и статус" }, "descEMAIL": "Email клиента", - "descINBOUND": "Имя конфигурации: примечание хоста, если задано, иначе примечание входящего", + "descINBOUND": "Собственное примечание входящего (имя конфигурации)", "descHOST": "Примечание хоста", "descID": "UUID клиента", "descSHORT_ID": "Первые 8 символов UUID", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 00987f503..427a45344 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1761,7 +1761,7 @@ "time": "Zaman ve durum" }, "descEMAIL": "Kullanıcı e-postası", - "descINBOUND": "Yapılandırma adı: ayarlanmışsa host'un açıklaması, aksi halde gelen bağlantının açıklaması", + "descINBOUND": "Gelen bağlantının kendi açıklaması (yapılandırma adı)", "descHOST": "Host açıklaması", "descID": "Kullanıcı UUID'si", "descSHORT_ID": "UUID'nin ilk 8 karakteri", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index d9268bcc3..50dc0bf96 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1761,7 +1761,7 @@ "time": "Час і статус" }, "descEMAIL": "Email клієнта", - "descINBOUND": "Назва конфігурації: примітка хоста, якщо задана, інакше примітка вхідного", + "descINBOUND": "Власна примітка вхідного (назва конфігурації)", "descHOST": "Примітка хоста", "descID": "UUID клієнта", "descSHORT_ID": "Перші 8 символів UUID", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 26e0a49e6..0c3b70c44 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1761,7 +1761,7 @@ "time": "Thời gian & trạng thái" }, "descEMAIL": "Email khách hàng", - "descINBOUND": "Tên cấu hình: ghi chú của host nếu được đặt, nếu không thì ghi chú của inbound", + "descINBOUND": "Ghi chú của chính inbound (tên cấu hình)", "descHOST": "Ghi chú host", "descID": "UUID khách hàng", "descSHORT_ID": "8 ký tự đầu của UUID", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 861588c69..c16c19734 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1761,7 +1761,7 @@ "time": "时间与状态" }, "descEMAIL": "客户端邮箱", - "descINBOUND": "配置名称:已设置时为主机的备注,否则为入站的备注", + "descINBOUND": "入站本身的备注(配置名称)", "descHOST": "主机备注", "descID": "客户端 UUID", "descSHORT_ID": "UUID 的前 8 个字符", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 2bdb50226..b93870da2 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1761,7 +1761,7 @@ "time": "時間與狀態" }, "descEMAIL": "客戶端電子郵件", - "descINBOUND": "配置名稱:設定時為 Host 的備註,否則為入站的備註", + "descINBOUND": "入站本身的備註(配置名稱)", "descHOST": "Host 備註", "descID": "客戶端 UUID", "descSHORT_ID": "UUID 的前 8 個字元",