mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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:
@@ -235,3 +235,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rule-disabled {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export interface RuleRow {
|
||||
key: number;
|
||||
enabled?: boolean;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
port?: string;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user