Как создать самоподписанный сертификат на 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.keynginx и 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 nginxnginx -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 apache2configtest должен ответить 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.1mkcert -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 имеют собственные хранилища доверенных сертификатов, и в каждое из них сертификат нужно добавлять отдельно.