Версия панели
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.ts — mihomo-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.