feat: предупреждение о доменах caddy.conf, которые не указывают на сервер

app_deploy возвращает домены сайтов из caddy.conf (caddy adapt), App после
деплоя сверяет их A/AAAA с адресом сервера, бот предупреждает: сайт не
откроется, сертификат не выпустится. Со своим CA сверки нет.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-01 09:22:05 +04:00
1 parent 803a6bf9f2
commit adcfb48750
7 files changed
+118 -8

No files matched your search

+3 -1
View File
@@ -9,6 +9,8 @@ Django приложение связывающее все части сайта
-[x] Супервизор долгих задач и ежедневная проверка точек
-[x] Docker-серверы: приложения в `~/apps` из бота
-[ ] Бэкапы named volumes docker-приложений: сейчас данные сервисов (база Gitea, RustDesk и т.п.) теряются при переустановке ОС и `remove` с `volumes`
-[ ] Проверка `reverse_proxy` из `caddy.conf` приложения: проверять не только домен, но и доступность самого docker-сервиса за прокси
-[ ] Опционально свой registry для образов docker-приложений
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
@@ -48,7 +50,7 @@ Django приложение связывающее все части сайта
- `bot/texts.py` - кнопки и тексты.
- `uploads.py` - разбор присланных файлов приложения без Django: zip, tar, tar.gz или один файл, слияние (`merge`), разница (`diff`) и проверка по конвенции (`check`: ошибки не дают применить, предупреждения - нет). Мусор архиваторов пропускается, пути наружу и ссылки - отказ, лимиты 5 Мб архив, 20 Мб распаковано, 500 файлов.
- `platforms.py` - сценарии docker-платформы поверх `serverus.DockerHost`: `install_platform`, `reinstall` (приложения разворачиваются заново с текущими версиями), `release_platform`, `create_app`, `prepare_upload`, `prepare_rollback`, `apply`, `control`, `logs`, `remove_app`, `refresh`.
- `platforms.py` - сценарии docker-платформы поверх `serverus.DockerHost`: `install_platform`, `reinstall` (приложения разворачиваются заново с текущими версиями), `release_platform`, `create_app`, `prepare_upload`, `prepare_rollback`, `apply` (после деплоя сверяет домены из `caddy.conf` с адресом сервера: `foreign_domains`, бот предупреждает о не указывающих на него), `control`, `logs`, `remove_app`, `refresh`.
## Docker-сервер
+7
View File
@@ -490,6 +490,13 @@ def deployed(name: str, data: dict) -> str:
recreated = data.get('recreated') or []
if recreated:
lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'<code>{escape(s)}</code>' for s in recreated))
for item in data.get('foreign_domains') or []:
domain = f'<code>{escape(item.domain)}</code>'
if item.addresses:
where = f'указывает на {escape(", ".join(item.addresses))}, а сервер - {escape(item.server)}'
else:
where = 'не найден в DNS'
lines.append(f'⚠️ Домен {domain} {where}: сайт по нему не откроется, сертификат не выпустится.')
return '\n'.join(lines)
+51 -2
View File
@@ -3,7 +3,9 @@
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
"""
import ipaddress
import logging
import socket
from dataclasses import dataclass
import serverus
@@ -26,6 +28,15 @@ class Upload:
warnings: list[str]
@dataclass(frozen=True)
class ForeignDomain:
"""Домен из caddy.conf, который не указывает на сервер: сайт по нему не откроется, сертификат не выпустится."""
domain: str
addresses: tuple[str, ...]
"""Адреса домена того же семейства, что адрес сервера. Пусто - домен не найден в DNS."""
server: str
def docker(platform: Platform) -> serverus.DockerHost:
return serverus.DockerHost(platform.server.host())
@@ -112,7 +123,8 @@ def _draft(app: DockerApp, old: dict[str, bytes], files: dict[str, bytes], kind:
def apply(version: AppVersion) -> dict:
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services.
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services,
domains, и foreign_domains - домены из caddy.conf, которые не указывают на сервер (см. foreign_domains).
Ошибка - версия отмечается неудачной. Ошибка проверки compose-файла, его портов или caddy.conf - на сервере остается
прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются.
@@ -127,7 +139,44 @@ def apply(version: AppVersion) -> dict:
raise
version.mark_applied()
_refresh_after(platform)
return result.data
return {**result.data, 'foreign_domains': foreign_domains(platform, result.data.get('domains') or [])}
def foreign_domains(platform: Platform, domains: list[str]) -> list[ForeignDomain]:
"""Домены, которые не указывают на сервер платформы: Let's Encrypt не выпустит для них сертификат.
Сверяются записи того же семейства, что адрес сервера (A для IPv4). Wildcard (для него нужен DNS-challenge) и адреса
вместо доменов пропускаются. Со своим CA (local_ca) сертификаты выпускаются без проверки домена, сверки нет.
"""
if local_ca() is not None:
return []
server = ipaddress.ip_address(platform.server.address)
family = socket.AF_INET6 if server.version == 6 else socket.AF_INET
result = []
for domain in domains:
if '*' in domain or _is_ip(domain):
continue
addresses = _resolve(domain, family)
if not addresses or any(ipaddress.ip_address(address) != server for address in addresses):
result.append(ForeignDomain(domain, addresses, str(server)))
return result
def _is_ip(value: str) -> bool:
try:
ipaddress.ip_address(value)
except ValueError:
return False
return True
def _resolve(domain: str, family: int) -> tuple[str, ...]:
"""Адреса домена. Не нашелся или DNS не ответил - пусто: деплой уже прошел, проверка его не роняет."""
try:
infos = socket.getaddrinfo(domain, None, family, socket.SOCK_STREAM)
except (OSError, UnicodeError):
return ()
return tuple(sorted({info[4][0] for info in infos}))
def control(app: DockerApp, action: str, service: str = ''):
+34 -3
View File
@@ -1,11 +1,12 @@
import io
import socket
import tarfile
import zipfile
from unittest import mock
import serverus
from django.db import IntegrityError
from django.test import TestCase
from django.test import TestCase, override_settings
from Telegram import interfaces
from Telegram.models import Bot, Chat, User as TelegramUser
from Telegram.testing import Conversation, FakeApi
@@ -55,10 +56,11 @@ def make_tar(files: dict[str, bytes], symlink: str = '') -> bytes:
class DockerRunner(FakeRunner):
"""FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy."""
def __init__(self, fail=None, snapshot=None):
def __init__(self, fail=None, snapshot=None, domains=()):
super().__init__(fail=fail)
self.deployed = {}
self.snapshot = SNAPSHOT if snapshot is None else snapshot
self.domains = list(domains)
def __call__(self, playbook, host, extravars):
result = super().__call__(playbook, host, extravars)
@@ -71,7 +73,7 @@ class DockerRunner(FakeRunner):
return result
data = {
'app_status': self.snapshot,
'app_deploy': {'changed': [], 'removed': [], 'recreated': ['web'], 'services': ['web']},
'app_deploy': {'changed': [], 'removed': [], 'recreated': ['web'], 'services': ['web'], 'domains': self.domains},
'app_logs': {'logs': 'line 1\nline 2'},
}.get(playbook)
if data is None:
@@ -320,6 +322,35 @@ class PlatformScenarioTests(TestCase):
platform.refresh_from_db()
self.assertEqual((platform.status, platform.status_snapshot), (Platform.Status.READY, SNAPSHOT))
def test_apply_reports_foreign_domains(self):
app = DockerApp.objects.create(platform=ready_platform(), name='git')
upload = platforms.prepare_upload(app, 'app.tar.gz', make_tar({'docker-compose.yaml': COMPOSE}), AppVersion.Kind.FULL)
answers = {'ok.example.com': ['10.0.1.1'], 'git.example.com': ['93.184.215.14'], 'half.example.com': ['10.0.1.1', '10.0.1.2']}
def getaddrinfo(host, port, family, kind):
if host not in answers:
raise socket.gaierror(socket.EAI_NONAME, 'Name or service not known')
return [(family, kind, 6, '', (address, 0)) for address in answers[host]]
domains = ['ok.example.com', 'git.example.com', 'half.example.com', 'none.example.com', '*.example.com', '10.0.1.2']
with patch_runner(DockerRunner(domains=domains)), mock.patch('App.platforms.socket.getaddrinfo', getaddrinfo):
data = platforms.apply(upload.version)
self.assertEqual(data['foreign_domains'], [
platforms.ForeignDomain('git.example.com', ('93.184.215.14',), '10.0.1.1'),
platforms.ForeignDomain('half.example.com', ('10.0.1.1', '10.0.1.2'), '10.0.1.1'),
platforms.ForeignDomain('none.example.com', (), '10.0.1.1'),
])
message = texts.deployed('git', data)
self.assertIn('<code>git.example.com</code> указывает на 93.184.215.14, а сервер - 10.0.1.1', message)
self.assertIn('<code>none.example.com</code> не найден в DNS', message)
self.assertNotIn('ok.example.com', message)
@override_settings(SERVERUS_CERTIFICATES='local')
def test_local_ca_skips_domain_check(self):
with mock.patch('App.platforms.socket.getaddrinfo') as getaddrinfo:
self.assertEqual(platforms.foreign_domains(ready_platform(), ['git.example.com']), [])
getaddrinfo.assert_not_called()
def test_apply_failure_keeps_previous_version(self):
platform = ready_platform()
app = deployed_app(platform)
+1 -1
View File
@@ -90,7 +90,7 @@ app.example.com {
Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер.
- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл, его опубликованные порты (после подстановки переменных из `.env`: не слушает ли их уже сервер или контейнер другого приложения) и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Упал сам `up` (например, образ не скачался) - файлы на сервере уже новые, отката нет: исправленные файлы нужно залить заново. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`.
- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл, его опубликованные порты (после подстановки переменных из `.env`: не слушает ли их уже сервер или контейнер другого приложения) и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Упал сам `up` (например, образ не скачался) - файлы на сервере уже новые, отката нет: исправленные файлы нужно залить заново. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`, `domains` - хосты сайтов из `caddy.conf` (`caddy adapt`, включая wildcard): вызывающий может проверить, что они указывают на сервер, иначе сертификат не выпустится.
- `status()` - все приложения: сервисы из compose-файла, сверенные с `docker ps -a`. Сервис без контейнера - `missing`. `data`: `{'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}`.
- `control(name, action, service='')` - `start` (`up -d`), `stop`, `restart`, `pull` (новые образы и `up -d`) приложения или одного сервиса.
- `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб.
+3 -1
View File
@@ -54,7 +54,9 @@ class DockerHost(Server):
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные.
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...]}.
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...], 'domains': [...]}.
domains - хосты сайтов из CADDY_FILE как их видит Caddy, в том числе wildcard: Caddy выпустит для них сертификаты,
только если они указывают на сервер.
"""
_check_app(name)
if name == INGRESS:
+19
View File
@@ -2,6 +2,7 @@
#
# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере)
# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто.
# Домены сайтов из caddy.conf (caddy adapt) возвращаются в результате.
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
# Упал (например, образ не скачался) - файлы уже новые, отката нет.
@@ -75,6 +76,17 @@
changed_when: false
when: new_caddy_conf.stat.exists
# домены сайтов из блока: вызывающий проверит, что они указывают на сервер, иначе сертификат не выпустится
- name: Read caddy.conf domains
ansible.builtin.command: >-
docker compose exec -T caddy caddy adapt --adapter caddyfile
--config /etc/caddy/.check/sites/{{ (app + '.caddy') | quote }}
args:
chdir: "{{ apps_dir }}/caddy"
register: caddy_adapt
changed_when: false
when: new_caddy_conf.stat.exists
- name: Sync files
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }}
register: sync_out
@@ -102,6 +114,12 @@
ansible.builtin.set_fact:
sync: "{{ sync_out.stdout | from_json }}"
services: "{{ services_out.stdout_lines }}"
domains: >-
{{ [] if caddy_adapt is skipped else
((caddy_adapt.stdout | from_json).get('apps', {}).get('http', {}).get('servers', {}) | dict2items
| map(attribute='value') | selectattr('routes', 'defined') | map(attribute='routes') | flatten(levels=1)
| selectattr('match', 'defined') | map(attribute='match') | flatten(levels=1)
| selectattr('host', 'defined') | map(attribute='host') | flatten(levels=1) | unique | list) }}
# новое приложение: up только что создал все контейнеры с присланными настройками
recreate: >-
{{ [] if (sync_out.stdout | from_json).created else
@@ -181,3 +199,4 @@
removed: "{{ sync.removed }}"
recreated: "{{ recreate }}"
services: "{{ services }}"
domains: "{{ domains }}"