balancer-loopback.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import { describe, it, expect } from 'vitest';
  2. import type { XraySettingsValue } from '@/hooks/useXraySetting';
  3. import {
  4. isBalancerLoopbackTag,
  5. loopbackTagFor,
  6. balancerTagFromLoopback,
  7. resolveLoopbackFallback,
  8. ensureBalancerLoopback,
  9. ensureMissingBalancerLoopbacks,
  10. removeBalancerLoopback,
  11. removeBalancerLoopbackIfOrphaned,
  12. propagateBalancerTagRename,
  13. detectBalancerCycles,
  14. cleanupOrphanedBalancerLoopbacks,
  15. } from '@/pages/xray/balancers/balancer-loopback';
  16. interface OutboundEntry {
  17. tag?: string;
  18. protocol?: string;
  19. settings?: { inboundTag?: string };
  20. }
  21. interface RuleEntry {
  22. type?: string;
  23. inboundTag?: string[];
  24. balancerTag?: string;
  25. domain?: string[];
  26. }
  27. interface BalancerEntry {
  28. tag?: string;
  29. selector?: string[];
  30. fallbackTag?: string;
  31. }
  32. function makeSettings(input: {
  33. outbounds?: OutboundEntry[];
  34. rules?: RuleEntry[];
  35. balancers?: BalancerEntry[];
  36. }): XraySettingsValue {
  37. return {
  38. outbounds: input.outbounds,
  39. routing: {
  40. rules: input.rules,
  41. balancers: input.balancers,
  42. },
  43. } as XraySettingsValue;
  44. }
  45. function outboundTags(settings: XraySettingsValue): string[] {
  46. return ((settings.outbounds ?? []) as OutboundEntry[]).map((o) => o.tag ?? '');
  47. }
  48. function loopbackOutbounds(settings: XraySettingsValue): OutboundEntry[] {
  49. return ((settings.outbounds ?? []) as OutboundEntry[]).filter((o) => o.protocol === 'loopback');
  50. }
  51. function ruleEntries(settings: XraySettingsValue): RuleEntry[] {
  52. return (settings.routing?.rules ?? []) as RuleEntry[];
  53. }
  54. function balancerEntries(settings: XraySettingsValue): BalancerEntry[] {
  55. return (settings.routing?.balancers ?? []) as BalancerEntry[];
  56. }
  57. describe('loopback tag helpers', () => {
  58. const cases: Array<{ tag: string; isLoopback: boolean; roundtrip: string | null }> = [
  59. { tag: '_bl_main', isLoopback: true, roundtrip: 'main' },
  60. { tag: 'main', isLoopback: false, roundtrip: null },
  61. { tag: '_bl_', isLoopback: true, roundtrip: '' },
  62. { tag: 'proxy_bl_', isLoopback: false, roundtrip: null },
  63. ];
  64. it.each(cases)('classifies $tag', ({ tag, isLoopback, roundtrip }) => {
  65. expect(isBalancerLoopbackTag(tag)).toBe(isLoopback);
  66. expect(balancerTagFromLoopback(tag)).toBe(roundtrip);
  67. });
  68. it('builds a loopback tag that round-trips back to the balancer tag', () => {
  69. expect(loopbackTagFor('cluster-a')).toBe('_bl_cluster-a');
  70. expect(balancerTagFromLoopback(loopbackTagFor('cluster-a'))).toBe('cluster-a');
  71. });
  72. });
  73. describe('resolveLoopbackFallback', () => {
  74. const settings = makeSettings({
  75. rules: [{ type: 'field', inboundTag: ['_bl_bal1'], balancerTag: 'bal1' }],
  76. });
  77. const cases: Array<{ name: string; input: string; expected: string }> = [
  78. {
  79. name: 'resolves a loopback tag through its routing rule',
  80. input: '_bl_bal1',
  81. expected: 'bal1',
  82. },
  83. { name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' },
  84. { name: 'returns an empty tag unchanged', input: '', expected: '' },
  85. { name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' },
  86. ];
  87. it.each(cases)('$name', ({ input, expected }) => {
  88. expect(resolveLoopbackFallback(settings, input)).toBe(expected);
  89. });
  90. });
  91. describe('ensureBalancerLoopback dedup', () => {
  92. it('creates exactly one loopback outbound and one rule when called repeatedly', () => {
  93. const settings = makeSettings({ outbounds: [], rules: [], balancers: [] });
  94. ensureBalancerLoopback(settings, 'bal1');
  95. ensureBalancerLoopback(settings, 'bal1');
  96. const loopbacks = loopbackOutbounds(settings);
  97. expect(loopbacks).toHaveLength(1);
  98. expect(loopbacks[0]).toEqual({
  99. tag: '_bl_bal1',
  100. protocol: 'loopback',
  101. settings: { inboundTag: '_bl_bal1' },
  102. });
  103. const matchingRules = ruleEntries(settings).filter(
  104. (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_bal1'),
  105. );
  106. expect(matchingRules).toHaveLength(1);
  107. expect(matchingRules[0].balancerTag).toBe('bal1');
  108. });
  109. it('does not duplicate a loopback shared by multiple balancers', () => {
  110. const settings = makeSettings({
  111. outbounds: [],
  112. rules: [],
  113. balancers: [
  114. { tag: 'A', selector: [], fallbackTag: '_bl_shared' },
  115. { tag: 'B', selector: [], fallbackTag: '_bl_shared' },
  116. ],
  117. });
  118. ensureMissingBalancerLoopbacks(settings);
  119. expect(loopbackOutbounds(settings)).toHaveLength(1);
  120. expect(
  121. ruleEntries(settings).filter(
  122. (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_shared'),
  123. ),
  124. ).toHaveLength(1);
  125. });
  126. });
  127. describe('ensureBalancerLoopback rule ordering', () => {
  128. function loopbackRuleIndex(settings: XraySettingsValue, lbTag: string): number {
  129. return ruleEntries(settings).findIndex(
  130. (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
  131. );
  132. }
  133. function generalRuleIndex(settings: XraySettingsValue): number {
  134. return ruleEntries(settings).findIndex(
  135. (r) => !Array.isArray(r.inboundTag) || r.inboundTag.length === 0,
  136. );
  137. }
  138. it('inserts a new loopback rule ahead of a general (no inboundTag) rule', () => {
  139. const settings = makeSettings({
  140. rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
  141. balancers: [{ tag: 'parent', selector: [] }],
  142. });
  143. ensureBalancerLoopback(settings, 'target');
  144. expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
  145. });
  146. it('repositions an existing loopback rule that landed after a general rule', () => {
  147. const settings = makeSettings({
  148. rules: [
  149. { type: 'field', domain: ['example.com'], balancerTag: 'parent' },
  150. { type: 'field', inboundTag: ['_bl_target'], balancerTag: 'stale' },
  151. ],
  152. balancers: [{ tag: 'parent', selector: [] }],
  153. });
  154. ensureBalancerLoopback(settings, 'target');
  155. const lbIdx = loopbackRuleIndex(settings, '_bl_target');
  156. expect(lbIdx).toBeLessThan(generalRuleIndex(settings));
  157. expect(ruleEntries(settings)[lbIdx].balancerTag).toBe('target');
  158. });
  159. it('leaves inboundTag-restricted rules in place and slots loopback ahead of general rules only', () => {
  160. const settings = makeSettings({
  161. rules: [
  162. { type: 'field', inboundTag: ['api'], balancerTag: 'stats' },
  163. { type: 'field', domain: ['example.com'], balancerTag: 'parent' },
  164. ],
  165. balancers: [{ tag: 'parent', selector: [] }],
  166. });
  167. ensureBalancerLoopback(settings, 'target');
  168. const entries = ruleEntries(settings);
  169. expect(entries[0].inboundTag).toEqual(['api']);
  170. const lbIdx = loopbackRuleIndex(settings, '_bl_target');
  171. const generalIdx = generalRuleIndex(settings);
  172. expect(lbIdx).toBeLessThan(generalIdx);
  173. expect(lbIdx).toBeGreaterThan(0);
  174. });
  175. it('ensureMissingBalancerLoopbacks repositions every mis-ordered loopback rule', () => {
  176. const settings = makeSettings({
  177. rules: [
  178. { type: 'field', domain: ['example.com'], balancerTag: 'B1' },
  179. { type: 'field', inboundTag: ['_bl_B2'], balancerTag: 'B2' },
  180. ],
  181. balancers: [
  182. { tag: 'B1', selector: [], fallbackTag: '_bl_B2' },
  183. { tag: 'B2', selector: [] },
  184. ],
  185. });
  186. ensureMissingBalancerLoopbacks(settings);
  187. expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(generalRuleIndex(settings));
  188. });
  189. it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => {
  190. const settings = makeSettings({
  191. rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
  192. balancers: [{ tag: 'parent', selector: [] }],
  193. });
  194. ensureBalancerLoopback(settings, 'target');
  195. ensureBalancerLoopback(settings, 'target');
  196. expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
  197. expect(
  198. ruleEntries(settings).filter(
  199. (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'),
  200. ),
  201. ).toHaveLength(1);
  202. });
  203. });
  204. describe('detectBalancerCycles', () => {
  205. const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [
  206. {
  207. name: 'two-balancer loop A -> B -> A',
  208. balancers: [
  209. { tag: 'A', fallbackTag: '_bl_B' },
  210. { tag: 'B', fallbackTag: '_bl_A' },
  211. ],
  212. expected: [
  213. ['A', 'B'],
  214. ['B', 'A'],
  215. ],
  216. },
  217. {
  218. name: 'self loop A -> A',
  219. balancers: [{ tag: 'A', fallbackTag: '_bl_A' }],
  220. expected: [['A', 'A']],
  221. },
  222. {
  223. name: 'three-balancer loop A -> B -> C -> A',
  224. balancers: [
  225. { tag: 'A', fallbackTag: '_bl_B' },
  226. { tag: 'B', fallbackTag: '_bl_C' },
  227. { tag: 'C', fallbackTag: '_bl_A' },
  228. ],
  229. expected: [
  230. ['A', 'B'],
  231. ['B', 'C'],
  232. ['C', 'A'],
  233. ],
  234. },
  235. {
  236. name: 'linear chain is not a cycle',
  237. balancers: [{ tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B' }],
  238. expected: [],
  239. },
  240. {
  241. name: 'non-loopback fallback is ignored',
  242. balancers: [{ tag: 'A', fallbackTag: 'direct' }],
  243. expected: [],
  244. },
  245. ];
  246. it.each(cases)('$name', ({ balancers, expected }) => {
  247. expect(detectBalancerCycles(makeSettings({ balancers }))).toEqual(expected);
  248. });
  249. });
  250. describe('propagateBalancerTagRename', () => {
  251. it('rewrites the loopback outbound, its rule and referring fallback tags', () => {
  252. const settings = makeSettings({
  253. outbounds: [{ tag: '_bl_old', protocol: 'loopback', settings: { inboundTag: '_bl_old' } }],
  254. rules: [{ type: 'field', inboundTag: ['_bl_old'], balancerTag: 'old' }],
  255. balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_old' }],
  256. });
  257. propagateBalancerTagRename(settings, 'old', 'new');
  258. const [outbound] = loopbackOutbounds(settings);
  259. expect(outbound.tag).toBe('_bl_new');
  260. expect(outbound.settings?.inboundTag).toBe('_bl_new');
  261. expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_new']);
  262. expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_new');
  263. });
  264. it('leaves unrelated loopback tags untouched', () => {
  265. const settings = makeSettings({
  266. outbounds: [
  267. { tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } },
  268. ],
  269. rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }],
  270. balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }],
  271. });
  272. propagateBalancerTagRename(settings, 'old', 'new');
  273. expect(loopbackOutbounds(settings)[0].tag).toBe('_bl_other');
  274. expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_other']);
  275. expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_other');
  276. });
  277. });
  278. describe('orphan cleanup', () => {
  279. it('cleanupOrphanedBalancerLoopbacks removes only unreferenced loopbacks', () => {
  280. const settings = makeSettings({
  281. outbounds: [
  282. { tag: '_bl_gone', protocol: 'loopback', settings: { inboundTag: '_bl_gone' } },
  283. { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
  284. { tag: 'proxy', protocol: 'vless' },
  285. ],
  286. rules: [
  287. { type: 'field', inboundTag: ['_bl_gone'], balancerTag: 'gone' },
  288. { type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' },
  289. ],
  290. balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
  291. });
  292. cleanupOrphanedBalancerLoopbacks(settings);
  293. expect(outboundTags(settings)).toEqual(['_bl_kept', 'proxy']);
  294. expect(ruleEntries(settings).map((r) => r.inboundTag)).toEqual([['_bl_kept']]);
  295. });
  296. it('removeBalancerLoopbackIfOrphaned keeps a loopback that is still referenced', () => {
  297. const settings = makeSettings({
  298. outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
  299. rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
  300. balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
  301. });
  302. removeBalancerLoopbackIfOrphaned(settings, 'kept');
  303. expect(loopbackOutbounds(settings)).toHaveLength(1);
  304. expect(ruleEntries(settings)).toHaveLength(1);
  305. });
  306. it('removeBalancerLoopbackIfOrphaned drops a loopback with no referrers', () => {
  307. const settings = makeSettings({
  308. outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
  309. rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
  310. balancers: [],
  311. });
  312. removeBalancerLoopbackIfOrphaned(settings, 'kept');
  313. expect(loopbackOutbounds(settings)).toHaveLength(0);
  314. expect(ruleEntries(settings)).toHaveLength(0);
  315. });
  316. it('removeBalancerLoopback deletes the outbound and rule directly', () => {
  317. const settings = makeSettings({
  318. outbounds: [
  319. { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
  320. { tag: 'proxy', protocol: 'vless' },
  321. ],
  322. rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
  323. balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
  324. });
  325. removeBalancerLoopback(settings, 'kept');
  326. expect(outboundTags(settings)).toEqual(['proxy']);
  327. expect(ruleEntries(settings)).toHaveLength(0);
  328. });
  329. });