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] Супервизор долгих задач и ежедневная проверка точек
-[x] Docker-серверы: приложения в `~/apps` из бота -[x] Docker-серверы: приложения в `~/apps` из бота
-[ ] Бэкапы named volumes docker-приложений: сейчас данные сервисов (база Gitea, RustDesk и т.п.) теряются при переустановке ОС и `remove` с `volumes` -[ ] Бэкапы named volumes docker-приложений: сейчас данные сервисов (база Gitea, RustDesk и т.п.) теряются при переустановке ОС и `remove` с `volumes`
-[ ] Проверка `reverse_proxy` из `caddy.conf` приложения: проверять не только домен, но и доступность самого docker-сервиса за прокси
-[ ] Опционально свой registry для образов docker-приложений
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов). Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
@@ -48,7 +50,7 @@ Django приложение связывающее все части сайта
- `bot/texts.py` - кнопки и тексты. - `bot/texts.py` - кнопки и тексты.
- `uploads.py` - разбор присланных файлов приложения без Django: zip, tar, tar.gz или один файл, слияние (`merge`), разница (`diff`) и проверка по конвенции (`check`: ошибки не дают применить, предупреждения - нет). Мусор архиваторов пропускается, пути наружу и ссылки - отказ, лимиты 5 Мб архив, 20 Мб распаковано, 500 файлов. - `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-сервер ## Docker-сервер
+7
View File
@@ -490,6 +490,13 @@ def deployed(name: str, data: dict) -> str:
recreated = data.get('recreated') or [] recreated = data.get('recreated') or []
if recreated: if recreated:
lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'<code>{escape(s)}</code>' for s in 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) return '\n'.join(lines)
+51 -2
View File
@@ -3,7 +3,9 @@
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной. получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
""" """
import ipaddress
import logging import logging
import socket
from dataclasses import dataclass from dataclasses import dataclass
import serverus import serverus
@@ -26,6 +28,15 @@ class Upload:
warnings: list[str] warnings: list[str]
@dataclass(frozen=True)
class ForeignDomain:
"""Домен из caddy.conf, который не указывает на сервер: сайт по нему не откроется, сертификат не выпустится."""
domain: str
addresses: tuple[str, ...]
"""Адреса домена того же семейства, что адрес сервера. Пусто - домен не найден в DNS."""
server: str
def docker(platform: Platform) -> serverus.DockerHost: def docker(platform: Platform) -> serverus.DockerHost:
return serverus.DockerHost(platform.server.host()) 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: 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 - на сервере остается Ошибка - версия отмечается неудачной. Ошибка проверки compose-файла, его портов или caddy.conf - на сервере остается
прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются. прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются.
@@ -127,7 +139,44 @@ def apply(version: AppVersion) -> dict:
raise raise
version.mark_applied() version.mark_applied()
_refresh_after(platform) _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 = ''): def control(app: DockerApp, action: str, service: str = ''):
+34 -3
View File
@@ -1,11 +1,12 @@
import io import io
import socket
import tarfile import tarfile
import zipfile import zipfile
from unittest import mock from unittest import mock
import serverus import serverus
from django.db import IntegrityError from django.db import IntegrityError
from django.test import TestCase from django.test import TestCase, override_settings
from Telegram import interfaces from Telegram import interfaces
from Telegram.models import Bot, Chat, User as TelegramUser from Telegram.models import Bot, Chat, User as TelegramUser
from Telegram.testing import Conversation, FakeApi from Telegram.testing import Conversation, FakeApi
@@ -55,10 +56,11 @@ def make_tar(files: dict[str, bytes], symlink: str = '') -> bytes:
class DockerRunner(FakeRunner): class DockerRunner(FakeRunner):
"""FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy.""" """FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy."""
def __init__(self, fail=None, snapshot=None): def __init__(self, fail=None, snapshot=None, domains=()):
super().__init__(fail=fail) super().__init__(fail=fail)
self.deployed = {} self.deployed = {}
self.snapshot = SNAPSHOT if snapshot is None else snapshot self.snapshot = SNAPSHOT if snapshot is None else snapshot
self.domains = list(domains)
def __call__(self, playbook, host, extravars): def __call__(self, playbook, host, extravars):
result = super().__call__(playbook, host, extravars) result = super().__call__(playbook, host, extravars)
@@ -71,7 +73,7 @@ class DockerRunner(FakeRunner):
return result return result
data = { data = {
'app_status': self.snapshot, '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'}, 'app_logs': {'logs': 'line 1\nline 2'},
}.get(playbook) }.get(playbook)
if data is None: if data is None:
@@ -320,6 +322,35 @@ class PlatformScenarioTests(TestCase):
platform.refresh_from_db() platform.refresh_from_db()
self.assertEqual((platform.status, platform.status_snapshot), (Platform.Status.READY, SNAPSHOT)) 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): def test_apply_failure_keeps_previous_version(self):
platform = ready_platform() platform = ready_platform()
app = deployed_app(platform) app = deployed_app(platform)
+1 -1
View File
@@ -90,7 +90,7 @@ app.example.com {
Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер. 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': ''}}}`. - `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`) приложения или одного сервиса. - `control(name, action, service='')` - `start` (`up -d`), `stop`, `restart`, `pull` (новые образы и `up -d`) приложения или одного сервиса.
- `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб. - `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб.
+3 -1
View File
@@ -54,7 +54,9 @@ class DockerHost(Server):
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE - конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой. перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные. Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные.
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...]}. Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...], 'domains': [...]}.
domains - хосты сайтов из CADDY_FILE как их видит Caddy, в том числе wildcard: Caddy выпустит для них сертификаты,
только если они указывают на сервер.
""" """
_check_app(name) _check_app(name)
if name == INGRESS: if name == INGRESS:
+19
View File
@@ -2,6 +2,7 @@
# #
# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере) # 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере)
# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто. # и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто.
# Домены сайтов из caddy.conf (caddy adapt) возвращаются в результате.
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются. # 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией. # 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
# Упал (например, образ не скачался) - файлы уже новые, отката нет. # Упал (например, образ не скачался) - файлы уже новые, отката нет.
@@ -75,6 +76,17 @@
changed_when: false changed_when: false
when: new_caddy_conf.stat.exists 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 - name: Sync files
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }} ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }}
register: sync_out register: sync_out
@@ -102,6 +114,12 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
sync: "{{ sync_out.stdout | from_json }}" sync: "{{ sync_out.stdout | from_json }}"
services: "{{ services_out.stdout_lines }}" 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 только что создал все контейнеры с присланными настройками # новое приложение: up только что создал все контейнеры с присланными настройками
recreate: >- recreate: >-
{{ [] if (sync_out.stdout | from_json).created else {{ [] if (sync_out.stdout | from_json).created else
@@ -181,3 +199,4 @@
removed: "{{ sync.removed }}" removed: "{{ sync.removed }}"
recreated: "{{ recreate }}" recreated: "{{ recreate }}"
services: "{{ services }}" services: "{{ services }}"
domains: "{{ domains }}"