В апреле я рассказывал здесь, зачем пишу ещё один настольный клиент для S3. В комментариях справедливо заметили, что технических деталей там не было. Исправляюсь, вот одна история изнутри. Клиент написан на Tauri 2 и Rust.
При проверке на российских ОС я наткнулся на странную ошибку. Приложение запускается, первое подключение к хранилищу по HTTPS проходит, а через несколько секунд все следующие запросы падают с ошибкой проверки сертификата. На Ubuntu, Debian и МСВСфере всё работает. Если отключить проверку обновлений, всё работает и на ALT.
Виноват оказался плагин tauri-plugin-updater, который меняет переменные окружения процесса. Разбираю подробно, потому что это касается любого приложения на Tauri 2, где есть этот плагин и TLS на rustls.
Как это выглядит
Система ALT Linux p11, пакет rpm, в приложении включена автоматическая проверка обновлений. Первое подключение к хранилищу по HTTPS успешно. После проверки обновлений новые TLS-соединения падают с invalid peer certificate: UnknownIssuer. Если перед запуском вручную задать SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt, ошибки нет.
Где лежат сертификаты в разных дистрибутивах
Система |
Файл с корневыми сертификатами |
Каталог |
|---|---|---|
Debian, Ubuntu, Astra |
|
есть, с отдельными сертификатами |
МСВСфера 10.1 (RHEL 10) |
|
есть, с отдельными сертификатами |
ALT Linux p11 |
|
нет |
Файла /etc/ssl/certs/ca-certificates.crt нет ни в МСВСфере, ни в ALT. Разница в том, что в МСВСфере сертификаты всё равно находятся через каталог, а в ALT нет ни файла, ни каталога.
Что делает плагин
Я смотрел tauri-plugin-updater версии 2.10.1, в основной ветке репозитория tauri-apps/plugins-workspace код тот же. В начале метода Updater::check() есть такой блок.
// Set SSL certs for linux if they aren't available. #[cfg(target_os = "linux")] { if std::env::var_os("SSL_CERT_FILE").is_none() { std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); } if std::env::var_os("SSL_CERT_DIR").is_none() { std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); } }
Задумка понятная, помочь HTTP-клиенту найти сертификаты. Только пути взяты из Debian, а переменные меняются для всего процесса и насовсем. Если приложение проверяет обновления при запуске, то после этого вызова любая библиотека, которая читает SSL_CERT_FILE и SSL_CERT_DIR, видит путь из Debian.
Почему ломается rustls
Системные корневые сертификаты для rustls обычно загружает крейт rustls-native-certs. С версии 0.8 функция load_native_certs() устроена так.
pub fn load_native_certs() -> CertificateResult { let paths = CertPaths::from_env(); match (&paths.dirs, &paths.file) { (v, _) if !v.is_empty() => paths.load(), (_, Some(_)) => paths.load(), _ => platform::load_native_certs(), } }
Если задана хотя бы одна из переменных, сертификаты берутся только оттуда, а обычный поиск по известным путям через openssl-probe уже не выполняется. После вызова плагина на ALT SSL_CERT_FILE указывает на файл, которого нет, и SSL_CERT_DIR указывает на каталог, которого тоже нет. Хранилище корневых сертификатов получается пустым, и rustls отвергает любое TLS-соединение с ошибкой UnknownIssuer.
До проверки обновлений переменных нет, openssl-probe находит /etc/pki/tls/certs/ca-bundle.crt, и всё работает. Поэтому первое подключение и проходит.
В МСВСфере 10.1 файла тоже нет, но в каталоге /etc/ssl/certs лежат сертификаты с именами-хешами, и они загружаются. Там ошибка видна только как сообщение о неудачном чтении файла.
Есть и вторая проблема. std::env::set_var в многопоточной программе небезопасна, в редакции Rust 2024 её пометили как unsafe как раз потому, что одновременное чтение окружения из другого потока может привести к неопределённому поведению. А check() выполняется в асинхронной среде, где в это же время другие потоки открывают соединения.
Как воспроизвести
Понадобится Docker. Tauri для проверки не нужен, достаточно повторить то, что делает плагин.
fn main() { let before = rustls_native_certs::load_native_certs(); println!("до: {} сертификатов", before.certs.len()); std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); let after = rustls_native_certs::load_native_certs(); println!("после: {} сертификатов, ошибки: {:?}", after.certs.len(), after.errors); }
docker run --rm -it alt:p11 bash apt-get update && apt-get install -y ca-certificates
В базовом образе alt:p11 пакета ca-certificates нет, поэтому его нужно поставить перед запуском. Вот что получилось у меня.
Система |
До |
После |
|---|---|---|
Debian 12 |
150 сертификатов |
150, без ошибок |
МСВСфера 10.1 |
150 сертификатов |
150 и ошибка чтения |
ALT Linux p11 |
121 сертификат |
0, файл и каталог не найдены |
В ALT после двух вызовов set_var приложение остаётся без единого корневого сертификата.
Как исправить у себя
Плагин ставит пути, только если переменные ещё не заданы. Значит, достаточно задать их самому, раньше него и правильно. Найти правильные пути помогает тот же openssl-probe, которым rustls-native-certs пользуется по умолчанию.
[target.'cfg(target_os = "linux")'.dependencies] openssl-probe = "0.2"
#[cfg(target_os = "linux")] fn pin_system_ca_paths() { let found = openssl_probe::probe(); if std::env::var_os("SSL_CERT_FILE").is_none() { if let Some(file) = found.cert_file.as_ref() { std::env::set_var("SSL_CERT_FILE", file); } } if std::env::var_os("SSL_CERT_DIR").is_none() { if let Some(dir) = found.cert_dir.first() { std::env::set_var("SSL_CERT_DIR", dir); } } } #[cfg(not(target_os = "linux"))] fn pin_system_ca_paths() {} pub fn run() { pin_system_ca_paths(); // первой строкой, пока других потоков ещё нет tauri::Builder::default() // ... }
Функцию важно вызвать первой строкой в run(). В этот момент асинхронная среда ещё не запущена, других потоков нет, и менять окружение безопасно. Переменные, которые пользователь задал сам, функция не трогает.
После этого на ALT p11 HTTPS работает и до, и после проверки обновлений. В МСВСфере пропадает ошибка чтения, а на Debian и Ubuntu ничего не меняется, openssl-probe находит там те же пути, что зашиты в плагин.
Как стоило бы исправить в самом плагине
Если плагину нужны пути к сертификатам для своего HTTP-клиента, их лучше передавать клиенту напрямую, а не через окружение процесса. А если без переменных никак, то искать реальные пути через openssl-probe вместо путей из Debian. Я завёл об этом задачу в репозитории tauri-apps/plugins-workspace.
Что я для себя вынес
Библиотека, которая меняет переменные окружения, меняет поведение всех остальных библиотек в процессе. Если видите set_var в зависимости, проверьте, кто ещё читает эти переменные.
Пути к сертификатам в Linux не стандартизованы. TLS стоит проверять хотя бы на трёх семействах, Debian, RHEL и ALT.
Если ошибка появляется не сразу, а после какой-то фоновой задачи, помогает отключать фоновые задачи по одной.
Проверял на ALT Linux p11, МСВСфере 10.1 и Debian 12 в контейнерах и в самом S3 TM Browser, где эту ошибку и нашёл. Если встречали похожее в других фреймворках, расскажите в комментариях.