
Если вы выпускаете сертификаты Let’s Encrypt на сервере за домашним роутером, вам знакома ситуация: HTTP-01 challenge требует, чтобы порт 80 был доступен снаружи. Держать 80-й порт постоянно открытым очень плохая идея.
Решение: открывать порт только на время самого продления, а сразу после - закрывать. И всё это автоматически, без участия человека.
Зачем это вообще нужно#
certbot с аутентификатором standalone поднимает свой веб-сервер на порту 80 и ждёт, пока Let’s Encrypt придёт по http://ваш-домен/.well-known/acme-challenge/.... Чтобы этот запрос дошёл:
- на роутере должен быть проброшен порт 80 (DNAT) на сервер;
- сам порт 80 на сервере должен быть свободен (иначе
standaloneне поднимется -Address already in use).
В моём случае на сервере уже крутится nginx, который слушает 80-й. Поэтому в схему пришлось добавить ещё и остановку/запуск nginx вокруг продления.
DietPi и certbot: что на самом деле делает скрипт#
Кажется логичным: раз это DietPi, certbot автоматически продлит сертификат сам. Я заглянул в сам скрипт /boot/dietpi/dietpi-letsencrypt - и вот что там реально происходит.
Во-первых, это не автоматика. dietpi-letsencrypt - интерактивный ручной инструмент. Он запускается вручную из меню, выпускает (или перевыпускает) сертификат один раз и выходит. Никакого cron-задания на переиздание DietPi не ставит - авто-продление целиком на совести certbot.timer.
Во-вторых, метод выпуска зависит от того, что уже стоит на сервере:
- есть nginx → используется плагин
--nginx(скрипт сам выбираетlocal aoptions=('--nginx')); - есть lighttpd →
--webroot -w /var/www; - веб-сервер не обнаружен → fallback на
--standalone, и вот тут DietPi сама останавливает занимающие :80 сервисы (fuser 80/tcp && dietpi-services stop) перед challenge и поднимает их обратно (dietpi-services start) после.
То есть при штатном сценарии с nginx certbot вообще не трогает работающий веб-сервер - плагин --nginx сам договаривается с ним и отдаёт challenge через него. Никакого стопа.
Почему же у меня в renewal-conf написано authenticator = standalone? Потому что сертификат когда-то выпускался вручную в ситуации, когда либо nginx ещё не слушал 80-й. И вот тут - подвох: standalone и работающий nginx несовместимы. Certbot пытается открыть сокет на :80, порт уже занят nginx, и продление падает с Address already in use.
А документация DietPi рекомендует:
Keep port 80 open for Certbot renewal
Это справедливо для штатного пути (плагин веб-сервера): nginx сам слушает 80-й и сам отдаёт challenge, поэтому «держать порт открытым» означает просто не выключать проброс. Но это противоречит желанию не держать 80-й постоянно торчащим наружу. Наша схема с pre/post-hook - это ровно та же идея, но с закрытием порта, когда он не нужен: порт на роутере открывается на пару секунд только для HTTP-01 challenge и тут же закрывается.
Идея#
Certbot умеет выполнять скрипты до (pre-hook) и после (post-hook) попытки продления. post-hook вызывается всегда - даже при ошибке, - поэтому порт гарантированно закроется.
План такой:
pre-hook- останавливаем nginx на сервере и открываем порт 80 на роутере.- certbot делает свои дела.
post-hook- запускаем nginx обратно и закрываем порт на роутере.
SSH-доступ к роутеру#
Скрипты управляют файрволом самого роутера — поэтому с сервера должен быть беспарольный SSH-доступ к нему. Обычный логин по паролю не подойдёт: в скриптах стоит ssh -o BatchMode=yes, и если ключ не настроен, соединение просто упадёт, а порт не откроется.
Что нужно сделать один раз:
Включить SSH на роутере.
Закинуть публичный ключ сервера на роутер, чтобы вход был без пароля.
Прописать алиас в
~/.ssh/configна сервере, чтобы скрипт не хранил ни IP, ни порт, ни имя пользователя:Host router Hostname 192.168.x.x Port <порт> User root IdentityFile ~/.ssh/id_bla_bla_bla
Теперь ssh router "uci show firewall" из скрипта работает без дополнительных параметров, а в самих скриптах достаточно ssh -o BatchMode=yes router "...".
Скрипты на сервере#
Кладём два скрипта, например, в /root/myscripts/. Путь до роутера настраиваем через ~/.ssh/config (отдельный Host), чтобы скрипт не содержал ни IP, ни ключей.
cert-port-open.sh - открывает порт и освобождает :80:
#!/bin/bash
# pre-hook: открыть порт 80 на роутере, остановить nginx для standalone
set -u
ROUTER_HOST="router" # алиас из ~/.ssh/config
FW_RULE="firewall.<cfg-id>" # uci-ид правила DNAT (см. ниже)
systemctl stop nginx
ssh -o StrictHostKeyChecking=no -o BatchMode=yes "$ROUTER_HOST" "
uci set ${FW_RULE}.enabled=1
uci commit firewall
fw4 reload
" >/dev/null 2>&1
exit 0cert-port-close.sh - закрывает порт и поднимает nginx:
#!/bin/bash
# post-hook: закрыть порт 80 на роутере, запустить nginx
set -u
ROUTER_HOST="router"
FW_RULE="firewall.<cfg-id>"
systemctl start nginx
ssh -o StrictHostKeyChecking=no -o BatchMode=yes "$ROUTER_HOST" "
uci set ${FW_RULE}.enabled=0
uci commit firewall
fw4 reload
" >/dev/null 2>&1
exit 0Готовые версии скриптов можно скачать здесь: cert-port-open.sh и cert-port-close.sh.
Настройка роутера#
На роутере создаём (или находим) правило DNAT, которое внешний порт 80 шлёт на сервер. Через UCI:
# один раз, на самом роутере (замените IP на адрес вашего сервера)
uci add firewall redirect
uci set firewall.@redirect[-1].enabled='0' # по умолчанию ВЫКЛЮЧЕНО
uci set firewall.@redirect[-1].dest='lan'
uci set firewall.@redirect[-1].target='DNAT'
uci set firewall.@redirect[-1].name='LSEcrypt'
uci set firewall.@redirect[-1].src='wan'
uci set firewall.@redirect[-1].src_dport='80'
uci set firewall.@redirect[-1].dest_ip='192.168.x.x'
uci set firewall.@redirect[-1].dest_port='80'
uci commit firewall
fw4 reloadСкрипты выше переключают enabled этого правила между 1 и 0. Чтобы узнать его cfg-ид, выполните на роутере uci show firewall | grep -B1 LSEcrypt и подставьте в FW_RULE.
Что такое <cfg-id>: в OpenWrt каждая секция конфига firewall при сохранении получает внутренний идентификатор вида cfgXXXXXX (набор букв и цифр, например cfg103837). Он уникален для каждой записи и используется в UCI для точного обращения к конкретному правилу, когда имя (name) неоднозначно или не задано. Именно поэтому в скриптах фигурирует firewall.<cfg-id>, а не человекочитаемое имя - чтобы команда uci set била точно в нужное правило. Вместо <cfg-id> подставьте свой реальный идентификатор (то, что показала команда выше).
Мой роутер - OpenWrt 24.10 с fw4; для старых версий на fw3 команда перезагрузки правил та же по смыслу (fw3 reload).
Подключаем хуки в certbot#
Прописываем хуки глобально, в /etc/letsencrypt/cli.ini:
pre-hook = /root/myscripts/cert-port-open.sh
post-hook = /root/myscripts/cert-port-close.shТеперь любой certbot renew (будь то по systemd-таймеру или вручную) откроет порт только на время выпуска и закроет сразу после.
Проверка#
Dry-run прогоняет хуки реально (не только симулирует challenge), так что это честная проверка:
certbot renew --dry-run --force-renewalВ логе /var/log/letsencrypt/letsencrypt.log должны появиться строки:
Running pre-hook command: /root/myscripts/cert-port-open.sh
Running post-hook command: /root/myscripts/cert-port-close.shПосле прогона убедитесь, что порт на роутере снова закрыт (uci get firewall.<cfg>.enabled → 0), а nginx снова active.
Итог#
Порт 80 на роутере теперь открыт ровно на несколько секунд, пока идёт HTTP-01 challenge, и автоматически закрывается. Никакого постоянно торчащего 80-го порта, никаких ручных действий. Авто-обновление сертификатов работает само.


