Browse Source

feat(inbounds): per-proxy Pinned Peer Cert SHA-256 + labeled External Proxy form

Redesign the Add Inbound -> Stream External Proxy section into labeled per-entry cards (Force TLS / Host / Port / Remark and, under TLS, SNI / Fingerprint / ALPN) and add a Pinned Peer Cert SHA-256 field with a generate-random-hash button to each entry.

The pin flows end to end into share links: pcs for vmess/vless/trojan/ss (stripped when a proxy forces security off) and the hex-normalized pinSHA256 for Hysteria. JSON and Clash subscriptions emit the native pinnedPeerCertSha256 / pin-sha256 via the cloned stream. Adds the forceTls label across all 13 locales plus frontend and Go tests.
MHSanaei 11 hours ago
parent
commit
e7c11c913a

+ 19 - 0
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<string, unknown>,
@@ -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 '';

+ 1 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -207,6 +207,7 @@ export default function InboundFormModal({
         sni: '',
         fingerprint: '',
         alpn: [],
+        pinnedPeerCertSha256: [],
       }]);
     } else {
       form.setFieldValue(['streamSettings', 'externalProxy'], []);

+ 72 - 0
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;
+  }
+}

+ 164 - 97
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 (
+    <div className="ext-proxy-field">
+      <span className="ext-proxy-flabel">{label}</span>
+      {children}
+    </div>
+  );
+}
+
 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 (
     <Form.Item
       noStyle
@@ -29,104 +62,138 @@ export default function ExternalProxyForm({
               <Switch checked={on} onChange={toggleExternalProxy} />
             </Form.Item>
             {on && (
-              <Form.List name={['streamSettings', 'externalProxy']}>
-                {(fields, { add, remove }) => (
-                  <>
-                    <Form.Item label=" " colon={false}>
+              <Form.Item wrapperCol={{ span: 24 }}>
+                <Form.List name={['streamSettings', 'externalProxy']}>
+                  {(fields, { add, remove }) => (
+                    <>
+                      <div className="ext-proxy-list">
+                        {fields.map((field, idx) => (
+                          <div key={field.key} className="ext-proxy-card">
+                            <div className="ext-proxy-card__head">
+                              <span className="ext-proxy-card__title">#{idx + 1}</span>
+                              <Button
+                                size="small"
+                                type="text"
+                                danger
+                                icon={<DeleteOutlined />}
+                                onClick={() => remove(field.name)}
+                              />
+                            </div>
+                            <div className="ext-proxy-grid ext-proxy-grid--dest">
+                              <Field label={t('pages.inbounds.form.forceTls')}>
+                                <Form.Item name={[field.name, 'forceTls']} noStyle>
+                                  <Select
+                                    style={{ width: '100%' }}
+                                    options={[
+                                      { value: 'same', label: t('pages.inbounds.same') },
+                                      { value: 'none', label: t('none') },
+                                      { value: 'tls', label: 'TLS' },
+                                    ]}
+                                  />
+                                </Form.Item>
+                              </Field>
+                              <Field label={t('host')}>
+                                <Form.Item name={[field.name, 'dest']} noStyle>
+                                  <Input placeholder={t('host')} />
+                                </Form.Item>
+                              </Field>
+                              <Field label={t('pages.inbounds.port')}>
+                                <Form.Item name={[field.name, 'port']} noStyle>
+                                  <InputNumber style={{ width: '100%' }} min={1} max={65535} />
+                                </Form.Item>
+                              </Field>
+                            </div>
+                            <Field label={t('pages.inbounds.remark')}>
+                              <Form.Item name={[field.name, 'remark']} noStyle>
+                                <Input placeholder={t('pages.inbounds.remark')} />
+                              </Form.Item>
+                            </Field>
+                            <Form.Item
+                              noStyle
+                              shouldUpdate={(prev, curr) =>
+                                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 (
+                                  <div className="ext-proxy-tls">
+                                    <div className="ext-proxy-grid ext-proxy-grid--tls">
+                                      <Field label="SNI">
+                                        <Form.Item name={[field.name, 'sni']} noStyle>
+                                          <Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
+                                        </Form.Item>
+                                      </Field>
+                                      <Field label={t('pages.inbounds.form.fingerprint')}>
+                                        <Form.Item name={[field.name, 'fingerprint']} noStyle>
+                                          <Select
+                                            style={{ width: '100%' }}
+                                            placeholder={t('pages.inbounds.form.fingerprint')}
+                                            options={[
+                                              { value: '', label: t('pages.inbounds.form.defaultOption') },
+                                              ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
+                                                value: fp,
+                                                label: fp,
+                                              })),
+                                            ]}
+                                          />
+                                        </Form.Item>
+                                      </Field>
+                                      <Field label="ALPN">
+                                        <Form.Item name={[field.name, 'alpn']} noStyle>
+                                          <Select
+                                            mode="multiple"
+                                            style={{ width: '100%' }}
+                                            placeholder="ALPN"
+                                            options={Object.values(ALPN_OPTION).map((a) => ({
+                                              value: a,
+                                              label: a,
+                                            }))}
+                                          />
+                                        </Form.Item>
+                                      </Field>
+                                    </div>
+                                    <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
+                                      <Space.Compact block>
+                                        <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
+                                          <Select
+                                            mode="tags"
+                                            tokenSeparators={[',', ' ']}
+                                            placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
+                                            style={{ width: 'calc(100% - 32px)' }}
+                                          />
+                                        </Form.Item>
+                                        <Button
+                                          icon={<ReloadOutlined />}
+                                          onClick={() => generateRandomPin(field.name)}
+                                          title={t('pages.inbounds.form.generateRandomPin')}
+                                        />
+                                      </Space.Compact>
+                                    </Field>
+                                  </div>
+                                );
+                              }}
+                            </Form.Item>
+                          </div>
+                        ))}
+                      </div>
                       <Button
-                        size="small"
-                        type="primary"
-                        onClick={() => add({
-                          forceTls: 'same',
-                          dest: '',
-                          port: 443,
-                          remark: '',
-                          sni: '',
-                          fingerprint: '',
-                          alpn: [],
-                        })}
+                        className="ext-proxy-add"
+                        block
+                        type="dashed"
+                        icon={<PlusOutlined />}
+                        onClick={() => add(newEntry())}
                       >
-                        <PlusOutlined />
+                        {t('add')}
                       </Button>
-                    </Form.Item>
-                    <Form.Item wrapperCol={{ span: 24 }}>
-                      {fields.map((field) => (
-                        <div key={field.key} style={{ margin: '8px 0' }}>
-                          <Space.Compact block>
-                            <Form.Item name={[field.name, 'forceTls']} noStyle>
-                              <Select
-                                style={{ width: '20%' }}
-                                options={[
-                                  { value: 'same', label: t('pages.inbounds.same') },
-                                  { value: 'none', label: t('none') },
-                                  { value: 'tls', label: 'TLS' },
-                                ]}
-                              />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'dest']} noStyle>
-                              <Input style={{ width: '30%' }} placeholder={t('host')} />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'port']} noStyle>
-                              <InputNumber style={{ width: '15%' }} min={1} max={65535} />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'remark']} noStyle>
-                              <Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
-                            </Form.Item>
-                            <InputAddon onClick={() => remove(field.name)}>
-                              <MinusOutlined />
-                            </InputAddon>
-                          </Space.Compact>
-                          <Form.Item
-                            noStyle
-                            shouldUpdate={(prev, curr) =>
-                              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 (
-                                <Space.Compact style={{ marginTop: 6 }} block>
-                                  <Form.Item name={[field.name, 'sni']} noStyle>
-                                    <Input style={{ width: '30%' }} placeholder={t('pages.inbounds.form.sniPlaceholder')} />
-                                  </Form.Item>
-                                  <Form.Item name={[field.name, 'fingerprint']} noStyle>
-                                    <Select
-                                      style={{ width: '30%' }}
-                                      placeholder={t('pages.inbounds.form.fingerprint')}
-                                      options={[
-                                        { value: '', label: t('pages.inbounds.form.defaultOption') },
-                                        ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
-                                          value: fp,
-                                          label: fp,
-                                        })),
-                                      ]}
-                                    />
-                                  </Form.Item>
-                                  <Form.Item name={[field.name, 'alpn']} noStyle>
-                                    <Select
-                                      mode="multiple"
-                                      style={{ width: '40%' }}
-                                      placeholder="ALPN"
-                                      options={Object.values(ALPN_OPTION).map((a) => ({
-                                        value: a,
-                                        label: a,
-                                      }))}
-                                    />
-                                  </Form.Item>
-                                </Space.Compact>
-                              );
-                            }}
-                          </Form.Item>
-                        </div>
-                      ))}
-                    </Form.Item>
-                  </>
-                )}
-              </Form.List>
+                    </>
+                  )}
+                </Form.List>
+              </Form.Item>
             )}
           </>
         );

+ 1 - 0
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<typeof ExternalProxyEntrySchema>;

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

+ 79 - 5
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)

+ 79 - 0
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",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "معامل CWND",
         "maxSendingWindow": "أقصى نافذة إرسال",
         "externalProxy": "وكيل خارجي",
+        "forceTls": "فرض TLS",
         "sniPlaceholder": "SNI (افتراضياً host)",
         "fingerprint": "بصمة",
         "defaultOption": "افتراضي",

+ 1 - 0
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",

+ 1 - 0
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",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "ضریب CWND",
         "maxSendingWindow": "حداکثر پنجره ارسال",
         "externalProxy": "پراکسی خارجی",
+        "forceTls": "اجبار TLS",
         "sniPlaceholder": "SNI (پیش‌فرض همان host)",
         "fingerprint": "اثرانگشت",
         "defaultOption": "پیش‌فرض",

+ 1 - 0
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",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "CWND 倍率",
         "maxSendingWindow": "最大送信ウィンドウ",
         "externalProxy": "外部プロキシ",
+        "forceTls": "TLS を強制",
         "sniPlaceholder": "SNI (デフォルトは host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "デフォルト",

+ 1 - 0
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",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "Множитель CWND",
         "maxSendingWindow": "Макс. окно отправки",
         "externalProxy": "External Proxy",
+        "forceTls": "Принудительный TLS",
         "sniPlaceholder": "SNI (по умолчанию = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "По умолчанию",

+ 1 - 0
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",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "Множник CWND",
         "maxSendingWindow": "Макс. вікно відправки",
         "externalProxy": "External Proxy",
+        "forceTls": "Примусовий TLS",
         "sniPlaceholder": "SNI (за замовчуванням = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "За замовчуванням",

+ 1 - 0
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",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "CWND 倍数",
         "maxSendingWindow": "最大发送窗口",
         "externalProxy": "外部代理",
+        "forceTls": "强制 TLS",
         "sniPlaceholder": "SNI (默认为 host)",
         "fingerprint": "指纹",
         "defaultOption": "默认",

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

@@ -547,6 +547,7 @@
         "cwndMultiplier": "CWND 倍數",
         "maxSendingWindow": "最大發送視窗",
         "externalProxy": "外部代理",
+        "forceTls": "強制 TLS",
         "sniPlaceholder": "SNI (預設為 host)",
         "fingerprint": "指紋",
         "defaultOption": "預設",