Browse Source

fix(routing): sync xray rules when panel inbound tags change or are deleted (#5367)

* fix(routing): sync xray rules when panel inbound tags change or are deleted

When an auto-generated inbound tag changes (e.g. port edit), propagate the
rename into xrayTemplateConfig routing rules and loopback outbounds. On
inbound delete, drop rules that only matched that tag and strip the tag from
rules that also match on domain, IP, or other fields.

Run the template update after the inbound DB transaction commits so SQLite
WAL reads see the stored xray settings reliably.

* fix(inbounds): return needRestart after deferred routing tag sync

Use a named needRestart return in UpdateInbound so the post-commit PropagateInboundTagRename defer can signal callers to restart Xray.

---------

Co-authored-by: Sanaei <[email protected]>
nima1024m 11 hours ago
parent
commit
af3f460065

+ 21 - 0
internal/web/service/inbound.go

@@ -785,6 +785,16 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 		return false, err
 	}
 
+	// Drop the deleted inbound's tag from any routing rules / loopback outbounds
+	// in xrayTemplateConfig so they don't point at a tag that no longer exists.
+	if loadErr == nil && ib.Tag != "" {
+		if routingChanged, syncErr := (&XraySettingService{}).RemoveInboundTagReferences(ib.Tag); syncErr != nil {
+			logger.Warning("DelInbound: sync routing on inbound delete failed:", syncErr)
+		} else if routingChanged {
+			needRestart = true
+		}
+	}
+
 	if err := db.Delete(model.Inbound{}, id).Error; err != nil {
 		return needRestart, err
 	}
@@ -1158,6 +1168,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if txErr != nil {
 		return inbound, false, txErr
 	}
+	// After the rename is committed, point any routing rules / loopback outbounds
+	// in xrayTemplateConfig at the new tag (oldInbound.Tag now holds the resolved
+	// new tag; tag holds the pre-edit one). Done post-commit so a sync failure
+	// can't roll back the inbound edit.
+	if tag != oldInbound.Tag {
+		if routingChanged, syncErr := (&XraySettingService{}).PropagateInboundTagRename(tag, oldInbound.Tag); syncErr != nil {
+			logger.Warning("UpdateInbound: sync routing on tag rename failed:", syncErr)
+		} else if routingChanged {
+			needRestart = true
+		}
+	}
 	if markDirty && oldInbound.NodeID != nil {
 		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
 			logger.Warning("mark node dirty failed:", dErr)

+ 311 - 0
internal/web/service/xray_setting_routing_sync.go

@@ -0,0 +1,311 @@
+package service
+
+import (
+	"encoding/json"
+)
+
+var routingMatcherKeys = []string{
+	"domain", "ip", "port", "sourcePort", "localPort", "network",
+	"sourceIP", "localIP", "user", "vlessRoute", "protocol", "attrs", "process",
+}
+
+func readInboundTags(raw any) []string {
+	switch tags := raw.(type) {
+	case []string:
+		return append([]string(nil), tags...)
+	case string:
+		if tags == "" {
+			return nil
+		}
+		return []string{tags}
+	case []any:
+		out := make([]string, 0, len(tags))
+		for _, item := range tags {
+			if s, ok := item.(string); ok && s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	default:
+		return nil
+	}
+}
+
+func writeInboundTags(rule map[string]any, tags []string) {
+	if len(tags) == 0 {
+		delete(rule, "inboundTag")
+		return
+	}
+	rule["inboundTag"] = tags
+}
+
+func ruleHasNonInboundMatchers(rule map[string]any) bool {
+	for _, key := range routingMatcherKeys {
+		if hasRoutingMatcherValue(rule[key]) {
+			return true
+		}
+	}
+	return false
+}
+
+func hasRoutingMatcherValue(raw any) bool {
+	switch v := raw.(type) {
+	case nil:
+		return false
+	case string:
+		return v != ""
+	case float64, int, int64, bool:
+		return true
+	case []string:
+		return len(v) > 0
+	case []any:
+		return len(v) > 0
+	case map[string]any:
+		return len(v) > 0
+	default:
+		return true
+	}
+}
+
+func replaceInboundTagInRules(rules []map[string]any, oldTag, newTag string) bool {
+	changed := false
+	for _, rule := range rules {
+		if replaceInboundTagInRule(rule, oldTag, newTag) {
+			changed = true
+		}
+	}
+	return changed
+}
+
+func replaceInboundTagInRule(rule map[string]any, oldTag, newTag string) bool {
+	tags := readInboundTags(rule["inboundTag"])
+	if len(tags) == 0 {
+		return false
+	}
+	updated := false
+	for i, tag := range tags {
+		if tag == oldTag {
+			tags[i] = newTag
+			updated = true
+		}
+	}
+	if updated {
+		writeInboundTags(rule, tags)
+	}
+	return updated
+}
+
+func removeInboundTagFromRules(rules []map[string]any, deletedTag string) ([]map[string]any, bool) {
+	if deletedTag == "" {
+		return rules, false
+	}
+	changed := false
+	out := make([]map[string]any, 0, len(rules))
+	for _, rule := range rules {
+		tags := readInboundTags(rule["inboundTag"])
+		if len(tags) == 0 {
+			out = append(out, rule)
+			continue
+		}
+		nextTags := make([]string, 0, len(tags))
+		hadDeleted := false
+		for _, tag := range tags {
+			if tag == deletedTag {
+				hadDeleted = true
+				continue
+			}
+			nextTags = append(nextTags, tag)
+		}
+		if !hadDeleted {
+			out = append(out, rule)
+			continue
+		}
+		changed = true
+		if len(nextTags) == 0 && !ruleHasNonInboundMatchers(rule) {
+			continue
+		}
+		if len(nextTags) == 0 {
+			delete(rule, "inboundTag")
+		} else {
+			writeInboundTags(rule, nextTags)
+		}
+		out = append(out, rule)
+	}
+	return out, changed
+}
+
+func replaceInboundTagInOutbounds(outbounds []any, oldTag, newTag string) bool {
+	changed := false
+	for _, outIface := range outbounds {
+		out, ok := outIface.(map[string]any)
+		if !ok {
+			continue
+		}
+		proto, _ := out["protocol"].(string)
+		if proto != "loopback" {
+			continue
+		}
+		settings, ok := out["settings"].(map[string]any)
+		if !ok {
+			continue
+		}
+		tag, _ := settings["inboundTag"].(string)
+		if tag != oldTag {
+			continue
+		}
+		settings["inboundTag"] = newTag
+		changed = true
+	}
+	return changed
+}
+
+func removeInboundTagFromOutbounds(outbounds []any, deletedTag string) bool {
+	changed := false
+	for _, outIface := range outbounds {
+		out, ok := outIface.(map[string]any)
+		if !ok {
+			continue
+		}
+		proto, _ := out["protocol"].(string)
+		if proto != "loopback" {
+			continue
+		}
+		settings, ok := out["settings"].(map[string]any)
+		if !ok {
+			continue
+		}
+		tag, _ := settings["inboundTag"].(string)
+		if tag != deletedTag {
+			continue
+		}
+		delete(settings, "inboundTag")
+		changed = true
+	}
+	return changed
+}
+
+func mutateXrayTemplateRouting(raw string, mutate func(cfg map[string]any) bool) (string, bool, error) {
+	raw = UnwrapXrayTemplateConfig(raw)
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	if !mutate(cfg) {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+func routingRulesFromCfg(cfg map[string]any) []map[string]any {
+	routing, _ := cfg["routing"].(map[string]any)
+	if routing == nil {
+		return nil
+	}
+	rawRules, ok := routing["rules"].([]any)
+	if !ok {
+		return nil
+	}
+	rules := make([]map[string]any, 0, len(rawRules))
+	for _, item := range rawRules {
+		rule, ok := item.(map[string]any)
+		if !ok {
+			continue
+		}
+		rules = append(rules, rule)
+	}
+	return rules
+}
+
+func setRoutingRulesInCfg(cfg map[string]any, rules []map[string]any) {
+	routing, _ := cfg["routing"].(map[string]any)
+	if routing == nil {
+		routing = map[string]any{}
+		cfg["routing"] = routing
+	}
+	items := make([]any, len(rules))
+	for i, rule := range rules {
+		items[i] = rule
+	}
+	routing["rules"] = items
+}
+
+func outboundsFromCfg(cfg map[string]any) []any {
+	outbounds, _ := cfg["outbounds"].([]any)
+	return outbounds
+}
+
+// PropagateInboundTagRename rewrites routing rules and loopback outbound
+// references when a panel inbound tag changes.
+func (s *XraySettingService) PropagateInboundTagRename(oldTag, newTag string) (bool, error) {
+	if oldTag == "" || newTag == "" || oldTag == newTag {
+		return false, nil
+	}
+	template, err := s.GetXrayConfigTemplate()
+	if err != nil {
+		return false, err
+	}
+	updated, changed, err := mutateXrayTemplateRouting(template, func(cfg map[string]any) bool {
+		mutated := false
+		rules := routingRulesFromCfg(cfg)
+		if len(rules) > 0 {
+			if replaceInboundTagInRules(rules, oldTag, newTag) {
+				setRoutingRulesInCfg(cfg, rules)
+				mutated = true
+			}
+		}
+		outbounds := outboundsFromCfg(cfg)
+		if len(outbounds) > 0 && replaceInboundTagInOutbounds(outbounds, oldTag, newTag) {
+			cfg["outbounds"] = outbounds
+			mutated = true
+		}
+		return mutated
+	})
+	if err != nil || !changed {
+		return false, err
+	}
+	if err := s.SaveXraySetting(updated); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// RemoveInboundTagReferences drops a deleted inbound tag from routing rules.
+// Rules that only matched that inbound are removed; rules with additional
+// matchers keep the rule and only lose the inboundTag entry.
+func (s *XraySettingService) RemoveInboundTagReferences(deletedTag string) (bool, error) {
+	if deletedTag == "" {
+		return false, nil
+	}
+	template, err := s.GetXrayConfigTemplate()
+	if err != nil {
+		return false, err
+	}
+	updated, changed, err := mutateXrayTemplateRouting(template, func(cfg map[string]any) bool {
+		mutated := false
+		rules := routingRulesFromCfg(cfg)
+		if len(rules) > 0 {
+			nextRules, rulesChanged := removeInboundTagFromRules(rules, deletedTag)
+			if rulesChanged {
+				setRoutingRulesInCfg(cfg, nextRules)
+				mutated = true
+			}
+		}
+		outbounds := outboundsFromCfg(cfg)
+		if len(outbounds) > 0 && removeInboundTagFromOutbounds(outbounds, deletedTag) {
+			cfg["outbounds"] = outbounds
+			mutated = true
+		}
+		return mutated
+	})
+	if err != nil || !changed {
+		return false, err
+	}
+	if err := s.SaveXraySetting(updated); err != nil {
+		return false, err
+	}
+	return true, nil
+}

+ 302 - 0
internal/web/service/xray_setting_routing_sync_test.go

@@ -0,0 +1,302 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func seedXrayTemplate(t *testing.T, template string) {
+	t.Helper()
+	s := &SettingService{}
+	if err := s.saveSetting("xrayTemplateConfig", template); err != nil {
+		t.Fatalf("saveSetting: %v", err)
+	}
+}
+
+func routingRulesFromTemplate(t *testing.T, template string) []map[string]any {
+	t.Helper()
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(template), &cfg); err != nil {
+		t.Fatalf("unmarshal template: %v", err)
+	}
+	return routingRulesFromCfg(cfg)
+}
+
+func TestPropagateInboundTagRename_UpdatesRoutingRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["in-21368-tcp"],"outboundTag":"direct"},
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"}
+			]
+		},
+		"outbounds": [{"tag":"direct","protocol":"freedom"}]
+	}`)
+
+	svc := &XraySettingService{}
+	changed, err := svc.PropagateInboundTagRename("in-21368-tcp", "in-33000-tcp")
+	if err != nil {
+		t.Fatalf("PropagateInboundTagRename: %v", err)
+	}
+	if !changed {
+		t.Fatal("expected routing template to change")
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rules := routingRulesFromTemplate(t, got)
+	if len(rules) != 2 {
+		t.Fatalf("rules len = %d, want 2", len(rules))
+	}
+	if tags := readInboundTags(rules[1]["inboundTag"]); tags[0] != "in-33000-tcp" {
+		t.Fatalf("renamed rule inboundTag = %v, want [in-33000-tcp]", tags)
+	}
+	if tags := readInboundTags(rules[0]["inboundTag"]); tags[0] != "api" {
+		t.Fatalf("api rule should stay untouched, got %v", tags)
+	}
+}
+
+func TestPropagateInboundTagRename_UpdatesLoopbackOutbound(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {"rules": []},
+		"outbounds": [
+			{"tag":"loop","protocol":"loopback","settings":{"inboundTag":"in-21368-tcp"}}
+		]
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.PropagateInboundTagRename("in-21368-tcp", "in-33000-tcp"); err != nil {
+		t.Fatalf("PropagateInboundTagRename: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(got), &cfg); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	outbounds := outboundsFromCfg(cfg)
+	settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
+	if settings["inboundTag"] != "in-33000-tcp" {
+		t.Fatalf("loopback inboundTag = %v, want in-33000-tcp", settings["inboundTag"])
+	}
+}
+
+func TestRemoveInboundTagReferences_DropsInboundOnlyRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["in-21368-tcp"],"outboundTag":"direct"},
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	changed, err := svc.RemoveInboundTagReferences("in-21368-tcp")
+	if err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+	if !changed {
+		t.Fatal("expected template to change")
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rules := routingRulesFromTemplate(t, got)
+	if len(rules) != 1 {
+		t.Fatalf("rules len = %d, want 1 (api rule only)", len(rules))
+	}
+	if tags := readInboundTags(rules[0]["inboundTag"]); tags[0] != "api" {
+		t.Fatalf("remaining rule = %v, want api rule", tags)
+	}
+}
+
+func TestRemoveInboundTagReferences_KeepsRuleWithOtherMatchers(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{
+					"type":"field",
+					"inboundTag":["in-21368-tcp"],
+					"domain":["example.com"],
+					"outboundTag":"direct"
+				}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.RemoveInboundTagReferences("in-21368-tcp"); err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, got, "direct")
+	if _, ok := rule["inboundTag"]; ok {
+		t.Fatalf("inboundTag should be removed, rule = %#v", rule)
+	}
+	if domain, _ := rule["domain"].([]any); len(domain) != 1 {
+		t.Fatalf("domain matcher should remain, rule = %#v", rule)
+	}
+}
+
+func TestRemoveInboundTagReferences_RemovesOneTagFromMultiInboundRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{
+					"type":"field",
+					"inboundTag":["in-21368-tcp","in-443-tcp"],
+					"outboundTag":"direct"
+				}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.RemoveInboundTagReferences("in-21368-tcp"); err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, got, "direct")
+	if tags := readInboundTags(rule["inboundTag"]); len(tags) != 1 || tags[0] != "in-443-tcp" {
+		t.Fatalf("inboundTag = %v, want [in-443-tcp]", tags)
+	}
+}
+
+func findRuleByOutbound(t *testing.T, template, outbound string) map[string]any {
+	t.Helper()
+	for _, rule := range routingRulesFromTemplate(t, template) {
+		if rule["outboundTag"] == outbound {
+			return rule
+		}
+	}
+	t.Fatalf("no rule with outboundTag %q in %s", outbound, template)
+	return nil
+}
+
+func TestPropagateInboundTagRename_WorksWithConflictDB(t *testing.T) {
+	setupConflictDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["in-22435-tcp"],"outboundTag":"direct"}
+			]
+		},
+		"outbounds": [{"tag":"direct","protocol":"freedom"}]
+	}`)
+
+	svc := &XraySettingService{}
+	changed, err := svc.PropagateInboundTagRename("in-22435-tcp", "in-33000-tcp")
+	if err != nil {
+		t.Fatalf("PropagateInboundTagRename: %v", err)
+	}
+	if !changed {
+		t.Fatal("expected template to change")
+	}
+}
+
+func TestUpdateInbound_PropagatesRoutingRuleOnPortChange(t *testing.T) {
+	setupConflictDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{"type":"field","inboundTag":["in-22435-tcp"],"outboundTag":"direct"}
+			]
+		},
+		"outbounds": [{"tag":"direct","protocol":"freedom"}]
+	}`)
+	seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 33000
+	update.Tag = "in-22435-tcp"
+	got, needRestart, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if got.Tag != "in-33000-tcp" {
+		t.Fatalf("returned tag = %q, want in-33000-tcp", got.Tag)
+	}
+	if !needRestart {
+		t.Fatal("expected needRestart after routing template sync on tag rename")
+	}
+
+	xraySvc := &XraySettingService{}
+	template, err := xraySvc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, template, "direct")
+	if tags := readInboundTags(rule["inboundTag"]); tags[0] != "in-33000-tcp" {
+		t.Fatalf("routing inboundTag = %v, want [in-33000-tcp]", tags)
+	}
+}
+
+func TestDelInbound_RemovesInboundOnlyRoutingRule(t *testing.T) {
+	setupConflictDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{"type":"field","inboundTag":["in-22435-tcp"],"outboundTag":"direct"},
+				{"type":"field","inboundTag":["in-443-tcp"],"outboundTag":"blocked"}
+			]
+		}
+	}`)
+	seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	if _, err := svc.DelInbound(existing.Id); err != nil {
+		t.Fatalf("DelInbound: %v", err)
+	}
+
+	xraySvc := &XraySettingService{}
+	template, err := xraySvc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rules := routingRulesFromTemplate(t, template)
+	for _, rule := range rules {
+		if rule["outboundTag"] == "direct" {
+			t.Fatalf("direct rule should be removed, got %#v", rule)
+		}
+	}
+	findRuleByOutbound(t, template, "blocked")
+}