diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index b73dcda9d..5deadda4d 100644 --- a/internal/web/service/inbound.go +++ b/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) diff --git a/internal/web/service/xray_setting_routing_sync.go b/internal/web/service/xray_setting_routing_sync.go new file mode 100644 index 000000000..363fcb33c --- /dev/null +++ b/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 +} diff --git a/internal/web/service/xray_setting_routing_sync_test.go b/internal/web/service/xray_setting_routing_sync_test.go new file mode 100644 index 000000000..c5ac2b28b --- /dev/null +++ b/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") +}