# Cert Unifier

Cert Unifier 3.1.1 — проверенный объединитель PEM и PFX для администраторов хранилищ
Let's Encrypt, Certbot и устаревших раскладок: он записывает по одному пакету на домен
только после доказательства, что закрытый ключ соответствует конечному сертификату, и
отказывается от записей, которые не может проверить.

[![CI](https://github.com/paulmann/cert-unifier/actions/workflows/ci.yml/badge.svg)](https://github.com/paulmann/cert-unifier/actions/workflows/ci.yml)
[![Release v3.1.1](https://img.shields.io/badge/release-v3.1.1-blue)](https://github.com/paulmann/cert-unifier/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![Bash 4.0+](https://img.shields.io/badge/bash-4.0%2B-4EAA25)](https://www.gnu.org/software/bash/)
[![OpenSSL 1.1.1+ / 3.x](https://img.shields.io/badge/openssl-1.1.1%2B%20%7C%203.x-721412)](https://www.openssl.org/)

<p align="center"><img src="https://github.com/paulmann/cert-unifier/raw/main/assets/social-preview.png" alt="Cert Unifier banner" width="100%"></p>

[English](README.md) · [Deutsch](README.de.md) · [中文](README.zh.md) · [हिन्दी](README.hi.md) · [Español](README.es.md) · [Русский](README.ru.md)

[Вики](https://github.com/paulmann/cert-unifier/wiki) · [Релизы](https://github.com/paulmann/cert-unifier/releases) · [Безопасность](SECURITY.md) · [Участие](CONTRIBUTING.md) · [Для агентов](AGENTS.md)

## Содержание

- [Что делает утилита](#что-делает-утилита)
- [Зачем утилите проверять соответствие](#зачем-утилите-проверять-соответствие)
- [Требования](#требования)
- [Установка](#установка)
- [Использование](#использование)
- [Параметры](#параметры)
- [Раскладки](#раскладки)
- [Сводка JSON](#сводка-json)
- [Коды возврата](#коды-возврата)
- [Безопасность](#безопасность)
- [Документация](#документация)
- [Тестирование](#тестирование)
- [Участие в разработке](#участие-в-разработке)
- [Лицензия](#лицензия)
- [История изменений](#история-изменений)

## Что делает утилита

Cert Unifier обходит дерево каталогов с материалом Let's Encrypt, проверяет,
что каждый закрытый ключ действительно принадлежит своему конечному
сертификату, и записывает по одному проверенному пакету на домен: закрытый
ключ, а за ним полная цепочка.

- **Сопоставление открытых ключей** для ключей RSA и EC (сравнение вывода
  `openssl x509 -pubkey` с `openssl pkey -pubout`), а не хеш модуля, который
  работает только для RSA.
- **Отказывается записывать то, что не может проверить**: несовпадающий ключ,
  ключ под парольной фразой, нечитаемый ключ или отсутствующий ключ
  останавливают эту запись с ошибкой вместо создания битого пакета.
- **Атомарная запись**: содержимое собирается в целевом каталоге, получает
  права 0600 и затем переименовывается на место назначения.
- **Идемпотентность**: побайтово идентичный пакет остаётся нетронутым (mtime
  сохраняется), а у существующего пакета с правами 0644 исправляются права.
- **Необязательный экспорт PKCS#12** для IIS/Windows и необязательные
  предупреждения об окончании срока действия.
- **Машиночитаемая** сводка `--json` для автоматизации.
- **Без обращения к сети**: утилита никогда не запрашивает, не продлевает, не
  отзывает и не устанавливает сертификаты и никогда не перезагружает службу.

## Зачем утилите проверять соответствие

Пакет полезен, только если ключ внутри него соответствует сертификату.
Типичные сбои происходят молча: Certbot при продлении меняет закрытый ключ,
устаревшие раскладки держат `_le1`, `_le2`, `_le10` рядом, а операторы
копируют не тот ключ. Веб-сервер, запущенный с несовпадающим ключом, падает
на рукопожатии позже, в самый неподходящий момент. Cert Unifier считает
соответствие ключа и сертификата предусловием, а не предупреждением.

## Требования

| Требование | Зачем |
|-------------|-----|
| Bash 4.0 или новее | массивы, `shopt`, подстановка процессов |
| `openssl` 1.1.1 или 3.x | сравнение открытых ключей, проверка PEM, экспорт PKCS#12 |
| `flock` (необязательно) | не даёт двум запускам одновременно трогать одно дерево |
| Чтение ключей и сертификатов | закрытый ключ читается в пакет |
| Запись в выходной каталог | пакеты записываются рядом с исходниками |

Поддерживаются Linux, macOS и WSL. В macOS поставляется Bash 3.2;
установите более новый Bash (`brew install bash`) или запускайте скрипт
через `bash` из Homebrew.

## Установка

```bash
wget https://raw.githubusercontent.com/paulmann/cert-unifier/main/cert-unifier.sh
chmod +x cert-unifier.sh
./cert-unifier.sh --version
```

Для всей системы:

```bash
sudo install -m 755 cert-unifier.sh /usr/local/bin/cert-unifier
```

## Использование

```bash
# 1. inspect the tree without writing anything
./cert-unifier.sh --dry-run --verbose /etc/letsencrypt/live

# 2. run it on the tree root, not on a single domain directory: for Certbot
#    the root is the directory that holds the per-domain folders, and a single
#    domain directory matches nothing (exit 2)
./cert-unifier.sh /etc/letsencrypt/live

# 3. PEM plus PKCS#12 (the password is read from the environment)
PFX_PASSWORD='...' ./cert-unifier.sh --pfx /var/www/certs

# 4. legacy tree with more than one generation per domain
./cert-unifier.sh --no-tld-filter --recursive /opt/certs
```

Каждая обработанная запись пишет `<domain>.pem` (закрытый ключ, затем
конечный сертификат, затем промежуточные) рядом с исходными файлами или в
каталог `--output-dir`, если он указан.

## Параметры

| Параметр | Описание | По умолчанию |
|--------|-------------|---------|
| `-h, --help` | Показать справку и выйти | |
| `-V, --version` | Показать версию и выйти | |
| `-d, --dry-run` | Проверить и отчитаться, ничего не записывая | выкл. |
| `-v, --verbose` | Журналирование уровня DEBUG | выкл. |
| `-t, --tlds LIST` | Разрешённые TLD, через запятую или пробел | `ru com` |
| `-o, --output-dir PATH` | Писать пакеты сюда, а не рядом с исходниками | как у источника |
| `--no-tld-filter` | Обрабатывать все домены | выкл. |
| `--no-key-validation` | Объединять, даже если ключ не удаётся проверить | выкл. |
| `--pfx` | Дополнительно писать `.pfx` | выкл. |
| `--pfx-password PASS` | Пароль PFX (предпочтительнее `PFX_PASSWORD`) | пусто |
| `--skip-pfx-on-mismatch` | Пропускать несовпадающие записи вместо ошибки | выкл. |
| `--check-expiry` | Предупреждать о сертификатах с близким сроком окончания | выкл. |
| `--expiry-days N` | Порог предупреждения об окончании срока, в днях | `30` |
| `--recursive` | Спускаться в подкаталоги для устаревшей раскладки | выкл. |
| `--json` | Выводить машиночитаемую сводку в stdout | выкл. |

Переменные окружения: `ALLOWED_TLDS`, `LOG_LEVEL` (`DEBUG`, `INFO`, `WARN`,
`ERROR`), `PFX_PASSWORD`, `CERT_UNIFIER_LOCK`, `NO_KEY_VALIDATION`.

## Раскладки

**Certbot**

```text
/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem
→ /etc/letsencrypt/live/example.com/example.com.pem
```

**Устаревшая**

```text
example.com_le10.crt      leaf certificate
example.com_le10.crtca    chain
example.com_le10.key      private key (or example.com.key)
→ example.com.pem
```

`N` может быть любым числом. Если в одном каталоге присутствует несколько
поколений одного домена, побеждает наибольший `N`, поэтому старый `_le1`
никогда не перезапишет более новый `_le10`.

## Сводка JSON

```bash
./cert-unifier.sh --json /etc/letsencrypt/live
```

```json
{
  "version": "3.1.1",
  "status": "success",
  "processed": 1,
  "skipped": 0,
  "planned": 0,
  "errors": 0,
  "warnings": 0,
  "results": [
    {"domain": "example.com", "status": "updated", "pem": "/etc/letsencrypt/live/example.com/example.com.pem", "fingerprint": "..."}
  ]
}
```

`status` принимает значения `success`, `none` (ничего не найдено) или `error`.
Предупреждения и ошибки по-прежнему направляются в stderr, чтобы stdout
оставался разбираемым.

## Коды возврата

| Код | Значение |
|------|---------|
| 0 | Всё запрошенное выполнено (в `--dry-run` всё запланировано) |
| 1 | Ошибка выполнения или проверки; хотя бы одна запись отклонена |
| 2 | Подходящих сертификатов не найдено |
| 130 | Прервано сигналом INT или TERM |

## Безопасность

- Закрытый ключ всегда записывается с правами 0600 при `umask 077`.
- Запись атомарна в целевом каталоге; неудачный запуск оставляет предыдущий
  пакет нетронутым.
- Единственный файл, создаваемый вне сканируемого дерева, — файл блокировки
  (`/var/lock/cert-unifier.lock`, переопределяется через `CERT_UNIFIER_LOCK`).
- Пароль PFX передаётся OpenSSL в командной строке, поэтому он виден в `ps` на
  время этого вызова. Предпочитайте короткоживущую переменную окружения;
  пустой пароль создаёт незашифрованный PFX.
- Скрипт никогда не удаляет исходные файлы, не обращается к сети и не
  перезагружает службу.

## Документация

| Документ | Содержание |
|----------|------------|
| [README.md](README.md) | Обзор, использование и параметры этого файла |
| [Вики](https://github.com/paulmann/cert-unifier/wiki) | Подробные руководства и заметки по эксплуатации |
| [AGENTS.md](AGENTS.md) | Неинтерактивные инструкции для ИИ-агентов |
| [CHANGELOG.md](CHANGELOG.md) | История версий с семантическим версионированием |
| [SECURITY.md](SECURITY.md) | Как сообщить об уязвимости |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Как предложить и проверить изменение |
| [Релизы](https://github.com/paulmann/cert-unifier/releases) | Опубликованные версии и их артефакты |

## Тестирование

```bash
chmod +x tests/run-tests.sh
./tests/run-tests.sh
```

Набор создаёт настоящие фикстуры RSA и EC с помощью OpenSSL и проверяет коды
возврата, содержимое пакетов, права, идемпотентность, поведение в режиме
сухого прогона, корректность JSON, экспорт PKCS#12, отказ при несовпадающих,
отсутствующих и защищённых парольной фразой ключах, а также очистку. Он
запускается в GitHub Actions при каждом push.

## Участие в разработке

См. [CONTRIBUTING.md](CONTRIBUTING.md) и [SECURITY.md](SECURITY.md). Изменения
должны сохранять чистоту `tests/run-tests.sh` и `shellcheck`.

## Лицензия

Проект распространяется по лицензии [MIT](LICENSE), © 2026 Михаил Дейнекин.

## История изменений

См. [CHANGELOG.md](CHANGELOG.md).
