SSD Nodes Learn Hosting plans →
Руководства Matt ConnorАвтор: Matt Connor

Как создать самоподписанный сертификат на Ubuntu 24.04

Создайте TLS-сертификат с SAN, который принимает Chrome. В статье приведена одна команда openssl, настройка nginx и добавление сертификата в доверенные без использования curl -k.

Что вы создаете

Самоподписанный TLS-сертификат, который корректно принимается современными браузерами и клиентами, правильные subjectAltName, корректные права доступа к ключам, интеграция в nginx или Apache, а также то, что пропускает почти каждое руководство: настройка доверия клиентов к сертификату, чтобы не нажимать на предупреждения и не вшивать curl -k в скрипты навсегда. В конце — создание приватного CA из пяти команд для случаев, когда один внутренний сервис превращается в шесть.

Сначала — решение, так как самоподписанный сертификат является подходящим инструментом гораздо реже, чем его используют. Если сервис доступен из публичного интернета по реальному DNS-имени, прекратите чтение и получите бесплатный сертификат Let's Encrypt с помощью certbot для nginx или аналогичное решение для Apache. Это бесплатно, сертификат обновляется автоматически, а основные браузеры и операционные системы уже доверяют ему. Самоподписанный сертификат на публичном сайте приучает пользователей игнорировать предупреждения безопасности, что хуже, чем использование обычного HTTP.

Самоподписанный сертификат — подходящий инструмент, когда публичный интернет не задействован: панель администратора, привязанная к адресу WireGuard-туннеля на вашем VPS, тестовый сервер в частной сети, трафик между бэкендами, устройство в домашней лаборатории или замена временного сертификата, который Webmin генерирует для себя на порту 10000. Let's Encrypt в любом случае не может выпустить сертификат для 10.8.0.1 или git.internal.lan, ни один публичный CA не включит частный IP или вымышленный TLD в сертификат. Для таких имен вы сами являетесь CA.

Все инструкции ниже выполняются на чистой системе Ubuntu 24.04, в которой поставляется OpenSSL 3.0.x (используйте openssl version для проверки). Для выполнения этих действий доступ в интернет не требуется; все работает в изолированной среде.

Почему старая однострочная команда создает сертификаты, которые отклоняет Chrome

Команда, которую предлагают все руководства, написанные до 2017 года, выглядит так:

# Do not run this: shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout selfsigned.key -out selfsigned.crt

Она задает ряд интерактивных вопросов, помещает имя вашего хоста в поле Common Name и создает сертификат без расширения subjectAltName. Такой сертификат неработоспособен с самого начала. Chrome перестал считывать Common Name в версии 58, еще в апреле 2017 года; RFC 2818 признал сопоставление по CN устаревшим еще в 2000 году, а Firefox и Safari ведут себя аналогичным образом. curl и модуль ssl в Python все еще используют Common Name в качестве резервного варианта, если в сертификате полностью отсутствует SAN. Именно поэтому сертификат без SAN может пройти быструю проверку curl, но при этом не работать ни в одном браузере. Для браузера сертификат идентифицирует сервер только через расширение SAN, и браузер сообщает об этом именно такими словами:

NET::ERR_CERT_COMMON_NAME_INVALID

This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.

Никакие манипуляции с хранилищем доверенных сертификатов не исправят эту ошибку, так как сертификат фактически никого не идентифицирует. Если вы сейчас видите NET::ERR_CERT_COMMON_NAME_INVALID, значит, в вашем сертификате отсутствует SAN (или указан неверный), и вам необходимо выпустить новый. К счастью, исправление выполняется одной командой.

Создание сертификата, принимаемого браузерами: одна команда

В OpenSSL начиная с версии 1.1.1 появился флаг -addext, поэтому больше не нужно использовать сложные конфигурационные файлы, как это делалось в старых руководствах для добавления SAN. На Ubuntu 24.04:

sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
  -keyout /etc/ssl/private/git.internal.key \
  -out /etc/ssl/certs/git.internal.crt \
  -subj "/CN=git.internal.lan" \
  -addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"

Что делает каждый флаг:

  • -x509 создает самоподписанный сертификат напрямую, вместо запроса на подпись.
  • -newkey rsa:4096 генерирует новый ключ на этом же этапе. RSA 4096 поддерживается любыми устаревшими клиентами; если все подключающиеся клиенты современные, -newkey ec -pkeyopt ec_paramgen_curve:P-256 будет меньше и быстрее.
  • -noenc — это запись для OpenSSL 3.x, заменяющая старый -nodes: отсутствие парольной фразы на ключе. Оба варианта работают. Ключ с парольной фразой заставит Nginx ожидать ввода при каждой загрузке, поэтому для серверного ключа рекомендуется использовать этот флаг.
  • -days 730, два года; подробнее об этом числе в разделе об истечении срока действия.
  • -subj позволяет ответить на интерактивные вопросы в одной строке. CN сейчас носит косметический характер, но все же укажите в нем основное имя; некоторые инструменты отображают именно его.
  • -addext "subjectAltName=..." — основной флаг. Перечислите все имена и все IP-адреса, которые будут вводить клиенты: DNS: записей для имен хостов (допускаются маски, такие как DNS:*.internal.lan), IP: записей для адресов. Если кто-либо будет переходить по адресу https://10.8.0.1, запись IP:10.8.0.1 должна присутствовать; наличие в SAN только DNS-имен приведет к ошибке NET::ERR_CERT_COMMON_NAME_INVALID.

Перед настройкой убедитесь, что SAN действительно добавлен:

openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltName

Правильный вывод:

X509v3 Subject Alternative Name:
    DNS:git.internal.lan, IP Address:10.8.0.1

Если вместо этого выводится No extensions in certificate, значит, в сертификате нет SAN и браузеры его отклонят. В этом случае пересоздайте сертификат, а не продолжайте работу.

Защита ключа

Приватный ключ, доступный для чтения любому пользователю в системе, не является приватным. В стандартной установке Ubuntu 24.04 каталог /etc/ssl/private уже имеет права 700 root:root, поэтому только root может просматривать его содержимое; права 710 root:ssl-cert устанавливаются только в том случае, если был установлен пакет ssl-cert (рекомендуется пакетом apache2). В любом случае, установите права на сам файл явно:

sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.key

nginx и Apache считывают сертификаты от имени root перед понижением привилегий, поэтому режим root:root 600 подходит для них. Если ключ предназначен для сервиса, который запускается от собственного пользователя и загружает ключ самостоятельно (например, Node-приложение, Gitea, Python-демон), передайте права на файл этому пользователю с помощью chown, сохранив режим 600. Чего делать нельзя: устанавливать режим 644, хранить копию в git-репозитории или в /tmp.

Подключение к Nginx

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name git.internal.lan;

    ssl_certificate     /etc/ssl/certs/git.internal.crt;
    ssl_certificate_key /etc/ssl/private/git.internal.key;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}
sudo nginx -t && sudo systemctl reload nginx

nginx -t должен вывести syntax is ok и test is successful до того, как будет выполнена перезагрузка. Если вместо этого выводится SSL_CTX_use_PrivateKey_file() failed ... key values mismatch, значит, сертификат и ключ были созданы в разных сессиях генерации; обратитесь к разделу с описанием возможных сбоев.

Интеграция с Apache

sudo a2enmod ssl proxy proxy_http

Одного ssl здесь недостаточно: приведенный ниже vhost использует ProxyPass, и без mod_proxy и mod_proxy_http проверка конфигурации завершится с ошибкой Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration. Сохраните vhost как /etc/apache2/sites-available/git-internal.conf:

<VirtualHost *:443>
    ServerName git.internal.lan
    SSLEngine on
    SSLCertificateFile      /etc/ssl/certs/git.internal.crt
    SSLCertificateKeyFile   /etc/ssl/private/git.internal.key

    ProxyPass        / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>
sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2

configtest должен ответить Syntax OK. Теперь выполните проверку с клиентской машины:

curl -v https://git.internal.lan/

и вы получите ошибку:

curl: (60) SSL certificate problem: self-signed certificate

Это не баг. Это работа TLS: curl ничего не знает о вашем сертификате и отказывается взаимодействовать с сервером, который не может аутентифицировать. Следующий раздел содержит правильное решение, и оно отличается от того, что сейчас делает половина интернета.

Обеспечение доверия клиентов и антипаттерны, которых следует избегать

Сначала о неверных методах решения, которые являются именно тем, чем кажутся. curl -k (или --insecure), встроенные в скрипт, verify=False в Python requests, NODE_TLS_REJECT_UNAUTHORIZED=0 в Node — ни один из этих вариантов не делает ваш сертификат доверенным. Они отключают проверку сертификата, что означает, что клиент будет без проблем взаимодействовать с любым сервером, предъявляющим любой сертификат, включая тот, который подставил злоумышленник. Вы сохраняете накладные расходы TLS, но теряете аутентификацию, ради которой он и внедрялся. Хуже того, эти флаги распространяются: их копируют в cron-задачи, затем в скрипты развертывания, а потом и в промышленный код, пока никто не вспомнит, какие соединения должны были быть временными. Если verify=False остается после сеанса отладки, в котором он был создан, значит, архитектура решения неверна.

Правильный подход — объяснить каждой клиентской ОС, что этот сертификат является доверенным корневым. На клиентах Ubuntu и Debian:

sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificates

Строка, имеющая значение в выводе (за ней следует блок Running hooks in /etc/ca-certificates/update.d...):

Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.

В этих строках скрыты два нюанса. Файл обязательно должен заканчиваться на .crt, расширение .pem игнорируется без предупреждений, и вы получаете 0 added без сообщения об ошибке. Содержимое должно быть в формате PEM, файл начинается с -----BEGIN CERTIFICATE-----; если у вас бинарный файл DER, сначала конвертируйте его с помощью openssl x509 -inform der -in file.der -out file.crt. Добавление самого самоподписанного сертификата в качестве корневого работает, так как самоподписанный сертификат является своим собственным корнем.

После этого curl, wget, git, apt и любое другое ПО, использующее OpenSSL с системным набором сертификатов, доверяет серверу без каких-либо дополнительных флагов. Несколько клиентов используют собственные хранилища доверенных сертификатов и требуют особого подхода:

  • Chrome/Chromium в Linux считывает базу данных NSS, а не системное хранилище: sudo apt install libnss3-tools, затем certutil -d sql:$HOME/.local/share/pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt для каждого пользователя. Этот путь является стандартным для Chrome начиная с версии 146; если у пользователя остался устаревший ~/.pki/nssdb от предыдущей установки, Chrome продолжит считывать его, поэтому на таких машинах укажите -d в sql:$HOME/.pki/nssdb.
  • Firefox имеет собственное хранилище: Настройки → Приватность и защита → Сертификаты → Просмотреть сертификаты → Импортировать. В Windows и macOS вы можете изменить security.enterprise_roots.enabled на true в about:config, чтобы браузер считывал хранилище ОС; Mozilla документирует этот параметр для Windows и macOS, но не для Linux, поэтому в Ubuntu используйте импорт.
  • Python requests поставляется с собственным набором CA (certifi) и игнорирует системное хранилище: передайте verify="/usr/local/share/ca-certificates/git.internal.crt" или экспортируйте REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt.
  • Node.js по умолчанию использует свои встроенные корневые сертификаты: экспортируйте NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt или настройте считывание системного хранилища, которое вы только что обновили, с помощью флага --use-system-ca (Node 23.9 и новее в Linux) или NODE_USE_SYSTEM_CA=1 (Node 24.6, 22.19 и новее).

На клиентах Windows дважды щелкните .crt и установите его в Доверенные корневые центры сертификации; на macOS добавьте его в системную связку ключей (System keychain) в приложении «Связка ключей» (Keychain Access) и установите параметр Всегда доверять (Always Trust).

Один корневой сертификат для множества сервисов: небольшая частная CA

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

Удобный вариант — mkcert, который есть в репозиториях Ubuntu 24.04 и работает с хранилищами NSS (Firefox, а также Chrome с одной оговоркой ниже), которые пропускает update-ca-certificates:

sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1

mkcert -install создает корневой сертификат и регистрирует его в системном хранилище, в доступных ему хранилищах NSS браузеров, а также в хранилище Java, если установлена переменная JAVA_HOME. Node не считывает системное хранилище без явного указания, поэтому запускайте приложения Node с флагом --use-system-ca (или NODE_USE_SYSTEM_CA=1), как указано выше, либо экспортируйте NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem". Третья команда создает файлы git.internal.lan+2.pem и git.internal.lan+2-key.pem, готовые для вставки в сниппеты nginx или Apache, приведенные выше. Инструмент спроектирован для машин разработчиков: корневой ключ хранится на той машине, где был запущен -install, поэтому он идеально подходит для ноутбука разработчика, но не подходит для парка серверов.

Оговорка на октябрь 2026 года: mkcert ищет базу данных NSS для Chrome только по устаревшему пути ~/.pki/nssdb, а не по ~/.local/share/pki/nssdb, который Chrome использует по умолчанию начиная с версии 146. На машине без устаревшей базы данных добавьте корневой сертификат для Chrome самостоятельно с помощью команды certutil из предыдущего раздела, используя -i "$(mkcert -CAROOT)/rootCA.pem".

Для серверов обычный OpenSSL позволяет создать CA всего пятью командами:

openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
  -out lab-ca.crt -subj "/CN=Lab Internal CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
  -addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
  -CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crt

Ловушка кроется в последней команде: openssl x509 -req по умолчанию отбрасывает все расширения из CSR, включая SAN, который вы тщательно добавили. Флаг -copy_extensions copy (опция OpenSSL 3.x, поэтому она работает в 24.04) переносит их; если его пропустить, подписанный сертификат будет без SAN, и Chrome снова встретит вас ошибкой NET::ERR_CERT_COMMON_NAME_INVALID. Проверьте результат той же командой openssl x509 -noout -ext subjectAltName, что и раньше.

Распространите lab-ca.crt среди клиентов, выполнив шаги по добавлению в хранилище доверенных сертификатов, описанные выше (достаточно сделать это один раз для каждой машины). Охраняйте lab-ca.key как зеницу ока: установите права доступа 600 и, по возможности, храните его на машине, которая не является одним из серверов, для которых он подписывает сертификаты, так как любой, у кого есть этот ключ, может выпустить сертификат для любого имени, которому поверят ваши клиенты.

Срок действия и ротация

Сроки действия сертификатов публичных CA сокращаются. В марте 2026 года CA/Browser Forum ограничил срок действия новых публично доверенных сертификатов 200 днями (ранее 398), с переходом к 100 дням в 2027 году и 47 дням к марту 2029 года. Однако эти правила обязательны только для публично доверенных CA. Ваш частный CA им не подчиняется, а браузеры не применяют эти ограничения к вручную установленным корневым сертификатам. Существует одно реальное ограничение: платформы Apple отклоняют любые TLS-сертификаты сервера со сроком действия более 825 дней, независимо от того, кто их выдал. Поэтому, если к сервису будут подключаться iPhone или Mac, ограничивайте срок действия конечных (leaf) сертификатов двумя годами или менее. -days 730 проходит этот порог везде; корневой сертификат на 10 лет с конечными сертификатами на 2 года — это удобная конфигурация для внутренней сети.

Сертификаты с длительным сроком действия выходят из строя всегда одинаково: незаметно и внезапно в дату, которую никто не помнит. Проверьте текущие сертификаты:

openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddate

Занесите обновление в календарь или настройте cron для уведомлений за 30 дней. openssl x509 -checkend 2592000 -in cert.crt завершается с ненулевым кодом, если до истечения срока осталось меньше указанного количества секунд. Если вы уже используете Uptime Kuma для мониторинга состояния, его HTTPS-мониторы бесплатно предупредят о приближении даты истечения сертификата.

Ротация сертификатов с частным CA — процесс предсказуемый: повторно выполните команды для создания CSR и подписи, замените файлы и перезагрузите веб-сервер. Корневой сертификат не изменился, поэтому клиенты не заметят никаких изменений.

Режимы сбоев и соответствующие им сообщения

NET::ERR_CERT_AUTHORITY_INVALID — это ожидаемое состояние до установки доверия, а не дефект сертификата. Если ошибка сохраняется после установки корневого сертификата: в Linux браузер Chrome использует NSS вместо системного хранилища (см. шаг certutil), а начиная с Chrome 146 эта база данных находится в ~/.local/share/pki/nssdb, если только не существует устаревшего пути ~/.pki/nssdb, поэтому корневой сертификат, добавленный по другому пути, не будет обнаружен; либо скопированный файл не заканчивается на .crt и update-ca-certificates выдает 0 added; либо сервер предоставляет другой сертификат, а не тот, которому вы доверяете — сравните отпечатки с помощью openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256.

NET::ERR_CERT_COMMON_NAME_INVALID — у сертификата отсутствует SAN или SAN не покрывает имя в адресной строке. Классический случай: в SAN указан DNS:git.internal.lan, но пользователь перешел на https://10.8.0.1. Изменения в хранилище доверенных сертификатов не исправят эту проблему; перевыпустите сертификат, добавив недостающую запись.

curl: (60) SSL certificate problem: self-signed certificate — curl не доверяет сертификату. Вариант self-signed certificate in certificate chain означает то же самое для сертификата, подписанного вашим частным CA. Разовое исправление: curl --cacert lab-ca.crt https://...; постоянное исправление: хранилище доверенных сертификатов. Не -k.

unable to load certificate ... Expecting: TRUSTED CERTIFICATE (или Expecting: CERTIFICATE REQUEST, либо no start line) — путаница с форматом PEM. Вы передали OpenSSL не тот тип файла: ключ или CSR там, где ожидался сертификат, или бинарный файл DER там, где ожидался PEM. head -1 filename покажет, что у вас есть на самом деле; сертификат начинается с -----BEGIN CERTIFICATE-----. Для DER выполните конвертацию с помощью openssl x509 -inform der -in file.der -out file.crt.

nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch) — сертификат и ключ не соответствуют друг другу, обычно это происходит, если команда генерации была запущена дважды и файлы перепутались. Проверьте с помощью openssl x509 -in git.internal.crt -noout -pubkey | sha256sum и openssl pkey -in git.internal.key -pubout | sha256sum; совпадающие хеши означают, что пара верна. Если они различаются, перегенерируйте их вместе.

FAQ

Почему Chrome всё ещё пишет «Не защищено» после создания самоподписанного сертификата?

Если ошибка имеет код NET::ERR_CERT_AUTHORITY_INVALID, с сертификатом всё в порядке, просто у Chrome пока нет оснований доверять ему. Установите его (или корневой сертификат вашего частного CA) в хранилище доверенных сертификатов клиента. Помните, что в Linux Chrome использует базу данных NSS через certutil (расположенную в ~/.local/share/pki/nssdb начиная с Chrome 146, или в устаревшей ~/.pki/nssdb, если она существует), а не системное хранилище. Если ошибка имеет код NET::ERR_CERT_COMMON_NAME_INVALID, в сертификате отсутствует поле Subject Alternative Name, соответствующее URL, и его необходимо перевыпустить с использованием -addext "subjectAltName=...".

Как заставить curl доверять самоподписанному сертификату без использования -k?

Скопируйте сертификат (в формате PEM, расширение .crt) в /usr/local/share/ca-certificates/ и выполните sudo update-ca-certificates; вывод должен содержать 1 added. После этого curl будет проверять его как любой публичный сертификат. Для разового запроса без внесения изменений в систему используйте curl --cacert /path/to/cert.crt, чтобы выполнить проверку только по этому файлу; флаг -k полностью отключает проверку, его не следует использовать в скриптах.

Как долго может действовать самоподписанный сертификат?

Технически — сколько угодно. Ограничения CA/Browser Forum (сейчас 200 дней, к 2029 году — 47) касаются только публично доверенных CA, а не частных. На практике ограничьте срок действия серверных сертификатов 825 днями, так как устройства Apple отклоняют любые сертификаты с большим сроком независимо от эмитента. Десятилетний частный корневой сертификат и двухлетние (-days 730) сертификаты конечных узлов — разумный стандарт; просто внесите дату обновления в календарь, так как просроченный внутренний сертификат незаметно выведет из строя всё в день, о котором никто не помнит.

Что использовать: самоподписанный сертификат или Let's Encrypt?

Если сервис имеет публичное DNS-имя и доступен из Интернета, всегда выбирайте Let's Encrypt: это бесплатно, автоматизировано и уже вызывает доверие у большинства браузеров и операционных систем (проверьте список совместимости Let's Encrypt, если необходимо подключение очень старых устройств). Самоподписанные сертификаты (или частный CA) предназначены для случаев, когда Let's Encrypt не может выпустить сертификат: частные IP-адреса, внутренние имена хостов вроде .lan, изолированные сети (air-gapped) и сервисы, намеренно скрытые за VPN. Решение зависит от доступности и именования, а не от криптографической стойкости — она идентична.

Почему мой сертификат отклоняется даже после добавления в /usr/local/share/ca-certificates?

Проверьте три момента. Файл должен заканчиваться на .crt, расширение .pem игнорируется без предупреждений, и update-ca-certificates сообщает 0 added. Содержимое должно быть текстом PEM, начинающимся с -----BEGIN CERTIFICATE-----, а не бинарным форматом DER. Кроме того, приложение должно фактически использовать системное хранилище: Chrome в Linux, Firefox, Python requests, Node.js (если не запущен с --use-system-ca) и Java имеют собственные хранилища доверенных сертификатов, и в каждое из них сертификат нужно добавлять отдельно.