reference-cleanup.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import type { XraySettingsValue } from '@/hooks/useXraySetting';
  2. import type { BalancerObject, RuleObject } from '@/schemas/routing';
  3. import { syncObservatories } from './balancers/balancer-helpers';
  4. /**
  5. * Reference cleanup for the Xray-config blob: when an outbound or balancer is
  6. * deleted, routing rules and balancers that point at it must be repaired in the
  7. * same edit, or the saved config breaks the core (a dangling balancerTag stops
  8. * Router.Init; a dangling outboundTag black-holes matched traffic).
  9. *
  10. * Keep/drop a rule by its destination: after the deletion, a rule that still has
  11. * an outboundTag or balancerTag is kept (the dead reference is dropped); a rule
  12. * left with neither is removed, since a destination-less rule black-holes the
  13. * traffic it matches. Deleting an outbound cascades: if it empties a balancer's
  14. * selector, that balancer is removed too, and its rules are repaired the same way.
  15. */
  16. export type RuleFate = 'removed' | 'modified';
  17. export interface RuleImpact {
  18. index: number;
  19. label: string;
  20. fate: RuleFate;
  21. keeps?: string;
  22. }
  23. export interface BalancerImpact {
  24. tag: string;
  25. reason: 'selectorEmptied';
  26. }
  27. export interface DeletionImpact {
  28. rules: RuleImpact[];
  29. balancers: BalancerImpact[];
  30. observatory: boolean;
  31. burst: boolean;
  32. }
  33. const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false });
  34. function ruleList(tt: XraySettingsValue): RuleObject[] {
  35. const r = tt.routing?.rules;
  36. return Array.isArray(r) ? r : [];
  37. }
  38. function balancerList(tt: XraySettingsValue): BalancerObject[] {
  39. const b = tt.routing?.balancers;
  40. return Array.isArray(b) ? b : [];
  41. }
  42. function outboundTagAt(tt: XraySettingsValue, index: number): string {
  43. const o = tt.outbounds?.[index];
  44. return typeof o?.tag === 'string' ? o.tag : '';
  45. }
  46. function balancerTagAt(tt: XraySettingsValue, index: number): string {
  47. const b = balancerList(tt)[index];
  48. return typeof b?.tag === 'string' ? b.tag : '';
  49. }
  50. function ruleLabel(rule: RuleObject, index: number): string {
  51. const tag = typeof rule.ruleTag === 'string' ? rule.ruleTag.trim() : '';
  52. return tag || `#${index + 1}`;
  53. }
  54. /** Balancers whose selector is left empty once `removedOutbounds` are gone. */
  55. function balancersEmptiedBy(tt: XraySettingsValue, removedOutbounds: Set<string>): string[] {
  56. if (removedOutbounds.size === 0) return [];
  57. const emptied: string[] = [];
  58. for (const b of balancerList(tt)) {
  59. const tag = typeof b?.tag === 'string' ? b.tag : '';
  60. if (tag === '') continue;
  61. const selector = Array.isArray(b?.selector) ? b.selector : [];
  62. if (selector.length === 0) continue;
  63. if (selector.every((s) => removedOutbounds.has(s))) emptied.push(tag);
  64. }
  65. return emptied;
  66. }
  67. /**
  68. * Single source of truth for how a deletion affects one rule, shared by the
  69. * preview (`ruleImpacts`) and the mutation (`applyCleanup`) so the two can never
  70. * disagree. Returns null when the rule is untouched; otherwise `keeps` names the
  71. * surviving destination, or is '' when none remains and the rule must be dropped.
  72. */
  73. function classifyRule(
  74. rule: RuleObject,
  75. removedOutbounds: Set<string>,
  76. removedBalancers: Set<string>,
  77. ): { losesOut: boolean; losesBal: boolean; keeps: string } | null {
  78. const out = typeof rule?.outboundTag === 'string' ? rule.outboundTag : '';
  79. const bal = typeof rule?.balancerTag === 'string' ? rule.balancerTag : '';
  80. const losesOut = out !== '' && removedOutbounds.has(out);
  81. const losesBal = bal !== '' && removedBalancers.has(bal);
  82. if (!losesOut && !losesBal) return null;
  83. const keptOut = out !== '' && !losesOut ? out : '';
  84. const keptBal = bal !== '' && !losesBal ? bal : '';
  85. return { losesOut, losesBal, keeps: keptOut || keptBal };
  86. }
  87. function ruleImpacts(
  88. tt: XraySettingsValue,
  89. removedOutbounds: Set<string>,
  90. removedBalancers: Set<string>,
  91. ): RuleImpact[] {
  92. const impacts: RuleImpact[] = [];
  93. ruleList(tt).forEach((rule, index) => {
  94. const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
  95. if (!verdict) return;
  96. impacts.push(
  97. verdict.keeps
  98. ? { index, label: ruleLabel(rule, index), fate: 'modified', keeps: verdict.keeps }
  99. : { index, label: ruleLabel(rule, index), fate: 'removed' },
  100. );
  101. });
  102. return impacts;
  103. }
  104. function applyCleanup(
  105. tt: XraySettingsValue,
  106. removedOutbounds: Set<string>,
  107. removedBalancers: Set<string>,
  108. ): void {
  109. if (tt.routing && Array.isArray(tt.routing.rules)) {
  110. const next: RuleObject[] = [];
  111. for (const rule of tt.routing.rules) {
  112. const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
  113. if (!verdict) {
  114. next.push(rule);
  115. continue;
  116. }
  117. if (verdict.losesOut) delete rule.outboundTag;
  118. if (verdict.losesBal) delete rule.balancerTag;
  119. if (verdict.keeps) next.push(rule);
  120. }
  121. tt.routing.rules = next;
  122. }
  123. if (tt.routing && Array.isArray(tt.routing.balancers)) {
  124. const survivors: BalancerObject[] = [];
  125. for (const balancer of tt.routing.balancers) {
  126. if (!balancer) continue;
  127. if (removedBalancers.has(balancer.tag)) continue;
  128. if (removedOutbounds.size > 0 && Array.isArray(balancer.selector)) {
  129. balancer.selector = balancer.selector.filter((s) => !removedOutbounds.has(s));
  130. }
  131. if (typeof balancer.fallbackTag === 'string' && removedOutbounds.has(balancer.fallbackTag)) {
  132. balancer.fallbackTag = '';
  133. }
  134. survivors.push(balancer);
  135. }
  136. tt.routing.balancers = survivors;
  137. }
  138. if (removedOutbounds.size > 0 && Array.isArray(tt.outbounds)) {
  139. tt.outbounds = tt.outbounds.filter(
  140. (o) => !(typeof o?.tag === 'string' && removedOutbounds.has(o.tag)),
  141. );
  142. for (const outbound of tt.outbounds) {
  143. const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
  144. ?.streamSettings?.sockopt;
  145. if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) {
  146. delete sockopt.dialerProxy;
  147. }
  148. }
  149. }
  150. syncObservatories(tt);
  151. }
  152. function observersRemovedBy(
  153. tt: XraySettingsValue,
  154. removedOutbounds: Set<string>,
  155. removedBalancers: Set<string>,
  156. ): { observatory: boolean; burst: boolean } {
  157. const hadObservatory = !!tt.observatory;
  158. const hadBurst = !!tt.burstObservatory;
  159. if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
  160. const clone = JSON.parse(JSON.stringify(tt)) as XraySettingsValue;
  161. applyCleanup(clone, removedOutbounds, removedBalancers);
  162. return {
  163. observatory: hadObservatory && !clone.observatory,
  164. burst: hadBurst && !clone.burstObservatory,
  165. };
  166. }
  167. export function planBalancerDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
  168. const tag = balancerTagAt(tt, index);
  169. if (!tag) return emptyImpact();
  170. const removedOutbounds = new Set<string>();
  171. const removedBalancers = new Set([tag]);
  172. const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
  173. return {
  174. rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
  175. balancers: [],
  176. observatory: obs.observatory,
  177. burst: obs.burst,
  178. };
  179. }
  180. export function applyBalancerDeletion(tt: XraySettingsValue, index: number): void {
  181. const tag = balancerTagAt(tt, index);
  182. if (!tag) {
  183. if (tt.routing && Array.isArray(tt.routing.balancers)) tt.routing.balancers.splice(index, 1);
  184. syncObservatories(tt);
  185. return;
  186. }
  187. applyCleanup(tt, new Set<string>(), new Set([tag]));
  188. }
  189. export function planOutboundDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
  190. const tag = outboundTagAt(tt, index);
  191. if (!tag) return emptyImpact();
  192. const removedOutbounds = new Set([tag]);
  193. const cascaded = balancersEmptiedBy(tt, removedOutbounds);
  194. const removedBalancers = new Set(cascaded);
  195. const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
  196. return {
  197. rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
  198. balancers: cascaded.map((bTag) => ({ tag: bTag, reason: 'selectorEmptied' as const })),
  199. observatory: obs.observatory,
  200. burst: obs.burst,
  201. };
  202. }
  203. export function applyOutboundDeletion(tt: XraySettingsValue, index: number): void {
  204. const tag = outboundTagAt(tt, index);
  205. if (!tag) {
  206. if (Array.isArray(tt.outbounds)) tt.outbounds.splice(index, 1);
  207. syncObservatories(tt);
  208. return;
  209. }
  210. const removedOutbounds = new Set([tag]);
  211. const removedBalancers = new Set(balancersEmptiedBy(tt, removedOutbounds));
  212. applyCleanup(tt, removedOutbounds, removedBalancers);
  213. }