diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index eed2ff8d..52f8d60d 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -119,6 +119,11 @@ function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string { return ''; } +function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): string { + if (Array.isArray(value)) return value.filter(Boolean).join(','); + return ''; +} + function applyExternalProxyTLSObj( externalProxy: ExternalProxyEntry | null | undefined, obj: Record, @@ -130,6 +135,8 @@ function applyExternalProxyTLSObj( if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint; const alpn = externalProxyAlpn(externalProxy.alpn); if (alpn.length > 0) obj.alpn = alpn; + const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256); + if (pins.length > 0) obj.pcs = pins; } export interface GenVmessLinkInput { @@ -270,6 +277,8 @@ function applyExternalProxyTLSParams( if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint); const alpn = externalProxyAlpn(externalProxy.alpn); if (alpn.length > 0) params.set('alpn', alpn); + const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256); + if (pins.length > 0) params.set('pcs', pins); } export interface GenVlessLinkInput { @@ -576,6 +585,7 @@ export interface GenHysteriaLinkInput { port?: number; remark?: string; clientAuth: string; + externalProxy?: ExternalProxyEntry | null; } // Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core @@ -616,6 +626,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string { port = inbound.port, remark = '', clientAuth, + externalProxy = null, } = input; if (inbound.protocol !== 'hysteria') return ''; @@ -635,6 +646,13 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string { if (tls.settings.pinnedPeerCertSha256.length > 0) { params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(',')); } + // An external-proxy entry can pin a different endpoint's certificate. + // Hysteria carries it as hex `pinSHA256` (not the `pcs` other protocols + // use), so coerce each entry through hysteriaPinHex like the main pin. + if (Array.isArray(externalProxy?.pinnedPeerCertSha256)) { + const epPins = externalProxy.pinnedPeerCertSha256.filter(Boolean).map(hysteriaPinHex); + if (epPins.length > 0) params.set('pinSHA256', epPins.join(',')); + } const udpMasks = stream.finalmask?.udp; if (Array.isArray(udpMasks)) { @@ -844,6 +862,7 @@ export function genLink(input: GenLinkInput): string { return genHysteriaLink({ inbound, address, port, remark, clientAuth: client.auth ?? '', + externalProxy, }); default: return ''; diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 1bd8e906..0c0db30a 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -207,6 +207,7 @@ export default function InboundFormModal({ sni: '', fingerprint: '', alpn: [], + pinnedPeerCertSha256: [], }]); } else { form.setFieldValue(['streamSettings', 'externalProxy'], []); diff --git a/frontend/src/pages/inbounds/form/transport/external-proxy.css b/frontend/src/pages/inbounds/form/transport/external-proxy.css new file mode 100644 index 00000000..91721aad --- /dev/null +++ b/frontend/src/pages/inbounds/form/transport/external-proxy.css @@ -0,0 +1,72 @@ +.ext-proxy-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ext-proxy-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 10px; + background: var(--ant-color-fill-quaternary); +} + +.ext-proxy-card__head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.ext-proxy-card__title { + font-weight: 600; + font-size: 13px; + opacity: 0.85; +} + +.ext-proxy-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ext-proxy-flabel { + font-size: 12px; + line-height: 1.2; + opacity: 0.65; +} + +.ext-proxy-grid { + display: grid; + gap: 8px; +} + +.ext-proxy-grid--dest { + grid-template-columns: 1fr 1.7fr 0.9fr; +} + +.ext-proxy-grid--tls { + grid-template-columns: 1fr 1fr 1fr; +} + +.ext-proxy-tls { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px dashed var(--ant-color-border-secondary); +} + +.ext-proxy-add { + margin-top: 10px; +} + +@media (max-width: 575px) { + .ext-proxy-grid--dest, + .ext-proxy-grid--tls { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/inbounds/form/transport/external-proxy.tsx b/frontend/src/pages/inbounds/form/transport/external-proxy.tsx index aefa7664..f8a354bf 100644 --- a/frontend/src/pages/inbounds/form/transport/external-proxy.tsx +++ b/frontend/src/pages/inbounds/form/transport/external-proxy.tsx @@ -1,16 +1,49 @@ +import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; -import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; +import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; -import { InputAddon } from '@/components/ui'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; +import './external-proxy.css'; + +const newEntry = () => ({ + forceTls: 'same', + dest: '', + port: 443, + remark: '', + sni: '', + fingerprint: '', + alpn: [], + pinnedPeerCertSha256: [], +}); + +function Field({ label, children }: { label: ReactNode; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + export default function ExternalProxyForm({ toggleExternalProxy, }: { toggleExternalProxy: (on: boolean) => void; }) { const { t } = useTranslation(); + const form = Form.useFormInstance(); + + const generateRandomPin = (name: number) => { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256']; + const current = (form.getFieldValue(path) as string[] | undefined) ?? []; + form.setFieldValue(path, [...current, hash]); + }; + return ( {on && ( - - {(fields, { add, remove }) => ( - <> - - - - - {fields.map((field) => ( -
- - - + + + + + + + + + + + + +
+ + + + + + + prev.streamSettings?.externalProxy?.[field.name]?.forceTls + !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls + } + > + {({ getFieldValue }) => { + const ft = getFieldValue([ + 'streamSettings', 'externalProxy', field.name, 'forceTls', + ]); + if (ft !== 'tls') return null; + return ( +
+
+ + + + + + + + ({ + value: a, + label: a, + }))} + /> + + +
+ + + + - - - - - - - - remove(field.name)}> - - - - - prev.streamSettings?.externalProxy?.[field.name]?.forceTls - !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls - } - > - {({ getFieldValue }) => { - const ft = getFieldValue([ - 'streamSettings', 'externalProxy', field.name, 'forceTls', - ]); - if (ft !== 'tls') return null; - return ( - - - - - - ({ - value: a, - label: a, - }))} - /> - - - ); - }} - -
- ))} -
- - )} -
+ + ))} + + + + )} + + )} ); diff --git a/frontend/src/schemas/protocols/stream/external-proxy.ts b/frontend/src/schemas/protocols/stream/external-proxy.ts index 1624fab3..a74c5967 100644 --- a/frontend/src/schemas/protocols/stream/external-proxy.ts +++ b/frontend/src/schemas/protocols/stream/external-proxy.ts @@ -22,5 +22,6 @@ export const ExternalProxyEntrySchema = z.object({ UtlsFingerprintSchema.optional(), ), alpn: z.array(AlpnSchema).optional(), + pinnedPeerCertSha256: z.array(z.string()).optional(), }); export type ExternalProxyEntry = z.infer; diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index a87323fd..91a7a7d8 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -196,6 +196,34 @@ describe('genHysteriaLink', () => { 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4', ); }); + + it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => { + const [, raw] = fixtures[0]; + const typed = InboundSchema.parse(raw); + const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0]; + + const link = genHysteriaLink({ + inbound: typed, + address: 'edge.example.com', + port: 8443, + remark: 'ep-pin', + clientAuth: client.auth, + externalProxy: { + forceTls: 'tls', + dest: 'edge.example.com', + port: 8443, + remark: 'ep-pin', + // base64 SHA-256 — must come out hex-normalized for Hysteria. + pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='], + }, + }); + + const url = new URL(link); + expect(url.searchParams.get('pinSHA256')).toBe( + 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4', + ); + expect(url.searchParams.has('pcs')).toBe(false); + }); }); describe('genWireguardLink + genWireguardConfig', () => { @@ -356,3 +384,49 @@ describe('genShadowsocksLink', () => { }); } }); + +describe('external proxy pinned cert (pcs)', () => { + const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!; + const typed = InboundSchema.parse(raw); + const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id; + + it('emits the external proxy pin list as pcs when forcing TLS', () => { + const link = genVlessLink({ + inbound: typed, + address: 'edge.example.com', + port: 8443, + forceTls: 'tls', + remark: 'ep-pin', + clientId, + externalProxy: { + forceTls: 'tls', + dest: 'edge.example.com', + port: 8443, + remark: 'ep-pin', + pinnedPeerCertSha256: ['aa11', 'bb22'], + }, + }); + + expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22'); + }); + + it('omits pcs when the external proxy forces security off', () => { + const link = genVlessLink({ + inbound: typed, + address: 'edge.example.com', + port: 8080, + forceTls: 'none', + remark: 'ep-none', + clientId, + externalProxy: { + forceTls: 'none', + dest: 'edge.example.com', + port: 8080, + remark: 'ep-none', + pinnedPeerCertSha256: ['aa11'], + }, + }); + + expect(new URL(link).searchParams.has('pcs')).toBe(false); + }); +}); diff --git a/sub/subService.go b/sub/subService.go index 23616931..baba974f 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -667,8 +667,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin } epRemark, _ := ep["remark"].(string) + epParams := cloneStringMap(params) + applyExternalProxyHysteriaParams(ep, epParams) + link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF)) - links = append(links, buildLinkWithParams(link, params, s.genRemark(inbound, email, epRemark))) + links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark))) } return strings.Join(links, "\n") } @@ -1017,7 +1020,7 @@ func buildVmessLink(obj map[string]any) string { func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any { newObj := map[string]any{} for key, value := range baseObj { - if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) { + if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "pcs")) { newObj[key] = value } } @@ -1037,6 +1040,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st if alpn, ok := externalProxyALPN(ep["alpn"]); ok { obj["alpn"] = alpn } + if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { + obj["pcs"] = joinAnyStrings(pins) + } } func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) { @@ -1052,6 +1058,29 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se if alpn, ok := externalProxyALPN(ep["alpn"]); ok { params["alpn"] = alpn } + if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { + params["pcs"] = joinAnyStrings(pins) + } +} + +// applyExternalProxyHysteriaParams overrides the cert pin for a single +// external-proxy entry on a Hysteria link. Hysteria carries the pin as a hex +// `pinSHA256` (not the `pcs` the URL-param protocols use), so each entry is +// coerced through hysteriaPinHex like the main pin. sni/fp/alpn are left as +// the inbound's own — Hysteria external proxies are typically alternate +// endpoints (port-hop / CDN) fronting the same certificate. +func applyExternalProxyHysteriaParams(ep map[string]any, params map[string]string) { + pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]) + if !ok { + return + } + hexPins := make([]string, 0, len(pins)) + for _, p := range pins { + if s, ok := p.(string); ok { + hexPins = append(hexPins, hysteriaPinHex(s)) + } + } + params["pinSHA256"] = strings.Join(hexPins, ",") } // cloneStreamForExternalProxy returns a shallow clone of stream with @@ -1096,6 +1125,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec if alpn, ok := externalProxyALPNList(ep["alpn"]); ok { tlsSettings["alpn"] = alpn } + if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { + settings, _ := tlsSettings["settings"].(map[string]any) + if settings == nil { + settings = map[string]any{} + tlsSettings["settings"] = settings + } + settings["pinnedPeerCertSha256"] = pins + } } func externalProxySNI(ep map[string]any) (string, bool) { @@ -1165,6 +1202,43 @@ func externalProxyALPNList(value any) ([]any, bool) { } } +// externalProxyPins extracts an external-proxy entry's pinnedPeerCertSha256 +// as a []any of non-empty strings. The []any element type matches what the +// JSON/Clash sub builders expect when reading the value back off the cloned +// stream's tlsSettings.settings. +func externalProxyPins(value any) ([]any, bool) { + switch v := value.(type) { + case []string: + out := make([]any, 0, len(v)) + for _, item := range v { + if item != "" { + out = append(out, item) + } + } + return out, len(out) > 0 + case []any: + out := make([]any, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out, len(out) > 0 + default: + return nil, false + } +} + +func joinAnyStrings(items []any) string { + parts := make([]string, 0, len(items)) + for _, item := range items { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + return strings.Join(parts, ",") +} + func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string { var links strings.Builder for index, externalProxy := range externalProxies { @@ -1204,8 +1278,8 @@ func buildLinkWithParams(link string, params map[string]string, fragment string) // buildLinkWithParamsAndSecurity is buildLinkWithParams plus an // external-proxy override: the `security` key in params is replaced with -// the supplied value, and TLS hint fields (alpn/sni/fp) are stripped when -// the override is `none`. +// the supplied value, and TLS hint fields (alpn/sni/fp/pcs) are stripped +// when the override is `none`. func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string { return appendQueryAndFragment(link, params, fragment, security, omitTLSFields) } @@ -1220,7 +1294,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec if securityOverride != "" && k == "security" { v = securityOverride } - if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") { + if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp" || k == "pcs") { continue } q.Set(k, v) diff --git a/sub/subService_test.go b/sub/subService_test.go index 0497746b..f7eceea8 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -617,6 +617,85 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) { } } +func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) { + params := map[string]string{"security": "tls"} + ep := map[string]any{ + "dest": "proxy.example.com", + "pinnedPeerCertSha256": []any{"aa11", "bb22"}, + } + + applyExternalProxyTLSParams(ep, params, "tls") + + if params["pcs"] != "aa11,bb22" { + t.Fatalf("pcs = %q, want aa11,bb22", params["pcs"]) + } +} + +func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) { + obj := map[string]any{"tls": "tls"} + ep := map[string]any{ + "dest": "proxy.example.com", + "pinnedPeerCertSha256": []any{"aa11"}, + } + + applyExternalProxyTLSObj(ep, obj, "tls") + + if obj["pcs"] != "aa11" { + t.Fatalf("pcs = %v, want aa11", obj["pcs"]) + } +} + +func TestApplyExternalProxyTLSToStream_SetsPinnedPeerCert(t *testing.T) { + stream := map[string]any{ + "security": "tls", + "tlsSettings": map[string]any{"serverName": "upstream.example.com"}, + } + ep := map[string]any{"dest": "edge.example.com", "pinnedPeerCertSha256": []any{"aa11", "bb22"}} + + working := cloneStreamForExternalProxy(stream) + applyExternalProxyTLSToStream(ep, working, "tls") + + ts := working["tlsSettings"].(map[string]any) + settings, _ := ts["settings"].(map[string]any) + pins, ok := settings["pinnedPeerCertSha256"].([]any) + if !ok || len(pins) != 2 || pins[0] != "aa11" || pins[1] != "bb22" { + t.Fatalf("pinnedPeerCertSha256 = %v, want [aa11 bb22]", settings["pinnedPeerCertSha256"]) + } +} + +func TestApplyExternalProxyHysteriaParams_PinIsHexNormalized(t *testing.T) { + // base64 SHA-256 pin must come out as bare lowercase hex for Hysteria's + // pinSHA256, which other (pcs) protocols leave untouched. + params := map[string]string{"security": "tls", "sni": "server.example.com"} + ep := map[string]any{ + "dest": "edge.example.com", + "pinnedPeerCertSha256": []any{"yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ="}, + } + + applyExternalProxyHysteriaParams(ep, params) + + if params["pinSHA256"] != "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" { + t.Fatalf("pinSHA256 = %q, want hex-normalized pin", params["pinSHA256"]) + } + if _, ok := params["pcs"]; ok { + t.Fatalf("pcs must not be set for Hysteria, got %v", params) + } + if params["sni"] != "server.example.com" { + t.Fatalf("sni = %q, want inbound sni preserved (no override for Hysteria)", params["sni"]) + } +} + +func TestApplyExternalProxyHysteriaParams_NoPinLeavesMainPin(t *testing.T) { + params := map[string]string{"security": "tls", "pinSHA256": "deadbeef"} + ep := map[string]any{"dest": "edge.example.com"} + + applyExternalProxyHysteriaParams(ep, params) + + if params["pinSHA256"] != "deadbeef" { + t.Fatalf("pinSHA256 = %q, want main pin preserved when proxy has none", params["pinSHA256"]) + } +} + func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) { params := map[string]string{ "security": "none", diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index 84865320..26f9fe5d 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -547,6 +547,7 @@ "cwndMultiplier": "معامل CWND", "maxSendingWindow": "أقصى نافذة إرسال", "externalProxy": "وكيل خارجي", + "forceTls": "فرض TLS", "sniPlaceholder": "SNI (افتراضياً host)", "fingerprint": "بصمة", "defaultOption": "افتراضي", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 34b67d3b..323d3466 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -547,6 +547,7 @@ "cwndMultiplier": "CWND Multiplier", "maxSendingWindow": "Max Sending Window", "externalProxy": "External Proxy", + "forceTls": "Force TLS", "sniPlaceholder": "SNI (defaults to host)", "fingerprint": "Fingerprint", "defaultOption": "Default", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 4d757f01..2c34785b 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -547,6 +547,7 @@ "cwndMultiplier": "Multiplicador CWND", "maxSendingWindow": "Máx. ventana de envío", "externalProxy": "Proxy externo", + "forceTls": "Forzar TLS", "sniPlaceholder": "SNI (por defecto = host)", "fingerprint": "Fingerprint", "defaultOption": "Por defecto", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index e316ec33..9be8418c 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -547,6 +547,7 @@ "cwndMultiplier": "ضریب CWND", "maxSendingWindow": "حداکثر پنجره ارسال", "externalProxy": "پراکسی خارجی", + "forceTls": "اجبار TLS", "sniPlaceholder": "SNI (پیش‌فرض همان host)", "fingerprint": "اثرانگشت", "defaultOption": "پیش‌فرض", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index eb3dcc38..71335545 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -547,6 +547,7 @@ "cwndMultiplier": "Pengganda CWND", "maxSendingWindow": "Maks. jendela pengiriman", "externalProxy": "Proxy eksternal", + "forceTls": "Paksa TLS", "sniPlaceholder": "SNI (default = host)", "fingerprint": "Fingerprint", "defaultOption": "Default", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 3d2e6217..f07edafd 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -547,6 +547,7 @@ "cwndMultiplier": "CWND 倍率", "maxSendingWindow": "最大送信ウィンドウ", "externalProxy": "外部プロキシ", + "forceTls": "TLS を強制", "sniPlaceholder": "SNI (デフォルトは host)", "fingerprint": "Fingerprint", "defaultOption": "デフォルト", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 2f0540e7..acb86e5c 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -547,6 +547,7 @@ "cwndMultiplier": "Multiplicador CWND", "maxSendingWindow": "Máx. janela de envio", "externalProxy": "Proxy externo", + "forceTls": "Forçar TLS", "sniPlaceholder": "SNI (padrão = host)", "fingerprint": "Fingerprint", "defaultOption": "Padrão", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index 43d341b1..335b76d7 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -547,6 +547,7 @@ "cwndMultiplier": "Множитель CWND", "maxSendingWindow": "Макс. окно отправки", "externalProxy": "External Proxy", + "forceTls": "Принудительный TLS", "sniPlaceholder": "SNI (по умолчанию = host)", "fingerprint": "Fingerprint", "defaultOption": "По умолчанию", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index d3c1a084..f36531d0 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -547,6 +547,7 @@ "cwndMultiplier": "CWND çarpanı", "maxSendingWindow": "Maks. gönderme penceresi", "externalProxy": "Harici proxy", + "forceTls": "TLS zorla", "sniPlaceholder": "SNI (varsayılan host)", "fingerprint": "Fingerprint", "defaultOption": "Varsayılan", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index a86ffbe5..cf568040 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -547,6 +547,7 @@ "cwndMultiplier": "Множник CWND", "maxSendingWindow": "Макс. вікно відправки", "externalProxy": "External Proxy", + "forceTls": "Примусовий TLS", "sniPlaceholder": "SNI (за замовчуванням = host)", "fingerprint": "Fingerprint", "defaultOption": "За замовчуванням", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 1daf4967..d11bf123 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -547,6 +547,7 @@ "cwndMultiplier": "Hệ số CWND", "maxSendingWindow": "Cửa sổ gửi tối đa", "externalProxy": "Proxy ngoài", + "forceTls": "Bắt buộc TLS", "sniPlaceholder": "SNI (mặc định = host)", "fingerprint": "Fingerprint", "defaultOption": "Mặc định", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 0e0f6295..daa7c187 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -547,6 +547,7 @@ "cwndMultiplier": "CWND 倍数", "maxSendingWindow": "最大发送窗口", "externalProxy": "外部代理", + "forceTls": "强制 TLS", "sniPlaceholder": "SNI (默认为 host)", "fingerprint": "指纹", "defaultOption": "默认", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 9aed7b5e..09538d0b 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -547,6 +547,7 @@ "cwndMultiplier": "CWND 倍數", "maxSendingWindow": "最大發送視窗", "externalProxy": "外部代理", + "forceTls": "強制 TLS", "sniPlaceholder": "SNI (預設為 host)", "fingerprint": "指紋", "defaultOption": "預設",