From 17e89db9794200e103ba46decbf3e518607c50e9 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 16 Sep 2026 11:56:20 +0200 Subject: [PATCH] feat(hosts): add a cipher suites override and accept custom suites The inbound TLS form offered cipherSuites as a closed single-choice list, but xray reads the value as a colon-separated list and accepts any name Go knows, so several suites or one missing from the list could not be set. Both the inbound and the new host field now use a tag picker that keeps the stored value as the colon-joined string xray expects; old single values open unchanged. A host's cipher suites replace the inbound's in the JSON subscription stream, and a blank field inherits them. Share links and Clash carry no cipher suite parameter, so their output is unchanged. --- docs/public/openapi.json | 14 +++++++ frontend/public/openapi.json | 14 +++++++ .../components/form/CipherSuitesSelect.tsx | 39 +++++++++++++++++++ frontend/src/components/form/index.ts | 1 + frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 8 ++++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/pages/hosts/HostFormModal.tsx | 8 ++++ .../src/pages/inbounds/form/security/tls.tsx | 9 +---- frontend/src/schemas/api/host.ts | 2 + .../src/test/cipher-suites-select.test.tsx | 34 ++++++++++++++++ internal/database/model/model.go | 1 + internal/sub/host_sub.go | 3 ++ internal/sub/host_sub_test.go | 28 +++++++++++++ internal/sub/service.go | 3 ++ internal/web/entity/entity.go | 1 + internal/web/service/host.go | 2 + internal/web/service/host_test.go | 24 ++++++++++++ internal/web/translation/ar-EG.json | 2 +- internal/web/translation/es-ES.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/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 +- 29 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/form/CipherSuitesSelect.tsx create mode 100644 frontend/src/test/cipher-suites-select.test.tsx diff --git a/docs/public/openapi.json b/docs/public/openapi.json index d1f9b499e..aa6e75fd7 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2301,6 +2301,9 @@ }, "type": "array" }, + "cipherSuites": { + "type": "string" + }, "createdAt": { "format": "int64", "type": "integer" @@ -2434,6 +2437,7 @@ "address", "allowInsecure", "alpn", + "cipherSuites", "createdAt", "echConfigList", "excludeFromSubTypes", @@ -2478,6 +2482,9 @@ }, "type": "array" }, + "cipherSuites": { + "type": "string" + }, "echConfigList": { "type": "string" }, @@ -2604,6 +2611,7 @@ "required": [ "allowInsecure", "alpn", + "cipherSuites", "echConfigList", "excludeFromSubTypes", "finalMask", @@ -11268,6 +11276,7 @@ "alpn": [ "" ], + "cipherSuites": "", "echConfigList": "", "excludeFromSubTypes": [ "" @@ -11362,6 +11371,7 @@ "alpn": [ "" ], + "cipherSuites": "", "echConfigList": "", "excludeFromSubTypes": [ "" @@ -11459,6 +11469,7 @@ "alpn": [ "" ], + "cipherSuites": "", "echConfigList": "", "excludeFromSubTypes": [ "" @@ -11609,6 +11620,7 @@ "alpn": [ "" ], + "cipherSuites": "", "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ @@ -11730,6 +11742,7 @@ "alpn": [ "" ], + "cipherSuites": "", "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ @@ -11981,6 +11994,7 @@ "alpn": [ "" ], + "cipherSuites": "", "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d1f9b499e..aa6e75fd7 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2301,6 +2301,9 @@ }, "type": "array" }, + "cipherSuites": { + "type": "string" + }, "createdAt": { "format": "int64", "type": "integer" @@ -2434,6 +2437,7 @@ "address", "allowInsecure", "alpn", + "cipherSuites", "createdAt", "echConfigList", "excludeFromSubTypes", @@ -2478,6 +2482,9 @@ }, "type": "array" }, + "cipherSuites": { + "type": "string" + }, "echConfigList": { "type": "string" }, @@ -2604,6 +2611,7 @@ "required": [ "allowInsecure", "alpn", + "cipherSuites", "echConfigList", "excludeFromSubTypes", "finalMask", @@ -11268,6 +11276,7 @@ "alpn": [ "" ], + "cipherSuites": "", "echConfigList": "", "excludeFromSubTypes": [ "" @@ -11362,6 +11371,7 @@ "alpn": [ "" ], + "cipherSuites": "", "echConfigList": "", "excludeFromSubTypes": [ "" @@ -11459,6 +11469,7 @@ "alpn": [ "" ], + "cipherSuites": "", "echConfigList": "", "excludeFromSubTypes": [ "" @@ -11609,6 +11620,7 @@ "alpn": [ "" ], + "cipherSuites": "", "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ @@ -11730,6 +11742,7 @@ "alpn": [ "" ], + "cipherSuites": "", "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ @@ -11981,6 +11994,7 @@ "alpn": [ "" ], + "cipherSuites": "", "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ diff --git a/frontend/src/components/form/CipherSuitesSelect.tsx b/frontend/src/components/form/CipherSuitesSelect.tsx new file mode 100644 index 000000000..91e738d12 --- /dev/null +++ b/frontend/src/components/form/CipherSuitesSelect.tsx @@ -0,0 +1,39 @@ +import { Select } from 'antd'; +import type { SelectProps } from 'antd'; + +import { TLS_CIPHER_OPTION } from '@/schemas/primitives'; + +const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v })); + +type CipherSuitesSelectProps = Omit< + SelectProps, + 'value' | 'onChange' | 'mode' | 'options' +> & { + // Injected by FormField: + value?: string; + onChange?: (value: string) => void; +}; + +// 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({ + value = '', + onChange, + ...rest +}: CipherSuitesSelectProps) { + const suites = value + .split(':') + .map((s) => s.trim()) + .filter(Boolean); + return ( + + + + ({ value: v, label: k })), - ]} - /> + diff --git a/frontend/src/schemas/api/host.ts b/frontend/src/schemas/api/host.ts index 1ecbee6fc..091e42dad 100644 --- a/frontend/src/schemas/api/host.ts +++ b/frontend/src/schemas/api/host.ts @@ -35,6 +35,7 @@ export const HostFormSchema = z.object({ (val) => (val === '' ? undefined : val), UtlsFingerprintSchema.optional(), ), + cipherSuites: z.string().default(''), overrideSniFromAddress: z.boolean().default(false), keepSniBlank: z.boolean().default(false), pinnedPeerCertSha256: z.array(z.string()).default([]), @@ -87,6 +88,7 @@ export const HostRecordSchema = z path: z.string().optional(), alpn: z.array(z.string()).nullish(), fingerprint: z.string().optional(), + cipherSuites: z.string().optional(), overrideSniFromAddress: z.boolean().optional(), keepSniBlank: z.boolean().optional(), pinnedPeerCertSha256: z.array(z.string()).nullish(), diff --git a/frontend/src/test/cipher-suites-select.test.tsx b/frontend/src/test/cipher-suites-select.test.tsx new file mode 100644 index 000000000..27494c08c --- /dev/null +++ b/frontend/src/test/cipher-suites-select.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { CipherSuitesSelect } from '@/components/form'; + +function renderSelect(value: string) { + const onChange = vi.fn(); + render(); + return onChange; +} + +describe('CipherSuitesSelect', () => { + it('shows each colon-separated suite as its own tag', () => { + renderSelect('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE'); + expect(screen.getByText('TLS_AES_256_GCM_SHA384')).toBeTruthy(); + expect(screen.getByText('MY_CUSTOM_SUITE')).toBeTruthy(); + }); + + it('stores a typed custom suite joined with colons after the existing one', () => { + const onChange = renderSelect('TLS_AES_256_GCM_SHA384'); + const input = screen.getByRole('combobox', { name: 'cipher suites' }); + fireEvent.change(input, { target: { value: 'MY_CUSTOM_SUITE' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE'); + }); + + 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'); + expect(remove).not.toBeNull(); + fireEvent.click(remove as Element); + expect(onChange).toHaveBeenLastCalledWith(''); + }); +}); diff --git a/internal/database/model/model.go b/internal/database/model/model.go index c3edf052f..be83b11fc 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1083,6 +1083,7 @@ type Host struct { Path string `json:"path" form:"path"` Alpn []string `json:"alpn" form:"alpn" gorm:"serializer:json"` Fingerprint string `json:"fingerprint" form:"fingerprint"` + CipherSuites string `json:"cipherSuites" form:"cipherSuites" gorm:"column:cipher_suites"` OverrideSniFromAddress bool `json:"overrideSniFromAddress" form:"overrideSniFromAddress" gorm:"column:override_sni_from_address"` KeepSniBlank bool `json:"keepSniBlank" form:"keepSniBlank" gorm:"column:keep_sni_blank"` PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256" form:"pinnedPeerCertSha256" gorm:"serializer:json;column:pinned_peer_cert_sha256"` diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index c960a6736..39dc5cd74 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -71,6 +71,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) if h.Fingerprint != "" { ep["fingerprint"] = h.Fingerprint } + if h.CipherSuites != "" { + ep["cipherSuites"] = h.CipherSuites + } if len(h.Alpn) > 0 { ep["alpn"] = stringsToAnySlice(h.Alpn) } diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 121a25e83..6d3461364 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -442,3 +442,31 @@ func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) { } } } + +// A host's cipher suites override the inbound's own in the JSON subscription, +// while a host that leaves the field blank inherits them. +func TestSub_HostCipherSuitesJSON(t *testing.T) { + seedSubDB(t) + ib := seedSubInbound(t, "s1", "cs", 4462, 1, + `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni","cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"}}`) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 0, Remark: "CS", Address: "cs.cdn.com", Port: 8443, Security: "tls", + CipherSuites: "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256", + }) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 1, Remark: "INHERIT", Address: "inh.cdn.com", Port: 8443, Security: "tls", + }) + + out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + if !strings.Contains(out, `"cipherSuites": "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) && + !strings.Contains(out, `"cipherSuites":"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) { + t.Fatalf("json tlsSettings should carry the host's cipher suites:\n%s", out) + } + if !strings.Contains(out, `"cipherSuites": "TLS_CHACHA20_POLY1305_SHA256"`) && + !strings.Contains(out, `"cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"`) { + t.Fatalf("a host with no cipher suites should inherit the inbound's:\n%s", out) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index a3101e37d..3a547c5c6 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2088,6 +2088,9 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec if alpn, ok := externalProxyALPNList(ep["alpn"]); ok { tlsSettings["alpn"] = alpn } + if cs, ok := ep["cipherSuites"].(string); ok && cs != "" { + tlsSettings["cipherSuites"] = cs + } if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { settings, _ := tlsSettings["settings"].(map[string]any) if settings == nil { diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 552844325..a69fc7e8d 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -382,6 +382,7 @@ type HostGroup struct { Path string `json:"path"` Alpn []string `json:"alpn"` Fingerprint string `json:"fingerprint"` + CipherSuites string `json:"cipherSuites"` OverrideSniFromAddress bool `json:"overrideSniFromAddress"` KeepSniBlank bool `json:"keepSniBlank"` PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256"` diff --git a/internal/web/service/host.go b/internal/web/service/host.go index 68ab9fd72..c7ea45f79 100644 --- a/internal/web/service/host.go +++ b/internal/web/service/host.go @@ -45,6 +45,7 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup { Path: h.Path, Alpn: h.Alpn, Fingerprint: h.Fingerprint, + CipherSuites: h.CipherSuites, OverrideSniFromAddress: h.OverrideSniFromAddress, KeepSniBlank: h.KeepSniBlank, PinnedPeerCertSha256: h.PinnedPeerCertSha256, @@ -133,6 +134,7 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host { Path: req.Path, Alpn: req.Alpn, Fingerprint: req.Fingerprint, + CipherSuites: req.CipherSuites, OverrideSniFromAddress: req.OverrideSniFromAddress, KeepSniBlank: req.KeepSniBlank, PinnedPeerCertSha256: req.PinnedPeerCertSha256, diff --git a/internal/web/service/host_test.go b/internal/web/service/host_test.go index 022ffd81a..8b22c5bc0 100644 --- a/internal/web/service/host_test.go +++ b/internal/web/service/host_test.go @@ -365,3 +365,27 @@ func TestUpdateHostGroup_ValidateBeforeDelete(t *testing.T) { t.Fatalf("remark not updated: %s", got2.Remark) } } + +// Host fields are copied by hand in buildHostRows and newHostGroup; a missed +// copy on either side silently blanks the value on the next edit-and-save. +func TestHostGroup_CipherSuitesRoundTrip(t *testing.T) { + setupBulkDB(t) + svc := &HostService{} + ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`) + const suites = "TLS_AES_256_GCM_SHA384:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + + created, err := svc.AddHostGroup(&entity.HostGroup{ + InboundIds: []int{ib.Id}, Remark: "cs", Hosts: []string{"cs.example.com"}, + Security: "tls", CipherSuites: suites, + }) + if err != nil { + t.Fatalf("AddHostGroup: %v", err) + } + g, err := svc.GetHostGroup(created[0].GroupId) + if err != nil { + t.Fatalf("GetHostGroup: %v", err) + } + if g.CipherSuites != suites { + t.Fatalf("CipherSuites = %q, want %q", g.CipherSuites, suites) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 609072ee5..3a0882976 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -596,7 +596,7 @@ "customSockopt": "sockopt مخصص", "addCustomOption": "إضافة خيار مخصص", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "مجموعات التشفير", "autoOption": "تلقائي", "minMaxVersion": "إصدار أدنى/أقصى", "rejectUnknownSni": "رفض SNI غير معروف", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 65b5c3af2..8834e1456 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -596,7 +596,7 @@ "customSockopt": "Sockopt personalizado", "addCustomOption": "Añadir opción personalizada", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "Conjuntos de cifrado", "autoOption": "Auto", "minMaxVersion": "Versión mín/máx", "rejectUnknownSni": "Rechazar SNI desconocido", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index d75616e5d..51dc469a9 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -596,7 +596,7 @@ "customSockopt": "Sockopt kustom", "addCustomOption": "Tambah opsi kustom", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "Rangkaian Sandi", "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 f084e6057..bdcdcdb06 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -617,7 +617,7 @@ "customSockopt": "カスタム sockopt", "addCustomOption": "カスタムオプション追加", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "暗号スイート", "autoOption": "自動", "minMaxVersion": "最小/最大バージョン", "rejectUnknownSni": "未知の SNI を拒否", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index a562047fe..9bafb73f2 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -617,7 +617,7 @@ "customSockopt": "Sockopt personalizado", "addCustomOption": "Adicionar opção personalizada", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "Conjuntos de cifras", "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 65c5868fb..76f7cbed3 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -619,7 +619,7 @@ "customSockopt": "Пользовательский sockopt", "addCustomOption": "Добавить опцию", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "Наборы шифров", "autoOption": "Авто", "minMaxVersion": "Мин/Макс версия", "rejectUnknownSni": "Отклонить неизвестный SNI", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index a5b911703..d48ee3ec1 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -596,7 +596,7 @@ "customSockopt": "Користувацький sockopt", "addCustomOption": "Додати опцію", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "Набори шифрів", "autoOption": "Авто", "minMaxVersion": "Мін/Макс версія", "rejectUnknownSni": "Відхиляти невідомий SNI", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 0b72f2170..786827e8b 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -617,7 +617,7 @@ "customSockopt": "Sockopt tùy chỉnh", "addCustomOption": "Thêm tùy chọn", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "Bộ mật mã", "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 59ad06c57..06bb533de 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -616,7 +616,7 @@ "customSockopt": "自定义 sockopt", "addCustomOption": "添加自定义选项", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "密码套件", "autoOption": "自动", "minMaxVersion": "最小/最大版本", "rejectUnknownSni": "拒绝未知 SNI", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 96fc1dec4..3ebd793ec 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -596,7 +596,7 @@ "customSockopt": "自訂 sockopt", "addCustomOption": "新增自訂選項", "serverNameIndication": "SNI", - "cipherSuites": "Cipher Suites", + "cipherSuites": "加密套件", "autoOption": "自動", "minMaxVersion": "最小/最大版本", "rejectUnknownSni": "拒絕未知 SNI",