@@ -72,6 +76,13 @@ export default function RuleCardList({
>
} />
+ toggleRule(index, checked)}
+ disabled={isApiRule(rule)}
+ style={{ marginLeft: 8 }}
+ />
diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx
index eafd1547e..a92ce665d 100644
--- a/frontend/src/pages/xray/routing/RuleFormModal.tsx
+++ b/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
= {
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}
>
+
+
diff --git a/frontend/src/pages/xray/routing/helpers.ts b/frontend/src/pages/xray/routing/helpers.ts
index 433fe6dcd..c332cb3dd 100644
--- a/frontend/src/pages/xray/routing/helpers.ts
+++ b/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 });
diff --git a/frontend/src/pages/xray/routing/types.ts b/frontend/src/pages/xray/routing/types.ts
index fc10e8b87..6fb8da4e9 100644
--- a/frontend/src/pages/xray/routing/types.ts
+++ b/frontend/src/pages/xray/routing/types.ts
@@ -1,5 +1,6 @@
export interface RuleRow {
key: number;
+ enabled?: boolean;
domain?: string;
ip?: string;
port?: string;
diff --git a/frontend/src/pages/xray/routing/useRoutingColumns.tsx b/frontend/src/pages/xray/routing/useRoutingColumns.tsx
index f8c33eb86..418e5401b 100644
--- a/frontend/src/pages/xray/routing/useRoutingColumns.tsx
+++ b/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 {
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) => (
-
+
onHandlePointerDown(index, ev)}
/>
{index + 1}
-
- {!isMobile && (
-
} onClick={() => openEdit(index)} />
- )}
-
{t('edit')}>, onClick: () => openEdit(index) }]
- : []),
- { key: 'up', label: , disabled: index === 0, onClick: () => moveUp(index) },
- {
- key: 'down',
- label: ,
- disabled: index === rowsLength - 1,
- onClick: () => moveDown(index),
- },
- { key: 'del', danger: true, label: <> {t('delete')}>, onClick: () => confirmDelete(index) },
- ],
- }}
- >
- } />
-
-
),
},
+ {
+ title: t('pages.clients.actions'),
+ align: 'center',
+ width: 80,
+ key: 'action',
+ render: (_v, _r, index) => (
+
+ {!isMobile && (
+
} onClick={() => openEdit(index)} />
+ )}
+
{t('edit')}>, onClick: () => openEdit(index) }]
+ : []),
+ { key: 'up', label: , disabled: index === 0, onClick: () => moveUp(index) },
+ {
+ key: 'down',
+ label: ,
+ disabled: index === rowsLength - 1,
+ onClick: () => moveDown(index),
+ },
+ { key: 'del', danger: true, label: <> {t('delete')}>, onClick: () => confirmDelete(index) },
+ ],
+ }}
+ >
+ } />
+
+
+ ),
+ },
+ {
+ title: t('enable'),
+ align: 'center',
+ width: 80,
+ key: 'enabled',
+ render: (_v, _r, index) => (
+
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],
);
}
diff --git a/frontend/src/schemas/routing.ts b/frontend/src/schemas/routing.ts
index 5eb1e4660..ba19b867f 100644
--- a/frontend/src/schemas/routing.ts
+++ b/frontend/src/schemas/routing.ts
@@ -17,6 +17,7 @@ export type RuleWebhook = z.infer;
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(),
diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts
index 273814fd2..7cd1f29a4 100644
--- a/frontend/src/schemas/xray.ts
+++ b/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(),
diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go
index 6be22140c..abed4e2dc 100644
--- a/internal/web/service/xray.go
+++ b/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() {
diff --git a/internal/web/service/xray_setting.go b/internal/web/service/xray_setting.go
index 44da2b6cd..a73534f5e 100644
--- a/internal/web/service/xray_setting.go
+++ b/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
diff --git a/internal/web/service/xray_strip_rules_test.go b/internal/web/service/xray_strip_rules_test.go
new file mode 100644
index 000000000..dabbca2a9
--- /dev/null
+++ b/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])
+ }
+ })
+}