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) 10 hours ago
  sdhfsl 536f9a6338 fix(tgbot): localize QR caption via I18nBot (#6564) 10 hours ago
  sdhfsl d59b77bcdb fix(sub): send stable X-HWID on external subscription fetch (#6567) 10 hours ago
  Sanaei 17e89db979 feat(hosts): add a cipher suites override and accept custom suites 11 hours ago
  Sanaei 040d01c5dc fix(clients): list HWID devices when the HWID limit is 0 11 hours ago
  Sanaei b78dd82869 fix(nodes): chart node net throughput in KB/s, not percent 11 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"
           },
+          "cipherSuites": {
+            "type": "string"
+          },
           "createdAt": {
             "format": "int64",
             "type": "integer"
@@ -2434,6 +2437,7 @@
           "address",
           "allowInsecure",
           "alpn",
+          "cipherSuites",
           "createdAt",
           "echConfigList",
           "excludeFromSubTypes",
@@ -2478,6 +2482,9 @@
             },
             "type": "array"
           },
+          "cipherSuites": {
+            "type": "string"
+          },
           "echConfigList": {
             "type": "string"
           },
@@ -2604,6 +2611,7 @@
         "required": [
           "allowInsecure",
           "alpn",
+          "cipherSuites",
           "echConfigList",
           "excludeFromSubTypes",
           "finalMask",
@@ -11268,6 +11276,7 @@
                       "alpn": [
                         ""
                       ],
+                      "cipherSuites": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                         ""
@@ -11362,6 +11371,7 @@
                     "alpn": [
                       ""
                     ],
+                    "cipherSuites": "",
                     "echConfigList": "",
                     "excludeFromSubTypes": [
                       ""
@@ -11459,6 +11469,7 @@
                       "alpn": [
                         ""
                       ],
+                      "cipherSuites": "",
                       "echConfigList": "",
                       "excludeFromSubTypes": [
                         ""
@@ -11609,6 +11620,7 @@
                       "alpn": [
                         ""
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "echConfigList": "",
                       "excludeFromSubTypes": [
@@ -11730,6 +11742,7 @@
                       "alpn": [
                         ""
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "echConfigList": "",
                       "excludeFromSubTypes": [
@@ -11981,6 +11994,7 @@
                       "alpn": [
                         ""
                       ],
+                      "cipherSuites": "",
                       "createdAt": 0,
                       "echConfigList": "",
                       "excludeFromSubTypes": [

+ 14 - 0
frontend/public/openapi.json

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

+ 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 GoRegexInput, validateGoRegex } from './GoRegexInput';
 export { default as SelectAllClearButtons } from './SelectAllClearButtons';
+export { default as CipherSuitesSelect } from './CipherSuitesSelect';
 export { default as RemarkTemplateField } from './RemarkTemplateField';
 export { default as RemarkVarPicker } from './RemarkVarPicker';
 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": [
       ""
     ],
+    "cipherSuites": "",
     "createdAt": 0,
     "echConfigList": "",
     "excludeFromSubTypes": [
@@ -636,6 +637,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "alpn": [
       ""
     ],
+    "cipherSuites": "",
     "echConfigList": "",
     "excludeFromSubTypes": [
       ""

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

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

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

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

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

@@ -573,6 +573,7 @@ export const HostSchema = z.object({
   address: z.string(),
   allowInsecure: z.boolean(),
   alpn: z.array(z.string()),
+  cipherSuites: z.string(),
   createdAt: z.number().int(),
   echConfigList: z.string(),
   excludeFromSubTypes: z.array(z.string()),
@@ -610,6 +611,7 @@ export type Host = z.infer<typeof HostSchema>;
 export const HostGroupSchema = z.object({
   allowInsecure: z.boolean(),
   alpn: z.array(z.string()),
+  cipherSuites: z.string(),
   echConfigList: z.string(),
   excludeFromSubTypes: z.array(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 type { InboundOption } from '@/schemas/client';
 import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
+import { CipherSuitesSelect } from '@/components/form';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
     path: host?.path ?? '',
     alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
     fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
+    cipherSuites: host?.cipherSuites ?? '',
     overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
     keepSniBlank: host?.keepSniBlank ?? false,
     pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
@@ -332,6 +334,12 @@ export default function HostFormModal({
                         <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
                           <Select mode="multiple" allowClear options={alpnOptions} />
                         </FormField>
+                        <FormField
+                          name="cipherSuites"
+                          label={t('pages.inbounds.form.cipherSuites')}
+                        >
+                          <CipherSuitesSelect />
+                        </FormField>
                         <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
                           <Select mode="tags" allowClear tokenSeparators={[',']} />
                         </FormField>

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

@@ -8,11 +8,11 @@ import {
 } from '@ant-design/icons';
 import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
 
+import { CipherSuitesSelect } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import {
   ALPN_OPTION,
   DOMAIN_STRATEGY_OPTION,
-  TLS_CIPHER_OPTION,
   TLS_VERSION_OPTION,
   USAGE_OPTION,
   UTLS_FINGERPRINT,
@@ -240,12 +240,7 @@ export default function TlsForm({
         name={['streamSettings', 'tlsSettings', '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>
       <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
         <Space.Compact block>

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

@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
 
 const REFRESH_MS = 15000;
 
+const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
+
 export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
   const { t } = useTranslation();
   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
-    // 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') => {
       try {
         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}
           markerRadius={2.6}
           showTooltip
+          valueMax={null}
+          yFormatter={formatKbps}
         />
       </div>
       <div className="series">
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
           fillOpacity={0.18}
           markerRadius={2.6}
           showTooltip
+          valueMax={null}
+          yFormatter={formatKbps}
         />
       </div>
     </div>

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

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

+ 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"`
 	Alpn                   []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
 	Fingerprint            string   `json:"fingerprint" form:"fingerprint"`
+	CipherSuites           string   `json:"cipherSuites" form:"cipherSuites" gorm:"column:cipher_suites"`
 	OverrideSniFromAddress bool     `json:"overrideSniFromAddress" form:"overrideSniFromAddress" gorm:"column:override_sni_from_address"`
 	KeepSniBlank           bool     `json:"keepSniBlank" form:"keepSniBlank" gorm:"column:keep_sni_blank"`
 	PinnedPeerCertSha256   []string `json:"pinnedPeerCertSha256" form:"pinnedPeerCertSha256" gorm:"serializer:json;column:pinned_peer_cert_sha256"`

+ 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"
 	"time"
 
+	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"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 (
 	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.
 	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)
 	if err != nil {
 		return nil, err
@@ -173,6 +177,41 @@ var (
 	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 }
 
 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 != "" {
 		ep["fingerprint"] = h.Fingerprint
 	}
+	if h.CipherSuites != "" {
+		ep["cipherSuites"] = h.CipherSuites
+	}
 	if len(h.Alpn) > 0 {
 		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 {
 		tlsSettings["alpn"] = alpn
 	}
+	if cs, ok := ep["cipherSuites"].(string); ok && cs != "" {
+		tlsSettings["cipherSuites"] = cs
+	}
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 		settings, _ := tlsSettings["settings"].(map[string]any)
 		if settings == nil {

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

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

+ 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/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 
 	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
 )
 
 type HwidRequest struct {
@@ -110,12 +112,15 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
 	if err != nil {
 		return res, err
 	}
+	req = normalizeHwidRequest(req)
 	if limit <= 0 {
 		res.Allowed = true
+		if len(req.Hwid) >= minHwidLength {
+			trackUnlimitedHwid(db, subID, req)
+		}
 		return res, nil
 	}
 
-	req = normalizeHwidRequest(req)
 	res.Active = true
 	res.Limit = limit
 	if len(req.Hwid) < minHwidLength {
@@ -177,6 +182,19 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
 	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
 // last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced.
 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 {
 		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) {

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

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

+ 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)
 	}
 }
+
+// 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))
 	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()
 	for _, cert := range certs {
@@ -203,6 +203,10 @@ func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
 	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
 // silently accept a bundle after parsing only its first certificate.
 func parseCertificateBundlePEM(bundle []byte) ([]*x509.Certificate, error) {

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

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"errors"
 	"strings"
 	"testing"
 
@@ -72,3 +73,21 @@ func TestNodeMtlsClientCAPoolRejectsPartiallyValidBundle(t *testing.T) {
 		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
-	t.SendMsgToTgbot(chatId, "QRCode for client "+email+":")
+	t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
 
 	// Send sub URL QR (filename: sub.png)
 	if png, err := createQR(subURL, 320); err == nil {

+ 3 - 2
internal/web/translation/ar-EG.json

@@ -596,7 +596,7 @@
         "customSockopt": "sockopt مخصص",
         "addCustomOption": "إضافة خيار مخصص",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "مجموعات التشفير",
         "autoOption": "تلقائي",
         "minMaxVersion": "إصدار أدنى/أقصى",
         "rejectUnknownSni": "رفض SNI غير معروف",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: اتعطل بنجاح.",
       "askToAddUserId": "مافيش إعدادات ليك!\r\nاطلب من الأدمن يضيف الـ Telegram ChatID الخاص بيك في إعداداتك.\r\n\r\nالـ ChatID بتاعك: <code>{{ .TgUserID }}</code>",
       "chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
-      "chooseInbound": "اختار الإدخال"
+      "chooseInbound": "اختار الإدخال",
+      "qrCodeForClient": "رمز QR للعميل {{ .Email }}:"
     }
   },
   "discord": {

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

@@ -2433,7 +2433,8 @@
       "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>",
       "chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
-      "chooseInbound": "Choose an Inbound"
+      "chooseInbound": "Choose an Inbound",
+      "qrCodeForClient": "QRCode for client {{ .Email }}:"
     }
   },
   "discord": {

+ 3 - 2
internal/web/translation/es-ES.json

@@ -596,7 +596,7 @@
         "customSockopt": "Sockopt personalizado",
         "addCustomOption": "Añadir opción personalizada",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Conjuntos de cifrado",
         "autoOption": "Auto",
         "minMaxVersion": "Versión mín/máx",
         "rejectUnknownSni": "Rechazar SNI desconocido",
@@ -2433,7 +2433,8 @@
       "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>",
       "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
-      "chooseInbound": "Elige un Inbound"
+      "chooseInbound": "Elige un Inbound",
+      "qrCodeForClient": "Código QR para el cliente {{ .Email }}:"
     }
   },
   "discord": {

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

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

+ 3 - 2
internal/web/translation/id-ID.json

@@ -596,7 +596,7 @@
         "customSockopt": "Sockopt kustom",
         "addCustomOption": "Tambah opsi kustom",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Rangkaian Sandi",
         "autoOption": "Otomatis",
         "minMaxVersion": "Versi Min/Maks",
         "rejectUnknownSni": "Tolak SNI tidak dikenal",
@@ -2433,7 +2433,8 @@
       "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>",
       "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
-      "chooseInbound": "Pilih Inbound"
+      "chooseInbound": "Pilih Inbound",
+      "qrCodeForClient": "Kode QR untuk klien {{ .Email }}:"
     }
   },
   "discord": {

+ 3 - 2
internal/web/translation/ja-JP.json

@@ -617,7 +617,7 @@
         "customSockopt": "カスタム sockopt",
         "addCustomOption": "カスタムオプション追加",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "暗号スイート",
         "autoOption": "自動",
         "minMaxVersion": "最小/最大バージョン",
         "rejectUnknownSni": "未知の SNI を拒否",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}:正常に無効化されました。",
       "askToAddUserId": "設定が見つかりませんでした!\r\n管理者に問い合わせて、設定にTelegramユーザーのChatIDを使用してください。\r\n\r\nあなたのユーザーChatID:<code>{{ .TgUserID }}</code>",
       "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
-      "chooseInbound": "インバウンドを選択"
+      "chooseInbound": "インバウンドを選択",
+      "qrCodeForClient": "クライアント {{ .Email }} のQRコード:"
     }
   },
   "discord": {

+ 3 - 2
internal/web/translation/pt-BR.json

@@ -617,7 +617,7 @@
         "customSockopt": "Sockopt personalizado",
         "addCustomOption": "Adicionar opção personalizada",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Conjuntos de cifras",
         "autoOption": "Auto",
         "minMaxVersion": "Versão mín/máx",
         "rejectUnknownSni": "Rejeitar SNI desconhecido",
@@ -2433,7 +2433,8 @@
       "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>",
       "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
-      "chooseInbound": "Escolha um Inbound"
+      "chooseInbound": "Escolha um Inbound",
+      "qrCodeForClient": "QR Code para o cliente {{ .Email }}:"
     }
   },
   "discord": {

+ 3 - 2
internal/web/translation/ru-RU.json

@@ -619,7 +619,7 @@
         "customSockopt": "Пользовательский sockopt",
         "addCustomOption": "Добавить опцию",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Наборы шифров",
         "autoOption": "Авто",
         "minMaxVersion": "Мин/Макс версия",
         "rejectUnknownSni": "Отклонить неизвестный SNI",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Отключено успешно.",
       "askToAddUserId": "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
-      "chooseInbound": "Выберите входящее подключение"
+      "chooseInbound": "Выберите входящее подключение",
+      "qrCodeForClient": "QR-код для клиента {{ .Email }}:"
     }
   },
   "discord": {

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

@@ -2433,7 +2433,8 @@
       "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>",
       "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": {

+ 3 - 2
internal/web/translation/uk-UA.json

@@ -596,7 +596,7 @@
         "customSockopt": "Користувацький sockopt",
         "addCustomOption": "Додати опцію",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "Набори шифрів",
         "autoOption": "Авто",
         "minMaxVersion": "Мін/Макс версія",
         "rejectUnknownSni": "Відхиляти невідомий SNI",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}: Успішно вимкнено.",
       "askToAddUserId": "Вашу конфігурацію не знайдено!\r\nБудь ласка, попросіть свого адміністратора використовувати ваш ідентифікатор Telegram у вашій конфігурації.\r\n\r\nВаш ідентифікатор користувача: <code>{{ .TgUserID }}</code>",
       "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
-      "chooseInbound": "Виберіть Вхідний"
+      "chooseInbound": "Виберіть Вхідний",
+      "qrCodeForClient": "QR-код для клієнта {{ .Email }}:"
     }
   },
   "discord": {

+ 3 - 2
internal/web/translation/vi-VN.json

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

+ 3 - 2
internal/web/translation/zh-CN.json

@@ -616,7 +616,7 @@
         "customSockopt": "自定义 sockopt",
         "addCustomOption": "添加自定义选项",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "密码套件",
         "autoOption": "自动",
         "minMaxVersion": "最小/最大版本",
         "rejectUnknownSni": "拒绝未知 SNI",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}:已成功禁用。",
       "askToAddUserId": "未找到您的配置!\r\n请向管理员询问,在您的配置中使用您的 Telegram 用户 ChatID。\r\n\r\n您的用户 ChatID:<code>{{ .TgUserID }}</code>",
       "chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
-      "chooseInbound": "选择一个入站"
+      "chooseInbound": "选择一个入站",
+      "qrCodeForClient": "客户端 {{ .Email }} 的二维码:"
     }
   },
   "discord": {

+ 3 - 2
internal/web/translation/zh-TW.json

@@ -596,7 +596,7 @@
         "customSockopt": "自訂 sockopt",
         "addCustomOption": "新增自訂選項",
         "serverNameIndication": "SNI",
-        "cipherSuites": "Cipher Suites",
+        "cipherSuites": "加密套件",
         "autoOption": "自動",
         "minMaxVersion": "最小/最大版本",
         "rejectUnknownSni": "拒絕未知 SNI",
@@ -2433,7 +2433,8 @@
       "disableSuccess": "✅ {{ .Email }}:已成功禁用。",
       "askToAddUserId": "未找到您的配置!\r\n請向管理員詢問,在您的配置中使用您的 Telegram 使用者 ChatID。\r\n\r\n您的使用者 ChatID:<code>{{ .TgUserID }}</code>",
       "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
-      "chooseInbound": "選擇一個入站"
+      "chooseInbound": "選擇一個入站",
+      "qrCodeForClient": "客戶端 {{ .Email }} 的二維碼:"
     }
   },
   "discord": {

+ 9 - 3
internal/web/web.go

@@ -6,6 +6,7 @@ import (
 	"context"
 	"crypto/tls"
 	"embed"
+	"errors"
 	"fmt"
 	"io"
 	"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
 			// client certs (VerifyClientCertIfGiven keeps browsers working). With
 			// 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)
 				logger.Info("Node mTLS enabled: verifying client certificates for the node API")
 			}