Перейти к содержанию

Приёмники сертификатов: haproxy, nginx, OpenVPN

Alatyr выдаёт сертификат — принимает его чужая программа: обратный прокси, VPN-сервер, веб-сервер. Эта страница — про их настройку, и всё здесь измерено на живых приёмниках, а не выведено из документации.

Главное, что стоит знать заранее: у приёмника есть ровно одна правильная конфигурация и несколько похожих на правильную. Похожие пропускают чужой сертификат или не замечают отзыва, и узнаётся это не от них — они молчат.

Если вы пришли сюда настраивать mTLS с нуля

Сначала Настройка mTLS: там порядок шагов, и первый из них — развести удостоверяющие центры целей. Проверка издателя, о которой вся эта страница, разделяет цели только если центры разные. Пока центр у user_mtls и wifi общий, издатель у обоих один, и ни одна конфигурация ниже не поможет.

Две вещи, которые приёмник обязан проверять

Цепочку доверия — иначе войдёт любой сертификат любого удостоверяющего центра.

Издателя листового сертификата — иначе войдёт ЛЮБОЙ сертификат вашей же PKI. Сертификат wifi (машинный, выдаётся молча) и сертификат user_mtls (пользовательский, защищён подтверждением владельца) выписаны одним корнем и несут один и тот же CN; если приёмник проверяет только цепочку, машинный сертификат откроет доступ к приложению. Это не теория: в матрице ниже такая конфигурация отвечает 200.

Плюс третья, про которую забывают: списки отзыва (CRL) нужны и на промежуточный удостоверяющий центр, и на корневой. С одним из двух отзыв не действует.

Проверка издателя и список отзыва — разные задачи, и подменять одну другой нельзя: первая разделяет цели, вторая закрывает отозванное. Кажется, что список отзыва заодно отсекает и машинный сертификат, — он отсекает его лишь потому, что для чужого промежуточного центра списка вы не дали. Стоит добавить и его список, и машинный сертификат снова пройдёт.

haproxy

Замерено на haproxy 3.0.27 (haproxy:3.0-alpine), сентябрь 2026.

frontend app_mtls
    # ca-file — ПОЛНАЯ цепочка: промежуточный УЦ пользовательской mTLS + корень.
    #
    # С одним промежуточным haproxy рвёт рукопожатие ВСЕМ, включая законный
    # сертификат: клиент видит ошибку TLS, до HTTP дело не доходит.
    bind :443 ssl crt /etc/haproxy/tls/app.pem \
         ca-file /etc/haproxy/tls/user-mtls-chain.pem verify required \
         crl-file /etc/haproxy/tls/mtls-crl.pem

    # ВОТ ЧТО РАЗДЕЛЯЕТ ЦЕЛИ. Без этих двух строк Wi-Fi-сертификат проходит
    # проверку цепочки целиком и попадает в приложение.
    acl issuer_ok ssl_c_i_dn(CN) -m str "Alatyr User mTLS Issuing CA"
    http-request deny deny_status 403 unless issuer_ok

    # `set-header` ЗАМЕЩАЕТ одноимённый заголовок клиента — подделать личность,
    # прислав свой X-MTLS-User, нельзя. Именно set-header, не add-header:
    # второй добавит значение рядом с клиентским, и приложение увидит оба.
    http-request set-header X-MTLS-User %[ssl_c_s_dn(CN)]

    default_backend app_upstream

nginx

Замерено на nginx 1.27.5, сентябрь 2026. Тот же блок с построчным разбором — в Настройке mTLS.

# ПОЛНАЯ цепочка: промежуточный УЦ пользовательской mTLS И корень.
# С одним промежуточным nginx отвечает 400 ВСЕМ, включая законный сертификат.
ssl_client_certificate /etc/nginx/tls/user-mtls-chain.pem;
ssl_verify_client on;
ssl_verify_depth 2;

# Списки отзыва — тоже ОБА, склеенные в один файл.
ssl_crl /etc/nginx/tls/alatyr-crl.pem;

location / {
    # ВОТ ЧТО РАЗДЕЛЯЕТ ЦЕЛИ. Формат RFC 2253: `CN=…`, без ведущей косой
    # черты. Со старой формой `/CN=…` сравнение не совпадёт никогда.
    if ($ssl_client_i_dn != "CN=Alatyr User mTLS Issuing CA") {
        return 403;
    }
    proxy_set_header X-MTLS-User $ssl_client_s_dn_cn;
    proxy_pass http://app_upstream;
}

Матрица замера: nginx 1.27.5 и haproxy 3.0.27

Одна PKI, одни и те же листья, живые приёмники. Пользовательский и Wi-Fi сертификаты несут один и тот же CN=alice@example.com — как в продукте.

Конфигурация приёмника законный user_mtls wifi (чужая цель) отозванный user_mtls
nginx, только промежуточный в ssl_client_certificate 400 400 400
nginx, промежуточный + корень 200 200 ← обход 200
nginx, + проверка издателя 200 403 200 ← отзыв не действует
nginx, + ssl_crl только промежуточного 400 400 400
nginx, + ssl_crl промежуточного и корня 200 400 400 ✅
haproxy, только промежуточный в ca-file отказ TLS отказ TLS отказ TLS
haproxy, промежуточный + корень 200 200 ← обход 200
haproxy, + ACL на издателя 200 403 200 ← отзыв не действует
haproxy, + crl-file только промежуточного отказ TLS отказ TLS отказ TLS
haproxy, + crl-file промежуточного и корня 200 отказ TLS отказ TLS ✅

Читается это так: рабочая конфигурация ровно одна на приёмник — последняя строка каждой половины. Остальные либо пропускают чужую цель, либо не действуют на отзыв, либо не работают вовсе.

Проверяйте обеими половинами: законным сертификатом И чужим. Конфигурация, проверенная только законным, неотличима от дырявой.

OpenVPN

Замерено на openvpn 2.6.14 (Debian), сентябрь 2026. Клиенты — openvpn с ключом, который не покидает чип: на Linux через tpm2-pkcs11, на Windows через cryptoapicert (ключ TPM в CNG), на macOS через интерфейс управления (ключ Secure Enclave).

Сервер, минимум:

# ca — цепочка УЦ цели vpn: промежуточный И корень. Для встроенного УЦ:
#   curl -s "$VAULT_ADDR/v1/pki_vpn/ca_chain" > /etc/openvpn/alatyr-vpn-chain.pem
ca   /etc/openvpn/alatyr-vpn-chain.pem
cert /etc/openvpn/server.crt
key  /etc/openvpn/server.key
remote-cert-tls client            # требовать у клиента EKU clientAuth
crl-verify /etc/openvpn/vpn-crl.pem   # без этой строки отзыв НЕ действует

# ВОТ ЧТО РАЗДЕЛЯЕТ ЦЕЛИ. Без проверки издателя сервер пускает сертификат
# ЛЮБОЙ цели того же корня — см. таблицу ниже.
script-security 2
tls-verify /etc/openvpn/alatyr-issuer-check.sh

/etc/openvpn/alatyr-issuer-check.sh (права 755):

#!/bin/sh
# OpenVPN зовёт скрипт на каждой глубине цепочки: $1 — глубина.
# На глубине 1 стоит издатель листа, его CN — в X509_1_CN.
[ "$1" != 1 ] && exit 0
[ "$X509_1_CN" = "Alatyr VPN Issuing CA" ] && exit 0
echo "issuer-check: отказ, издатель $X509_1_CN" >&2
exit 1

Почему «положить в ca только цепочку vpn» недостаточно

Кажется, что без промежуточного УЦ чужой цели сервер её сертификат не проверит. Это не так: клиент вправе приложить свой промежуточный сам (extra-certs), и OpenSSL построит цепочку через него до общего корня. Замер 2026-09-23, сервер с ca = цепочка vpn, клиент — сертификат цели user_mtls того же корня с приложенным промежуточным:

Сервер Итог
без tls-verify Initialization Sequence Completed — туннель поднят сертификатом ЧУЖОЙ цели
с проверкой издателя выше VERIFY SCRIPT ERROR: depth=1, CN=Alatyr User mTLS Issuing CA, туннеля нет
законный сертификат vpn VERIFY SCRIPT OK: depth=1, CN=Alatyr VPN Issuing CA, туннель поднят

Машинный сертификат wifi выдаётся без участия человека и тоже несёт clientAuth — без проверки издателя он открыл бы VPN.

crl-verify у OpenVPN проверяет только лист: строки VERIFY WARNING: depth=1 … unable to get certificate CRL в журнале — предупреждение, а не отказ. Достаточно списка отзыва промежуточного УЦ vpn ($VAULT_ADDR/v1/pki_vpn/crl/pem).

Клиент с ключом в чипе — строки, которые печатает alatyr-agent vpn на машине сотрудника (подробно — Настройка VPN):

# Linux
pkcs11-providers /usr/lib/x86_64-linux-gnu/pkcs11/libtpm2_pkcs11.so
pkcs11-id 'IBM/SW%20%20%20TPM/.../alatyr-agent/<CKA_ID>'
# Windows
cryptoapicert "THUMB:<отпечаток>"
# macOS
cert "<путь к сертификату>"
management "/var/run/alatyr-openvpn-<uid>.sock" unix
management-client-user <учётка сотрудника>
management-hold
management-external-key nopadding

Отзыв у OpenVPN действует без перезагрузки службы

Для прокси выше подмена файла CRL на диске сама по себе не делает ничего — нужен reload. У OpenVPN не так, и это измерено:

Шаг Что сделано Результат
1 подключение до отзыва CONNECTED,SUCCESS, сервер записал CN владельца
2 сертификат отозван, новый CRL положен на диск, служба не перезапускалась в журнале при следующем рукопожатии CRL: loaded 1 CRLs from file
3 подключение после отзыва VERIFY ERROR: depth=0, error=certificate revoked, туннель не поднялся
4 выдан новый сертификат на ТОТ ЖЕ ключ, больше ничего не менялось CONNECTED,SUCCESS снова

Шаг 4 — доказательство чистоты замера: клиент, ключ и pkcs11-id те же, изменился только сертификат, и исход перевернулся.

Вывод для оператора. Обновляйте файл CRL по расписанию — перезагрузка службы не нужна, существующие туннели не рвутся. Но верно и обратное: старый файл продолжает действовать молча. Просроченный CRL — это не «проверка выключена», а «проверка по устаревшим данным», и заметно это только по дате внутри файла.

Чеклист

  1. Удостоверяющие центры целей разведены на стороне Alatyr — шаг 1 настройки mTLS. Без этого пункты 2 и ниже не помогут.
  2. В ca-file / ssl_client_certificate / ca — вся цепочка: промежуточный и корень.
  3. Проверка издателя листа — отдельной строкой (у OpenVPN — скрипт tls-verify). Без неё разделения целей нет.
  4. CRL — на промежуточный и на корневой удостоверяющий центр.
  5. Обновление CRL по расписанию; для прокси — с перезагрузкой, для OpenVPN без неё.
  6. Заголовок личности — замещать (set-header), а не добавлять.
  7. Проверка приёмника — двумя сертификатами: законным и чужой цели.

Что дальше

  • Настройка mTLS — порядок шагов целиком: развести центры, разрешить выдачу, одобрить заявку, научить приложение проверять сертификат.
  • Отзыв доступа — готовый скрипт обновления списков отзыва по расписанию, с обеими точками монтирования.
  • Нюансы и пределы — чего механизм не обещает, включая то, почему emailProtection целей не разделяет.
  • Диагностика — указатель по типовым отказам.