Mihomo YAML теряет Pinned Peer Cert SHA256 и Verify Peer Cert By Name

Версия панели

3.3.2 (проблема актуальна и на 3.4.2 — проверил changelog между 3.3.2 и 3.4.2, релевантных коммитов нет)

Суть проблемы

MihomoGeneratorService.buildHysteria2TlsFields() (и аналогичная ветка case 'tls': в applySecurityFields() для остальных протоколов) использует host.securityOptions.pinnedPeerCertSha256 как булев флаг, а не прокидывает его значение. В итоге у любого хоста с заполненным пином сертификата и security: tls в сгенерированном Mihomo-конфиге получается skip-cert-verify: true, а само значение пина никуда не попадает — оно просто теряется.

Причина (найдено в исходниках)

libs/.../mihomo-generator.service.ts:

private buildHysteria2TlsFields(host: ResolvedProxyConfig): Record<string, unknown> {
    if (host.security !== 'tls') return {};
    const { serverName, pinnedPeerCertSha256, fingerprint, alpn } = host.securityOptions;

    return {
        ...(serverName && { sni: serverName }),
        ...(pinnedPeerCertSha256 && { 'skip-cert-verify': true }),   // <-- баг: значение отбрасывается, используется только его наличие
        ...(fingerprint && { 'client-fingerprint': fingerprint }),
        ...(alpn && { alpn: alpn.split(',') }),
    };
}

Та же логика есть в applySecurityFields() для обычной ветки case 'tls': (не Reality):

// allowInsecure
if (opts.pinnedPeerCertSha256 && node.type !== 'ss') {
    node['skip-cert-verify'] = true;
}

Обратите внимание на два разных поля с похожими именами в securityOptions:

  • fingerprint — это профиль имитации uTLS ClientHello (chrome/firefox/safari). Корректно маппится в client-fingerprint у Mihomo.
  • pinnedPeerCertSha256 — это собственно пин сертификата. Его значение нигде не прокидывается дальше — используется только как проверка “заполнено/не заполнено” для отключения проверки.

То есть проблема не специфична для Hysteria2 — она касается любого хоста с security: tls (не Reality) и заполненным пином. Заметнее всего это именно на Hysteria2-хостах, потому что там обычно самоподписанные сертификаты именно с tls-секьюрити (не reality), где ветка с reality-opts (которая пин вообще не трогает ни в каком виде) не применяется.

Ожидаемое поведение

В официальной документации Mihomo (https://wiki.metacubex.one) для пиннинга TLS/Hysteria2 указано поле fingerprint:

fingerprint: xxxx # openssl x509 -noout -fingerprint -sha256 -inform pem -in yourcert.pem

Это hex SHA-256 хэш всего leaf-сертификата целиком.

Ожидаемый вывод:

fingerprint: <hex-sha256-полного-сертификата>
skip-cert-verify: false

Фактическое поведение

- name: HYSTERIA443-LX-N1
  type: hysteria2
  server: node1.example.com
  port: 443
  password: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  obfs: gecko
  obfs-password: xxxxxxxxxxxxxxxxxxxxxx
  obfs-min-packet-size: 512
  obfs-max-packet-size: 1200
  sni: my.playstation.com
  skip-cert-verify: true
  alpn:
    - h3
  client-fingerprint: chrome
  udp: true

Поле fingerprint: отсутствует в выводе полностью, хотя “Pinned Peer Cert SHA256” у этого хоста заполнено. Проверка TLS-сертификата фактически полностью отключена для этого прокси, что убирает защиту от MITM на уровне TLS.

Дополнительный контекст

Это не случай, когда фича вообще не была реализована — pinnedPeerCertSha256 был корректно прокинут для Xray JSON generator (см. PR #180, замёржен коммитом d3a5ac78, упомянутым в changelog 2.8.0 как “Replace allowInsecure with pinnedPeerCertSha256 in host commands and schemas”). В списке изменённых файлов того PR фигурирует только xray-json.generator.service.tsmihomo-generator.service.ts при этом обновлён не был и до сих пор содержит старую логику времён allowInsecure (использует сам факт наличия пина как флаг, а не его значение).

Предлагаемый фикс

И в buildHysteria2TlsFields(), и в ветке case 'tls': внутри applySecurityFields() нужно прокидывать реальное значение пина в поле fingerprint у Mihomo, а не использовать его наличие как флаг для skip-cert-verify:

...(pinnedPeerCertSha256 && { fingerprint: pinnedPeerCertSha256 }),

…и оставлять skip-cert-verify: true только как fallback, если пин вообще не задан — в идеале ещё и с явным предупреждением в UI панели в этом случае, поскольку сейчас это тихо отключает важную проверку безопасности.

Про формат значения — по подтверждению из комьюнити-форума (по поводу параметра pinSHA256 в URI-схеме Hysteria2), панель намеренно не модифицирует и не кодирует значения, введённые в поля хоста — что хранится, то и уходит наружу как есть, а корректный формат под конкретного клиента — забота пользователя. Тот же принцип стоит применить и здесь: просто прокидывать pinnedPeerCertSha256 как есть в поле fingerprint у Mihomo, без какой-либо конвертации. Пользователям, которые пиннят конкретно под Mihomo, нужно будет хранить в этом поле hex-хэш всего сертификата (а не base64 SPKI-хэш, который используют некоторые другие ядра) — точно так же, как и сейчас им приходится подбирать правильный формат под целевого клиента.

Дополнение: та же функция теряет ещё и verifyPeerCertByName

Пока разбирался с проблемой выше, нашёл рядом ещё одну недостающую деталь в той же функции buildHysteria2TlsFields().

Функция деструктурирует только:

const { serverName, pinnedPeerCertSha256, fingerprint, alpn } = host.securityOptions;

Поля verifyPeerCertByName здесь нет вообще — оно не используется в этой функции ни в каком виде. Это Xray-core-поле (в панели — “Verify Peer Cert By Name” в настройках хоста), нужное, когда subject/SAN самоподписанного сертификата не совпадает с отправляемым SNI. У Mihomo для этого сценария есть прямой аналог, задокументированный прямо рядом с fingerprint:

name-cert-verify: example.com

Раз это поле нигде не подключено, любое значение из “Verify Peer Cert By Name” для хостов с security: tls (в т.ч. Hysteria2) тихо теряется при генерации Mihomo-подписки — точно так же, как и пин из основной проблемы этого issue.

Предлагаемый фикс — добавить в buildHysteria2TlsFields() (и в общую ветку case 'tls': в applySecurityFields(), там то же самое отсутствует):

...(host.securityOptions.verifyPeerCertByName && { 'name-cert-verify': host.securityOptions.verifyPeerCertByName }),

Раз причина та же самая (недокрученный до конца перенос TLS-полей на Mihomo-генератор из PR #180) — решил дописать сюда же, а не заводить отдельный issue.

Проблему решил пробросом нужных значений через “Маппер”. Вопрос закрыт.