feat(balancer): add balancer-to-balancer fallback support

Xray does not natively support using a balancer as fallbackTag for
another balancer. This feature automates the loopback workaround:
when a user selects a balancer as fallback, the panel generates a
loopback outbound + routing rule in the template.

How it works:
- User picks fallback balancer from dropdown
- Panel creates loopback outbound _bl_{target} + routing rule
- Balancer fallbackTag set to _bl_{target}
- Traffic: Balancer A → loopback _bl_B → routing rule → Balancer B

Key features:
- Dedup: multiple balancers sharing same fallback reuse one loopback
- DFS cycle detection at edit time and on save
- Self-reference guard (cannot select own balancer)
- Delete protection (blocks if used as fallback by others)
- Cleans up routing rules referencing deleted balancers
- Override resolves balancer tags through loopback mechanism
- All live status tags resolved for display
- Internal _bl_ objects filtered from Outbounds/Routing UI
- Backward-compatible with old _bl_ naming format
- Translations for all 13 locales
This commit is contained in:
Ssentiago
2026-06-26 09:15:37 +03:00
parent 60c54827aa
commit 56d5825cb5
23 changed files with 611 additions and 65 deletions
+49 -1
View File
@@ -906,11 +906,23 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
}
// OverrideBalancer forces a balancer in the running core to use the given
// outbound tag; an empty target clears the override.
// outbound tag; an empty target clears the override. When target names
// another balancer, the override resolves to the loopback outbound that
// routes traffic through the target balancer via the routing rules.
func (s *XrayService) OverrideBalancer(tag, target string) error {
if !s.IsXrayRunning() {
return errors.New("xray is not running")
}
if target != "" {
resolved, err := s.resolveOverrideTarget(target)
if err != nil {
return err
}
if resolved == "" {
return errors.New("balancer fallback target not found in routing rules")
}
target = resolved
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
return err
}
@@ -918,6 +930,42 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
return s.xrayAPI.SetBalancerTarget(tag, target)
}
// resolveOverrideTarget checks if target names a balancer and, if so,
// returns the loopback outbound tag that routes to it through the
// routing rules. Returns empty if target is already a concrete outbound.
func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
template, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
return "", err
}
var cfg map[string]any
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
return "", err
}
routing, _ := cfg["routing"].(map[string]any)
if routing == nil {
return "", nil
}
rules, _ := routing["rules"].([]any)
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if rule["balancerTag"] != target {
continue
}
inboundTags, ok := rule["inboundTag"].([]any)
if !ok || len(inboundTags) == 0 {
continue
}
if lbTag, ok := inboundTags[0].(string); ok && strings.HasPrefix(lbTag, "_bl_") {
return lbTag, nil
}
}
return "", nil
}
// TestRoute asks the running core which outbound its router picks for the
// described connection.
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "وسم موازن فريد",
"selector": "المحدد",
"fallback": "Fallback",
"fallbackBalancerHint": "اختر موزان أحمال آخر كبديل",
"balancerFallbackInfo": "سيتم توجيه حركة المرور عبر: الموزان → Loopback → الخادم → الموزان المستهدف → الاتصال الخارجي. سيؤدي هذا إلى إضافة قفزة إضافية عبر الخادم، مما قد يسبب تأخيرات طفيفة.",
"balancerFallbackCycle": "لا يمكن تعيين هذا الموزان كبديل — سيؤدي ذلك إلى إنشاء تبعية دائرية.",
"balancerDeleteInUse": "لا يمكن حذف هذا الموزان — يتم استخدامه كبديل لـ: {names}",
"expected": "المتوقع",
"expectedPlaceholder": "العدد الأمثل للعقد",
"maxRtt": "أقصى RTT",
"tolerance": "التحمل",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل."
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
"cycleTooltip": "حلقة: {path} → (العودة إلى {start})"
},
"wireguard": {
"secretKey": "المفتاح السري",
@@ -2152,4 +2157,4 @@
"statusDown": "غير متصل",
"statusUp": "متصل"
}
}
}
+7 -1
View File
@@ -1825,6 +1825,12 @@
"tagPlaceholder": "unique balancer tag",
"selector": "Selector",
"fallback": "Fallback",
"fallbackBalancerHint": "Select another balancer as fallback",
"balancerFallbackInfo": "Traffic will be routed through: Balancer → Loopback → Server → Target Balancer → Outbound. This adds an extra hop through the server, which may introduce slight delays.",
"balancerFallbackCycle": "Cannot set this balancer as fallback — it would create a circular dependency.",
"balancerDeleteInUse": "Cannot delete this balancer — it is used as fallback by: {names}",
"reservedPrefix": "_bl_ prefix is reserved for internal balancer loopback objects",
"cycleTooltip": "Cycle: {path} → (back to {start})",
"expected": "Expected",
"expectedPlaceholder": "optimal node count",
"maxRtt": "Max RTT",
@@ -2155,4 +2161,4 @@
"statusDown": "DOWN",
"statusUp": "UP"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "etiqueta única de balanceador",
"selector": "Selector",
"fallback": "Fallback",
"fallbackBalancerHint": "Seleccione otro balanceador como respaldo",
"balancerFallbackInfo": "El tráfico se enrutará a través de: Balanceador → Loopback → Servidor → Balanceador destino → Conexión saliente. Esto agrega un salto adicional a través del servidor, lo que puede introducir ligeros retrasos.",
"balancerFallbackCycle": "No se puede establecer este balanceador como respaldo — crearía una dependencia circular.",
"balancerDeleteInUse": "No se puede eliminar este balanceador — se usa como respaldo para: {names}",
"expected": "Esperado",
"expectedPlaceholder": "número óptimo de nodos",
"maxRtt": "Máx. RTT",
"tolerance": "Tolerancia",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag."
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.",
"cycleTooltip": "Ciclo: {path} → (volver a {start})"
},
"wireguard": {
"secretKey": "Llave secreta",
@@ -2152,4 +2157,4 @@
"statusDown": "CAÍDO",
"statusUp": "ACTIVO"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "تگ منحصربه‌فرد بالانسر",
"selector": "انتخابگر",
"fallback": "Fallback",
"fallbackBalancerHint": "یک بالانسر دیگر به عنوان پشتیبان انتخاب کنید",
"balancerFallbackInfo": "ترافیک از مسیر زیر مسیریابی می‌شود: بالانسر → Loopback → سرور → بالانسر مقصد → اتصال خروجی. این یک پرش اضافی از طریق سرور اضافه می‌کند که ممکن است تأخیرهای جزئی ایجاد کند.",
"balancerFallbackCycle": "امکان تنظیم این بالانسر به عنوان پشتیبان وجود ندارد — وابستگی دایره‌ای ایجاد می‌کند.",
"balancerDeleteInUse": "امکان حذف این بالانسر وجود ندارد — به عنوان پشتیبان برای موارد زیر استفاده می‌شود: {names}",
"expected": "مورد انتظار",
"expectedPlaceholder": "تعداد نود بهینه",
"maxRtt": "حداکثر RTT",
"tolerance": "تحمل",
"baselines": "خطوط پایه",
"costs": "هزینه‌ها",
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد."
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
"cycleTooltip": "حلقه: {path} → (بازگشت به {start})"
},
"wireguard": {
"secretKey": "کلید شخصی",
@@ -2152,4 +2157,4 @@
"statusDown": "قطع",
"statusUp": "وصل"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "tag balancer unik",
"selector": "Selector",
"fallback": "Fallback",
"fallbackBalancerHint": "Pilih load balancer lain sebagai fallback",
"balancerFallbackInfo": "Lalu lintas akan di-routing melalui: Load Balancer → Loopback → Server → Load Balancer tujuan → Koneksi keluar. Ini menambahkan hop tambahan melalui server, yang dapat menyebabkan sedikit penundaan.",
"balancerFallbackCycle": "Tidak dapat mengatur load balancer ini sebagai fallback — akan menciptakan dependensi siklik.",
"balancerDeleteInUse": "Tidak dapat menghapus load balancer ini — digunakan sebagai fallback untuk: {names}",
"expected": "Diharapkan",
"expectedPlaceholder": "jumlah node optimal",
"maxRtt": "Maks. RTT",
"tolerance": "Toleransi",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi."
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.",
"cycleTooltip": "Siklus: {path} → (kembali ke {start})"
},
"wireguard": {
"secretKey": "Kunci Rahasia",
@@ -2152,4 +2157,4 @@
"statusDown": "MATI",
"statusUp": "AKTIF"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "一意のバランサータグ",
"selector": "セレクター",
"fallback": "Fallback",
"fallbackBalancerHint": "フォールバックとして別のバランサーを選択してください",
"balancerFallbackInfo": "トラフィックは以下のルートで転送されます:バランサー → Loopback → サーバー → ターゲットバランサー → アウトバウンド。これによりサーバーを経由する追加ホップが発生し、多少の遅延が生じる場合があります。",
"balancerFallbackCycle": "このバランサーをフォールバックに設定できません — 循環依存が発生します。",
"balancerDeleteInUse": "このバランサーを削除できません — 以下のバランサーのフォールバックとして使用されています:{names}",
"expected": "期待値",
"expectedPlaceholder": "最適ノード数",
"maxRtt": "最大 RTT",
"tolerance": "許容範囲",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。"
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
"cycleTooltip": "循環: {path} → ({start} に戻る)"
},
"wireguard": {
"secretKey": "シークレットキー",
@@ -2152,4 +2157,4 @@
"statusDown": "ダウン",
"statusUp": "アップ"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "tag única do balanceador",
"selector": "Seletor",
"fallback": "Fallback",
"fallbackBalancerHint": "Selecione outro balanceador como fallback",
"balancerFallbackInfo": "O tráfego será roteado através de: Balanceador → Loopback → Servidor → Balanceador de destino → Conexão de saída. Isso adiciona um salto extra pelo servidor, o que pode introduzir pequenos atrasos.",
"balancerFallbackCycle": "Não é possível definir este balanceador como fallback — isso criaria uma dependência circular.",
"balancerDeleteInUse": "Não é possível excluir este balanceador — ele é usado como fallback para: {names}",
"expected": "Esperado",
"expectedPlaceholder": "número ótimo de nós",
"maxRtt": "Máx. RTT",
"tolerance": "Tolerância",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará."
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
"cycleTooltip": "Ciclo: {path} → (voltar para {start})"
},
"wireguard": {
"secretKey": "Chave Secreta",
@@ -2152,4 +2157,4 @@
"statusDown": "INATIVO",
"statusUp": "ATIVO"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "уникальный тег балансировщика",
"selector": "Селектор",
"fallback": "Fallback",
"fallbackBalancerHint": "Выберите другой балансировщик в качестве запасного",
"balancerFallbackInfo": "Трафик будет маршрутизирован через: Балансировщик → Loopback → Сервер → Целевой балансировщик → Исходящее соединение. Это добавляет дополнительный хоп через сервер, что может вызвать небольшие задержки.",
"balancerFallbackCycle": "Невозможно назначить этот балансировщик запасным — это создаст циклическую зависимость.",
"balancerDeleteInUse": "Невозможно удалить этот балансировщик — он используется как запасной для: {names}",
"expected": "Ожидаемое",
"expectedPlaceholder": "оптимальное число узлов",
"maxRtt": "Макс. RTT",
"tolerance": "Допуск",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag."
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
"cycleTooltip": "Цикл: {path} → (обратно к {start})"
},
"wireguard": {
"secretKey": "Секретный ключ",
@@ -2152,4 +2157,4 @@
"statusDown": "НЕДОСТУПЕН",
"statusUp": "РАБОТАЕТ"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "benzersiz dengeleyici etiketi",
"selector": "Seçici",
"fallback": "Fallback",
"fallbackBalancerHint": "Yedek olarak başka bir dengeleyici seçin",
"balancerFallbackInfo": "Trafik şu yoldan yönlendirilecektir: Dengeleyici → Loopback → Sunucu → Hedef dengeleyici → Bağlantı çıkışı. Bu, sunucu üzerinden ek bir atlama ekler ve hafif gecikmelere neden olabilir.",
"balancerFallbackCycle": "Bu dengeleyiciyi yedek olarak ayarlayamazsınız — döngüsel bağımlılık oluşturur.",
"balancerDeleteInUse": "Bu dengeleyici silinemez — şu dengeleyicilerin yedeği olarak kullanılmaktadır: {names}",
"expected": "Beklenen",
"expectedPlaceholder": "optimal düğüm sayısı",
"maxRtt": "Maks. RTT",
"tolerance": "Tolerans",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur."
"balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur.",
"cycleTooltip": "Döngü: {path} → ({start} adresine geri dön)"
},
"wireguard": {
"secretKey": "Gizli Anahtar",
@@ -2152,4 +2157,4 @@
"statusDown": "ÇEVRİMDIŞI",
"statusUp": "ÇEVRİMİÇİ"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "унікальний тег балансувальника",
"selector": "Селектор",
"fallback": "Fallback",
"fallbackBalancerHint": "Оберіть інший балансувач як резервний",
"balancerFallbackInfo": "Трафік буде маршрутизовано через: Балансувач → Loopback → Сервер → Цільовий балансувач → Вихідне зʼєднання. Це додає додатковий хоп через сервер, що може спричинити невеликі затримки.",
"balancerFallbackCycle": "Неможливо призначити цей балансувач резервним — це створить циклічну залежність.",
"balancerDeleteInUse": "Неможливо видалити цей балансувач — він використовується як резервний для: {names}",
"expected": "Очікуване",
"expectedPlaceholder": "оптимальна кількість вузлів",
"maxRtt": "Макс. RTT",
"tolerance": "Допуск",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag."
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
"cycleTooltip": "Цикл: {path} → (назад до {start})"
},
"wireguard": {
"secretKey": "Приватний ключ",
@@ -2152,4 +2157,4 @@
"statusDown": "НЕДОСТУПНО",
"statusUp": "ДОСТУПНО"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "tag balancer duy nhất",
"selector": "Selector",
"fallback": "Fallback",
"fallbackBalancerHint": "Chọn một load balancer khác làm dự phòng",
"balancerFallbackInfo": "Lưu lượng sẽ được định tuyến qua: Load Balancer → Loopback → Máy chủ → Load Balancer mục tiêu → kết nối ra ngoài. Điều này thêm một hop bổ sung qua máy chủ, có thể gây ra độ trễ nhẹ.",
"balancerFallbackCycle": "Không thể đặt load balancer này làm dự phòng — sẽ tạo ra phụ thuộc vòng.",
"balancerDeleteInUse": "Không thể xóa load balancer này — nó được sử dụng làm dự phòng cho: {names}",
"expected": "Kỳ vọng",
"expectedPlaceholder": "số node tối ưu",
"maxRtt": "RTT tối đa",
"tolerance": "Dung sai",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động."
"balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động.",
"cycleTooltip": "Vòng lặp: {path} → (quay lại {start})"
},
"wireguard": {
"secretKey": "Khoá bí mật",
@@ -2152,4 +2157,4 @@
"statusDown": "NGỪNG HOẠT ĐỘNG",
"statusUp": "HOẠT ĐỘNG"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "唯一均衡器标签",
"selector": "选择器",
"fallback": "Fallback",
"fallbackBalancerHint": "选择另一个负载均衡器作为备用",
"balancerFallbackInfo": "流量将通过以下路径路由:负载均衡器 → Loopback → 服务器 → 目标负载均衡器 → 出站连接。这会增加一个经过服务器的额外跳转,可能会引入轻微延迟。",
"balancerFallbackCycle": "无法将此负载均衡器设置为备用 — 这会创建循环依赖。",
"balancerDeleteInUse": "无法删除此负载均衡器 — 它被用作以下负载均衡器的备用:{names}",
"expected": "期望",
"expectedPlaceholder": "最佳节点数",
"maxRtt": "最大 RTT",
"tolerance": "容差",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。"
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。",
"cycleTooltip": "循环: {path} → (回到 {start})"
},
"wireguard": {
"secretKey": "密钥",
@@ -2152,4 +2157,4 @@
"statusDown": "断开",
"statusUp": "恢复"
}
}
}
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "唯一均衡器標籤",
"selector": "選擇器",
"fallback": "Fallback",
"fallbackBalancerHint": "選擇另一個負載平衡器作為備用",
"balancerFallbackInfo": "流量將透過以下路徑路由:負載平衡器 → Loopback → 伺服器 → 目標負載平衡器 → 出站連線。這會增加一個經過伺服器的額外跳轉,可能會引入輕微延遲。",
"balancerFallbackCycle": "無法將此負載平衡器設定為備用 — 這會建立循環依賴。",
"balancerDeleteInUse": "無法刪除此負載平衡器 — 它被用作以下負載平衡器的備用:{names}",
"expected": "期望",
"expectedPlaceholder": "最佳節點數",
"maxRtt": "最大 RTT",
"tolerance": "容差",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。"
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。",
"cycleTooltip": "循環: {path} → (回到 {start})"
},
"wireguard": {
"secretKey": "金鑰",
@@ -2152,4 +2157,4 @@
"statusDown": "中斷",
"statusUp": "恢復"
}
}
}