From f9926947126df6f0420fc95dceaebed303d5618d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 25 Oct 2026 15:54:48 +0100 Subject: [PATCH] feat(tls): cipher suite presets and a uTLS fingerprint warning Adds PattN's semi-python and real-firefox client cipher lists as presets in the cipher suites picker. A preset replaces the whole list because the order is part of the ClientHello. The inbound TLS form now warns when cipher suites are set under a uTLS fingerprint other than "unsafe": clients ignore them there, while an empty fingerprint is already pinned to unsafe in share links. --- .../components/form/CipherSuitesSelect.tsx | 24 ++++++++++++-- .../src/pages/inbounds/form/security/tls.tsx | 15 +++++++++ frontend/src/schemas/primitives/options.ts | 31 +++++++++++++++++++ .../src/test/cipher-suites-select.test.tsx | 16 ++++++++++ .../src/test/ech-dynamic-helper-tls.test.tsx | 23 ++++++++++++++ internal/web/translation/ar-EG.json | 2 ++ internal/web/translation/en-US.json | 2 ++ internal/web/translation/es-ES.json | 2 ++ internal/web/translation/fa-IR.json | 2 ++ internal/web/translation/id-ID.json | 2 ++ internal/web/translation/ja-JP.json | 2 ++ internal/web/translation/pt-BR.json | 2 ++ internal/web/translation/ru-RU.json | 2 ++ internal/web/translation/sk-SK.json | 2 ++ internal/web/translation/tr-TR.json | 2 ++ internal/web/translation/uk-UA.json | 2 ++ internal/web/translation/vi-VN.json | 2 ++ internal/web/translation/zh-CN.json | 2 ++ internal/web/translation/zh-TW.json | 2 ++ 19 files changed, 134 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/form/CipherSuitesSelect.tsx b/frontend/src/components/form/CipherSuitesSelect.tsx index 91e738d12..4bace3d45 100644 --- a/frontend/src/components/form/CipherSuitesSelect.tsx +++ b/frontend/src/components/form/CipherSuitesSelect.tsx @@ -1,10 +1,17 @@ import { Select } from 'antd'; import type { SelectProps } from 'antd'; +import { useTranslation } from 'react-i18next'; -import { TLS_CIPHER_OPTION } from '@/schemas/primitives'; +import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives'; const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v })); +const PRESET_PREFIX = 'preset:'; +const PRESET_OPTIONS = Object.keys(TLS_CIPHER_PRESETS).map((name) => ({ + value: PRESET_PREFIX + name, + label: name, +})); + type CipherSuitesSelectProps = Omit< SelectProps, 'value' | 'onChange' | 'mode' | 'options' @@ -14,6 +21,13 @@ type CipherSuitesSelectProps = Omit< onChange?: (value: string) => void; }; +function expandPreset(next: string[]): string[] { + const picked = [...next].reverse().find((v) => v.startsWith(PRESET_PREFIX)); + if (!picked) return next; + const name = picked.slice(PRESET_PREFIX.length) as keyof typeof TLS_CIPHER_PRESETS; + return TLS_CIPHER_PRESETS[name].split(':'); +} + // xray splits cipherSuites on ':' into a list, so the picker edits tags while // the stored value stays the single colon-joined string xray reads. export default function CipherSuitesSelect({ @@ -21,6 +35,7 @@ export default function CipherSuitesSelect({ onChange, ...rest }: CipherSuitesSelectProps) { + const { t } = useTranslation(); const suites = value .split(':') .map((s) => s.trim()) @@ -31,9 +46,12 @@ export default function CipherSuitesSelect({ tokenSeparators={[':', ',']} {...rest} mode="tags" - options={CIPHER_SUITE_OPTIONS} + options={[ + { label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS }, + { label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS }, + ]} value={suites} - onChange={(next) => onChange?.(next.join(':'))} + onChange={(next) => onChange?.(expandPreset(next).join(':'))} /> ); } diff --git a/frontend/src/pages/inbounds/form/security/tls.tsx b/frontend/src/pages/inbounds/form/security/tls.tsx index 2902a98c2..4e06006e9 100644 --- a/frontend/src/pages/inbounds/form/security/tls.tsx +++ b/frontend/src/pages/inbounds/form/security/tls.tsx @@ -325,6 +325,14 @@ export default function TlsForm({ control, name: 'streamSettings.tlsSettings.certificates', }); + const cipherSuites = useWatch({ control, name: 'streamSettings.tlsSettings.cipherSuites' }); + const fingerprint = useWatch({ + control, + name: 'streamSettings.tlsSettings.settings.fingerprint', + }); + // An empty fingerprint is fine: share links pin it to unsafe whenever cs is set. + const cipherSuitesIgnoredByClients = + Boolean(cipherSuites) && Boolean(fingerprint) && fingerprint !== UTLS_FINGERPRINT.UTLS_UNSAFE; return ( <> @@ -333,6 +341,13 @@ export default function TlsForm({ + {t('pages.inbounds.form.cipherSuitesNeedUnsafe')} + + ) : undefined + } > diff --git a/frontend/src/schemas/primitives/options.ts b/frontend/src/schemas/primitives/options.ts index d2de6d48b..c6d257aea 100644 --- a/frontend/src/schemas/primitives/options.ts +++ b/frontend/src/schemas/primitives/options.ts @@ -74,6 +74,37 @@ export const TLS_CIPHER_OPTION = Object.freeze({ ECDHE_RSA_CHACHA20_POLY1305: 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256', }); +// Whole ordered client lists (PattN's presets): the order is part of the ClientHello, +// and clients apply them only under the uTLS fingerprint "unsafe". +export const TLS_CIPHER_PRESETS = Object.freeze({ + 'semi-python': [ + 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', + 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', + 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', + 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', + 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256', + 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256', + 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA', + 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA', + 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256', + 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256', + ].join(':'), + 'real-firefox': [ + 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', + 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', + 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256', + 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256', + 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', + 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', + 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA', + 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA', + 'TLS_RSA_WITH_AES_128_GCM_SHA256', + 'TLS_RSA_WITH_AES_256_GCM_SHA384', + 'TLS_RSA_WITH_AES_128_CBC_SHA', + 'TLS_RSA_WITH_AES_256_CBC_SHA', + ].join(':'), +}); + export const USAGE_OPTION = Object.freeze({ ENCIPHERMENT: 'encipherment', VERIFY: 'verify', diff --git a/frontend/src/test/cipher-suites-select.test.tsx b/frontend/src/test/cipher-suites-select.test.tsx index 27494c08c..2141077eb 100644 --- a/frontend/src/test/cipher-suites-select.test.tsx +++ b/frontend/src/test/cipher-suites-select.test.tsx @@ -24,6 +24,22 @@ describe('CipherSuitesSelect', () => { expect(onChange).toHaveBeenLastCalledWith('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE'); }); + // A preset is a whole ordered list (the order is part of the ClientHello), so + // picking one replaces the suites instead of appending a tag named after it. + it('replaces the suites with the picked preset list', async () => { + const onChange = renderSelect('TLS_AES_256_GCM_SHA384'); + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'cipher suites' })); + fireEvent.click(await screen.findByText('real-firefox')); + expect(onChange).toHaveBeenLastCalledWith( + 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:' + + 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256:' + + 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:' + + 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA:TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:' + + 'TLS_RSA_WITH_AES_128_GCM_SHA256:TLS_RSA_WITH_AES_256_GCM_SHA384:' + + 'TLS_RSA_WITH_AES_128_CBC_SHA:TLS_RSA_WITH_AES_256_CBC_SHA', + ); + }); + it('stores an empty string once every suite is removed', () => { const onChange = renderSelect('TLS_AES_256_GCM_SHA384'); const remove = document.querySelector('.ant-select-selection-item-remove'); diff --git a/frontend/src/test/ech-dynamic-helper-tls.test.tsx b/frontend/src/test/ech-dynamic-helper-tls.test.tsx index 0b674d077..78f0b2e6a 100644 --- a/frontend/src/test/ech-dynamic-helper-tls.test.tsx +++ b/frontend/src/test/ech-dynamic-helper-tls.test.tsx @@ -136,3 +136,26 @@ describe('inbound TLS dynamic ECH helper', () => { expect(screen.getByTestId('ech-cdn-warning').textContent).toMatch(/Cloudflare CDN/i); }); }); + +// Xray drops cipherSuites under every uTLS fingerprint but unsafe; an empty one +// is left out because share links pin it to unsafe. +describe('inbound TLS cipherSuites fingerprint warning', () => { + function tlsValues(cipherSuites: string, fingerprint: string) { + return { streamSettings: { tlsSettings: { cipherSuites, settings: { fingerprint } } } }; + } + + it('warns when cipher suites sit under a uTLS fingerprint', async () => { + renderTls(tlsValues('TLS_AES_128_GCM_SHA256', 'chrome')); + expect(await screen.findByTestId('cipher-suites-fingerprint-warning')).toBeTruthy(); + }); + + it.each([ + ['unsafe fingerprint', 'TLS_AES_128_GCM_SHA256', 'unsafe'], + ['empty fingerprint', 'TLS_AES_128_GCM_SHA256', ''], + ['no cipher suites', '', 'chrome'], + ])('stays silent with %s', async (_, cipherSuites, fingerprint) => { + renderTls(tlsValues(cipherSuites, fingerprint)); + await screen.findByTestId('ech-dynamic-helper'); + expect(screen.queryByTestId('cipher-suites-fingerprint-warning')).toBeNull(); + }); +}); diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index cb3f46a1e..58d2c5e4c 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -601,6 +601,8 @@ "addCustomOption": "إضافة خيار مخصص", "serverNameIndication": "SNI", "cipherSuites": "مجموعات التشفير", + "cipherSuitesNeedUnsafe": "يطبّق العملاء مجموعات التشفير هذه فقط مع بصمة uTLS \"unsafe\"، وتُتجاهل مع أي بصمة أخرى.", + "presets": "إعدادات مسبقة", "autoOption": "تلقائي", "minMaxVersion": "إصدار أدنى/أقصى", "rejectUnknownSni": "رفض SNI غير معروف", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 3684c5035..38a3c675e 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -603,6 +603,8 @@ "addCustomOption": "Add custom option", "serverNameIndication": "Server Name Indication", "cipherSuites": "Cipher Suites", + "cipherSuitesNeedUnsafe": "Clients apply these cipher suites only with the uTLS fingerprint \"unsafe\"; under any other fingerprint they are ignored.", + "presets": "Presets", "autoOption": "Auto", "minMaxVersion": "Min/Max Version", "rejectUnknownSni": "Reject Unknown SNI", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 96f8f5117..fbcc76191 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -601,6 +601,8 @@ "addCustomOption": "Añadir opción personalizada", "serverNameIndication": "SNI", "cipherSuites": "Conjuntos de cifrado", + "cipherSuitesNeedUnsafe": "Los clientes solo aplican estos cipher suites con la huella uTLS \"unsafe\"; con cualquier otra huella se ignoran.", + "presets": "Preajustes", "autoOption": "Auto", "minMaxVersion": "Versión mín/máx", "rejectUnknownSni": "Rechazar SNI desconocido", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index ad8865009..6e790b04a 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -601,6 +601,8 @@ "addCustomOption": "افزودن گزینه دلخواه", "serverNameIndication": "SNI", "cipherSuites": "مجموعه‌های رمز", + "cipherSuitesNeedUnsafe": "کلاینت‌ها این cipher suiteها را فقط با فینگرپرینت uTLS «unsafe» اعمال می‌کنند و با هر فینگرپرینت دیگری نادیده گرفته می‌شوند.", + "presets": "پیش‌تنظیم‌ها", "autoOption": "خودکار", "minMaxVersion": "نسخه حداقل/حداکثر", "rejectUnknownSni": "رد SNI ناشناخته", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 5ad1c8a84..bc95dc0a7 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -601,6 +601,8 @@ "addCustomOption": "Tambah opsi kustom", "serverNameIndication": "SNI", "cipherSuites": "Rangkaian Sandi", + "cipherSuitesNeedUnsafe": "Klien hanya menerapkan cipher suite ini dengan fingerprint uTLS \"unsafe\"; dengan fingerprint lain akan diabaikan.", + "presets": "Preset", "autoOption": "Otomatis", "minMaxVersion": "Versi Min/Maks", "rejectUnknownSni": "Tolak SNI tidak dikenal", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 5df309ad2..7a7c8021d 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -652,6 +652,8 @@ "addCustomOption": "カスタムオプション追加", "serverNameIndication": "SNI", "cipherSuites": "暗号スイート", + "cipherSuitesNeedUnsafe": "クライアントはこれらの暗号スイートを uTLS フィンガープリント \"unsafe\" の場合にのみ適用し、それ以外のフィンガープリントでは無視します。", + "presets": "プリセット", "autoOption": "自動", "minMaxVersion": "最小/最大バージョン", "rejectUnknownSni": "未知の SNI を拒否", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 0d052ee89..dba34991e 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -652,6 +652,8 @@ "addCustomOption": "Adicionar opção personalizada", "serverNameIndication": "SNI", "cipherSuites": "Conjuntos de cifras", + "cipherSuitesNeedUnsafe": "Os clientes só aplicam estes cipher suites com a impressão digital uTLS \"unsafe\"; com qualquer outra eles são ignorados.", + "presets": "Predefinições", "autoOption": "Auto", "minMaxVersion": "Versão mín/máx", "rejectUnknownSni": "Rejeitar SNI desconhecido", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index d4fd7a841..8ab6ca7f4 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -654,6 +654,8 @@ "addCustomOption": "Добавить опцию", "serverNameIndication": "SNI", "cipherSuites": "Наборы шифров", + "cipherSuitesNeedUnsafe": "Клиенты применяют эти наборы шифров только с отпечатком uTLS «unsafe»; при любом другом отпечатке они игнорируются.", + "presets": "Пресеты", "autoOption": "Авто", "minMaxVersion": "Мин/Макс версия", "rejectUnknownSni": "Отклонить неизвестный SNI", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index 233ff2b9f..4d7a7cd73 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -603,6 +603,8 @@ "addCustomOption": "Pridať vlastné nastavenie", "serverNameIndication": "Názov servera (SNI)", "cipherSuites": "Šifrovacie sady", + "cipherSuitesNeedUnsafe": "Klienti použijú tieto šifrovacie sady iba s odtlačkom uTLS „unsafe“; pri akomkoľvek inom odtlačku sa ignorujú.", + "presets": "Predvoľby", "autoOption": "Automaticky", "minMaxVersion": "Najnižšia/najvyššia verzia", "rejectUnknownSni": "Odmietnuť neznáme SNI", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 95022dc6f..024822587 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -601,6 +601,8 @@ "addCustomOption": "Özel Seçenek Ekle", "serverNameIndication": "Server Name Indication (SNI)", "cipherSuites": "Şifreleme Takımları", + "cipherSuitesNeedUnsafe": "İstemciler bu şifre paketlerini yalnızca \"unsafe\" uTLS parmak iziyle uygular; başka bir parmak izinde yok sayılır.", + "presets": "Hazır ayarlar", "autoOption": "Otomatik", "minMaxVersion": "Min/Maks Sürüm", "rejectUnknownSni": "Bilinmeyen SNI Reddet", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index eebcf7779..f21d413cd 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -601,6 +601,8 @@ "addCustomOption": "Додати опцію", "serverNameIndication": "SNI", "cipherSuites": "Набори шифрів", + "cipherSuitesNeedUnsafe": "Клієнти застосовують ці набори шифрів лише з відбитком uTLS «unsafe»; з будь-яким іншим відбитком вони ігноруються.", + "presets": "Пресети", "autoOption": "Авто", "minMaxVersion": "Мін/Макс версія", "rejectUnknownSni": "Відхиляти невідомий SNI", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 1be0ccc93..57450713c 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -652,6 +652,8 @@ "addCustomOption": "Thêm tùy chọn", "serverNameIndication": "SNI", "cipherSuites": "Bộ mật mã", + "cipherSuitesNeedUnsafe": "Client chỉ áp dụng các cipher suite này với fingerprint uTLS \"unsafe\"; với fingerprint khác chúng sẽ bị bỏ qua.", + "presets": "Cấu hình sẵn", "autoOption": "Tự động", "minMaxVersion": "Phiên bản Min/Max", "rejectUnknownSni": "Từ chối SNI lạ", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 339a47c9e..0ef602822 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -651,6 +651,8 @@ "addCustomOption": "添加自定义选项", "serverNameIndication": "SNI", "cipherSuites": "密码套件", + "cipherSuitesNeedUnsafe": "客户端仅在 uTLS 指纹为 \"unsafe\" 时应用这些加密套件;使用其他指纹时会被忽略。", + "presets": "预设", "autoOption": "自动", "minMaxVersion": "最小/最大版本", "rejectUnknownSni": "拒绝未知 SNI", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index d1c33bc79..e77b37ab0 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -601,6 +601,8 @@ "addCustomOption": "新增自訂選項", "serverNameIndication": "SNI", "cipherSuites": "加密套件", + "cipherSuitesNeedUnsafe": "用戶端僅在 uTLS 指紋為 \"unsafe\" 時套用這些加密套件;使用其他指紋時會被忽略。", + "presets": "預設集", "autoOption": "自動", "minMaxVersion": "最小/最大版本", "rejectUnknownSni": "拒絕未知 SNI",