diff --git a/frontend/src/pages/xray/balancers/balancer-loopback.ts b/frontend/src/pages/xray/balancers/balancer-loopback.ts index db90cf7de..6d9212414 100644 --- a/frontend/src/pages/xray/balancers/balancer-loopback.ts +++ b/frontend/src/pages/xray/balancers/balancer-loopback.ts @@ -52,6 +52,18 @@ function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): numb return count; } +function isGeneralRoutingRule(rule: Record): boolean { + const inboundTag = rule.inboundTag; + return !Array.isArray(inboundTag) || inboundTag.length === 0; +} + +function firstGeneralRoutingRuleIndex(rules: Array>): number { + for (let i = 0; i < rules.length; i += 1) { + if (isGeneralRoutingRule(rules[i])) return i; + } + return rules.length; +} + export function ensureBalancerLoopback( settings: XraySettingsValue, targetBalancerTag: string, @@ -72,13 +84,19 @@ export function ensureBalancerLoopback( if (!settings.routing) settings.routing = { rules: [], balancers: [] }; if (!Array.isArray(settings.routing.rules)) settings.routing.rules = []; - const existingRuleIdx = (settings.routing.rules as Array<{ inboundTag?: string[] }>).findIndex( - (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag), + const rules = settings.routing.rules as Array>; + + const existingRuleIdx = rules.findIndex( + (r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag), ); + if (existingRuleIdx >= 0) { - (settings.routing.rules as Record[])[existingRuleIdx].balancerTag = targetBalancerTag; + const existing = rules[existingRuleIdx]; + existing.balancerTag = targetBalancerTag; + rules.splice(existingRuleIdx, 1); + rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing); } else { - (settings.routing.rules as Record[]).push({ + rules.splice(firstGeneralRoutingRuleIndex(rules), 0, { type: 'field', inboundTag: [lbTag], balancerTag: targetBalancerTag, diff --git a/frontend/src/test/balancer-loopback.test.ts b/frontend/src/test/balancer-loopback.test.ts index be142ae5c..ffd846b00 100644 --- a/frontend/src/test/balancer-loopback.test.ts +++ b/frontend/src/test/balancer-loopback.test.ts @@ -24,6 +24,7 @@ interface RuleEntry { type?: string; inboundTag?: string[]; balancerTag?: string; + domain?: string[]; } interface BalancerEntry { tag?: string; @@ -140,6 +141,105 @@ describe('ensureBalancerLoopback dedup', () => { }); }); +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[][] }> = [ {