Explorar o código

feat(settings): add Block tab for JSON subscription routing rules (#6466)

* feat(settings): add Block tab for JSON subscription routing rules

Expose the existing blackhole outbound in the subscription formats UI so
operators can add block domain/IP rules without editing subJsonRules by
hand. Scope Direct/Block helpers by outboundTag so the tabs keep separate
rule objects, and keep block rules ahead of direct for Xray match order.

* fix(settings): preserve rule order and clear foreign leftovers

Stop sorting the whole subJsonRules array on every write. Prepend block
defaults only when enabling Block. Clearing the last managed tag also
drops foreign-tag leftovers so the panel can reach an empty setting.
Fixes oxfmt on SubscriptionFormatsTab.

---------

Co-authored-by: mrchatam <[email protected]>
mrchatam hai 4 horas
pai
achega
b332d88438

+ 98 - 46
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -8,6 +8,7 @@ import {
   RocketOutlined,
   SendOutlined,
   SettingOutlined,
+  StopOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { onNumber } from '@/utils/onNumber';
@@ -31,10 +32,17 @@ const DEFAULT_MUX = {
   xudpConcurrency: 16,
   xudpProxyUDP443: 'reject',
 };
-const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
+
+type SubJsonRule = { type: string; outboundTag: string; domain?: string[]; ip?: string[] };
+
+const DEFAULT_DIRECT_RULES: SubJsonRule[] = [
   { type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
   { type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
 ];
+const DEFAULT_BLOCK_RULES: SubJsonRule[] = [
+  { type: 'field', outboundTag: 'block', domain: ['geosite:category-ads-all'] },
+];
+const BLOCK_IP_RULE: SubJsonRule = { type: 'field', outboundTag: 'block', ip: [] };
 
 const directIPsOptions = [
   { label: 'Private IP', value: 'geoip:private' },
@@ -57,6 +65,10 @@ const directDomainsOptions = [
   { label: 'Meta', value: 'geosite:meta' },
   { label: 'Google', value: 'geosite:google' },
 ];
+const blockDomainsOptions = [
+  { label: 'Ads All', value: 'geosite:category-ads-all' },
+  { label: 'Adult +18', value: 'geosite:category-porn' },
+];
 
 function readJson<T>(raw: string, fallback: T): T {
   try {
@@ -67,6 +79,11 @@ function readJson<T>(raw: string, fallback: T): T {
   }
 }
 
+function readRules(raw: string): SubJsonRule[] {
+  const parsed = readJson<unknown>(raw, null);
+  return Array.isArray(parsed) ? (parsed as SubJsonRule[]) : [];
+}
+
 export default function SubscriptionFormatsTab({
   allSetting,
   updateSetting,
@@ -75,7 +92,6 @@ export default function SubscriptionFormatsTab({
   const { isMobile } = useMediaQuery();
 
   const muxEnabled = allSetting.subJsonMux !== '';
-  const directEnabled = allSetting.subJsonRules !== '';
 
   const muxObj = useMemo(
     () =>
@@ -92,57 +108,48 @@ export default function SubscriptionFormatsTab({
     updateSetting({ subJsonMux: JSON.stringify(next) });
   }
 
-  const ruleArray = useMemo(() => {
-    if (!directEnabled) return null;
-    return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
-  }, [allSetting.subJsonRules, directEnabled]);
-
-  const directIPs = useMemo(() => {
-    if (!ruleArray) return [];
-    const ipRule = ruleArray.find((r) => r.ip);
-    return ipRule?.ip ?? [];
-  }, [ruleArray]);
+  const ruleArray = useMemo(() => readRules(allSetting.subJsonRules), [allSetting.subJsonRules]);
+  const directEnabled = ruleArray.some((r) => r.outboundTag === 'direct');
+  const blockEnabled = ruleArray.some((r) => r.outboundTag === 'block');
 
-  const directDomains = useMemo(() => {
-    if (!ruleArray) return [];
-    const dRule = ruleArray.find((r) => r.domain);
-    return dRule?.domain ?? [];
-  }, [ruleArray]);
+  const ruleValues = (tag: string, key: 'ip' | 'domain') =>
+    ruleArray.find((r) => r.outboundTag === tag && r[key])?.[key] ?? [];
 
-  function setDirectEnabled(v: boolean) {
-    updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
+  function writeRules(rules: SubJsonRule[]) {
+    updateSetting({ subJsonRules: rules.length > 0 ? JSON.stringify(rules) : '' });
   }
 
-  function setDirectIPs(value: string[]) {
-    if (!ruleArray) return;
-    let rules = [...ruleArray];
-    if (value.length === 0) {
-      rules = rules.filter((r) => !r.ip);
-    } else {
-      let idx = rules.findIndex((r) => r.ip);
-      if (idx === -1) {
-        rules.push({ ...DEFAULT_RULES[1] });
-        idx = rules.length - 1;
-      }
-      rules[idx] = { ...rules[idx], ip: [...value] };
+  function setTagEnabled(tag: string, defaults: SubJsonRule[], enabled: boolean) {
+    const rest = ruleArray.filter((r) => r.outboundTag !== tag);
+    if (!enabled) {
+      // Turning off the last managed tag also drops foreign-tag leftovers so
+      // the panel still has a path back to an empty subJsonRules.
+      const hasManaged = rest.some((r) => r.outboundTag === 'direct' || r.outboundTag === 'block');
+      writeRules(hasManaged ? rest : []);
+      return;
     }
-    updateSetting({ subJsonRules: JSON.stringify(rules) });
+    // Prepend block defaults so ads match before direct; never re-sort the rest.
+    writeRules(tag === 'block' ? [...defaults, ...rest] : [...rest, ...defaults]);
   }
 
-  function setDirectDomains(value: string[]) {
-    if (!ruleArray) return;
+  function setRuleValues(
+    tag: string,
+    key: 'ip' | 'domain',
+    template: SubJsonRule,
+    value: string[],
+  ) {
     let rules = [...ruleArray];
     if (value.length === 0) {
-      rules = rules.filter((r) => !r.domain);
+      rules = rules.filter((r) => !(r.outboundTag === tag && r[key]));
     } else {
-      let idx = rules.findIndex((r) => r.domain);
-      if (idx === -1) {
-        rules.push({ ...DEFAULT_RULES[0] });
-        idx = rules.length - 1;
+      let index = rules.findIndex((r) => r.outboundTag === tag && r[key]);
+      if (index === -1) {
+        rules.push({ ...template });
+        index = rules.length - 1;
       }
-      rules[idx] = { ...rules[idx], domain: [...value] };
+      rules[index] = { ...rules[index], [key]: [...value] };
     }
-    updateSetting({ subJsonRules: JSON.stringify(rules) });
+    writeRules(rules);
   }
 
   return (
@@ -385,16 +392,19 @@ export default function SubscriptionFormatsTab({
                 title={t('pages.settings.direct')}
                 description={t('pages.settings.directDesc')}
               >
-                <Switch checked={directEnabled} onChange={setDirectEnabled} />
+                <Switch
+                  checked={directEnabled}
+                  onChange={(v) => setTagEnabled('direct', DEFAULT_DIRECT_RULES, v)}
+                />
               </SettingListItem>
               {directEnabled && (
                 <div className="format-settings">
                   <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
                     <Select
                       mode="tags"
-                      value={directIPs}
+                      value={ruleValues('direct', 'ip')}
                       style={{ width: '100%' }}
-                      onChange={setDirectIPs}
+                      onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
                       options={directIPsOptions}
                     />
                   </SettingListItem>
@@ -408,9 +418,11 @@ export default function SubscriptionFormatsTab({
                   >
                     <Select
                       mode="tags"
-                      value={directDomains}
+                      value={ruleValues('direct', 'domain')}
                       style={{ width: '100%' }}
-                      onChange={setDirectDomains}
+                      onChange={(v) =>
+                        setRuleValues('direct', 'domain', DEFAULT_DIRECT_RULES[0], v)
+                      }
                       options={directDomainsOptions}
                     />
                   </SettingListItem>
@@ -419,6 +431,46 @@ export default function SubscriptionFormatsTab({
             </>
           ),
         },
+        {
+          key: '5',
+          label: catTabLabel(<StopOutlined />, t('pages.settings.block'), isMobile),
+          children: (
+            <>
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.block')}
+                description={t('pages.settings.blockDesc')}
+              >
+                <Switch
+                  checked={blockEnabled}
+                  onChange={(v) => setTagEnabled('block', DEFAULT_BLOCK_RULES, v)}
+                />
+              </SettingListItem>
+              {blockEnabled && (
+                <div className="format-settings">
+                  <SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
+                    <Select
+                      mode="tags"
+                      value={ruleValues('block', 'domain')}
+                      style={{ width: '100%' }}
+                      onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
+                      options={blockDomainsOptions}
+                    />
+                  </SettingListItem>
+                  <SettingListItem paddings="small" title={t('pages.xray.blockips')}>
+                    <Select
+                      mode="tags"
+                      value={ruleValues('block', 'ip')}
+                      style={{ width: '100%' }}
+                      onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
+                      options={directIPsOptions}
+                    />
+                  </SettingListItem>
+                </div>
+              )}
+            </>
+          ),
+        },
       ]}
     />
   );

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -1293,6 +1293,8 @@
       "muxSett": "إعدادات MUX",
       "direct": "اتصال مباشر",
       "directDesc": "ينشئ اتصال مباشر مع الدومينات أو نطاقات IP لدولة معينة.",
+      "block": "حظر الاتصال",
+      "blockDesc": "يحظر الاتصالات بالنطاقات أو نطاقات IP المحددة باستخدام مسار blackhole.",
       "notifications": "الإشعارات",
       "certs": "الشهادات",
       "externalTraffic": "الترافيك الخارجي",

+ 2 - 0
internal/web/translation/en-US.json

@@ -1415,6 +1415,8 @@
       "muxSett": "Mux Settings",
       "direct": "Direct Connection",
       "directDesc": "Directly establishes connections with domains or IP ranges of a specific country.",
+      "block": "Block Connection",
+      "blockDesc": "Block connections to selected domains or IP ranges using the blackhole outbound.",
       "notifications": "Notifications",
       "certs": "Certificates",
       "externalTraffic": "External Traffic",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Configuración Mux",
       "direct": "Conexión Directa",
       "directDesc": "Establece conexiones directas con dominios o rangos de IP de un país específico.",
+      "block": "Bloquear conexión",
+      "blockDesc": "Bloquea conexiones a dominios o rangos de IP seleccionados usando el outbound blackhole.",
       "notifications": "Notificaciones",
       "certs": "Certificados",
       "externalTraffic": "Tráfico Externo",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -1297,6 +1297,8 @@
       "muxSett": "تنظیمات ماکس",
       "direct": "اتصال مستقیم",
       "directDesc": "به طور مستقیم با دامنه ها یا محدوده آی‌پی یک کشور خاص ارتباط برقرار می کند",
+      "block": "مسدود کردن اتصال",
+      "blockDesc": "اتصالات به دامنه‌ها یا محدوده‌های IP انتخاب‌شده را با outbound بلک‌هول مسدود می‌کند.",
       "notifications": "اعلان‌ها",
       "certs": "گواهی‌ها",
       "externalTraffic": "ترافیک خارجی",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Pengaturan Mux",
       "direct": "Koneksi langsung",
       "directDesc": "Secara langsung membuat koneksi dengan domain atau rentang IP negara tertentu.",
+      "block": "Blokir Koneksi",
+      "blockDesc": "Memblokir koneksi ke domain atau rentang IP yang dipilih menggunakan outbound blackhole.",
       "notifications": "Notifikasi",
       "certs": "Sertifikat",
       "externalTraffic": "Lalu Lintas Eksternal",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -1293,6 +1293,8 @@
       "muxSett": "マルチプレクサ設定",
       "direct": "直接接続",
       "directDesc": "特定の国のドメインまたはIP範囲に直接接続する",
+      "block": "接続をブロック",
+      "blockDesc": "blackholeアウトバウンドを使用して、選択したドメインまたはIP範囲への接続をブロックします。",
       "notifications": "通知",
       "certs": "証明書",
       "externalTraffic": "外部トラフィック",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Configurações de Mux",
       "direct": "Conexão Direta",
       "directDesc": "Estabelece conexões diretamente com domínios ou intervalos de IP de um país específico.",
+      "block": "Bloquear conexão",
+      "blockDesc": "Bloqueia conexões para domínios ou intervalos de IP selecionados usando o outbound blackhole.",
       "notifications": "Notificações",
       "certs": "Certificados",
       "externalTraffic": "Tráfego Externo",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Настройки Mux",
       "direct": "Прямое подключение",
       "directDesc": "Устанавливает прямые соединения с доменами или IP-адресами определённой страны.",
+      "block": "Блокировка соединений",
+      "blockDesc": "Блокирует соединения с выбранными доменами или диапазонами IP через outbound blackhole.",
       "notifications": "Уведомления",
       "certs": "Сертификаты",
       "externalTraffic": "Внешний трафик",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Mux Ayarları",
       "direct": "Doğrudan Bağlantı",
       "directDesc": "Belirli bir ülkenin alan adları veya IP aralıkları ile doğrudan bağlantı kurar.",
+      "block": "Bağlantıyı Engelle",
+      "blockDesc": "Seçilen alan adlarına veya IP aralıklarına giden bağlantıları blackhole outbound ile engeller.",
       "notifications": "Bildirimler",
       "certs": "Sertifikalar",
       "externalTraffic": "Harici Trafik",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Налаштування Mux",
       "direct": "Пряме підключення",
       "directDesc": "Безпосередньо встановлює з’єднання з доменами або діапазонами IP певної країни.",
+      "block": "Блокування з’єднань",
+      "blockDesc": "Блокує з’єднання з вибраними доменами або діапазонами IP через outbound blackhole.",
       "notifications": "Сповіщення",
       "certs": "Сертифікати",
       "externalTraffic": "Зовнішній трафік",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -1293,6 +1293,8 @@
       "muxSett": "Mux Cài đặt",
       "direct": "Kết nối trực tiếp",
       "directDesc": "Trực tiếp thiết lập kết nối với tên miền hoặc dải IP của một quốc gia cụ thể.",
+      "block": "Chặn kết nối",
+      "blockDesc": "Chặn kết nối tới các tên miền hoặc dải IP đã chọn bằng outbound blackhole.",
       "notifications": "Thông báo",
       "certs": "Chứng chỉ",
       "externalTraffic": "Lưu lượng bên ngoài",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1293,6 +1293,8 @@
       "muxSett": "复用器设置",
       "direct": "直接连接",
       "directDesc": "直接与特定国家的域或 IP 范围建立连接",
+      "block": "阻止连接",
+      "blockDesc": "使用 blackhole 出站阻止对所选域名或 IP 范围的连接。",
       "notifications": "通知",
       "certs": "证书",
       "externalTraffic": "外部流量",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -1293,6 +1293,8 @@
       "muxSett": "複用器設定",
       "direct": "直接連線",
       "directDesc": "直接與特定國家的域或 IP 範圍建立連線",
+      "block": "封鎖連線",
+      "blockDesc": "使用 blackhole 出站封鎖對所選網域或 IP 範圍的連線。",
       "notifications": "通知",
       "certs": "證書",
       "externalTraffic": "外部流量",