Перейти к основному содержимому

Авто-открытие порта 80 на OpenWrt для certbot

·1021 слово·5 минут·
logo

Если вы выпускаете сертификаты Let’s Encrypt на сервере за домашним роутером, вам знакома ситуация: HTTP-01 challenge требует, чтобы порт 80 был доступен снаружи. Держать 80-й порт постоянно открытым очень плохая идея.

Решение: открывать порт только на время самого продления, а сразу после - закрывать. И всё это автоматически, без участия человека.

Зачем это вообще нужно
#

certbot с аутентификатором standalone поднимает свой веб-сервер на порту 80 и ждёт, пока Let’s Encrypt придёт по http://ваш-домен/.well-known/acme-challenge/.... Чтобы этот запрос дошёл:

  1. на роутере должен быть проброшен порт 80 (DNAT) на сервер;
  2. сам порт 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 вызывается всегда - даже при ошибке, - поэтому порт гарантированно закроется.

План такой:

  1. pre-hook - останавливаем nginx на сервере и открываем порт 80 на роутере.
  2. certbot делает свои дела.
  3. post-hook - запускаем nginx обратно и закрываем порт на роутере.

SSH-доступ к роутеру
#

Скрипты управляют файрволом самого роутера — поэтому с сервера должен быть беспарольный SSH-доступ к нему. Обычный логин по паролю не подойдёт: в скриптах стоит ssh -o BatchMode=yes, и если ключ не настроен, соединение просто упадёт, а порт не откроется.

Что нужно сделать один раз:

  1. Включить SSH на роутере.

  2. Закинуть публичный ключ сервера на роутер, чтобы вход был без пароля.

  3. Прописать алиас в ~/.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 0

cert-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>.enabled0), а nginx снова active.

Итог
#

Порт 80 на роутере теперь открыт ровно на несколько секунд, пока идёт HTTP-01 challenge, и автоматически закрывается. Никакого постоянно торчащего 80-го порта, никаких ручных действий. Авто-обновление сертификатов работает само.

Related