mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 01:04:59 +03:00
feat(inbound): excludeFromSub hides links without disabling (#6463)
* feat(inbound): excludeFromSub hides links without disabling Add a per-inbound flag that omits subscription output while keeping the inbound enabled for Xray, auth, and traffic accounting. Fixes #6435. * fix(inbound): excludeFromSub review follow-ups gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored on node mirror, and exercise the legacy add-column migration path in tests. * fix(sub): keep excluded inbounds' clients in the usage header The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded inbound's clients never reached seenEmails in the raw, Clash or JSON renderer. A client that lives only on a hidden inbound (one client per inbound sharing a subId) dropped out of the Subscription-Userinfo usage, quota and expiry and out of the info-node state, while the inbound kept serving it and counting its traffic. The query returns every enabled inbound again; each renderer skips an excluded inbound's links but still counts its clients, the same rule the Clash renderer already applies to external links it cannot express. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -63,6 +63,12 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
if len(clients) == 0 {
|
||||
continue
|
||||
}
|
||||
if inbound.ExcludeFromSub {
|
||||
if countHiddenClients(clients, seenEmails) {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
|
||||
@@ -141,6 +141,12 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
if len(clients) == 0 {
|
||||
continue
|
||||
}
|
||||
if inbound.ExcludeFromSub {
|
||||
if countHiddenClients(clients, seenEmails) {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
|
||||
@@ -330,6 +330,18 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
|
||||
return out
|
||||
}
|
||||
|
||||
// countHiddenClients adds an excludeFromSub inbound's clients to the usage set:
|
||||
// the inbound still serves them, so only its links leave the subscription.
|
||||
func countHiddenClients(clients []model.Client, seenEmails map[string]struct{}) (anyEnabled bool) {
|
||||
for _, client := range clients {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
if client.Enable {
|
||||
anyEnabled = true
|
||||
}
|
||||
}
|
||||
return anyEnabled
|
||||
}
|
||||
|
||||
// overlayInboundTunnelIdentity copies per-inbound tunnel fields from settings.
|
||||
// An unmatched peer is dropped, malformed settings yield nothing, and empty optional secrets replace shared values (#6641).
|
||||
func (s *SubService) overlayInboundTunnelIdentity(inbound *model.Inbound, clients []model.Client) ([]model.Client, error) {
|
||||
@@ -473,6 +485,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
if len(clients) == 0 {
|
||||
continue
|
||||
}
|
||||
if inbound.ExcludeFromSub {
|
||||
if countHiddenClients(clients, seenEmails) {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.projectThroughFallbackMaster(inbound)
|
||||
// Host overrides apply AFTER fallback projection so a host's
|
||||
// address/TLS wins over the projected master stream.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// An excluded inbound keeps serving its clients, so every renderer must drop
|
||||
// its links yet still count its clients' usage in the Subscription-Userinfo header.
|
||||
func TestExcludedInboundHidesLinksButKeepsUsage(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
shown := seedSubInbound(t, "sub-excl", "shown", 24401, 1, `{"network":"tcp","security":"none"}`)
|
||||
hidden := seedSubInbound(t, "sub-excl", "hidden", 24402, 2, `{"network":"tcp","security":"none"}`)
|
||||
if err := db.Model(hidden).Update("exclude_from_sub", true).Error; err != nil {
|
||||
t.Fatalf("mark excluded: %v", err)
|
||||
}
|
||||
for _, row := range []*xray.ClientTraffic{
|
||||
{InboundId: shown.Id, Email: "shown@e", Up: 100, Down: 200, Enable: true},
|
||||
{InboundId: hidden.Id, Email: "hidden@e", Up: 1000, Down: 2000, Enable: true},
|
||||
} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed traffic %s: %v", row.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
const wantHeader = "upload=1100; download=2200; "
|
||||
assertOnlyShown := func(t *testing.T, out string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(out, "24401") {
|
||||
t.Fatalf("output lost the shown inbound:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "24402") {
|
||||
t.Fatalf("output leaked the excluded inbound:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("raw", func(t *testing.T) {
|
||||
links, _, _, traffic, err := NewSubService("").GetSubs("sub-excl", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("links = %q, want only the shown inbound's link", links)
|
||||
}
|
||||
assertOnlyShown(t, links[0])
|
||||
if traffic.Up != 1100 || traffic.Down != 2200 {
|
||||
t.Fatalf("usage = up %d/down %d, want 1100/2200 including the excluded inbound's client", traffic.Up, traffic.Down)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clash", func(t *testing.T) {
|
||||
out, header, err := NewSubClashService(false, "", NewSubService("")).GetClash("sub-excl", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
assertOnlyShown(t, out)
|
||||
if !strings.HasPrefix(header, wantHeader) {
|
||||
t.Fatalf("header = %q, want prefix %q", header, wantHeader)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json", func(t *testing.T) {
|
||||
out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("sub-excl", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
assertOnlyShown(t, out)
|
||||
if !strings.HasPrefix(header, wantHeader) {
|
||||
t.Fatalf("header = %q, want prefix %q", header, wantHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user