Browse Source

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.
Sanaei 10 hours ago
parent
commit
17e89db979

+ 14 - 0
docs/public/openapi.json

@@ -2301,6 +2301,9 @@
             },
             },
             "type": "array"
             "type": "array"
           },
           },
+          "cipherSuites": {
+            "type": "string"
+          },
           "createdAt": {
           "createdAt": {
             "format": "int64",
             "format": "int64",
             "type": "integer"
             "type": "integer"
@@ -2434,6 +2437,7 @@
           "address",
           "address",
           "allowInsecure",
           "allowInsecure",
           "alpn",
           "alpn",
+          "cipherSuites",
           "createdAt",
           "createdAt",
           "echConfigList",
           "echConfigList",
           "excludeFromSubTypes",
           "excludeFromSubTypes",
@@ -2478,6 +2482,9 @@
             },
             },
             "type": "array"
             "type": "array"
           },
           },
+          "cipherSuites": {
+            "type": "string"
+          },
           "echConfigList": {
           "echConfigList": {
             "type": "string"
             "type": "string"
           },
           },
@@ -2604,6 +2611,7 @@
         "required": [
         "required": [
           "allowInsecure",
           "allowInsecure",
           "alpn",
           "alpn",
+          "cipherSuites",
           "echConfigList",
           "echConfigList",
           "excludeFromSubTypes",
           "excludeFromSubTypes",
           "finalMask",
           "finalMask",
@@ -11268,6 +11276,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
                         ""
                         ""
@@ -11362,6 +11371,7 @@
                     "alpn": [
                     "alpn": [
                       ""
                       ""
                     ],
                     ],
+                    "cipherSuites": "",
                     "echConfigList": "",
                     "echConfigList": "",
                     "excludeFromSubTypes": [
                     "excludeFromSubTypes": [
                       ""
                       ""
@@ -11459,6 +11469,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
                         ""
                         ""
@@ -11609,6 +11620,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "createdAt": 0,
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
@@ -11730,6 +11742,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "createdAt": 0,
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
@@ -11981,6 +11994,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "createdAt": 0,
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [

+ 14 - 0
frontend/public/openapi.json

@@ -2301,6 +2301,9 @@
             },
             },
             "type": "array"
             "type": "array"
           },
           },
+          "cipherSuites": {
+            "type": "string"
+          },
           "createdAt": {
           "createdAt": {
             "format": "int64",
             "format": "int64",
             "type": "integer"
             "type": "integer"
@@ -2434,6 +2437,7 @@
           "address",
           "address",
           "allowInsecure",
           "allowInsecure",
           "alpn",
           "alpn",
+          "cipherSuites",
           "createdAt",
           "createdAt",
           "echConfigList",
           "echConfigList",
           "excludeFromSubTypes",
           "excludeFromSubTypes",
@@ -2478,6 +2482,9 @@
             },
             },
             "type": "array"
             "type": "array"
           },
           },
+          "cipherSuites": {
+            "type": "string"
+          },
           "echConfigList": {
           "echConfigList": {
             "type": "string"
             "type": "string"
           },
           },
@@ -2604,6 +2611,7 @@
         "required": [
         "required": [
           "allowInsecure",
           "allowInsecure",
           "alpn",
           "alpn",
+          "cipherSuites",
           "echConfigList",
           "echConfigList",
           "excludeFromSubTypes",
           "excludeFromSubTypes",
           "finalMask",
           "finalMask",
@@ -11268,6 +11276,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
                         ""
                         ""
@@ -11362,6 +11371,7 @@
                     "alpn": [
                     "alpn": [
                       ""
                       ""
                     ],
                     ],
+                    "cipherSuites": "",
                     "echConfigList": "",
                     "echConfigList": "",
                     "excludeFromSubTypes": [
                     "excludeFromSubTypes": [
                       ""
                       ""
@@ -11459,6 +11469,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
                         ""
                         ""
@@ -11609,6 +11620,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "createdAt": 0,
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
@@ -11730,6 +11742,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "createdAt": 0,
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [
@@ -11981,6 +11994,7 @@
                       "alpn": [
                       "alpn": [
                         ""
                         ""
                       ],
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "createdAt": 0,
                       "echConfigList": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                       "excludeFromSubTypes": [

+ 39 - 0
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<string[]>,
+  '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 (
+    <Select
+      allowClear
+      tokenSeparators={[':', ',']}
+      {...rest}
+      mode="tags"
+      options={CIPHER_SUITE_OPTIONS}
+      value={suites}
+      onChange={(next) => onChange?.(next.join(':'))}
+    />
+  );
+}

+ 1 - 0
frontend/src/components/form/index.ts

@@ -3,6 +3,7 @@ export { default as JsonEditor } from './JsonEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
 export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
 export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
 export { default as SelectAllClearButtons } from './SelectAllClearButtons';
 export { default as SelectAllClearButtons } from './SelectAllClearButtons';
+export { default as CipherSuitesSelect } from './CipherSuitesSelect';
 export { default as RemarkTemplateField } from './RemarkTemplateField';
 export { default as RemarkTemplateField } from './RemarkTemplateField';
 export { default as RemarkVarPicker } from './RemarkVarPicker';
 export { default as RemarkVarPicker } from './RemarkVarPicker';
 export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
 export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';

+ 2 - 0
frontend/src/generated/examples.ts

@@ -591,6 +591,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "alpn": [
     "alpn": [
       ""
       ""
     ],
     ],
+    "cipherSuites": "",
     "createdAt": 0,
     "createdAt": 0,
     "echConfigList": "",
     "echConfigList": "",
     "excludeFromSubTypes": [
     "excludeFromSubTypes": [
@@ -636,6 +637,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "alpn": [
     "alpn": [
       ""
       ""
     ],
     ],
+    "cipherSuites": "",
     "echConfigList": "",
     "echConfigList": "",
     "excludeFromSubTypes": [
     "excludeFromSubTypes": [
       ""
       ""

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -2275,6 +2275,9 @@ export const SCHEMAS: Record<string, unknown> = {
         },
         },
         "type": "array"
         "type": "array"
       },
       },
+      "cipherSuites": {
+        "type": "string"
+      },
       "createdAt": {
       "createdAt": {
         "format": "int64",
         "format": "int64",
         "type": "integer"
         "type": "integer"
@@ -2408,6 +2411,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "address",
       "address",
       "allowInsecure",
       "allowInsecure",
       "alpn",
       "alpn",
+      "cipherSuites",
       "createdAt",
       "createdAt",
       "echConfigList",
       "echConfigList",
       "excludeFromSubTypes",
       "excludeFromSubTypes",
@@ -2452,6 +2456,9 @@ export const SCHEMAS: Record<string, unknown> = {
         },
         },
         "type": "array"
         "type": "array"
       },
       },
+      "cipherSuites": {
+        "type": "string"
+      },
       "echConfigList": {
       "echConfigList": {
         "type": "string"
         "type": "string"
       },
       },
@@ -2578,6 +2585,7 @@ export const SCHEMAS: Record<string, unknown> = {
     "required": [
     "required": [
       "allowInsecure",
       "allowInsecure",
       "alpn",
       "alpn",
+      "cipherSuites",
       "echConfigList",
       "echConfigList",
       "excludeFromSubTypes",
       "excludeFromSubTypes",
       "finalMask",
       "finalMask",

+ 2 - 0
frontend/src/generated/types.ts

@@ -537,6 +537,7 @@ export interface Host {
   address: string;
   address: string;
   allowInsecure: boolean;
   allowInsecure: boolean;
   alpn: string[];
   alpn: string[];
+  cipherSuites: string;
   createdAt: number;
   createdAt: number;
   echConfigList: string;
   echConfigList: string;
   excludeFromSubTypes: string[];
   excludeFromSubTypes: string[];
@@ -573,6 +574,7 @@ export interface Host {
 export interface HostGroup {
 export interface HostGroup {
   allowInsecure: boolean;
   allowInsecure: boolean;
   alpn: string[];
   alpn: string[];
+  cipherSuites: string;
   echConfigList: string;
   echConfigList: string;
   excludeFromSubTypes: string[];
   excludeFromSubTypes: string[];
   finalMask: string;
   finalMask: string;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -573,6 +573,7 @@ export const HostSchema = z.object({
   address: z.string(),
   address: z.string(),
   allowInsecure: z.boolean(),
   allowInsecure: z.boolean(),
   alpn: z.array(z.string()),
   alpn: z.array(z.string()),
+  cipherSuites: z.string(),
   createdAt: z.number().int(),
   createdAt: z.number().int(),
   echConfigList: z.string(),
   echConfigList: z.string(),
   excludeFromSubTypes: z.array(z.string()),
   excludeFromSubTypes: z.array(z.string()),
@@ -610,6 +611,7 @@ export type Host = z.infer<typeof HostSchema>;
 export const HostGroupSchema = z.object({
 export const HostGroupSchema = z.object({
   allowInsecure: z.boolean(),
   allowInsecure: z.boolean(),
   alpn: z.array(z.string()),
   alpn: z.array(z.string()),
+  cipherSuites: z.string(),
   echConfigList: z.string(),
   echConfigList: z.string(),
   excludeFromSubTypes: z.array(z.string()),
   excludeFromSubTypes: z.array(z.string()),
   finalMask: z.string(),
   finalMask: z.string(),

+ 8 - 0
frontend/src/pages/hosts/HostFormModal.tsx

@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
 import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
 import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
 import type { InboundOption } from '@/schemas/client';
 import type { InboundOption } from '@/schemas/client';
 import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
 import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
+import { CipherSuitesSelect } from '@/components/form';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
     path: host?.path ?? '',
     path: host?.path ?? '',
     alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
     alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
     fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
     fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
+    cipherSuites: host?.cipherSuites ?? '',
     overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
     overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
     keepSniBlank: host?.keepSniBlank ?? false,
     keepSniBlank: host?.keepSniBlank ?? false,
     pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
     pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
@@ -332,6 +334,12 @@ export default function HostFormModal({
                         <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
                         <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
                           <Select mode="multiple" allowClear options={alpnOptions} />
                           <Select mode="multiple" allowClear options={alpnOptions} />
                         </FormField>
                         </FormField>
+                        <FormField
+                          name="cipherSuites"
+                          label={t('pages.inbounds.form.cipherSuites')}
+                        >
+                          <CipherSuitesSelect />
+                        </FormField>
                         <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
                         <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
                           <Select mode="tags" allowClear tokenSeparators={[',']} />
                           <Select mode="tags" allowClear tokenSeparators={[',']} />
                         </FormField>
                         </FormField>

+ 2 - 7
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -8,11 +8,11 @@ import {
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
 import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
 
 
+import { CipherSuitesSelect } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { FormField } from '@/components/form/rhf';
 import {
 import {
   ALPN_OPTION,
   ALPN_OPTION,
   DOMAIN_STRATEGY_OPTION,
   DOMAIN_STRATEGY_OPTION,
-  TLS_CIPHER_OPTION,
   TLS_VERSION_OPTION,
   TLS_VERSION_OPTION,
   USAGE_OPTION,
   USAGE_OPTION,
   UTLS_FINGERPRINT,
   UTLS_FINGERPRINT,
@@ -240,12 +240,7 @@ export default function TlsForm({
         name={['streamSettings', 'tlsSettings', 'cipherSuites']}
         name={['streamSettings', 'tlsSettings', 'cipherSuites']}
         label={t('pages.inbounds.form.cipherSuites')}
         label={t('pages.inbounds.form.cipherSuites')}
       >
       >
-        <Select
-          options={[
-            { value: '', label: t('pages.inbounds.form.autoOption') },
-            ...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
-          ]}
-        />
+        <CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
       </FormField>
       </FormField>
       <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
       <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
         <Space.Compact block>
         <Space.Compact block>

+ 2 - 0
frontend/src/schemas/api/host.ts

@@ -35,6 +35,7 @@ export const HostFormSchema = z.object({
     (val) => (val === '' ? undefined : val),
     (val) => (val === '' ? undefined : val),
     UtlsFingerprintSchema.optional(),
     UtlsFingerprintSchema.optional(),
   ),
   ),
+  cipherSuites: z.string().default(''),
   overrideSniFromAddress: z.boolean().default(false),
   overrideSniFromAddress: z.boolean().default(false),
   keepSniBlank: z.boolean().default(false),
   keepSniBlank: z.boolean().default(false),
   pinnedPeerCertSha256: z.array(z.string()).default([]),
   pinnedPeerCertSha256: z.array(z.string()).default([]),
@@ -87,6 +88,7 @@ export const HostRecordSchema = z
     path: z.string().optional(),
     path: z.string().optional(),
     alpn: z.array(z.string()).nullish(),
     alpn: z.array(z.string()).nullish(),
     fingerprint: z.string().optional(),
     fingerprint: z.string().optional(),
+    cipherSuites: z.string().optional(),
     overrideSniFromAddress: z.boolean().optional(),
     overrideSniFromAddress: z.boolean().optional(),
     keepSniBlank: z.boolean().optional(),
     keepSniBlank: z.boolean().optional(),
     pinnedPeerCertSha256: z.array(z.string()).nullish(),
     pinnedPeerCertSha256: z.array(z.string()).nullish(),

+ 34 - 0
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(<CipherSuitesSelect aria-label="cipher suites" value={value} onChange={onChange} />);
+  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('');
+  });
+});

+ 1 - 0
internal/database/model/model.go

@@ -1083,6 +1083,7 @@ type Host struct {
 	Path                   string   `json:"path" form:"path"`
 	Path                   string   `json:"path" form:"path"`
 	Alpn                   []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
 	Alpn                   []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
 	Fingerprint            string   `json:"fingerprint" form:"fingerprint"`
 	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"`
 	OverrideSniFromAddress bool     `json:"overrideSniFromAddress" form:"overrideSniFromAddress" gorm:"column:override_sni_from_address"`
 	KeepSniBlank           bool     `json:"keepSniBlank" form:"keepSniBlank" gorm:"column:keep_sni_blank"`
 	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"`
 	PinnedPeerCertSha256   []string `json:"pinnedPeerCertSha256" form:"pinnedPeerCertSha256" gorm:"serializer:json;column:pinned_peer_cert_sha256"`

+ 3 - 0
internal/sub/host_sub.go

@@ -71,6 +71,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
 	if h.Fingerprint != "" {
 	if h.Fingerprint != "" {
 		ep["fingerprint"] = h.Fingerprint
 		ep["fingerprint"] = h.Fingerprint
 	}
 	}
+	if h.CipherSuites != "" {
+		ep["cipherSuites"] = h.CipherSuites
+	}
 	if len(h.Alpn) > 0 {
 	if len(h.Alpn) > 0 {
 		ep["alpn"] = stringsToAnySlice(h.Alpn)
 		ep["alpn"] = stringsToAnySlice(h.Alpn)
 	}
 	}

+ 28 - 0
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)
+	}
+}

+ 3 - 0
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 {
 	if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
 		tlsSettings["alpn"] = alpn
 		tlsSettings["alpn"] = alpn
 	}
 	}
+	if cs, ok := ep["cipherSuites"].(string); ok && cs != "" {
+		tlsSettings["cipherSuites"] = cs
+	}
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 		settings, _ := tlsSettings["settings"].(map[string]any)
 		settings, _ := tlsSettings["settings"].(map[string]any)
 		if settings == nil {
 		if settings == nil {

+ 1 - 0
internal/web/entity/entity.go

@@ -382,6 +382,7 @@ type HostGroup struct {
 	Path                   string   `json:"path"`
 	Path                   string   `json:"path"`
 	Alpn                   []string `json:"alpn"`
 	Alpn                   []string `json:"alpn"`
 	Fingerprint            string   `json:"fingerprint"`
 	Fingerprint            string   `json:"fingerprint"`
+	CipherSuites           string   `json:"cipherSuites"`
 	OverrideSniFromAddress bool     `json:"overrideSniFromAddress"`
 	OverrideSniFromAddress bool     `json:"overrideSniFromAddress"`
 	KeepSniBlank           bool     `json:"keepSniBlank"`
 	KeepSniBlank           bool     `json:"keepSniBlank"`
 	PinnedPeerCertSha256   []string `json:"pinnedPeerCertSha256"`
 	PinnedPeerCertSha256   []string `json:"pinnedPeerCertSha256"`

+ 2 - 0
internal/web/service/host.go

@@ -45,6 +45,7 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
 		Path:                   h.Path,
 		Path:                   h.Path,
 		Alpn:                   h.Alpn,
 		Alpn:                   h.Alpn,
 		Fingerprint:            h.Fingerprint,
 		Fingerprint:            h.Fingerprint,
+		CipherSuites:           h.CipherSuites,
 		OverrideSniFromAddress: h.OverrideSniFromAddress,
 		OverrideSniFromAddress: h.OverrideSniFromAddress,
 		KeepSniBlank:           h.KeepSniBlank,
 		KeepSniBlank:           h.KeepSniBlank,
 		PinnedPeerCertSha256:   h.PinnedPeerCertSha256,
 		PinnedPeerCertSha256:   h.PinnedPeerCertSha256,
@@ -133,6 +134,7 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
 				Path:                   req.Path,
 				Path:                   req.Path,
 				Alpn:                   req.Alpn,
 				Alpn:                   req.Alpn,
 				Fingerprint:            req.Fingerprint,
 				Fingerprint:            req.Fingerprint,
+				CipherSuites:           req.CipherSuites,
 				OverrideSniFromAddress: req.OverrideSniFromAddress,
 				OverrideSniFromAddress: req.OverrideSniFromAddress,
 				KeepSniBlank:           req.KeepSniBlank,
 				KeepSniBlank:           req.KeepSniBlank,
 				PinnedPeerCertSha256:   req.PinnedPeerCertSha256,
 				PinnedPeerCertSha256:   req.PinnedPeerCertSha256,

+ 24 - 0
internal/web/service/host_test.go

@@ -365,3 +365,27 @@ func TestUpdateHostGroup_ValidateBeforeDelete(t *testing.T) {
 		t.Fatalf("remark not updated: %s", got2.Remark)
 		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)
+	}
+}

+ 1 - 1
internal/web/translation/ar-EG.json

@@ -596,7 +596,7 @@
         "customSockopt": "sockopt مخصص",
         "customSockopt": "sockopt مخصص",
         "addCustomOption": "إضافة خيار مخصص",
         "addCustomOption": "إضافة خيار مخصص",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "مجموعات التشفير",
         "autoOption": "تلقائي",
         "autoOption": "تلقائي",
         "minMaxVersion": "إصدار أدنى/أقصى",
         "minMaxVersion": "إصدار أدنى/أقصى",
         "rejectUnknownSni": "رفض SNI غير معروف",
         "rejectUnknownSni": "رفض SNI غير معروف",

+ 1 - 1
internal/web/translation/es-ES.json

@@ -596,7 +596,7 @@
         "customSockopt": "Sockopt personalizado",
         "customSockopt": "Sockopt personalizado",
         "addCustomOption": "Añadir opción personalizada",
         "addCustomOption": "Añadir opción personalizada",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Conjuntos de cifrado",
         "autoOption": "Auto",
         "autoOption": "Auto",
         "minMaxVersion": "Versión mín/máx",
         "minMaxVersion": "Versión mín/máx",
         "rejectUnknownSni": "Rechazar SNI desconocido",
         "rejectUnknownSni": "Rechazar SNI desconocido",

+ 1 - 1
internal/web/translation/id-ID.json

@@ -596,7 +596,7 @@
         "customSockopt": "Sockopt kustom",
         "customSockopt": "Sockopt kustom",
         "addCustomOption": "Tambah opsi kustom",
         "addCustomOption": "Tambah opsi kustom",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Rangkaian Sandi",
         "autoOption": "Otomatis",
         "autoOption": "Otomatis",
         "minMaxVersion": "Versi Min/Maks",
         "minMaxVersion": "Versi Min/Maks",
         "rejectUnknownSni": "Tolak SNI tidak dikenal",
         "rejectUnknownSni": "Tolak SNI tidak dikenal",

+ 1 - 1
internal/web/translation/ja-JP.json

@@ -617,7 +617,7 @@
         "customSockopt": "カスタム sockopt",
         "customSockopt": "カスタム sockopt",
         "addCustomOption": "カスタムオプション追加",
         "addCustomOption": "カスタムオプション追加",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "暗号スイート",
         "autoOption": "自動",
         "autoOption": "自動",
         "minMaxVersion": "最小/最大バージョン",
         "minMaxVersion": "最小/最大バージョン",
         "rejectUnknownSni": "未知の SNI を拒否",
         "rejectUnknownSni": "未知の SNI を拒否",

+ 1 - 1
internal/web/translation/pt-BR.json

@@ -617,7 +617,7 @@
         "customSockopt": "Sockopt personalizado",
         "customSockopt": "Sockopt personalizado",
         "addCustomOption": "Adicionar opção personalizada",
         "addCustomOption": "Adicionar opção personalizada",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Conjuntos de cifras",
         "autoOption": "Auto",
         "autoOption": "Auto",
         "minMaxVersion": "Versão mín/máx",
         "minMaxVersion": "Versão mín/máx",
         "rejectUnknownSni": "Rejeitar SNI desconhecido",
         "rejectUnknownSni": "Rejeitar SNI desconhecido",

+ 1 - 1
internal/web/translation/ru-RU.json

@@ -619,7 +619,7 @@
         "customSockopt": "Пользовательский sockopt",
         "customSockopt": "Пользовательский sockopt",
         "addCustomOption": "Добавить опцию",
         "addCustomOption": "Добавить опцию",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Наборы шифров",
         "autoOption": "Авто",
         "autoOption": "Авто",
         "minMaxVersion": "Мин/Макс версия",
         "minMaxVersion": "Мин/Макс версия",
         "rejectUnknownSni": "Отклонить неизвестный SNI",
         "rejectUnknownSni": "Отклонить неизвестный SNI",

+ 1 - 1
internal/web/translation/uk-UA.json

@@ -596,7 +596,7 @@
         "customSockopt": "Користувацький sockopt",
         "customSockopt": "Користувацький sockopt",
         "addCustomOption": "Додати опцію",
         "addCustomOption": "Додати опцію",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Набори шифрів",
         "autoOption": "Авто",
         "autoOption": "Авто",
         "minMaxVersion": "Мін/Макс версія",
         "minMaxVersion": "Мін/Макс версія",
         "rejectUnknownSni": "Відхиляти невідомий SNI",
         "rejectUnknownSni": "Відхиляти невідомий SNI",

+ 1 - 1
internal/web/translation/vi-VN.json

@@ -617,7 +617,7 @@
         "customSockopt": "Sockopt tùy chỉnh",
         "customSockopt": "Sockopt tùy chỉnh",
         "addCustomOption": "Thêm tùy chọn",
         "addCustomOption": "Thêm tùy chọn",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Bộ mật mã",
         "autoOption": "Tự động",
         "autoOption": "Tự động",
         "minMaxVersion": "Phiên bản Min/Max",
         "minMaxVersion": "Phiên bản Min/Max",
         "rejectUnknownSni": "Từ chối SNI lạ",
         "rejectUnknownSni": "Từ chối SNI lạ",

+ 1 - 1
internal/web/translation/zh-CN.json

@@ -616,7 +616,7 @@
         "customSockopt": "自定义 sockopt",
         "customSockopt": "自定义 sockopt",
         "addCustomOption": "添加自定义选项",
         "addCustomOption": "添加自定义选项",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "密码套件",
         "autoOption": "自动",
         "autoOption": "自动",
         "minMaxVersion": "最小/最大版本",
         "minMaxVersion": "最小/最大版本",
         "rejectUnknownSni": "拒绝未知 SNI",
         "rejectUnknownSni": "拒绝未知 SNI",

+ 1 - 1
internal/web/translation/zh-TW.json

@@ -596,7 +596,7 @@
         "customSockopt": "自訂 sockopt",
         "customSockopt": "自訂 sockopt",
         "addCustomOption": "新增自訂選項",
         "addCustomOption": "新增自訂選項",
         "serverNameIndication": "SNI",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "加密套件",
         "autoOption": "自動",
         "autoOption": "自動",
         "minMaxVersion": "最小/最大版本",
         "minMaxVersion": "最小/最大版本",
         "rejectUnknownSni": "拒絕未知 SNI",
         "rejectUnknownSni": "拒絕未知 SNI",