diff --git a/internal/web/job/node_traffic_sync_job.go b/internal/web/job/node_traffic_sync_job.go index 50b0e440e..fcdc50839 100644 --- a/internal/web/job/node_traffic_sync_job.go +++ b/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) diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 3f529e070..20af5453a 100644 --- a/internal/web/runtime/remote.go +++ b/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) { diff --git a/internal/web/service/host.go b/internal/web/service/host.go index 8dde81389..5bbf60c97 100644 --- a/internal/web/service/host.go +++ b/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 diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 0a441fec6..d80cfccc7 100644 --- a/internal/web/service/inbound_node.go +++ b/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 diff --git a/internal/web/service/node_host_adopt_test.go b/internal/web/service/node_host_adopt_test.go new file mode 100644 index 000000000..1edb98b31 --- /dev/null +++ b/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(¢ral).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) + } +}