# Сертификаты: wildcard или свой на каждую ноду

Короткий ответ: **по умолчанию — свой сертификат на каждое постоянное имя,
wildcard — только как явный режим для большого парка временных поддоменов.**
Ниже — почему, и как это сделано в этих скриптах.

## 1. Какие сертификаты есть в схеме

Их три разных контура, и их нельзя смешивать:

| Контур | Что это | Кто выпускает |
|---|---|---|
| Публичный TLS панели | Браузер → nginx панели (`443/tcp`) | ACME (HTTP-01 по умолчанию) |
| Публичный TLS ноды | Клиент → nginx ноды → Xray; сюда же REALITY fallback, TLS XHTTP и decoy-сайт | ACME на ноде или push с панели (5.6) |
| mTLS панель ↔ нода | Служебный `SECRET_KEY` на `2222/tcp` | Панель Remnawave, **не ACME** |

Есть ещё внутренние сертификаты (пиннинг служебного API, self-signed для
bootstrap) — это отдельная история, в общий ACME-контур они не попадают.

Практический вывод: сертификат панели, сертификат ноды и `SECRET_KEY` живут
независимо. Продление одного не должно перезапускать другое, а «общий
сертификат на всё» — это как раз то, чего лучше избегать.

## 2. Что именно покрывает wildcard

`*.example.com` покрывает **ровно один уровень**:

- `node1.example.com` — да;
- `example.com` (apex) — **нет**, его нужно добавлять отдельным SAN;
- `a.node1.example.com` — **нет**, второй уровень не покрыт.

Поэтому «wildcard на всё» на практике всё равно требует списка SAN
(apex, панель, отдельные имена), а вместе с SAN растёт и цена ошибки:
один пропущенный SAN — и клиент получает не тот сертификат.

В `cert_manager.sh issue --domain '*.example.com'` wildcard автоматически
добавляется в SAN, а базовое имя `example.com` становится основным
(строка `if [[ $domain == \*.* ]]` в `cmd_issue`). Это закрывает apex.
Второй уровень по-прежнему надо перечислять руками.

## 3. Чем wildcard хуже на практике

### 3.1. Один ключ на все имена

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

Если раздавать один и тот же wildcard по нодам, ключ должен полежать на
каждой из них. Это резко повышает ценность любой отдельной ноды: раньше
её взлом давал один сервер, теперь — всю зону.

### 3.2. Нужен DNS-01 и токен DNS-провайдера

Wildcard нельзя выпустить через HTTP-01: CA не может проверить
произвольное имя, которого ещё нет в DNS. Остаётся DNS-01, а значит:

- токен Cloudflare/GCore должен быть на каждой ноде, которая продлевает
  сертификат, либо сертификат выпускается централизованно и
  распространяется на ноды;
- токен с правом менять DNS зоны — это доступ ко **всей** зоне, а не к
  одной записи;
- если API провайдера лежит или токен отозван, продление встанет сразу
  на всех нодах.

Уменьшить радиус поражения можно: отдельный токен только на эту зону,
минимальные права, или делегирование через CNAME на acme-dns. В этих
скриптах поддержаны Cloudflare и GCore; acme-dns пока нет, поэтому
токен на зону — осознанный компромисс.

### 3.3. Лимиты Let's Encrypt

Let's Encrypt считает лимиты по зарегистрированному домену. Основные:

- 50 сертификатов на домен (`example.com`) в неделю;
- 5 одинаковых сертификатов (duplicate) в неделю;
- 5 неудачных проверок на одно имя в час;
- отдельные лимиты на заказы и аккаунты.

Парк из десятков нод на одном домене — это десятки сертификатов. При
редких перевыпусках это нормально, при частых переустановках и
экспериментах — можно упереться. Wildcard занимает одну позицию вместо
N. Но это экономия не «бесплатная»: платите DNS-01 и общим ключом.

### 3.4. DNS-зависимость и propagation

HTTP-01 проверяет, что имя резолвится на этот хост и на 80 порту лежит
нужный файл. DNS-01 проверяет TXT-запись. Ошибки бывают и там, и там, но
у DNS-01 добавляются: медленное распространение, несколько NS,
неправильная зона, CNAME вместо TXT. В скриптах стоят паузы 30 с для
Cloudflare и 90 с для GCore — это не гарантия, а разумный дефолт.

### 3.5. Прозрачность сертификатов (CT)

Все публичные сертификаты попадают в CT-логи. При сертификате на домен
в логах видно имя каждой ноды — это удобно для атакующего и для
блокировок по имени. Wildcard скрывает имена поддоменов, в логе видно
только `example.com` и `*.example.com`. Это единственный реальный плюс
wildcard для приватности. Но DNS-записи всё равно могут утекать, так что
это не анонимность, а лишь меньше подсказок.

### 3.6. Ремонт и откат

Сертификат на домен можно отозвать и перевыпустить, не трогая соседей.
Wildcard перевыпускается целиком: ошибка в SAN, лимит или сбой DNS
задевает весь парк. Откат отдельной ноды на предыдущий релиз при этом
возможен (у нас хранятся релизы), но общий ключ всё равно один.

## 4. Когда wildcard всё-таки оправдан

- Много **временных** поддоменов, которые создаются и удаляются
  автоматически (эфемерные ноды, тестовые стенды).
- Имена нод не должны светиться в CT-логах.
- Есть аккуратный DNS-провайдер с API и отдельный токен на зону.
- Вы готовы признать: ключ общий, отзыв общий, DNS-01 обязателен.

Не оправдан:

- 2–10 постоянных нод с известными именами — проще и безопаснее
  выпустить каждому свой сертификат;
- панель и ноды в одном сертификате — панель не должна зависеть от
  ключа, который лежит на каждой ноде;
- «чтобы не настраивать DNS для каждой ноды» — это экономия времени
  ценой безопасности всего парка.

## 5. Как это сделано в rebuild

### 5.1. Один домен — один сертификат

`panel_setup.sh` выпускает сертификат для домена панели (и SAN
`--sub-domain`, `--alt`). `node_setup.sh` — для домена ноды (и SAN
`--docs-domain`, `--alt`). Исключение — осознанная схема с wildcard на
панели и раздачей нодам (5.6): тогда одна пара живёт на нескольких
машинах, и это цена за отказ от DNS-токена на каждой ноде.

Путь фиксирован и одинаков для nginx и Xray:

```
/opt/remnawave/tls/<domain>/live/fullchain.pem
/opt/remnawave/tls/<domain>/live/privkey.pem
/opt/remnawave/tls/<domain>/releases/<timestamp>/
/opt/remnawave/tls/<domain>/live -> releases/<timestamp>
```

`live` — симлинк. Новый релиз сначала собирается в `releases/`, затем
атомарно подменяется симлинк. Старые релизы (до 5) остаются для отката.

### 5.2. Способы выпуска

| Метод | Когда | Что нужно |
|---|---|---|
| `http01` (по умолчанию) | есть DNS A/AAAA на этот хост и открыт 80 | nginx с bootstrap-конфигом, webroot `/opt/remnawave/acme` |
| `dns01` | wildcard, 80 закрыт, нет публичного A | токен провайдера, файл прав 0600 |
| `selfsigned` | decoy до появления DNS, локальные тесты | ничего; браузеры не доверяют |
| `import` | сертификат уже есть | `--cert` и `--key`, продление снаружи |

Для HTTP-01 скрипт поднимает **только nginx** с временным конфигом на 80 и
получает сертификат. Дальше пути расходятся: нода включает полный конфиг с
443 сразу (нода ещё не запущена, окна со сломанным TLS нет), а панель — только
после того, как backend healthy, потому что upstream полного конфига — это
контейнер `remnawave`, которого до старта стека ещё нет.

### 5.3. Проверка перед установкой

`cert_manager.sh` не верит коду возврата certbot. Перед установкой
проверяется:

1. сертификат и ключ читаются (`openssl x509`, `openssl pkey`);
2. публичные части сертификата и ключа совпадают (SHA-256 SPKI);
3. срок больше минимума (`-checkend`);
4. SAN действительно покрывает домен (`-checkhost`);
5. цепочка проверяется системными CA (`openssl verify`); для
   self-signed этот шаг пропускается;
6. после установки — `nginx -t`, затем `nginx -s reload`. Если reload
   не удался, симлинк возвращается на предыдущий релиз.

Те же проверки повторяются перед пропуском уже существующего
сертификата в `panel_setup.sh`/`node_setup.sh`: метод выпуска, все SAN и
запас 30 дней. Просто «файл есть» — недостаточно.

### 5.4. Продление

- systemd-таймер `remnawave-cert-renew.timer`, ежедневно около 03:17 с
  разбросом до 6 часов;
- `cert_manager.sh renew --all` продлевает то, что истекает в течение
  30 дней (`RENEW_DAYS`);
- импортированные и полученные пушем сертификаты пропускаются: их
  продление — снаружи;
- self-signed перегенерируется;
- nginx перечитывает конфиг (`reload`), живые соединения не рвутся;
- для Xray есть `XRAY_RESTART_ON_RENEW` (по умолчанию `false`). Если
  ваша версия Xray не перечитывает файл сертификата сама, включите
  `true`, но это разорвёт активные соединения. Проверьте на тестовой
  ноде.
- если сертификат `PUSH_SOURCE` сменился, renew сам вызывает `push-all`
  (см. 5.6); ошибка пуша делает renew неуспешным.

### 5.5. Ключи

Приватный ключ создаётся на той машине, где используется, и никогда не
передаётся по HTTP. Права:

- `privkey.pem` — `0600`;
- `fullchain.pem` — `0644`;
- каталог `releases` — `0750`;
- DNS-credentials — `0600`, иначе выпуск отказывается стартовать.

`cert_manager.sh export` предупреждает, что архив содержит ключ.
`cert_manager.sh push` передаёт его только по SSH (`scp`/`ssh` с
`BatchMode=yes`), вместе с копией toolkit, и устанавливает на удалённой
стороне через `install-from`, который проверяет архив на небезопасные
пути. Раньше тулкит на удалённой стороне должен был стоять заранее;
теперь `push` приносит его с собой.

Секрет ноды (`SECRET_KEY`) — не сертификат, но правило то же: только файл
или скрытый ввод, никогда аргументом командной строки.

### 5.6. Wildcard на панели и раздача на ноды

Схема для парка нод на одном домене, когда выбран wildcard:

- сертификат панели выпускается отдельно и wildcard не содержит;
- wildcard (`example.com` + `*.example.com`) выпускается на панели один
  раз и продлевается только там;
- ноды не имеют DNS-токена и не ходят в ACME: панель присылает им пару
  по SSH, ноды работают с методом `pushed`.

Порядок:

```bash
# на панели: A-записи через GCore API
cert_manager.sh dns set-a --zone example.com --name panel \
    --ip 203.0.113.10 --dns-credentials /root/gcore.ini
cert_manager.sh dns set-a --zone example.com --name node1 \
    --ip 203.0.113.11 --dns-credentials /root/gcore.ini

# wildcard
cert_manager.sh issue --domain example.com --alt '*.example.com' \
    --method dns01 --dns-provider gcore \
    --dns-credentials /root/gcore.ini --email admin@example.com

# /etc/remnawave/cert-manager.conf на панели
#   PUSH_SOURCE=example.com
#   PUSH_TARGETS="node1.example.com node2.example.com=100.118.0.12"

# разослать сейчас и дальше — автоматически при продлении
cert_manager.sh push-all

# на ноде
node_setup.sh --method pushed --domain node1.example.com ...
```

`PUSH_TARGETS`: пробелами, каждая цель `домен_на_ноде[=ssh-хост]`. Без
`=ssh-хост` SSH идёт на сам домен; с ним — на адрес NetBird или любой
другой, куда панель достаёт по SSH. Пользователь и порт —
`PUSH_USER`/`PUSH_PORT` (по умолчанию `root:22`). Нужен рабочий
`BatchMode=yes` доступ по ключу: пароли таймер ввести не сможет.

`install-from` на ноде пишет `METHOD=pushed`, поэтому её собственный
таймер не пытается продлить чужой сертификат. Повторный
`node_setup.sh --method pushed` видит валидную пару и ничего не
перевыпускает; если пары нет, установка останавливается с просьбой
сначала выполнить `push-all` на панели.

Что это стоит: один ключ на все ноды, общий отзыв и SSH-доступ панели
ко всем нодам. Поэтому панель в этот wildcard не включается.

### 5.7. A-записи через GCore API

`cert_manager.sh dns set-a` работает с DNS v2 API GCore
(`https://api.gcore.com/dns/v2/zones/{zone}/{name}/{type}`), заголовок
`Authorization: APIKey ...`. Токен читается из файла плагина
`certbot-dns-gcore` (`dns_gcore_apitoken = ...`, права 0600). Команда
создаёт запись через `POST`, если её нет, и обновляет через `PUT`, если
есть; одинаковое значение и TTL ничего не меняют. Поддерживается только
GCore — Cloudflare здесь пока нет.


## 6. Команды

```bash
# выпуск на ноде (обычно вызывается из node_setup.sh)
cert_manager.sh issue --domain node1.example.com --email you@example.com \
    --method http01

# wildcard, только DNS-01
cert_manager.sh issue --domain '*.example.com' --email you@example.com \
    --method dns01 --dns-provider cloudflare \
    --dns-credentials /etc/remnawave/cloudflare.ini

# проверить, что лежит и сколько жить
cert_manager.sh status
cert_manager.sh verify --domain node1.example.com

# откатить на предыдущий релиз
cert_manager.sh rollback --domain node1.example.com

# перенести сертификат на другую ноду по SSH
cert_manager.sh push --domain node1.example.com --host 100.118.0.12
cert_manager.sh push --domain example.com --as node1.example.com --host 100.118.0.12
cert_manager.sh push-all

# A-запись через GCore API
cert_manager.sh dns set-a --zone example.com --name node1 \
    --ip 203.0.113.12 --dns-credentials /root/gcore.ini

# поставить/обновить таймер продления
cert_manager.sh timer --config /etc/remnawave/cert-manager.conf
```

## 7. Что делать, если выпуск не прошёл

| Симптом | Проверка | Решение |
|---|---|---|
| `NXDOMAIN` или чужой IP | `dig +short A node1.example.com` | поправить A/AAAA, дождаться TTL |
| A есть, AAAA указывает мимо | `dig +short AAAA ...` | убрать AAAA или направить на тот же хост |
| 80 недоступен | `curl -v http://node1.example.com/.well-known/acme-challenge/test` | открыть 80 в nftables и у провайдера |
| CAA запрещает LE | `dig +short CAA example.com` | добавить `letsencrypt.org` |
| Лимит LE | `/var/log/letsencrypt/letsencrypt.log` | подождать, тестировать через `--staging` |
| DNS-01 не видит TXT | `dig +short TXT _acme-challenge...` | увеличить propagation, проверить зону/NS |
| Токен DNS не тот | ответ API 401/403 | новый токен с правами на зону, `chmod 600` |
| `dns set-a` вернул 404 на зону | `GET /dns/v2/zones/{zone}` | зона не в этом аккаунте или имя с опечаткой |
| `dns set-a` вернул 409 на GET | конфликт A/AAAA/CNAME | у имени уже есть другой тип записи; разберите вручную |
| пуш не проходит | `ssh -o BatchMode=yes root@host true` | ключи панели, порт, firewall ноды |
| нода не обновилась после renew | `cert_manager.sh status` на ноде | проверить `PUSH_TARGETS` и лог `remnawave-cert-renew` на панели |
| Время на хосте уплыло | `timedatectl` | включить NTP |
| Wildcard не покрыл имя | `openssl x509 -checkhost a.b.example.com` | второй уровень не покрыт, добавить SAN |
| nginx не стартует после выпуска | `nginx -t` | смотреть путь к сертификату и права |

Перед первым выпуском на новой ноде используйте `--staging`: он не
тратит лимиты и показывает, что DNS/80/CAA в порядке. После успешного
staging-прогона выпускайте боевой.

## 8. Чек-лист перед выпуском

1. A-запись (и AAAA, если есть) указывает на эту машину.
2. 80/tcp открыт в nftables и у облачного провайдера.
3. Нет CAA, запрещающего Let's Encrypt.
4. E-mail реальный: на него приходят уведомления об истечении.
5. Домен не wildcard, если вы не выбрали `dns01` осознанно.
6. Токен DNS-провайдера лежит в файле `0600` и ограничен одной зоной.
7. Есть вторая SSH-сессия, если скрипт меняет firewall.
8. Для новой схемы сначала `--dry-run`, потом `--staging`, потом боевой.

## 9. Особенности 2 ГБ VPS

- certbot ставится в venv `/opt/remnawave/venv`; на диске это порядка
  сотни мегабайт, в памяти при выпуске — десятки мегабайт. На 2 ГБ это
  нормально, но не стоит одновременно тянуть образы и выпускать
  сертификат.
- Продление в 03:17 с разбросом до 6 часов снижает пик.
- Хранятся 5 релизов на домен; этого достаточно для отката и не
  занимает много места.
- Перезапуск ноды ради сертификата не нужен: nginx перечитывает
  конфиг, Xray — по возможности тоже (см. `XRAY_RESTART_ON_RENEW`).

## 10. Миграция со старой схемы

1. Старый путь `/opt/remnawave/nginx/ssl` больше не используется.
   Новый контракт — `/opt/remnawave/tls/<domain>/live`.
2. Общий HTTP-endpoint с приватным ключом и статическим токеном удалён.
   Ключ остаётся на ноде; перенос — только явным `push` по SSH.
3. Если старый сертификат ещё годен, его можно импортировать:
   `cert_manager.sh install --domain ... --cert ... --key ... --method import`.
4. После перехода проверьте в профиле Xray путь к сертификату: он должен
   указывать на новый `live`.
5. Старые ключи и токены, которые лежали в открытом виде, считайте
   скомпрометированными и перевыпустите.
