ソースを参照

fix(node): adopt a node inbound's host overrides into the master

Per-inbound Host overrides (Security/SNI/Fingerprint/ALPN and friends)
are looked up by the local inbound id when subscriptions render, but
nothing in the node sync ever fetched the node's hosts table: an
inbound adopted from a managed node got zero Host rows on the master,
so its subscription configs fell back to a bare TLS block without the
fingerprint/SNI the node was configured with.

When a traffic snapshot carries a tag with no central row yet - the
only moment adoption can happen - the sync job now also pulls the
node's existing hosts/list endpoint (best-effort, so old nodes just
skip it) and the adoption branch materializes that inbound's groups
against the new central id inside the same transaction, reusing the
group-to-rows projection the hosts API already uses. Master stays
authoritative afterwards: this is a one-time import, not a continuous
sync, matching how the inbound's own settings are adopted.

Closes #5890
MHSanaei 1 日 前
コミット
cbd2940a63

+ 14 - 0
internal/web/job/node_traffic_sync_job.go

@@ -391,6 +391,20 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	}
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
+	if !dirty {
+		if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
+			logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
+		} else if pending {
+			hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
+			groups, hgErr := rt.FetchHostGroups(hostCtx)
+			hostCancel()
+			if hgErr != nil {
+				logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
+			} else {
+				snap.HostGroups = groups
+			}
+		}
+	}
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {
 		logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)

+ 20 - 0
internal/web/runtime/remote.go

@@ -21,6 +21,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
@@ -669,6 +670,25 @@ type TrafficSnapshot struct {
 	// the per-GUID endpoint — OnlineEmails is the fallback then.
 	OnlineTree    map[string][]string
 	LastOnlineMap map[string]int64
+	// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
+	// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
+	HostGroups []*entity.HostGroup
+}
+
+// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound
+// keeps its subscription TLS/SNI/fingerprint settings on the master.
+func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil)
+	if err != nil {
+		return nil, err
+	}
+	var groups []*entity.HostGroup
+	if len(env.Obj) > 0 {
+		if err := json.Unmarshal(env.Obj, &groups); err != nil {
+			return nil, fmt.Errorf("decode host groups: %w", err)
+		}
+	}
+	return groups, nil
 }
 
 func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {

+ 15 - 0
internal/web/service/host.go

@@ -154,6 +154,21 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
 	return rows
 }
 
+// adoptedHostRows projects a node's host groups onto a freshly adopted central
+// inbound so TLS/SNI/fingerprint overrides survive the node-to-master import.
+func adoptedHostRows(groups []*entity.HostGroup, nodeInboundId, centralInboundId int) []*model.Host {
+	var rows []*model.Host
+	for _, g := range groups {
+		if g == nil || !slices.Contains(g.InboundIds, nodeInboundId) {
+			continue
+		}
+		scoped := *g
+		scoped.InboundIds = []int{centralInboundId}
+		rows = append(rows, buildHostRows(g.GroupId, &scoped)...)
+	}
+	return rows
+}
+
 func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
 	for _, inboundId := range inboundIds {
 		var count int64

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

@@ -224,6 +224,41 @@ func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
 	).Error
 }
 
+// SnapshotHasUnadoptedInbounds reports whether the snapshot carries a tag with
+// no central row yet, i.e. the next merge would adopt a new inbound.
+func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
+	if snap == nil || len(snap.Inbounds) == 0 {
+		return false, nil
+	}
+	var tags []string
+	if err := database.GetDB().Model(model.Inbound{}).
+		Where("node_id = ?", nodeID).
+		Pluck("tag", &tags).Error; err != nil {
+		return false, err
+	}
+	prefix := nodeTagPrefix(&nodeID)
+	known := make(map[string]struct{}, len(tags)*2)
+	for _, tag := range tags {
+		known[tag] = struct{}{}
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(tag, prefix); found {
+				known[stripped] = struct{}{}
+			} else {
+				known[prefix+tag] = struct{}{}
+			}
+		}
+	}
+	for _, ib := range snap.Inbounds {
+		if ib == nil {
+			continue
+		}
+		if _, ok := known[ib.Tag]; !ok {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
@@ -537,6 +572,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if newIb.Tag != snapIb.Tag {
 				tagToCentral[newIb.Tag] = &newIb
 			}
+			if rows := adoptedHostRows(snap.HostGroups, snapIb.Id, newIb.Id); len(rows) > 0 {
+				if err := tx.Create(&rows).Error; err != nil {
+					logger.Warningf("setRemoteTraffic: adopt host rows for tag %q failed: %v", newIb.Tag, err)
+				}
+			}
 			newInboundIDs[newIb.Id] = struct{}{}
 			structuralChange = true
 			continue

+ 77 - 0
internal/web/service/node_host_adopt_test.go

@@ -0,0 +1,77 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 6
+	if err := db.Create(&model.Node{
+		Id:       nodeID,
+		Name:     "host-node",
+		Address:  "10.0.0.6",
+		Port:     2053,
+		ApiToken: "t",
+		Guid:     "host-node-guid",
+	}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Id:       77,
+			Tag:      "host-adopt-443",
+			Enable:   true,
+			Port:     443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[]}`,
+		}},
+		HostGroups: []*entity.HostGroup{{
+			GroupId:     "g-node",
+			InboundIds:  []int{77, 99},
+			Hosts:       []string{"cdn.example.com:8443"},
+			Remark:      "cdn",
+			Security:    "tls",
+			Sni:         "sni.example.com",
+			Fingerprint: "firefox",
+		}},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var central model.Inbound
+	if err := db.Where("tag = ?", "host-adopt-443").First(&central).Error; err != nil {
+		t.Fatalf("load adopted inbound: %v", err)
+	}
+	var hosts []model.Host
+	if err := db.Where("inbound_id = ?", central.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load adopted hosts: %v", err)
+	}
+	if len(hosts) != 1 {
+		t.Fatalf("adopted host rows = %d, want 1", len(hosts))
+	}
+	h := hosts[0]
+	if h.GroupId != "g-node" || h.Address != "cdn.example.com" || h.Port != 8443 ||
+		h.Security != "tls" || h.Sni != "sni.example.com" || h.Fingerprint != "firefox" || h.Remark != "cdn" {
+		t.Fatalf("adopted host mismatch: %+v", h)
+	}
+
+	var total int64
+	if err := db.Model(&model.Host{}).Count(&total).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if total != 1 {
+		t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total)
+	}
+}