6 Commits 7ef22f94c9 ... f8db7f6c29

Author SHA1 Message Date
  n0ctal f8db7f6c29 fix(nodes): say which half of node mTLS failed, and say it as an error (#6565) 12 hours ago
  sdhfsl 536f9a6338 fix(tgbot): localize QR caption via I18nBot (#6564) 12 hours ago
  sdhfsl d59b77bcdb fix(sub): send stable X-HWID on external subscription fetch (#6567) 12 hours ago
  Sanaei 17e89db979 feat(hosts): add a cipher suites override and accept custom suites 12 hours ago
  Sanaei 040d01c5dc fix(clients): list HWID devices when the HWID limit is 0 13 hours ago
  Sanaei b78dd82869 fix(nodes): chart node net throughput in KB/s, not percent 13 hours ago
42 changed files with 467 additions and 41 deletions
  1. 14 0
      docs/public/openapi.json
  2. 14 0
      frontend/public/openapi.json
  3. 39 0
      frontend/src/components/form/CipherSuitesSelect.tsx
  4. 1 0
      frontend/src/components/form/index.ts
  5. 2 0
      frontend/src/generated/examples.ts
  6. 8 0
      frontend/src/generated/schemas.ts
  7. 2 0
      frontend/src/generated/types.ts
  8. 2 0
      frontend/src/generated/zod.ts
  9. 8 0
      frontend/src/pages/hosts/HostFormModal.tsx
  10. 2 7
      frontend/src/pages/inbounds/form/security/tls.tsx
  11. 7 1
      frontend/src/pages/nodes/NodeHistoryPanel.tsx
  12. 2 0
      frontend/src/schemas/api/host.ts
  13. 34 0
      frontend/src/test/cipher-suites-select.test.tsx
  14. 54 0
      frontend/src/test/node-history-panel.test.tsx
  15. 1 0
      internal/database/model/model.go
  16. 67 0
      internal/sub/external_hwid_test.go
  17. 43 4
      internal/sub/external_subscription.go
  18. 3 0
      internal/sub/host_sub.go
  19. 28 0
      internal/sub/host_sub_test.go
  20. 3 0
      internal/sub/service.go
  21. 1 0
      internal/web/entity/entity.go
  22. 19 1
      internal/web/service/client_hwid.go
  23. 17 0
      internal/web/service/client_hwid_test.go
  24. 2 0
      internal/web/service/host.go
  25. 24 0
      internal/web/service/host_test.go
  26. 5 1
      internal/web/service/setting_mtls.go
  27. 19 0
      internal/web/service/setting_mtls_bundle_test.go
  28. 1 1
      internal/web/service/tgbot/tgbot_client.go
  29. 3 2
      internal/web/translation/ar-EG.json
  30. 2 1
      internal/web/translation/en-US.json
  31. 3 2
      internal/web/translation/es-ES.json
  32. 2 1
      internal/web/translation/fa-IR.json
  33. 3 2
      internal/web/translation/id-ID.json
  34. 3 2
      internal/web/translation/ja-JP.json
  35. 3 2
      internal/web/translation/pt-BR.json
  36. 3 2
      internal/web/translation/ru-RU.json
  37. 2 1
      internal/web/translation/tr-TR.json
  38. 3 2
      internal/web/translation/uk-UA.json
  39. 3 2
      internal/web/translation/vi-VN.json
  40. 3 2
      internal/web/translation/zh-CN.json
  41. 3 2
      internal/web/translation/zh-TW.json
  42. 9 3
      internal/web/web.go

+ 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>

+ 7 - 1
frontend/src/pages/nodes/NodeHistoryPanel.tsx

@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
 
 
 const REFRESH_MS = 15000;
 const REFRESH_MS = 15000;
 
 
+const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
+
 export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
 export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [cpuPoints, setCpuPoints] = useState<number[]>([]);
   const [cpuPoints, setCpuPoints] = useState<number[]>([]);
@@ -51,7 +53,7 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
     };
     };
 
 
     // cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
     // cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
-    // as KB/s (no upper clamp, the sparkline auto-scales).
+    // as KB/s, which must opt out of Sparkline's 0-100 "%" defaults.
     const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
     const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
       try {
       try {
         const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
         const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
@@ -148,6 +150,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
           fillOpacity={0.18}
           fillOpacity={0.18}
           markerRadius={2.6}
           markerRadius={2.6}
           showTooltip
           showTooltip
+          valueMax={null}
+          yFormatter={formatKbps}
         />
         />
       </div>
       </div>
       <div className="series">
       <div className="series">
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
           fillOpacity={0.18}
           fillOpacity={0.18}
           markerRadius={2.6}
           markerRadius={2.6}
           showTooltip
           showTooltip
+          valueMax={null}
+          yFormatter={formatKbps}
         />
         />
       </div>
       </div>
     </div>
     </div>

+ 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('');
+  });
+});

+ 54 - 0
frontend/src/test/node-history-panel.test.tsx

@@ -0,0 +1,54 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import NodeHistoryPanel from '@/pages/nodes/NodeHistoryPanel';
+import { HttpUtil, Msg } from '@/utils';
+
+const plots = vi.hoisted(() => [] as { scales: { y: { range: () => [number, number] } } }[]);
+
+vi.mock('uplot', () => ({
+  default: class {
+    static paths = { spline: () => undefined };
+    static pxRatio = 1;
+    constructor(opts: (typeof plots)[number]) {
+      plots.push(opts);
+    }
+    setData() {}
+    setSize() {}
+    redraw() {}
+    destroy() {}
+  },
+}));
+
+// The net series fell through to Sparkline's percentage defaults: a 0-100 scale
+// and a "%" label, so 512 KB/s rendered as "512%" far above the chart.
+describe('NodeHistoryPanel', () => {
+  it('charts net throughput in KB/s on its own scale', async () => {
+    const samples: Record<string, number> = {
+      cpu: 40,
+      mem: 60,
+      netUp: 512 * 1024,
+      netDown: 200 * 1024,
+    };
+    vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+      const metric = url.split('/').at(-2) ?? '';
+      return new Msg(true, '', [{ t: 1_700_000_000, v: samples[metric] }]);
+    });
+
+    render(<NodeHistoryPanel node={{ id: 7 }} />);
+
+    await waitFor(() => expect(screen.getAllByRole('img')).toHaveLength(4));
+    expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
+      '40%',
+      '60%',
+      '512',
+      '200',
+    ]);
+    expect(plots.map((p) => p.scales.y.range())).toEqual([
+      [0, 100],
+      [0, 100],
+      [0, 512 * 1.1],
+      [0, 200 * 1.1],
+    ]);
+  });
+});

+ 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"`

+ 67 - 0
internal/sub/external_hwid_test.go

@@ -0,0 +1,67 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// #6559: the Master panel must send a stable X-HWID when fetching external
+// subscriptions, otherwise an HWID-limited donor answers 404.
+func TestServerHwidStableAcrossCalls(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	first := serverHwid()
+	if first == "" {
+		t.Fatal("serverHwid returned empty")
+	}
+
+	second := serverHwid()
+	if second != first {
+		t.Fatalf("hwid not stable: %q vs %q", first, second)
+	}
+
+	var row model.Setting
+	if err := database.GetDB().Where("key = ?", serverHwidKey).First(&row).Error; err != nil {
+		t.Fatalf("hwid not persisted: %v", err)
+	}
+	if row.Value != first {
+		t.Fatalf("persisted hwid %q != returned %q", row.Value, first)
+	}
+}
+
+// The fetch must carry the stable id so an HWID-limited donor lets it through.
+func TestFetchSendsStableHwid(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	var gotHwid string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotHwid = r.Header.Get("X-HWID")
+		_, _ = w.Write([]byte("vless://uuid@host:443?security=none#x"))
+	}))
+	defer srv.Close()
+
+	res := fetchSubscriptionLinks(srv.URL)
+	if res.err != nil {
+		t.Fatalf("fetch: %v", res.err)
+	}
+	if len(res.links) != 1 {
+		t.Fatalf("links = %v", res.links)
+	}
+	if gotHwid == "" {
+		t.Fatal("X-HWID header missing on fetch")
+	}
+	if gotHwid != serverHwid() {
+		t.Fatalf("sent %q != stable %q", gotHwid, serverHwid())
+	}
+}

+ 43 - 4
internal/sub/external_subscription.go

@@ -9,15 +9,15 @@ import (
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
+	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 )
 
 
-// External subscription fetching: a "subscription" external link is a remote
-// URL whose body is a (often base64-encoded) newline list of share links. We
-// fetch it on demand, cache the decoded links briefly, and bound the request
-// with a short timeout so a slow/dead provider can't stall a client's sub.
+// External subscription fetching: a remote URL whose body is a share-link
+// list. Fetches are cached briefly and bounded so a dead provider can't stall.
 
 
 const (
 const (
 	subscriptionCacheTTL      = 5 * time.Minute
 	subscriptionCacheTTL      = 5 * time.Minute
@@ -150,6 +150,10 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
 	}
 	}
 	// Some providers gate the link body on a known client User-Agent.
 	// Some providers gate the link body on a known client User-Agent.
 	req.Header.Set("User-Agent", "v2rayNG/1.8.5")
 	req.Header.Set("User-Agent", "v2rayNG/1.8.5")
+	// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
+	if hwid := serverHwid(); hwid != "" {
+		req.Header.Set("X-HWID", hwid)
+	}
 	resp, err := subscriptionHTTPClient.Do(req)
 	resp, err := subscriptionHTTPClient.Do(req)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -173,6 +177,41 @@ var (
 	errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
 	errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
 )
 )
 
 
+// serverHwidKey is the settings row holding this panel's stable identity
+// for outbound external-subscription fetches.
+const serverHwidKey = "externalSubHwid"
+
+// serverHwidMu serializes first-time creation: without it, concurrent first
+// fetches of different URLs each mint and persist their own UUID.
+var serverHwidMu sync.Mutex
+
+// serverHwid returns a stable per-installation id, creating and persisting
+// it on first use. Empty means the DB is unreachable: send no header then.
+func serverHwid() string {
+	serverHwidMu.Lock()
+	defer serverHwidMu.Unlock()
+	db := database.GetDB()
+	if db == nil {
+		return ""
+	}
+	var row model.Setting
+	if err := db.Where("key = ?", serverHwidKey).First(&row).Error; err == nil {
+		if strings.TrimSpace(row.Value) != "" {
+			return strings.TrimSpace(row.Value)
+		}
+	}
+	hwid := "3x-ui-server-" + uuid.NewString()
+	row = model.Setting{Key: serverHwidKey, Value: hwid}
+	if err := db.Where(model.Setting{Key: serverHwidKey}).FirstOrCreate(&row).Error; err != nil {
+		logger.Warningf("sub: persisting server hwid failed: %v", err)
+		return ""
+	}
+	if strings.TrimSpace(row.Value) == "" {
+		return hwid
+	}
+	return strings.TrimSpace(row.Value)
+}
+
 type subError struct{ msg string }
 type subError struct{ msg string }
 
 
 func (e *subError) Error() string { return e.msg }
 func (e *subError) Error() string { return e.msg }

+ 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"`

+ 19 - 1
internal/web/service/client_hwid.go

@@ -9,8 +9,10 @@ import (
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 
 
 	"gorm.io/gorm"
 	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
 )
 )
 
 
 type HwidRequest struct {
 type HwidRequest struct {
@@ -110,12 +112,15 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
 	if err != nil {
 	if err != nil {
 		return res, err
 		return res, err
 	}
 	}
+	req = normalizeHwidRequest(req)
 	if limit <= 0 {
 	if limit <= 0 {
 		res.Allowed = true
 		res.Allowed = true
+		if len(req.Hwid) >= minHwidLength {
+			trackUnlimitedHwid(db, subID, req)
+		}
 		return res, nil
 		return res, nil
 	}
 	}
 
 
-	req = normalizeHwidRequest(req)
 	res.Active = true
 	res.Active = true
 	res.Limit = limit
 	res.Limit = limit
 	if len(req.Hwid) < minHwidLength {
 	if len(req.Hwid) < minHwidLength {
@@ -177,6 +182,19 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
 	return res, err
 	return res, err
 }
 }
 
 
+// trackUnlimitedHwid lists devices of a sub with no HWID limit in the panel. It is
+// best-effort: a failed write must not deny a subscription nothing restricts.
+func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) {
+	now := time.Now().UnixMilli()
+	err := db.Clauses(clause.OnConflict{
+		Columns:   []clause.Column{{Name: "sub_id"}, {Name: "hwid_hash"}},
+		DoUpdates: clause.AssignmentColumns([]string{"last_seen", "user_agent", "device_os", "os_version", "device_model"}),
+	}).Create(&model.ClientHwid{SubID: subID, HwidHash: hashHwid(req.Hwid), FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error
+	if err != nil {
+		logger.Warning("track HWID for unlimited subscription failed:", err)
+	}
+}
+
 // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or
 // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or
 // last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced.
 // last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced.
 func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) {
 func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) {

+ 17 - 0
internal/web/service/client_hwid_test.go

@@ -45,6 +45,23 @@ func TestClientHwidGate(t *testing.T) {
 	if !res.Allowed || res.Active {
 	if !res.Allowed || res.Active {
 		t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
 		t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
 	}
 	}
+
+	for _, ua := range []string{"Happ/1.0", "Happ/2.0"} {
+		res, err = svc.EnforceHwidForSubID("sub-hwid", HwidRequest{Hwid: "device-one", UserAgent: ua})
+		if err != nil {
+			t.Fatalf("no-limit gate with HWID: %v", err)
+		}
+		if res != (HwidGateResult{Allowed: true}) {
+			t.Fatalf("no limit should allow HWID without active headers: %+v", res)
+		}
+	}
+	list, err := svc.ListClientHwids("[email protected]")
+	if err != nil {
+		t.Fatalf("list HWIDs: %v", err)
+	}
+	if len(list) != 1 || list[0].UserAgent != "Happ/2.0" {
+		t.Fatalf("no limit should still track one device with fresh metadata, got %+v", list)
+	}
 }
 }
 
 
 func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
 func TestClientHwidGateRegistersAndBlocks(t *testing.T) {

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

+ 5 - 1
internal/web/service/setting_mtls.go

@@ -194,7 +194,7 @@ func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
 	}
 	}
 	certs, err := parseCertificateBundlePEM([]byte(caPem))
 	certs, err := parseCertificateBundlePEM([]byte(caPem))
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf("nodeMtlsClientCAPem is not a valid certificate bundle: %w", err)
+		return nil, fmt.Errorf("%w: %w", ErrNodeMtlsTrustBundleInvalid, err)
 	}
 	}
 	pool := x509.NewCertPool()
 	pool := x509.NewCertPool()
 	for _, cert := range certs {
 	for _, cert := range certs {
@@ -203,6 +203,10 @@ func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
 	return pool, nil
 	return pool, nil
 }
 }
 
 
+// ErrNodeMtlsTrustBundleInvalid separates a stored bundle that will not parse
+// from a settings read that failed, which callers report differently.
+var ErrNodeMtlsTrustBundleInvalid = errors.New("nodeMtlsClientCAPem is not a valid certificate bundle")
+
 // parseCertificateBundlePEM avoids AppendCertsFromPEM because that helper can
 // parseCertificateBundlePEM avoids AppendCertsFromPEM because that helper can
 // silently accept a bundle after parsing only its first certificate.
 // silently accept a bundle after parsing only its first certificate.
 func parseCertificateBundlePEM(bundle []byte) ([]*x509.Certificate, error) {
 func parseCertificateBundlePEM(bundle []byte) ([]*x509.Certificate, error) {

+ 19 - 0
internal/web/service/setting_mtls_bundle_test.go

@@ -1,6 +1,7 @@
 package service
 package service
 
 
 import (
 import (
+	"errors"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
@@ -72,3 +73,21 @@ func TestNodeMtlsClientCAPoolRejectsPartiallyValidBundle(t *testing.T) {
 		t.Fatalf("NodeMtlsClientCAPool() = %v, error = %v, want %q", pool, err, want)
 		t.Fatalf("NodeMtlsClientCAPool() = %v, error = %v, want %q", pool, err, want)
 	}
 	}
 }
 }
+
+// The boot path tells the operator whether the bundle itself is unusable or the
+// settings read failed, so the parse failure has to carry a matchable cause.
+func TestNodeMtlsClientCAPoolTagsAnInvalidBundle(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+
+	if err := s.setString("nodeMtlsClientCAPem", "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"); err != nil {
+		t.Fatalf("setString: %v", err)
+	}
+
+	pool, err := s.NodeMtlsClientCAPool()
+	if pool != nil {
+		t.Fatalf("NodeMtlsClientCAPool() returned a pool built from an unusable bundle")
+	}
+	if !errors.Is(err, ErrNodeMtlsTrustBundleInvalid) {
+		t.Fatalf("NodeMtlsClientCAPool() error = %v, want it to wrap ErrNodeMtlsTrustBundleInvalid", err)
+	}
+}

+ 1 - 1
internal/web/service/tgbot/tgbot_client.go

@@ -348,7 +348,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
 	}
 	}
 
 
 	// Inform user
 	// Inform user
-	t.SendMsgToTgbot(chatId, "QRCode for client "+email+":")
+	t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
 
 
 	// Send sub URL QR (filename: sub.png)
 	// Send sub URL QR (filename: sub.png)
 	if png, err := createQR(subURL, 320); err == nil {
 	if png, err := createQR(subURL, 320); err == nil {

+ 3 - 2
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 غير معروف",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: اتعطل بنجاح.",
       "disableSuccess": "✅ {{ .Email }}: اتعطل بنجاح.",
       "askToAddUserId": "مافيش إعدادات ليك!\r\nاطلب من الأدمن يضيف الـ Telegram ChatID الخاص بيك في إعداداتك.\r\n\r\nالـ ChatID بتاعك: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "مافيش إعدادات ليك!\r\nاطلب من الأدمن يضيف الـ Telegram ChatID الخاص بيك في إعداداتك.\r\n\r\nالـ ChatID بتاعك: <code>{{ .TgUserID }}</code>",
       "chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
       "chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
-      "chooseInbound": "اختار الإدخال"
+      "chooseInbound": "اختار الإدخال",
+      "qrCodeForClient": "رمز QR للعميل {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

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

@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Disabled successfully.",
       "disableSuccess": "✅ {{ .Email }}: Disabled successfully.",
       "askToAddUserId": "Your configuration is not found!\r\nPlease ask your admin to use your Telegram ChatID in your configuration(s).\r\n\r\nYour ChatID: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "Your configuration is not found!\r\nPlease ask your admin to use your Telegram ChatID in your configuration(s).\r\n\r\nYour ChatID: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
       "chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
-      "chooseInbound": "Choose an Inbound"
+      "chooseInbound": "Choose an Inbound",
+      "qrCodeForClient": "QRCode for client {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }} : Deshabilitado exitosamente.",
       "disableSuccess": "✅ {{ .Email }} : Deshabilitado exitosamente.",
       "askToAddUserId": "¡No se encuentra su configuración!\r\nPor favor, pídale a su administrador que use su ChatID de usuario de Telegram en su(s) configuración(es).\r\n\r\nSu ChatID de usuario: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "¡No se encuentra su configuración!\r\nPor favor, pídale a su administrador que use su ChatID de usuario de Telegram en su(s) configuración(es).\r\n\r\nSu ChatID de usuario: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
       "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
-      "chooseInbound": "Elige un Inbound"
+      "chooseInbound": "Elige un Inbound",
+      "qrCodeForClient": "Código QR para el cliente {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

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

@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }} : با موفقیت غیرفعال شد.",
       "disableSuccess": "✅ {{ .Email }} : با موفقیت غیرفعال شد.",
       "askToAddUserId": "پیکربندی شما یافت نشد!\r\nلطفاً از مدیر خود بخواهید که شناسه کاربر تلگرام خود را در پیکربندی (های) خود استفاده کند.\r\n\r\nشناسه کاربری شما: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "پیکربندی شما یافت نشد!\r\nلطفاً از مدیر خود بخواهید که شناسه کاربر تلگرام خود را در پیکربندی (های) خود استفاده کند.\r\n\r\nشناسه کاربری شما: <code>{{ .TgUserID }}</code>",
       "chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید",
       "chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید",
-      "chooseInbound": "یک ورودی انتخاب کنید"
+      "chooseInbound": "یک ورودی انتخاب کنید",
+      "qrCodeForClient": "کد QR برای کاربر {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Dinonaktifkan dengan berhasil.",
       "disableSuccess": "✅ {{ .Email }}: Dinonaktifkan dengan berhasil.",
       "askToAddUserId": "Konfigurasi Anda tidak ditemukan!\r\nSilakan minta admin Anda untuk menggunakan ChatID Telegram Anda dalam konfigurasi Anda.\r\n\r\nChatID Pengguna Anda: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "Konfigurasi Anda tidak ditemukan!\r\nSilakan minta admin Anda untuk menggunakan ChatID Telegram Anda dalam konfigurasi Anda.\r\n\r\nChatID Pengguna Anda: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
       "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
-      "chooseInbound": "Pilih Inbound"
+      "chooseInbound": "Pilih Inbound",
+      "qrCodeForClient": "Kode QR untuk klien {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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 を拒否",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}:正常に無効化されました。",
       "disableSuccess": "✅ {{ .Email }}:正常に無効化されました。",
       "askToAddUserId": "設定が見つかりませんでした!\r\n管理者に問い合わせて、設定にTelegramユーザーのChatIDを使用してください。\r\n\r\nあなたのユーザーChatID:<code>{{ .TgUserID }}</code>",
       "askToAddUserId": "設定が見つかりませんでした!\r\n管理者に問い合わせて、設定にTelegramユーザーのChatIDを使用してください。\r\n\r\nあなたのユーザーChatID:<code>{{ .TgUserID }}</code>",
       "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
       "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
-      "chooseInbound": "インバウンドを選択"
+      "chooseInbound": "インバウンドを選択",
+      "qrCodeForClient": "クライアント {{ .Email }} のQRコード:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Desativado com sucesso.",
       "disableSuccess": "✅ {{ .Email }}: Desativado com sucesso.",
       "askToAddUserId": "Sua configuração não foi encontrada!\r\nPeça ao seu administrador para usar seu Telegram ChatID em suas configurações.\r\n\r\nSeu ChatID: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "Sua configuração não foi encontrada!\r\nPeça ao seu administrador para usar seu Telegram ChatID em suas configurações.\r\n\r\nSeu ChatID: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
       "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
-      "chooseInbound": "Escolha um Inbound"
+      "chooseInbound": "Escolha um Inbound",
+      "qrCodeForClient": "QR Code para o cliente {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Отключено успешно.",
       "disableSuccess": "✅ {{ .Email }}: Отключено успешно.",
       "askToAddUserId": "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
       "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
-      "chooseInbound": "Выберите входящее подключение"
+      "chooseInbound": "Выберите входящее подключение",
+      "qrCodeForClient": "QR-код для клиента {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

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

@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Başarıyla devre dışı bırakıldı.",
       "disableSuccess": "✅ {{ .Email }}: Başarıyla devre dışı bırakıldı.",
       "askToAddUserId": "Yapılandırmanız bulunamadı!\r\nLütfen yöneticinizden Telegram Chat ID'nizi yapılandırmanıza eklemesini isteyin.\r\n\r\nSizin Chat ID'niz: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "Yapılandırmanız bulunamadı!\r\nLütfen yöneticinizden Telegram Chat ID'nizi yapılandırmanıza eklemesini isteyin.\r\n\r\nSizin Chat ID'niz: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin",
       "chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin",
-      "chooseInbound": "Bir Gelen Bağlantı Seçin"
+      "chooseInbound": "Bir Gelen Bağlantı Seçin",
+      "qrCodeForClient": "{{ .Email }} istemcisi için QR Kodu:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Успішно вимкнено.",
       "disableSuccess": "✅ {{ .Email }}: Успішно вимкнено.",
       "askToAddUserId": "Вашу конфігурацію не знайдено!\r\nБудь ласка, попросіть свого адміністратора використовувати ваш ідентифікатор Telegram у вашій конфігурації.\r\n\r\nВаш ідентифікатор користувача: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "Вашу конфігурацію не знайдено!\r\nБудь ласка, попросіть свого адміністратора використовувати ваш ідентифікатор Telegram у вашій конфігурації.\r\n\r\nВаш ідентифікатор користувача: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
       "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
-      "chooseInbound": "Виберіть Вхідний"
+      "chooseInbound": "Виберіть Вхідний",
+      "qrCodeForClient": "QR-код для клієнта {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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ạ",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }} : Đã Tắt Thành Công.",
       "disableSuccess": "✅ {{ .Email }} : Đã Tắt Thành Công.",
       "askToAddUserId": "Cấu hình của bạn không được tìm thấy!\r\nVui lòng yêu cầu Quản trị viên sử dụng ID người dùng telegram của bạn trong cấu hình của bạn.\r\n\r\nID người dùng của bạn: <code>{{ .TgUserID }}</code>",
       "askToAddUserId": "Cấu hình của bạn không được tìm thấy!\r\nVui lòng yêu cầu Quản trị viên sử dụng ID người dùng telegram của bạn trong cấu hình của bạn.\r\n\r\nID người dùng của bạn: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}",
       "chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}",
-      "chooseInbound": "Chọn một Inbound"
+      "chooseInbound": "Chọn một Inbound",
+      "qrCodeForClient": "Mã QR cho khách hàng {{ .Email }}:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}:已成功禁用。",
       "disableSuccess": "✅ {{ .Email }}:已成功禁用。",
       "askToAddUserId": "未找到您的配置!\r\n请向管理员询问,在您的配置中使用您的 Telegram 用户 ChatID。\r\n\r\n您的用户 ChatID:<code>{{ .TgUserID }}</code>",
       "askToAddUserId": "未找到您的配置!\r\n请向管理员询问,在您的配置中使用您的 Telegram 用户 ChatID。\r\n\r\n您的用户 ChatID:<code>{{ .TgUserID }}</code>",
       "chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
       "chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
-      "chooseInbound": "选择一个入站"
+      "chooseInbound": "选择一个入站",
+      "qrCodeForClient": "客户端 {{ .Email }} 的二维码:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 3 - 2
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",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}:已成功禁用。",
       "disableSuccess": "✅ {{ .Email }}:已成功禁用。",
       "askToAddUserId": "未找到您的配置!\r\n請向管理員詢問,在您的配置中使用您的 Telegram 使用者 ChatID。\r\n\r\n您的使用者 ChatID:<code>{{ .TgUserID }}</code>",
       "askToAddUserId": "未找到您的配置!\r\n請向管理員詢問,在您的配置中使用您的 Telegram 使用者 ChatID。\r\n\r\n您的使用者 ChatID:<code>{{ .TgUserID }}</code>",
       "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
       "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
-      "chooseInbound": "選擇一個入站"
+      "chooseInbound": "選擇一個入站",
+      "qrCodeForClient": "客戶端 {{ .Email }} 的二維碼:"
     }
     }
   },
   },
   "discord": {
   "discord": {

+ 9 - 3
internal/web/web.go

@@ -6,6 +6,7 @@ import (
 	"context"
 	"context"
 	"crypto/tls"
 	"crypto/tls"
 	"embed"
 	"embed"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"io/fs"
 	"io/fs"
@@ -626,9 +627,14 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 			// Opt-in node mTLS: when a trust CA is configured, request and verify
 			// Opt-in node mTLS: when a trust CA is configured, request and verify
 			// client certs (VerifyClientCertIfGiven keeps browsers working). With
 			// client certs (VerifyClientCertIfGiven keeps browsers working). With
 			// no CA the listener is unchanged.
 			// no CA the listener is unchanged.
-			if pool, perr := s.settingService.NodeMtlsClientCAPool(); perr != nil {
-				logger.Warning("node mTLS: failed to build client CA trust pool:", perr)
-			} else if pool != nil {
+			pool, perr := s.settingService.NodeMtlsClientCAPool()
+			switch {
+			case errors.Is(perr, service.ErrNodeMtlsTrustBundleInvalid):
+				logger.Error("Node mTLS is configured but its trust bundle will not parse, so client certificates are not accepted:", perr)
+			case perr != nil:
+				logger.Error("Node mTLS trust bundle could not be read, so client certificates are not accepted:", perr)
+			}
+			if pool != nil {
 				applyNodeMtls(c, pool)
 				applyNodeMtls(c, pool)
 				logger.Info("Node mTLS enabled: verifying client certificates for the node API")
 				logger.Info("Node mTLS enabled: verifying client certificates for the node API")
 			}
 			}