Add Enable/Disable Toggle for Xray Routing Rules (#5296)

* feat: add enable/disable toggle for xray routing rules

* fix(routing): never let the internal api rule be disabled

The Enable/Disable toggle could strip the stats api rule: its table
switch was locked, but the rule-form modal's Enable dropdown was not,
and stripDisabledRules had no api-rule guard (EnsureStatsRouting's
delete only runs when the api rule isn't already first). A disabled
api rule then dropped out of the generated config and broke traffic
accounting.

- stripDisabledRules now always keeps the api rule, even if marked
  disabled, and strips the panel-only enabled key from every rule
- extract isApiRule helper (backend + frontend) and reuse it across
  the table switch, card switch, and form modal
- disable the form-modal Enable dropdown for the api rule
- add stripDisabledRules tests covering the api-rule survival path

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Abdalrahman
2026-06-15 01:43:49 +03:00
committed by GitHub
parent 66a9a788fc
commit 53f6ed394f
12 changed files with 287 additions and 61 deletions
+54
View File
@@ -120,6 +120,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
xrayConfig.API = ensureAPIServices(xrayConfig.API)
xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig)
_, _, _ = s.inboundService.AddTraffic(nil, nil)
@@ -711,6 +712,59 @@ func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
return out
}
// stripDisabledRules removes routing rules marked `enabled: false` from the
// generated runtime config and strips the panel-only `enabled` key from the
// rest, since xray-core has no such field. The internal api rule is always
// kept (see isApiRule) so traffic stats can't be toggled off. The stored
// template is untouched — only the generated config is filtered.
func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
if len(routerCfg) == 0 {
return routerCfg
}
var parsed map[string]any
if err := json.Unmarshal(routerCfg, &parsed); err != nil {
return routerCfg
}
rules, ok := parsed["rules"].([]any)
if !ok || len(rules) == 0 {
return routerCfg
}
var activeRules []any
changed := false
for _, rawRule := range rules {
rule, ok := rawRule.(map[string]any)
if !ok {
activeRules = append(activeRules, rawRule)
continue
}
if enabledRaw, exists := rule["enabled"]; exists {
// The internal api rule carries traffic stats and must never be
// dropped, even if it was somehow marked disabled.
enabled, ok := enabledRaw.(bool)
if ok && !enabled && !isApiRule(rule) {
changed = true
continue
}
delete(rule, "enabled")
changed = true
}
activeRules = append(activeRules, rule)
}
if !changed {
return routerCfg
}
parsed["rules"] = activeRules
out, err := json.Marshal(parsed)
if err != nil {
return routerCfg
}
return out
}
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
if !s.IsXrayRunning() {
+35 -26
View File
@@ -237,6 +237,7 @@ func EnsureStatsRouting(raw string) (string, error) {
"outboundTag": "api",
}
}
delete(apiRule, "enabled")
rules = append([]map[string]any{apiRule}, rules...)
rulesJSON, err := json.Marshal(rules)
@@ -258,35 +259,43 @@ func EnsureStatsRouting(raw string) (string, error) {
return string(out), nil
}
// isApiRule reports whether a routing rule targets the internal api inbound
// (inboundTag contains "api" and outboundTag is "api").
func isApiRule(rule map[string]any) bool {
if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
return false
}
raw, ok := rule["inboundTag"]
if !ok {
return false
}
// inboundTag is usually []string but can come as []any from a
// roundtrip through map[string]any. Accept both shapes.
switch tags := raw.(type) {
case []any:
for _, t := range tags {
if s, ok := t.(string); ok && s == "api" {
return true
}
}
case []string:
if slices.Contains(tags, "api") {
return true
}
case string:
if tags == "api" {
return true
}
}
return false
}
// findApiRule returns the index of the routing rule that targets the
// internal api inbound (inboundTag contains "api" and outboundTag is
// "api"), or -1 if no such rule exists.
// internal api inbound, or -1 if no such rule exists.
func findApiRule(rules []map[string]any) int {
for i, rule := range rules {
if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
continue
}
raw, ok := rule["inboundTag"]
if !ok {
continue
}
// inboundTag is usually []string but can come as []any from a
// roundtrip through map[string]any. Accept both shapes.
switch tags := raw.(type) {
case []any:
for _, t := range tags {
if s, ok := t.(string); ok && s == "api" {
return i
}
}
case []string:
if slices.Contains(tags, "api") {
return i
}
case string:
if tags == "api" {
return i
}
if isApiRule(rule) {
return i
}
}
return -1
@@ -0,0 +1,88 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
)
// rulesOf unmarshals a router config and returns its rules for assertions.
func rulesOf(t *testing.T, raw json_util.RawMessage) []map[string]any {
t.Helper()
var parsed struct {
Rules []map[string]any `json:"rules"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
return parsed.Rules
}
func TestStripDisabledRules(t *testing.T) {
t.Run("empty config is returned untouched", func(t *testing.T) {
if got := stripDisabledRules(nil); got != nil {
t.Fatalf("expected nil passthrough, got %s", got)
}
})
t.Run("missing or empty rules is a no-op", func(t *testing.T) {
in := json_util.RawMessage(`{"domainStrategy":"AsIs"}`)
if got := stripDisabledRules(in); string(got) != string(in) {
t.Fatalf("config without rules was modified: %s", got)
}
})
t.Run("drops disabled rules and strips the enabled key from the rest", func(t *testing.T) {
in := json_util.RawMessage(`{"rules":[
{"outboundTag":"direct","domain":["a.com"],"enabled":true},
{"outboundTag":"block","domain":["b.com"],"enabled":false},
{"outboundTag":"proxy","domain":["c.com"]}
]}`)
rules := rulesOf(t, stripDisabledRules(in))
if len(rules) != 2 {
t.Fatalf("expected 2 active rules, got %d: %v", len(rules), rules)
}
for _, r := range rules {
if _, ok := r["enabled"]; ok {
t.Fatalf("enabled key must not survive into the runtime config: %v", r)
}
}
if rules[0]["outboundTag"] != "direct" || rules[1]["outboundTag"] != "proxy" {
t.Fatalf("kept rules or their order are wrong: %v", rules)
}
})
t.Run("never drops the api rule even when marked disabled", func(t *testing.T) {
in := json_util.RawMessage(`{"rules":[
{"inboundTag":["api"],"outboundTag":"api","enabled":false},
{"outboundTag":"block","domain":["b.com"],"enabled":false}
]}`)
rules := rulesOf(t, stripDisabledRules(in))
if len(rules) != 1 {
t.Fatalf("expected only the api rule to survive, got %d: %v", len(rules), rules)
}
if rules[0]["outboundTag"] != "api" {
t.Fatalf("api rule was dropped: %v", rules)
}
if _, ok := rules[0]["enabled"]; ok {
t.Fatalf("enabled key must be stripped from the api rule too: %v", rules[0])
}
})
t.Run("non-object rules pass through, disabled object is dropped", func(t *testing.T) {
in := json_util.RawMessage(`{"rules":["weird",{"outboundTag":"block","enabled":false}]}`)
var parsed struct {
Rules []any `json:"rules"`
}
if err := json.Unmarshal(stripDisabledRules(in), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Rules) != 1 {
t.Fatalf("expected 1 surviving rule (the string), got %v", parsed.Rules)
}
if s, _ := parsed.Rules[0].(string); s != "weird" {
t.Fatalf("non-object rule should be preserved, got %v", parsed.Rules[0])
}
})
}