| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- import { describe, it, expect } from 'vitest';
- import type { XraySettingsValue } from '@/hooks/useXraySetting';
- import {
- isBalancerLoopbackTag,
- loopbackTagFor,
- balancerTagFromLoopback,
- resolveLoopbackFallback,
- ensureBalancerLoopback,
- ensureMissingBalancerLoopbacks,
- removeBalancerLoopback,
- removeBalancerLoopbackIfOrphaned,
- propagateBalancerTagRename,
- detectBalancerCycles,
- cleanupOrphanedBalancerLoopbacks,
- } from '@/pages/xray/balancers/balancer-loopback';
- interface OutboundEntry {
- tag?: string;
- protocol?: string;
- settings?: { inboundTag?: string };
- }
- interface RuleEntry {
- type?: string;
- inboundTag?: string[];
- balancerTag?: string;
- domain?: string[];
- }
- interface BalancerEntry {
- tag?: string;
- selector?: string[];
- fallbackTag?: string;
- }
- function makeSettings(input: {
- outbounds?: OutboundEntry[];
- rules?: RuleEntry[];
- balancers?: BalancerEntry[];
- }): XraySettingsValue {
- return {
- outbounds: input.outbounds,
- routing: {
- rules: input.rules,
- balancers: input.balancers,
- },
- } as XraySettingsValue;
- }
- function outboundTags(settings: XraySettingsValue): string[] {
- return ((settings.outbounds ?? []) as OutboundEntry[]).map((o) => o.tag ?? '');
- }
- function loopbackOutbounds(settings: XraySettingsValue): OutboundEntry[] {
- return ((settings.outbounds ?? []) as OutboundEntry[]).filter((o) => o.protocol === 'loopback');
- }
- function ruleEntries(settings: XraySettingsValue): RuleEntry[] {
- return (settings.routing?.rules ?? []) as RuleEntry[];
- }
- function balancerEntries(settings: XraySettingsValue): BalancerEntry[] {
- return (settings.routing?.balancers ?? []) as BalancerEntry[];
- }
- describe('loopback tag helpers', () => {
- const cases: Array<{ tag: string; isLoopback: boolean; roundtrip: string | null }> = [
- { tag: '_bl_main', isLoopback: true, roundtrip: 'main' },
- { tag: 'main', isLoopback: false, roundtrip: null },
- { tag: '_bl_', isLoopback: true, roundtrip: '' },
- { tag: 'proxy_bl_', isLoopback: false, roundtrip: null },
- ];
- it.each(cases)('classifies $tag', ({ tag, isLoopback, roundtrip }) => {
- expect(isBalancerLoopbackTag(tag)).toBe(isLoopback);
- expect(balancerTagFromLoopback(tag)).toBe(roundtrip);
- });
- it('builds a loopback tag that round-trips back to the balancer tag', () => {
- expect(loopbackTagFor('cluster-a')).toBe('_bl_cluster-a');
- expect(balancerTagFromLoopback(loopbackTagFor('cluster-a'))).toBe('cluster-a');
- });
- });
- describe('resolveLoopbackFallback', () => {
- const settings = makeSettings({
- rules: [{ type: 'field', inboundTag: ['_bl_bal1'], balancerTag: 'bal1' }],
- });
- const cases: Array<{ name: string; input: string; expected: string }> = [
- {
- name: 'resolves a loopback tag through its routing rule',
- input: '_bl_bal1',
- expected: 'bal1',
- },
- { name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' },
- { name: 'returns an empty tag unchanged', input: '', expected: '' },
- { name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' },
- ];
- it.each(cases)('$name', ({ input, expected }) => {
- expect(resolveLoopbackFallback(settings, input)).toBe(expected);
- });
- });
- describe('ensureBalancerLoopback dedup', () => {
- it('creates exactly one loopback outbound and one rule when called repeatedly', () => {
- const settings = makeSettings({ outbounds: [], rules: [], balancers: [] });
- ensureBalancerLoopback(settings, 'bal1');
- ensureBalancerLoopback(settings, 'bal1');
- const loopbacks = loopbackOutbounds(settings);
- expect(loopbacks).toHaveLength(1);
- expect(loopbacks[0]).toEqual({
- tag: '_bl_bal1',
- protocol: 'loopback',
- settings: { inboundTag: '_bl_bal1' },
- });
- const matchingRules = ruleEntries(settings).filter(
- (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_bal1'),
- );
- expect(matchingRules).toHaveLength(1);
- expect(matchingRules[0].balancerTag).toBe('bal1');
- });
- it('does not duplicate a loopback shared by multiple balancers', () => {
- const settings = makeSettings({
- outbounds: [],
- rules: [],
- balancers: [
- { tag: 'A', selector: [], fallbackTag: '_bl_shared' },
- { tag: 'B', selector: [], fallbackTag: '_bl_shared' },
- ],
- });
- ensureMissingBalancerLoopbacks(settings);
- expect(loopbackOutbounds(settings)).toHaveLength(1);
- expect(
- ruleEntries(settings).filter(
- (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_shared'),
- ),
- ).toHaveLength(1);
- });
- });
- describe('ensureBalancerLoopback rule ordering', () => {
- function loopbackRuleIndex(settings: XraySettingsValue, lbTag: string): number {
- return ruleEntries(settings).findIndex(
- (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
- );
- }
- function generalRuleIndex(settings: XraySettingsValue): number {
- return ruleEntries(settings).findIndex(
- (r) => !Array.isArray(r.inboundTag) || r.inboundTag.length === 0,
- );
- }
- it('inserts a new loopback rule ahead of a general (no inboundTag) rule', () => {
- const settings = makeSettings({
- rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
- balancers: [{ tag: 'parent', selector: [] }],
- });
- ensureBalancerLoopback(settings, 'target');
- expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
- });
- it('repositions an existing loopback rule that landed after a general rule', () => {
- const settings = makeSettings({
- rules: [
- { type: 'field', domain: ['example.com'], balancerTag: 'parent' },
- { type: 'field', inboundTag: ['_bl_target'], balancerTag: 'stale' },
- ],
- balancers: [{ tag: 'parent', selector: [] }],
- });
- ensureBalancerLoopback(settings, 'target');
- const lbIdx = loopbackRuleIndex(settings, '_bl_target');
- expect(lbIdx).toBeLessThan(generalRuleIndex(settings));
- expect(ruleEntries(settings)[lbIdx].balancerTag).toBe('target');
- });
- it('leaves inboundTag-restricted rules in place and slots loopback ahead of general rules only', () => {
- const settings = makeSettings({
- rules: [
- { type: 'field', inboundTag: ['api'], balancerTag: 'stats' },
- { type: 'field', domain: ['example.com'], balancerTag: 'parent' },
- ],
- balancers: [{ tag: 'parent', selector: [] }],
- });
- ensureBalancerLoopback(settings, 'target');
- const entries = ruleEntries(settings);
- expect(entries[0].inboundTag).toEqual(['api']);
- const lbIdx = loopbackRuleIndex(settings, '_bl_target');
- const generalIdx = generalRuleIndex(settings);
- expect(lbIdx).toBeLessThan(generalIdx);
- expect(lbIdx).toBeGreaterThan(0);
- });
- it('ensureMissingBalancerLoopbacks repositions every mis-ordered loopback rule', () => {
- const settings = makeSettings({
- rules: [
- { type: 'field', domain: ['example.com'], balancerTag: 'B1' },
- { type: 'field', inboundTag: ['_bl_B2'], balancerTag: 'B2' },
- ],
- balancers: [
- { tag: 'B1', selector: [], fallbackTag: '_bl_B2' },
- { tag: 'B2', selector: [] },
- ],
- });
- ensureMissingBalancerLoopbacks(settings);
- expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(generalRuleIndex(settings));
- });
- it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => {
- const settings = makeSettings({
- rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
- balancers: [{ tag: 'parent', selector: [] }],
- });
- ensureBalancerLoopback(settings, 'target');
- ensureBalancerLoopback(settings, 'target');
- expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
- expect(
- ruleEntries(settings).filter(
- (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'),
- ),
- ).toHaveLength(1);
- });
- });
- describe('detectBalancerCycles', () => {
- const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [
- {
- name: 'two-balancer loop A -> B -> A',
- balancers: [
- { tag: 'A', fallbackTag: '_bl_B' },
- { tag: 'B', fallbackTag: '_bl_A' },
- ],
- expected: [
- ['A', 'B'],
- ['B', 'A'],
- ],
- },
- {
- name: 'self loop A -> A',
- balancers: [{ tag: 'A', fallbackTag: '_bl_A' }],
- expected: [['A', 'A']],
- },
- {
- name: 'three-balancer loop A -> B -> C -> A',
- balancers: [
- { tag: 'A', fallbackTag: '_bl_B' },
- { tag: 'B', fallbackTag: '_bl_C' },
- { tag: 'C', fallbackTag: '_bl_A' },
- ],
- expected: [
- ['A', 'B'],
- ['B', 'C'],
- ['C', 'A'],
- ],
- },
- {
- name: 'linear chain is not a cycle',
- balancers: [{ tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B' }],
- expected: [],
- },
- {
- name: 'non-loopback fallback is ignored',
- balancers: [{ tag: 'A', fallbackTag: 'direct' }],
- expected: [],
- },
- ];
- it.each(cases)('$name', ({ balancers, expected }) => {
- expect(detectBalancerCycles(makeSettings({ balancers }))).toEqual(expected);
- });
- });
- describe('propagateBalancerTagRename', () => {
- it('rewrites the loopback outbound, its rule and referring fallback tags', () => {
- const settings = makeSettings({
- outbounds: [{ tag: '_bl_old', protocol: 'loopback', settings: { inboundTag: '_bl_old' } }],
- rules: [{ type: 'field', inboundTag: ['_bl_old'], balancerTag: 'old' }],
- balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_old' }],
- });
- propagateBalancerTagRename(settings, 'old', 'new');
- const [outbound] = loopbackOutbounds(settings);
- expect(outbound.tag).toBe('_bl_new');
- expect(outbound.settings?.inboundTag).toBe('_bl_new');
- expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_new']);
- expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_new');
- });
- it('leaves unrelated loopback tags untouched', () => {
- const settings = makeSettings({
- outbounds: [
- { tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } },
- ],
- rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }],
- balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }],
- });
- propagateBalancerTagRename(settings, 'old', 'new');
- expect(loopbackOutbounds(settings)[0].tag).toBe('_bl_other');
- expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_other']);
- expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_other');
- });
- });
- describe('orphan cleanup', () => {
- it('cleanupOrphanedBalancerLoopbacks removes only unreferenced loopbacks', () => {
- const settings = makeSettings({
- outbounds: [
- { tag: '_bl_gone', protocol: 'loopback', settings: { inboundTag: '_bl_gone' } },
- { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
- { tag: 'proxy', protocol: 'vless' },
- ],
- rules: [
- { type: 'field', inboundTag: ['_bl_gone'], balancerTag: 'gone' },
- { type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' },
- ],
- balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
- });
- cleanupOrphanedBalancerLoopbacks(settings);
- expect(outboundTags(settings)).toEqual(['_bl_kept', 'proxy']);
- expect(ruleEntries(settings).map((r) => r.inboundTag)).toEqual([['_bl_kept']]);
- });
- it('removeBalancerLoopbackIfOrphaned keeps a loopback that is still referenced', () => {
- const settings = makeSettings({
- outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
- rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
- balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
- });
- removeBalancerLoopbackIfOrphaned(settings, 'kept');
- expect(loopbackOutbounds(settings)).toHaveLength(1);
- expect(ruleEntries(settings)).toHaveLength(1);
- });
- it('removeBalancerLoopbackIfOrphaned drops a loopback with no referrers', () => {
- const settings = makeSettings({
- outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
- rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
- balancers: [],
- });
- removeBalancerLoopbackIfOrphaned(settings, 'kept');
- expect(loopbackOutbounds(settings)).toHaveLength(0);
- expect(ruleEntries(settings)).toHaveLength(0);
- });
- it('removeBalancerLoopback deletes the outbound and rule directly', () => {
- const settings = makeSettings({
- outbounds: [
- { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
- { tag: 'proxy', protocol: 'vless' },
- ],
- rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
- balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
- });
- removeBalancerLoopback(settings, 'kept');
- expect(outboundTags(settings)).toEqual(['proxy']);
- expect(ruleEntries(settings)).toHaveLength(0);
- });
- });
|