import type { XraySettingsValue } from '@/hooks/useXraySetting'; import type { BalancerObject, RuleObject } from '@/schemas/routing'; import { syncObservatories } from './balancers/balancer-helpers'; /** * Reference cleanup for the Xray-config blob: when an outbound or balancer is * deleted, routing rules and balancers that point at it must be repaired in the * same edit, or the saved config breaks the core (a dangling balancerTag stops * Router.Init; a dangling outboundTag black-holes matched traffic). * * Keep/drop a rule by its destination: after the deletion, a rule that still has * an outboundTag or balancerTag is kept (the dead reference is dropped); a rule * left with neither is removed, since a destination-less rule black-holes the * traffic it matches. Deleting an outbound cascades: if it empties a balancer's * selector, that balancer is removed too, and its rules are repaired the same way. */ export type RuleFate = 'removed' | 'modified'; export interface RuleImpact { index: number; label: string; fate: RuleFate; keeps?: string; } export interface BalancerImpact { tag: string; reason: 'selectorEmptied'; } export interface DeletionImpact { rules: RuleImpact[]; balancers: BalancerImpact[]; observatory: boolean; burst: boolean; } const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false }); function ruleList(tt: XraySettingsValue): RuleObject[] { const r = tt.routing?.rules; return Array.isArray(r) ? r : []; } function balancerList(tt: XraySettingsValue): BalancerObject[] { const b = tt.routing?.balancers; return Array.isArray(b) ? b : []; } function outboundTagAt(tt: XraySettingsValue, index: number): string { const o = tt.outbounds?.[index]; return typeof o?.tag === 'string' ? o.tag : ''; } function balancerTagAt(tt: XraySettingsValue, index: number): string { const b = balancerList(tt)[index]; return typeof b?.tag === 'string' ? b.tag : ''; } function ruleLabel(rule: RuleObject, index: number): string { const tag = typeof rule.ruleTag === 'string' ? rule.ruleTag.trim() : ''; return tag || `#${index + 1}`; } /** Balancers whose selector is left empty once `removedOutbounds` are gone. */ function balancersEmptiedBy(tt: XraySettingsValue, removedOutbounds: Set): string[] { if (removedOutbounds.size === 0) return []; const emptied: string[] = []; for (const b of balancerList(tt)) { const tag = typeof b?.tag === 'string' ? b.tag : ''; if (tag === '') continue; const selector = Array.isArray(b?.selector) ? b.selector : []; if (selector.length === 0) continue; if (selector.every((s) => removedOutbounds.has(s))) emptied.push(tag); } return emptied; } /** * Single source of truth for how a deletion affects one rule, shared by the * preview (`ruleImpacts`) and the mutation (`applyCleanup`) so the two can never * disagree. Returns null when the rule is untouched; otherwise `keeps` names the * surviving destination, or is '' when none remains and the rule must be dropped. */ function classifyRule( rule: RuleObject, removedOutbounds: Set, removedBalancers: Set, ): { losesOut: boolean; losesBal: boolean; keeps: string } | null { const out = typeof rule?.outboundTag === 'string' ? rule.outboundTag : ''; const bal = typeof rule?.balancerTag === 'string' ? rule.balancerTag : ''; const losesOut = out !== '' && removedOutbounds.has(out); const losesBal = bal !== '' && removedBalancers.has(bal); if (!losesOut && !losesBal) return null; const keptOut = out !== '' && !losesOut ? out : ''; const keptBal = bal !== '' && !losesBal ? bal : ''; return { losesOut, losesBal, keeps: keptOut || keptBal }; } function ruleImpacts( tt: XraySettingsValue, removedOutbounds: Set, removedBalancers: Set, ): RuleImpact[] { const impacts: RuleImpact[] = []; ruleList(tt).forEach((rule, index) => { const verdict = classifyRule(rule, removedOutbounds, removedBalancers); if (!verdict) return; impacts.push( verdict.keeps ? { index, label: ruleLabel(rule, index), fate: 'modified', keeps: verdict.keeps } : { index, label: ruleLabel(rule, index), fate: 'removed' }, ); }); return impacts; } function applyCleanup( tt: XraySettingsValue, removedOutbounds: Set, removedBalancers: Set, ): void { if (tt.routing && Array.isArray(tt.routing.rules)) { const next: RuleObject[] = []; for (const rule of tt.routing.rules) { const verdict = classifyRule(rule, removedOutbounds, removedBalancers); if (!verdict) { next.push(rule); continue; } if (verdict.losesOut) delete rule.outboundTag; if (verdict.losesBal) delete rule.balancerTag; if (verdict.keeps) next.push(rule); } tt.routing.rules = next; } if (tt.routing && Array.isArray(tt.routing.balancers)) { const survivors: BalancerObject[] = []; for (const balancer of tt.routing.balancers) { if (!balancer) continue; if (removedBalancers.has(balancer.tag)) continue; if (removedOutbounds.size > 0 && Array.isArray(balancer.selector)) { balancer.selector = balancer.selector.filter((s) => !removedOutbounds.has(s)); } if (typeof balancer.fallbackTag === 'string' && removedOutbounds.has(balancer.fallbackTag)) { balancer.fallbackTag = ''; } survivors.push(balancer); } tt.routing.balancers = survivors; } if (removedOutbounds.size > 0 && Array.isArray(tt.outbounds)) { tt.outbounds = tt.outbounds.filter( (o) => !(typeof o?.tag === 'string' && removedOutbounds.has(o.tag)), ); for (const outbound of tt.outbounds) { const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) ?.streamSettings?.sockopt; if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) { delete sockopt.dialerProxy; } } } syncObservatories(tt); } function observersRemovedBy( tt: XraySettingsValue, removedOutbounds: Set, removedBalancers: Set, ): { observatory: boolean; burst: boolean } { const hadObservatory = !!tt.observatory; const hadBurst = !!tt.burstObservatory; if (!hadObservatory && !hadBurst) return { observatory: false, burst: false }; const clone = JSON.parse(JSON.stringify(tt)) as XraySettingsValue; applyCleanup(clone, removedOutbounds, removedBalancers); return { observatory: hadObservatory && !clone.observatory, burst: hadBurst && !clone.burstObservatory, }; } export function planBalancerDeletion(tt: XraySettingsValue, index: number): DeletionImpact { const tag = balancerTagAt(tt, index); if (!tag) return emptyImpact(); const removedOutbounds = new Set(); const removedBalancers = new Set([tag]); const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers); return { rules: ruleImpacts(tt, removedOutbounds, removedBalancers), balancers: [], observatory: obs.observatory, burst: obs.burst, }; } export function applyBalancerDeletion(tt: XraySettingsValue, index: number): void { const tag = balancerTagAt(tt, index); if (!tag) { if (tt.routing && Array.isArray(tt.routing.balancers)) tt.routing.balancers.splice(index, 1); syncObservatories(tt); return; } applyCleanup(tt, new Set(), new Set([tag])); } export function planOutboundDeletion(tt: XraySettingsValue, index: number): DeletionImpact { const tag = outboundTagAt(tt, index); if (!tag) return emptyImpact(); const removedOutbounds = new Set([tag]); const cascaded = balancersEmptiedBy(tt, removedOutbounds); const removedBalancers = new Set(cascaded); const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers); return { rules: ruleImpacts(tt, removedOutbounds, removedBalancers), balancers: cascaded.map((bTag) => ({ tag: bTag, reason: 'selectorEmptied' as const })), observatory: obs.observatory, burst: obs.burst, }; } export function applyOutboundDeletion(tt: XraySettingsValue, index: number): void { const tag = outboundTagAt(tt, index); if (!tag) { if (Array.isArray(tt.outbounds)) tt.outbounds.splice(index, 1); syncObservatories(tt); return; } const removedOutbounds = new Set([tag]); const removedBalancers = new Set(balancersEmptiedBy(tt, removedOutbounds)); applyCleanup(tt, removedOutbounds, removedBalancers); }