Przeglądaj źródła

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 3 godzin temu
rodzic
commit
b332d88438

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

@@ -8,6 +8,7 @@ import {
   RocketOutlined,
   RocketOutlined,
   SendOutlined,
   SendOutlined,
   SettingOutlined,
   SettingOutlined,
+  StopOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
 import { onNumber } from '@/utils/onNumber';
 import { onNumber } from '@/utils/onNumber';
@@ -31,10 +32,17 @@ const DEFAULT_MUX = {
   xudpConcurrency: 16,
   xudpConcurrency: 16,
   xudpProxyUDP443: 'reject',
   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', domain: ['geosite:category-ir'] },
   { type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip: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 = [
 const directIPsOptions = [
   { label: 'Private IP', value: 'geoip:private' },
   { label: 'Private IP', value: 'geoip:private' },
@@ -57,6 +65,10 @@ const directDomainsOptions = [
   { label: 'Meta', value: 'geosite:meta' },
   { label: 'Meta', value: 'geosite:meta' },
   { label: 'Google', value: 'geosite:google' },
   { 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 {
 function readJson<T>(raw: string, fallback: T): T {
   try {
   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({
 export default function SubscriptionFormatsTab({
   allSetting,
   allSetting,
   updateSetting,
   updateSetting,
@@ -75,7 +92,6 @@ export default function SubscriptionFormatsTab({
   const { isMobile } = useMediaQuery();
   const { isMobile } = useMediaQuery();
 
 
   const muxEnabled = allSetting.subJsonMux !== '';
   const muxEnabled = allSetting.subJsonMux !== '';
-  const directEnabled = allSetting.subJsonRules !== '';
 
 
   const muxObj = useMemo(
   const muxObj = useMemo(
     () =>
     () =>
@@ -92,57 +108,48 @@ export default function SubscriptionFormatsTab({
     updateSetting({ subJsonMux: JSON.stringify(next) });
     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];
     let rules = [...ruleArray];
     if (value.length === 0) {
     if (value.length === 0) {
-      rules = rules.filter((r) => !r.domain);
+      rules = rules.filter((r) => !(r.outboundTag === tag && r[key]));
     } else {
     } 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 (
   return (
@@ -385,16 +392,19 @@ export default function SubscriptionFormatsTab({
                 title={t('pages.settings.direct')}
                 title={t('pages.settings.direct')}
                 description={t('pages.settings.directDesc')}
                 description={t('pages.settings.directDesc')}
               >
               >
-                <Switch checked={directEnabled} onChange={setDirectEnabled} />
+                <Switch
+                  checked={directEnabled}
+                  onChange={(v) => setTagEnabled('direct', DEFAULT_DIRECT_RULES, v)}
+                />
               </SettingListItem>
               </SettingListItem>
               {directEnabled && (
               {directEnabled && (
                 <div className="format-settings">
                 <div className="format-settings">
                   <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
                   <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
                     <Select
                     <Select
                       mode="tags"
                       mode="tags"
-                      value={directIPs}
+                      value={ruleValues('direct', 'ip')}
                       style={{ width: '100%' }}
                       style={{ width: '100%' }}
-                      onChange={setDirectIPs}
+                      onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
                       options={directIPsOptions}
                       options={directIPsOptions}
                     />
                     />
                   </SettingListItem>
                   </SettingListItem>
@@ -408,9 +418,11 @@ export default function SubscriptionFormatsTab({
                   >
                   >
                     <Select
                     <Select
                       mode="tags"
                       mode="tags"
-                      value={directDomains}
+                      value={ruleValues('direct', 'domain')}
                       style={{ width: '100%' }}
                       style={{ width: '100%' }}
-                      onChange={setDirectDomains}
+                      onChange={(v) =>
+                        setRuleValues('direct', 'domain', DEFAULT_DIRECT_RULES[0], v)
+                      }
                       options={directDomainsOptions}
                       options={directDomainsOptions}
                     />
                     />
                   </SettingListItem>
                   </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",
       "muxSett": "إعدادات MUX",
       "direct": "اتصال مباشر",
       "direct": "اتصال مباشر",
       "directDesc": "ينشئ اتصال مباشر مع الدومينات أو نطاقات IP لدولة معينة.",
       "directDesc": "ينشئ اتصال مباشر مع الدومينات أو نطاقات IP لدولة معينة.",
+      "block": "حظر الاتصال",
+      "blockDesc": "يحظر الاتصالات بالنطاقات أو نطاقات IP المحددة باستخدام مسار blackhole.",
       "notifications": "الإشعارات",
       "notifications": "الإشعارات",
       "certs": "الشهادات",
       "certs": "الشهادات",
       "externalTraffic": "الترافيك الخارجي",
       "externalTraffic": "الترافيك الخارجي",

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

@@ -1415,6 +1415,8 @@
       "muxSett": "Mux Settings",
       "muxSett": "Mux Settings",
       "direct": "Direct Connection",
       "direct": "Direct Connection",
       "directDesc": "Directly establishes connections with domains or IP ranges of a specific country.",
       "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",
       "notifications": "Notifications",
       "certs": "Certificates",
       "certs": "Certificates",
       "externalTraffic": "External Traffic",
       "externalTraffic": "External Traffic",

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

@@ -1293,6 +1293,8 @@
       "muxSett": "Configuración Mux",
       "muxSett": "Configuración Mux",
       "direct": "Conexión Directa",
       "direct": "Conexión Directa",
       "directDesc": "Establece conexiones directas con dominios o rangos de IP de un país específico.",
       "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",
       "notifications": "Notificaciones",
       "certs": "Certificados",
       "certs": "Certificados",
       "externalTraffic": "Tráfico Externo",
       "externalTraffic": "Tráfico Externo",

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

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

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

@@ -1293,6 +1293,8 @@
       "muxSett": "Pengaturan Mux",
       "muxSett": "Pengaturan Mux",
       "direct": "Koneksi langsung",
       "direct": "Koneksi langsung",
       "directDesc": "Secara langsung membuat koneksi dengan domain atau rentang IP negara tertentu.",
       "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",
       "notifications": "Notifikasi",
       "certs": "Sertifikat",
       "certs": "Sertifikat",
       "externalTraffic": "Lalu Lintas Eksternal",
       "externalTraffic": "Lalu Lintas Eksternal",

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

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

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

@@ -1293,6 +1293,8 @@
       "muxSett": "Configurações de Mux",
       "muxSett": "Configurações de Mux",
       "direct": "Conexão Direta",
       "direct": "Conexão Direta",
       "directDesc": "Estabelece conexões diretamente com domínios ou intervalos de IP de um país específico.",
       "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",
       "notifications": "Notificações",
       "certs": "Certificados",
       "certs": "Certificados",
       "externalTraffic": "Tráfego Externo",
       "externalTraffic": "Tráfego Externo",

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

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

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

@@ -1293,6 +1293,8 @@
       "muxSett": "Mux Ayarları",
       "muxSett": "Mux Ayarları",
       "direct": "Doğrudan Bağlantı",
       "direct": "Doğrudan Bağlantı",
       "directDesc": "Belirli bir ülkenin alan adları veya IP aralıkları ile doğrudan bağlantı kurar.",
       "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",
       "notifications": "Bildirimler",
       "certs": "Sertifikalar",
       "certs": "Sertifikalar",
       "externalTraffic": "Harici Trafik",
       "externalTraffic": "Harici Trafik",

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

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

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

@@ -1293,6 +1293,8 @@
       "muxSett": "Mux Cài đặt",
       "muxSett": "Mux Cài đặt",
       "direct": "Kết nối trực tiếp",
       "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ể.",
       "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",
       "notifications": "Thông báo",
       "certs": "Chứng chỉ",
       "certs": "Chứng chỉ",
       "externalTraffic": "Lưu lượng bên ngoài",
       "externalTraffic": "Lưu lượng bên ngoài",

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

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

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

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