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",