1
0

2 Ревизии 49fbcdc09c ... 66ef5bbc05

Автор SHA1 Съобщение Дата
  MHSanaei 66ef5bbc05 fix(sub): emit host TLS verification at xray level in JSON subscription преди 1 ден
  MHSanaei dd9569478e fix(node): ignore a node's stats for clients detached from the inbound преди 1 ден

+ 46 - 0
internal/sub/host_sub_test.go

@@ -618,3 +618,49 @@ func TestSub_HostCipherSuitesJSON(t *testing.T) {
 		t.Fatalf("a host with no cipher suites should inherit the inbound's:\n%s", out)
 	}
 }
+
+// Xray reads a client's TLS verification fields at the top of tlsSettings; a
+// nested "settings" map is panel-only shape and xray silently ignores it.
+func TestSub_HostTLSVerificationJSONAtXrayLevel(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "ech", 4461, 1,
+		`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}}}`)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "ECH", Address: "ech.cdn.com", Port: 443, Security: "tls",
+		EchConfigList: "cloudflare-ech.com+udp://1.1.1.1", VerifyPeerCertByName: "cert.example.com",
+		PinnedPeerCertSha256: []string{"aa11", "bb22"}, AllowInsecure: true,
+	})
+
+	out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	var config map[string]any
+	if err := json.Unmarshal([]byte(out), &config); err != nil {
+		t.Fatalf("unmarshal JSON subscription: %v", err)
+	}
+	outbounds, _ := config["outbounds"].([]any)
+	if len(outbounds) == 0 {
+		t.Fatalf("JSON subscription has no outbounds: %s", out)
+	}
+	outbound, _ := outbounds[0].(map[string]any)
+	stream, _ := outbound["streamSettings"].(map[string]any)
+	tls, _ := stream["tlsSettings"].(map[string]any)
+	want := map[string]any{
+		"serverName":           "base.sni",
+		"fingerprint":          "chrome",
+		"echConfigList":        "cloudflare-ech.com+udp://1.1.1.1",
+		"verifyPeerCertByName": "cert.example.com",
+		"pinnedPeerCertSha256": "aa11,bb22",
+	}
+	for key, value := range want {
+		if tls[key] != value {
+			t.Errorf("tlsSettings.%s = %#v, want %#v", key, tls[key], value)
+		}
+	}
+	for _, key := range []string{"settings", "allowInsecure"} {
+		if _, ok := tls[key]; ok {
+			t.Errorf("tlsSettings.%s must not reach xray: %#v", key, tls)
+		}
+	}
+}

+ 18 - 1
internal/sub/json_service.go

@@ -634,6 +634,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		security, _ := newStream["security"].(string)
 		if hasExternalProxy {
 			applyExternalProxyTLSToStream(extPrxy, newStream, security)
+			liftHostTLSVerification(newStream)
 		}
 		applyHostStreamOverrides(extPrxy, newStream)
 		if finalmask, ok := newStream["finalmask"].(map[string]any); ok {
@@ -774,6 +775,23 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	if cs, ok := tData["cipherSuites"].(string); ok && cs != "" {
 		tlsData["cipherSuites"] = cs
 	}
+	putClientTLSVerification(tlsData, tlsClientSettings)
+	return tlsData
+}
+
+// liftHostTLSVerification moves the host overrides applyExternalProxyTLSToStream
+// wrote into the panel-shaped tlsSettings.settings up to where xray reads them.
+func liftHostTLSVerification(stream map[string]any) {
+	tlsSettings, _ := stream["tlsSettings"].(map[string]any)
+	inner, ok := tlsSettings["settings"].(map[string]any)
+	if !ok {
+		return
+	}
+	delete(tlsSettings, "settings")
+	putClientTLSVerification(tlsSettings, inner)
+}
+
+func putClientTLSVerification(tlsData map[string]any, tlsClientSettings map[string]any) {
 	if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
 		tlsData["echConfigList"] = ech
 	}
@@ -785,7 +803,6 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	if pins, ok := pinnedSha256List(tlsClientSettings); ok {
 		tlsData["pinnedPeerCertSha256"] = strings.Join(pins, ",")
 	}
-	return tlsData
 }
 
 func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {

+ 0 - 10
internal/sub/service_test.go

@@ -744,16 +744,6 @@ func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
 		}
 	})
 
-	t.Run("json stream settings", func(t *testing.T) {
-		stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
-		ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
-		applyExternalProxyTLSToStream(ep, stream, "tls")
-		settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
-		if settings["echConfigList"] != ech {
-			t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
-		}
-	})
-
 	t.Run("non-tls security drops ech", func(t *testing.T) {
 		params := map[string]string{}
 		ep := map[string]any{"echConfigList": ech}

+ 19 - 0
internal/web/service/inbound_node.go

@@ -441,6 +441,20 @@ func snapshotDropsEveryHubClient(tx *gorm.DB, inboundID int, wireSettings string
 	return links > 0
 }
 
+// snapshotAttachedEmails lowercases the emails a snapshot inbound's settings list;
+// ok is false when the settings cannot be parsed, so membership is unknown.
+func snapshotAttachedEmails(settings string) (map[string]struct{}, bool) {
+	clients, err := ParseInboundSettingsClients(settings)
+	if err != nil {
+		return nil, false
+	}
+	emails := make(map[string]struct{}, len(clients))
+	for i := range clients {
+		emails[strings.ToLower(clients[i].Email)] = struct{}{}
+	}
+	return emails, true
+}
+
 // clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
 // other nodes: email is unique, so adopting one would overwrite a client this
 // node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
@@ -942,7 +956,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		snapEmails := make(map[string]struct{}, len(snapIb.ClientStats))
 		// Parsed once per inbound on the first renewal candidate, not per client.
 		var snapExpiries map[string]int64
+		attachedOnNode, membershipKnown := snapshotAttachedEmails(snapIb.Settings)
 		for _, cs := range snapIb.ClientStats {
+			// A node detach keeps the stat row, so its quota and verdict are stale (#6724).
+			if _, attached := attachedOnNode[strings.ToLower(cs.Email)]; membershipKnown && !attached {
+				continue
+			}
 			snapEmails[cs.Email] = struct{}{}
 
 			// Node-wide total, not this inbound's possibly-stale copy (#5274).

+ 61 - 0
internal/web/service/node_detached_client_stats_test.go

@@ -0,0 +1,61 @@
+package service
+
+import (
+	"errors"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A node detach keeps the client's stat row (keepTraffic), so the node goes on
+// reporting it under an inbound whose settings no longer list the client (#6724).
+func TestNodeSnapshotIgnoresStatsOfClientDetachedFromInbound(t *testing.T) {
+	const gib = int64(1) << 30
+	const emptyClients = `{"clients": []}`
+
+	t.Run("stale node quota and disable never reach the master row", func(t *testing.T) {
+		db := initTrafficTestDB(t)
+		createNodeInbound(t, db, 1, "n1-in", 41001)
+		local := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 41010, Protocol: model.VLESS}
+		if err := db.Create(local).Error; err != nil {
+			t.Fatalf("create local inbound: %v", err)
+		}
+		const email = "moved"
+		if err := db.Create(&xray.ClientTraffic{InboundId: local.Id, Email: email, Enable: true, Total: 200 * gib, Up: 5, Down: 5}).Error; err != nil {
+			t.Fatalf("seed client_traffics: %v", err)
+		}
+		if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 40, Down: 40}).Error; err != nil {
+			t.Fatalf("seed node baseline: %v", err)
+		}
+
+		svc := &InboundService{}
+		// Two ticks: the first copies the quota, which makes the second's disable look genuine.
+		for range 2 {
+			syncNodeWithSettings(t, svc, 1, "n1-in", emptyClients,
+				xray.ClientTraffic{Email: email, Up: 40, Down: 40, Total: 100 * gib, Enable: false})
+		}
+
+		got := readTraffic(t, db, email)
+		if got.Total != 200*gib || !got.Enable {
+			t.Fatalf("master row = total %d enable %v, want total %d enable true", got.Total, got.Enable, 200*gib)
+		}
+	})
+
+	t.Run("a detached email gets no master traffic row", func(t *testing.T) {
+		db := initTrafficTestDB(t)
+		createNodeInbound(t, db, 1, "n1-in", 41001)
+
+		svc := &InboundService{}
+		syncNodeWithSettings(t, svc, 1, "n1-in", emptyClients,
+			xray.ClientTraffic{Email: "gone", Up: 40, Down: 40, Total: 100 * gib, Enable: true})
+
+		var ct xray.ClientTraffic
+		err := db.Model(xray.ClientTraffic{}).Where("email = ?", "gone").First(&ct).Error
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			t.Fatalf("client_traffics row for detached email: err = %v, row = %+v; want ErrRecordNotFound", err, ct)
+		}
+	})
+}