Bläddra i källkod

feat(tls): surface pinnedPeerCertSha256 in panel, share links, and subs

Adds a panel-only `pinnedPeerCertSha256` field on TLS settings with a tags input and a random-hash generator. The hashes ride share links as `pcs` (v2rayN-compatible), Clash sub as `pin-sha256`, and JSON sub as `pinnedPeerCertSha256`, while remaining stripped from the run-config sent to xray-core.
MHSanaei 5 timmar sedan
förälder
incheckning
3f0b7fbe97

+ 9 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -225,6 +225,9 @@ export function genVmessLink(input: GenVmessLinkInput): string {
     if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
     if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
     if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
+    if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
+      obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
+    }
   }
 
   applyExternalProxyTLSObj(externalProxy, obj, tls);
@@ -349,6 +352,9 @@ export function genVlessLink(input: GenVlessLinkInput): string {
       params.set('alpn', tls.alpn.join(','));
       if (tls.serverName.length > 0) params.set('sni', tls.serverName);
       if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
+      if (tls.settings.pinnedPeerCertSha256.length > 0) {
+        params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
+      }
       if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
     }
     applyExternalProxyTLSParams(externalProxy, params, security);
@@ -428,6 +434,9 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
   params.set('alpn', tls.alpn.join(','));
   if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
   if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+  if (tls.settings.pinnedPeerCertSha256.length > 0) {
+    params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
+  }
 }
 
 // Reality query-string writer shared by VLESS and Trojan. Preserves the

+ 38 - 0
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -572,6 +572,21 @@ export default function InboundFormModal({
     form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
   };
 
+  const generateRandomPinHash = () => {
+    const bytes = new Uint8Array(32);
+    crypto.getRandomValues(bytes);
+    let binary = '';
+    for (const b of bytes) binary += String.fromCharCode(b);
+    const hash = btoa(binary);
+    const current = (form.getFieldValue(
+      ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+    ) as string[] | undefined) ?? [];
+    form.setFieldValue(
+      ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+      [...current, hash],
+    );
+  };
+
   const setCertFromPanel = async (certName: number) => {
     setSaving(true);
     try {
@@ -2826,6 +2841,29 @@ export default function InboundFormModal({
               >
                 <Input />
               </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.pinnedPeerCertSha256')}
+                tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
+              >
+                <Space.Compact block>
+                  <Form.Item
+                    name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
+                    noStyle
+                  >
+                    <Select
+                      mode="tags"
+                      tokenSeparators={[',', ' ']}
+                      placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
+                      style={{ width: 'calc(100% - 32px)' }}
+                    />
+                  </Form.Item>
+                  <Button
+                    icon={<ReloadOutlined />}
+                    onClick={generateRandomPinHash}
+                    title={t('pages.inbounds.form.generateRandomPin')}
+                  />
+                </Space.Compact>
+              </Form.Item>
               <Form.Item label=" ">
                 <Space>
                   <Button type="primary" loading={saving} onClick={getNewEchCert}>

+ 2 - 1
frontend/src/schemas/protocols/security/tls.ts

@@ -51,6 +51,7 @@ export type TlsCert = z.infer<typeof TlsCertSchema>;
 export const TlsClientSettingsSchema = z.object({
   fingerprint: UtlsFingerprintSchema.default('chrome'),
   echConfigList: z.string().default(''),
+  pinnedPeerCertSha256: z.array(z.string()).default([]),
 });
 export type TlsClientSettings = z.infer<typeof TlsClientSettingsSchema>;
 
@@ -67,6 +68,6 @@ export const TlsStreamSettingsSchema = z.object({
   certificates: z.array(TlsCertSchema).default([]),
   alpn: z.array(AlpnSchema).default(['h2', 'http/1.1']),
   echServerKeys: z.string().default(''),
-  settings: TlsClientSettingsSchema.default({ fingerprint: 'chrome', echConfigList: '' }),
+  settings: TlsClientSettingsSchema.default({ fingerprint: 'chrome', echConfigList: '', pinnedPeerCertSha256: [] }),
 });
 export type TlsStreamSettings = z.infer<typeof TlsStreamSettingsSchema>;

+ 95 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -70,6 +70,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
       "settings": {
         "echConfigList": "",
         "fingerprint": "chrome",
+        "pinnedPeerCertSha256": [],
       },
     },
   },
@@ -207,6 +208,7 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
       "settings": {
         "echConfigList": "",
         "fingerprint": "chrome",
+        "pinnedPeerCertSha256": [],
       },
     },
     "wsSettings": {
@@ -378,6 +380,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
       "settings": {
         "echConfigList": "",
         "fingerprint": "chrome",
+        "pinnedPeerCertSha256": [],
       },
     },
     "wsSettings": {
@@ -394,6 +397,97 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
 }
 `;
 
+exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably 1`] = `
+{
+  "down": 0,
+  "enable": true,
+  "expiryTime": 0,
+  "id": 43,
+  "listen": "",
+  "port": 443,
+  "protocol": "vless",
+  "remark": "alice-vless-ws-tls-pinned",
+  "settings": {
+    "clients": [
+      {
+        "comment": "",
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 0,
+        "flow": "",
+        "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02",
+        "limitIp": 0,
+        "reset": 0,
+        "subId": "abc123def",
+        "tgId": 0,
+        "totalGB": 0,
+      },
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": [],
+  },
+  "sniffing": {
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns",
+    ],
+    "domainsExcluded": [],
+    "enabled": true,
+    "ipsExcluded": [],
+    "metadataOnly": false,
+    "routeOnly": false,
+  },
+  "streamSettings": {
+    "network": "ws",
+    "security": "tls",
+    "tlsSettings": {
+      "alpn": [
+        "h2",
+        "http/1.1",
+      ],
+      "certificates": [
+        {
+          "buildChain": false,
+          "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
+          "keyFile": "/etc/ssl/private/cdn.example.test.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+        },
+      ],
+      "cipherSuites": "",
+      "disableSystemRoot": false,
+      "echServerKeys": "",
+      "enableSessionResumption": false,
+      "maxVersion": "1.3",
+      "minVersion": "1.2",
+      "rejectUnknownSni": false,
+      "serverName": "cdn.example.test",
+      "settings": {
+        "echConfigList": "",
+        "fingerprint": "chrome",
+        "pinnedPeerCertSha256": [
+          "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+          "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
+        ],
+      },
+    },
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "headers": {},
+      "heartbeatPeriod": 0,
+      "host": "cdn.example.test",
+      "path": "/ws",
+    },
+  },
+  "tag": "inbound-vless-pinned-1",
+  "total": 0,
+  "up": 0,
+}
+`;
+
 exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] = `
 {
   "down": 0,
@@ -468,6 +562,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
       "settings": {
         "echConfigList": "",
         "fingerprint": "chrome",
+        "pinnedPeerCertSha256": [],
       },
     },
   },

+ 4 - 0
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -12,6 +12,8 @@ exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"v
 
 exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 
+exports[`genInboundLinks orchestrator > vless-ws-tls-pinned: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test&pcs=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D%2CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB%3D#parity-test"`;
+
 exports[`genInboundLinks orchestrator > vmess-tcp-tls: byte-stable 1`] = `"vmess://ewogICJ2IjogIjIiLAogICJwcyI6ICJwYXJpdHktdGVzdCIsCiAgImFkZCI6ICJvdmVycmlkZS50ZXN0IiwKICAicG9ydCI6IDg0NDMsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgInNjeSI6ICJhdXRvIiwKICAibmV0IjogInRjcCIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJzbmkiOiAidm1lc3MuZXhhbXBsZS50ZXN0IiwKICAiZnAiOiAiY2hyb21lIiwKICAiYWxwbiI6ICJoMixodHRwLzEuMSIKfQ=="`;
 
 exports[`genInboundLinks orchestrator > wireguard-server: byte-stable 1`] = `
@@ -38,6 +40,8 @@ exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-
 
 exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 
+exports[`genVlessLink > vless-ws-tls-pinned: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test&pcs=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D%2CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB%3D#parity-test"`;
+
 exports[`genVmessLink > vmess-tcp-tls: byte-stable 1`] = `"vmess://ewogICJ2IjogIjIiLAogICJwcyI6ICJwYXJpdHktdGVzdCIsCiAgImFkZCI6ICJleGFtcGxlLnRlc3QiLAogICJwb3J0IjogODQ0MywKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAic2N5IjogImF1dG8iLAogICJuZXQiOiAidGNwIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInNuaSI6ICJ2bWVzcy5leGFtcGxlLnRlc3QiLAogICJmcCI6ICJjaHJvbWUiLAogICJhbHBuIjogImgyLGh0dHAvMS4xIgp9"`;
 
 exports[`genWireguardLink + genWireguardConfig > wireguard-server: byte-stable 1`] = `

+ 1 - 0
frontend/src/test/__snapshots__/security.test.ts.snap

@@ -66,6 +66,7 @@ exports[`SecuritySettingsSchema fixtures > parses tls-cert-file byte-stably 1`]
     "settings": {
       "echConfigList": "",
       "fingerprint": "chrome",
+      "pinnedPeerCertSha256": [],
     },
   },
 }

+ 80 - 0
frontend/src/test/golden/fixtures/inbound-full/vless-ws-tls-pinned.json

@@ -0,0 +1,80 @@
+{
+  "id": 43,
+  "up": 0,
+  "down": 0,
+  "total": 0,
+  "remark": "alice-vless-ws-tls-pinned",
+  "enable": true,
+  "expiryTime": 0,
+  "listen": "",
+  "port": 443,
+  "tag": "inbound-vless-pinned-1",
+  "sniffing": {
+    "enabled": true,
+    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "metadataOnly": false,
+    "routeOnly": false,
+    "ipsExcluded": [],
+    "domainsExcluded": []
+  },
+  "protocol": "vless",
+  "settings": {
+    "clients": [
+      {
+        "id": "8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02",
+        "email": "[email protected]",
+        "flow": "",
+        "limitIp": 0,
+        "totalGB": 0,
+        "expiryTime": 0,
+        "enable": true,
+        "tgId": 0,
+        "subId": "abc123def",
+        "comment": "",
+        "reset": 0
+      }
+    ],
+    "decryption": "none",
+    "encryption": "none",
+    "fallbacks": []
+  },
+  "streamSettings": {
+    "network": "ws",
+    "wsSettings": {
+      "acceptProxyProtocol": false,
+      "path": "/ws",
+      "host": "cdn.example.test",
+      "headers": {},
+      "heartbeatPeriod": 0
+    },
+    "security": "tls",
+    "tlsSettings": {
+      "serverName": "cdn.example.test",
+      "minVersion": "1.2",
+      "maxVersion": "1.3",
+      "cipherSuites": "",
+      "rejectUnknownSni": false,
+      "disableSystemRoot": false,
+      "enableSessionResumption": false,
+      "certificates": [
+        {
+          "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
+          "keyFile": "/etc/ssl/private/cdn.example.test.key",
+          "oneTimeLoading": false,
+          "usage": "encipherment",
+          "buildChain": false
+        }
+      ],
+      "alpn": ["h2", "http/1.1"],
+      "echServerKeys": "",
+      "settings": {
+        "fingerprint": "chrome",
+        "echConfigList": "",
+        "pinnedPeerCertSha256": [
+          "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+          "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
+        ]
+      }
+    }
+  }
+}

+ 3 - 0
sub/subClashService.go

@@ -482,6 +482,9 @@ func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
 	if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
 		tlsData["fingerprint"] = fingerprint
 	}
+	if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
+		tlsData["pin-sha256"] = pins
+	}
 	return tlsData
 }
 

+ 3 - 0
sub/subJsonService.go

@@ -272,6 +272,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
 		tlsData["fingerprint"] = fingerprint
 	}
+	if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
+		tlsData["pinnedPeerCertSha256"] = pins
+	}
 	return tlsData
 }
 

+ 33 - 0
sub/subService.go

@@ -809,6 +809,9 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
 		if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
 			params["fp"], _ = fpValue.(string)
 		}
+		if pins, ok := pinnedSha256List(tlsSettings); ok {
+			params["pcs"] = strings.Join(pins, ",")
+		}
 	}
 }
 
@@ -831,7 +834,37 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
 		if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
 			obj["fp"], _ = fpValue.(string)
 		}
+		if pins, ok := pinnedSha256List(tlsSettings); ok {
+			obj["pcs"] = strings.Join(pins, ",")
+		}
+	}
+}
+
+// pinnedSha256List extracts tlsSettings.settings.pinnedPeerCertSha256 as a
+// []string. The field is panel-only (stripped before the run-config reaches
+// xray-core via web/service/xray.go) but flows into share links so clients
+// can pin the server's certificate hash.
+func pinnedSha256List(tlsClientSettings any) ([]string, bool) {
+	raw, ok := searchKey(tlsClientSettings, "pinnedPeerCertSha256")
+	if !ok {
+		return nil, false
+	}
+	arr, ok := raw.([]any)
+	if !ok || len(arr) == 0 {
+		return nil, false
+	}
+	out := make([]string, 0, len(arr))
+	for _, v := range arr {
+		s, ok := v.(string)
+		if !ok || s == "" {
+			continue
+		}
+		out = append(out, s)
+	}
+	if len(out) == 0 {
+		return nil, false
 	}
+	return out, true
 }
 
 func applyShareRealityParams(stream map[string]any, params map[string]string) {

+ 4 - 0
web/translation/ar-EG.json

@@ -536,6 +536,10 @@
         "buildChain": "بناء السلسلة",
         "echKey": "ECH key",
         "echConfig": "تكوين ECH",
+        "pinnedPeerCertSha256": "SHA-256 لشهادة النظير المثبَّتة",
+        "pinnedPeerCertSha256Tip": "تجزئات SHA-256 المُرمَّزة بـ Base64 لشهادة النظير. للوحة فقط — لا تُكتب في إعدادات xray على الخادم، لكنها تُضمَّن في روابط المشاركة ليتمكَّن العملاء من تثبيت الشهادة.",
+        "pinnedPeerCertSha256Placeholder": "تجزئة (تجزئات) base64، مفصولة بفواصل",
+        "generateRandomPin": "إنشاء تجزئة عشوائية",
         "getNewEchCert": "احصل على شهادة ECH جديدة",
         "show": "عرض",
         "xver": "Xver",

+ 4 - 0
web/translation/en-US.json

@@ -536,6 +536,10 @@
         "buildChain": "Build Chain",
         "echKey": "ECH key",
         "echConfig": "ECH config",
+        "pinnedPeerCertSha256": "Pinned Peer Cert SHA-256",
+        "pinnedPeerCertSha256Tip": "Base64-encoded SHA-256 hashes of the peer certificate. Panel-only — not written to the server's xray config, but included in share links so clients can pin the certificate.",
+        "pinnedPeerCertSha256Placeholder": "base64 hash(es), comma-separated",
+        "generateRandomPin": "Generate random hash",
         "getNewEchCert": "Get New ECH Cert",
         "show": "Show",
         "xver": "Xver",

+ 4 - 0
web/translation/es-ES.json

@@ -536,6 +536,10 @@
         "buildChain": "Construir cadena",
         "echKey": "ECH key",
         "echConfig": "Config ECH",
+        "pinnedPeerCertSha256": "SHA-256 del cert. del par fijado",
+        "pinnedPeerCertSha256Tip": "Hashes SHA-256 codificados en Base64 del certificado del par. Solo en el panel — no se escribe en la config xray del servidor, pero se incluye en los enlaces para que los clientes puedan fijar el certificado.",
+        "pinnedPeerCertSha256Placeholder": "hash(es) base64, separados por comas",
+        "generateRandomPin": "Generar hash aleatorio",
         "getNewEchCert": "Obtener nuevo cert ECH",
         "show": "Mostrar",
         "xver": "Xver",

+ 4 - 0
web/translation/fa-IR.json

@@ -536,6 +536,10 @@
         "buildChain": "ساخت زنجیره",
         "echKey": "کلید ECH",
         "echConfig": "پیکربندی ECH",
+        "pinnedPeerCertSha256": "SHA-256 پین‌شدهٔ گواهی همتا",
+        "pinnedPeerCertSha256Tip": "هش‌های SHA-256 با کدگذاری Base64 از گواهی همتا. فقط در پنل — در پیکربندی xray سرور نوشته نمی‌شود، اما در لینک‌های اشتراک‌گذاری گنجانده می‌شود تا کلاینت‌ها بتوانند گواهی را پین کنند.",
+        "pinnedPeerCertSha256Placeholder": "هش(های) base64، با کاما جدا شوند",
+        "generateRandomPin": "تولید هش تصادفی",
         "getNewEchCert": "دریافت گواهی ECH جدید",
         "show": "نمایش",
         "xver": "Xver",

+ 4 - 0
web/translation/id-ID.json

@@ -536,6 +536,10 @@
         "buildChain": "Bangun rantai",
         "echKey": "ECH key",
         "echConfig": "Konfig ECH",
+        "pinnedPeerCertSha256": "SHA-256 Sertifikat Peer Tersemat",
+        "pinnedPeerCertSha256Tip": "Hash SHA-256 berenkode Base64 dari sertifikat peer. Hanya panel — tidak ditulis ke konfig xray server, tetapi disertakan dalam link berbagi agar klien dapat menyematkan sertifikat.",
+        "pinnedPeerCertSha256Placeholder": "hash base64, dipisah koma",
+        "generateRandomPin": "Hasilkan hash acak",
         "getNewEchCert": "Dapatkan sertifikat ECH baru",
         "show": "Tampilkan",
         "xver": "Xver",

+ 4 - 0
web/translation/ja-JP.json

@@ -536,6 +536,10 @@
         "buildChain": "Build Chain",
         "echKey": "ECH key",
         "echConfig": "ECH config",
+        "pinnedPeerCertSha256": "ピン留めピア証明書 SHA-256",
+        "pinnedPeerCertSha256Tip": "ピア証明書の Base64 エンコード SHA-256 ハッシュ。パネルのみ — サーバーの xray 設定には書き込まれませんが、共有リンクには含まれ、クライアントが証明書をピン留めできます。",
+        "pinnedPeerCertSha256Placeholder": "Base64 ハッシュ、カンマ区切り",
+        "generateRandomPin": "ランダムハッシュを生成",
         "getNewEchCert": "新しい ECH 証明書を取得",
         "show": "表示",
         "xver": "Xver",

+ 4 - 0
web/translation/pt-BR.json

@@ -536,6 +536,10 @@
         "buildChain": "Construir cadeia",
         "echKey": "ECH key",
         "echConfig": "Config ECH",
+        "pinnedPeerCertSha256": "SHA-256 do cert. do par fixado",
+        "pinnedPeerCertSha256Tip": "Hashes SHA-256 codificados em Base64 do certificado do par. Apenas no painel — não é gravado na config xray do servidor, mas é incluído nos links de compartilhamento para que clientes possam fixar o certificado.",
+        "pinnedPeerCertSha256Placeholder": "hash(es) base64, separados por vírgula",
+        "generateRandomPin": "Gerar hash aleatório",
         "getNewEchCert": "Obter novo certificado ECH",
         "show": "Mostrar",
         "xver": "Xver",

+ 4 - 0
web/translation/ru-RU.json

@@ -536,6 +536,10 @@
         "buildChain": "Build Chain",
         "echKey": "ECH key",
         "echConfig": "ECH config",
+        "pinnedPeerCertSha256": "Закреплённый SHA-256 сертификата пира",
+        "pinnedPeerCertSha256Tip": "SHA-256-хеши сертификата пира в кодировке Base64. Только для панели — не записывается в конфиг xray сервера, но включается в ссылки-приглашения, чтобы клиенты могли закрепить сертификат.",
+        "pinnedPeerCertSha256Placeholder": "Base64-хеш(и), через запятую",
+        "generateRandomPin": "Сгенерировать случайный хеш",
         "getNewEchCert": "Получить новый ECH-сертификат",
         "show": "Показать",
         "xver": "Xver",

+ 4 - 0
web/translation/tr-TR.json

@@ -536,6 +536,10 @@
         "buildChain": "Zincir oluştur",
         "echKey": "ECH key",
         "echConfig": "ECH yapılandırması",
+        "pinnedPeerCertSha256": "Sabitlenmiş Peer Sertifikası SHA-256",
+        "pinnedPeerCertSha256Tip": "Peer sertifikasının Base64 kodlu SHA-256 hash'leri. Sadece panel — sunucunun xray yapılandırmasına yazılmaz, ancak istemcilerin sertifikayı sabitleyebilmesi için paylaşım bağlantılarına eklenir.",
+        "pinnedPeerCertSha256Placeholder": "base64 hash(ler), virgülle ayrılmış",
+        "generateRandomPin": "Rastgele hash üret",
         "getNewEchCert": "Yeni ECH sertifikası al",
         "show": "Göster",
         "xver": "Xver",

+ 4 - 0
web/translation/uk-UA.json

@@ -536,6 +536,10 @@
         "buildChain": "Build Chain",
         "echKey": "ECH key",
         "echConfig": "ECH config",
+        "pinnedPeerCertSha256": "Закріплений SHA-256 сертифіката пира",
+        "pinnedPeerCertSha256Tip": "SHA-256-хеші сертифіката пира в кодуванні Base64. Лише панель — не записується в конфіг xray сервера, але додається до посилань спільного доступу, щоб клієнти могли закріпити сертифікат.",
+        "pinnedPeerCertSha256Placeholder": "Base64-хеш(і), через кому",
+        "generateRandomPin": "Згенерувати випадковий хеш",
         "getNewEchCert": "Отримати новий ECH-сертифікат",
         "show": "Показати",
         "xver": "Xver",

+ 4 - 0
web/translation/vi-VN.json

@@ -536,6 +536,10 @@
         "buildChain": "Tạo chuỗi",
         "echKey": "ECH key",
         "echConfig": "Cấu hình ECH",
+        "pinnedPeerCertSha256": "SHA-256 chứng chỉ peer đã ghim",
+        "pinnedPeerCertSha256Tip": "Hash SHA-256 mã hóa Base64 của chứng chỉ peer. Chỉ panel — không ghi vào cấu hình xray máy chủ, nhưng được đưa vào liên kết chia sẻ để client có thể ghim chứng chỉ.",
+        "pinnedPeerCertSha256Placeholder": "hash base64, phân tách bằng dấu phẩy",
+        "generateRandomPin": "Tạo hash ngẫu nhiên",
         "getNewEchCert": "Lấy chứng chỉ ECH mới",
         "show": "Hiện",
         "xver": "Xver",

+ 4 - 0
web/translation/zh-CN.json

@@ -536,6 +536,10 @@
         "buildChain": "构建证书链",
         "echKey": "ECH key",
         "echConfig": "ECH 配置",
+        "pinnedPeerCertSha256": "固定对端证书 SHA-256",
+        "pinnedPeerCertSha256Tip": "对端证书的 Base64 编码 SHA-256 哈希。仅面板使用 — 不写入服务器的 xray 配置,但会包含在分享链接中,以便客户端固定证书。",
+        "pinnedPeerCertSha256Placeholder": "base64 哈希,逗号分隔",
+        "generateRandomPin": "生成随机哈希",
         "getNewEchCert": "获取新 ECH 证书",
         "show": "显示",
         "xver": "Xver",

+ 4 - 0
web/translation/zh-TW.json

@@ -536,6 +536,10 @@
         "buildChain": "建立憑證鏈",
         "echKey": "ECH key",
         "echConfig": "ECH 設定",
+        "pinnedPeerCertSha256": "釘選對端憑證 SHA-256",
+        "pinnedPeerCertSha256Tip": "對端憑證的 Base64 編碼 SHA-256 雜湊。僅面板使用 — 不寫入伺服器的 xray 設定,但會包含在分享連結中,以便用戶端釘選憑證。",
+        "pinnedPeerCertSha256Placeholder": "base64 雜湊,以逗號分隔",
+        "generateRandomPin": "產生隨機雜湊",
         "getNewEchCert": "取得新 ECH 憑證",
         "show": "顯示",
         "xver": "Xver",