Просмотр исходного кода

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 <[email protected]>
Abdalrahman 12 часов назад
Родитель
Сommit
53f6ed394f

+ 4 - 0
frontend/src/pages/xray/routing/RoutingTab.css

@@ -235,3 +235,7 @@
   text-align: center;
 }
 
+.rule-disabled {
+  opacity: 0.5;
+  filter: grayscale(1);
+}

+ 10 - 0
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -58,6 +58,7 @@ export default function RoutingTab({
     () =>
       rules.map((rule, idx) => {
         const r: RuleRow = { key: idx };
+        r.enabled = rule.enabled !== false;
         r.domain = arrJoin(rule.domain);
         r.ip = arrJoin(rule.ip);
         r.port = rule.port;
@@ -185,6 +186,13 @@ export default function RoutingTab({
       [list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
     });
   }
+  function toggleRule(idx: number, enabled: boolean) {
+    mutate((tt) => {
+      const list = tt.routing?.rules;
+      if (!list || !list[idx]) return;
+      list[idx].enabled = enabled;
+    });
+  }
 
   function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
     if (ev.button != null && ev.button !== 0) return;
@@ -247,6 +255,7 @@ export default function RoutingTab({
     moveUp,
     moveDown,
     confirmDelete,
+    toggleRule,
   });
 
   const tableScrollX = desktopColumns.reduce((sum, c) => {
@@ -289,6 +298,7 @@ export default function RoutingTab({
                     moveUp={moveUp}
                     moveDown={moveDown}
                     confirmDelete={confirmDelete}
+                    toggleRule={toggleRule}
                   />
                 ) : (
                   <Table

+ 14 - 3
frontend/src/pages/xray/routing/RuleCardList.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Dropdown, Tag, Tooltip } from 'antd';
+import { Button, Dropdown, Tag, Tooltip, Switch } from 'antd';
 import {
   MoreOutlined,
   EditOutlined,
@@ -13,7 +13,7 @@ import {
 } from '@ant-design/icons';
 
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
-import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
+import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers';
 import type { RuleRow } from './types';
 
 interface RuleCardListProps {
@@ -25,6 +25,7 @@ interface RuleCardListProps {
   moveUp: (idx: number) => void;
   moveDown: (idx: number) => void;
   confirmDelete: (idx: number) => void;
+  toggleRule: (idx: number, enabled: boolean) => void;
 }
 
 export default function RuleCardList({
@@ -36,6 +37,7 @@ export default function RuleCardList({
   moveUp,
   moveDown,
   confirmDelete,
+  toggleRule,
 }: RuleCardListProps) {
   const { t } = useTranslation();
   const { data: inboundOptions } = useInboundOptions();
@@ -50,7 +52,9 @@ export default function RuleCardList({
             key={rule.key}
             className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
               dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
-            } ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
+            } ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
+              rule.enabled === false ? 'rule-disabled' : ''
+            }`}
             data-row-key={index}
           >
             <div className="rule-card-head">
@@ -72,6 +76,13 @@ export default function RuleCardList({
               >
                 <Button shape="circle" size="small" icon={<MoreOutlined />} />
               </Dropdown>
+              <Switch
+                size="small"
+                checked={rule.enabled !== false}
+                onChange={(checked) => toggleRule(index, checked)}
+                disabled={isApiRule(rule)}
+                style={{ marginLeft: 8 }}
+              />
             </div>
 
             <div className="rule-flow">

+ 17 - 1
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -5,9 +5,10 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
 import { InputAddon } from '@/components/ui';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
-import { buildRemarkByTag, formatInboundTag } from './helpers';
+import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
 
 export interface RoutingRule {
+  enabled?: boolean;
   type?: string;
   domain?: string | string[];
   ip?: string | string[];
@@ -38,6 +39,7 @@ interface RuleFormModalProps {
 type FormState = RuleFormValues;
 
 const initialForm = (): FormState => ({
+  enabled: true,
   domain: '',
   ip: '',
   port: '',
@@ -81,6 +83,7 @@ export default function RuleFormModal({
     if (!open) return;
     if (rule) {
       setForm({
+        enabled: rule.enabled !== false,
         domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
         ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
         port: rule.port || '',
@@ -109,6 +112,7 @@ export default function RuleFormModal({
     const v = validated.data;
     const built: Record<string, unknown> = {
       type: 'field',
+      enabled: v.enabled,
       domain: csv(v.domain),
       ip: csv(v.ip),
       port: v.port,
@@ -151,6 +155,18 @@ export default function RuleFormModal({
       onCancel={onClose}
     >
       <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
+        <Form.Item label={t('enable')}>
+          <Select
+            value={form.enabled}
+            onChange={(v) => update('enabled', v)}
+            disabled={isApiRule(rule ?? {})}
+            options={[
+              { value: true, label: t('enable') },
+              { value: false, label: t('disable') },
+            ]}
+          />
+        </Form.Item>
+
         <Form.Item
           label={
             <Tooltip title={t('pages.xray.rules.useComma')}>

+ 7 - 0
frontend/src/pages/xray/routing/helpers.ts

@@ -69,6 +69,13 @@ export function inboundTagChipPreview(
   return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
 }
 
+/** The internal api rule (stats traffic) — its enabled state must stay locked on. */
+export function isApiRule(rule: { outboundTag?: string; inboundTag?: string | string[] }): boolean {
+  if (rule.outboundTag !== 'api') return false;
+  const tags = Array.isArray(rule.inboundTag) ? rule.inboundTag : csv(rule.inboundTag);
+  return tags.includes('api');
+}
+
 export function ruleCriteriaChips(rule: RuleRow) {
   const chips: { label: string; value?: string }[] = [];
   if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });

+ 1 - 0
frontend/src/pages/xray/routing/types.ts

@@ -1,5 +1,6 @@
 export interface RuleRow {
   key: number;
+  enabled?: boolean;
   domain?: string;
   ip?: string;
   port?: string;

+ 55 - 31
frontend/src/pages/xray/routing/useRoutingColumns.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Dropdown, Tag } from 'antd';
+import { Button, Dropdown, Switch, Tag } from 'antd';
 import {
   MoreOutlined,
   EditOutlined,
@@ -15,7 +15,7 @@ import type { ColumnsType } from 'antd/es/table';
 
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import CriterionRow from './CriterionRow';
-import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
+import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle, isApiRule } from './helpers';
 import type { RuleRow } from './types';
 
 interface RoutingColumnsParams {
@@ -28,6 +28,7 @@ interface RoutingColumnsParams {
   moveUp: (idx: number) => void;
   moveDown: (idx: number) => void;
   confirmDelete: (idx: number) => void;
+  toggleRule: (idx: number, enabled: boolean) => void;
 }
 
 export function useRoutingColumns({
@@ -40,6 +41,7 @@ export function useRoutingColumns({
   moveUp,
   moveDown,
   confirmDelete,
+  toggleRule,
 }: RoutingColumnsParams): ColumnsType<RuleRow> {
   const { t } = useTranslation();
   const { data: inboundOptions } = useInboundOptions();
@@ -49,44 +51,66 @@ export function useRoutingColumns({
       {
         title: '#',
         align: 'center',
-        width: 100,
-        key: 'action',
+        width: 60,
+        key: 'index',
         render: (_v, _r, index) => (
-          <div className="action-cell">
+          <div className="action-cell" style={{ justifyContent: 'center' }}>
             <HolderOutlined
               className="drag-handle"
               title={t('pages.xray.routing.dragToReorder')}
               onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
             />
             <span className="row-index">{index + 1}</span>
-            <div className={!isMobile ? 'action-buttons' : ''}>
-              {!isMobile && (
-                <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
-              )}
-              <Dropdown
-                trigger={['click']}
-                menu={{
-                  items: [
-                    ...(isMobile
-                      ? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
-                      : []),
-                    { key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
-                    {
-                      key: 'down',
-                      label: <ArrowDownOutlined />,
-                      disabled: index === rowsLength - 1,
-                      onClick: () => moveDown(index),
-                    },
-                    { key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
-                  ],
-                }}
-              >
-                <Button shape="circle" size="small" icon={<MoreOutlined />} />
-              </Dropdown>
-            </div>
           </div>
         ),
       },
+      {
+        title: t('pages.clients.actions'),
+        align: 'center',
+        width: 80,
+        key: 'action',
+        render: (_v, _r, index) => (
+          <div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
+            {!isMobile && (
+              <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
+            )}
+            <Dropdown
+              trigger={['click']}
+              menu={{
+                items: [
+                  ...(isMobile
+                    ? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
+                    : []),
+                  { key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
+                  {
+                    key: 'down',
+                    label: <ArrowDownOutlined />,
+                    disabled: index === rowsLength - 1,
+                    onClick: () => moveDown(index),
+                  },
+                  { key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
+                ],
+              }}
+            >
+              <Button shape="circle" size="small" icon={<MoreOutlined />} />
+            </Dropdown>
+          </div>
+        ),
+      },
+      {
+        title: t('enable'),
+        align: 'center',
+        width: 80,
+        key: 'enabled',
+        render: (_v, _r, index) => (
+          <Switch
+            size="small"
+            checked={_r.enabled !== false}
+            onChange={(checked) => toggleRule(index, checked)}
+            disabled={isApiRule(_r)}
+          />
+        ),
+      },
       {
         title: t('pages.xray.rules.source'),
         align: 'left',
@@ -184,6 +208,6 @@ export function useRoutingColumns({
           ),
       },
     ],
-    [t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
+    [t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete, toggleRule],
   );
 }

+ 1 - 0
frontend/src/schemas/routing.ts

@@ -17,6 +17,7 @@ export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
 
 export const RuleObjectSchema = z.object({
   type: z.literal('field').default('field'),
+  enabled: z.boolean().optional(),
   domain: z.array(z.string()).optional(),
   ip: z.array(z.string()).optional(),
   port: PortValueSchema.optional(),

+ 1 - 0
frontend/src/schemas/xray.ts

@@ -84,6 +84,7 @@ export const OutboundTestResultSchema = z.object({
 export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
 
 export const RuleFormSchema = z.object({
+  enabled: z.boolean(),
   domain: z.string(),
   ip: z.string(),
   port: z.string(),

+ 54 - 0
internal/web/service/xray.go

@@ -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
internal/web/service/xray_setting.go

@@ -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

+ 88 - 0
internal/web/service/xray_strip_rules_test.go

@@ -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])
+		}
+	})
+}