From adcfb48750cd6df7f5fd11a07920a6f27c8fcb63 Mon Sep 17 00:00:00 2001 From: protokey Date: Thu, 1 Oct 2026 09:22:05 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=B0=D1=85=20caddy.conf,=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=83=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app_deploy возвращает домены сайтов из caddy.conf (caddy adapt), App после деплоя сверяет их A/AAAA с адресом сервера, бот предупреждает: сайт не откроется, сертификат не выпустится. Со своим CA сверки нет. Co-Authored-By: Claude Opus 5.5 --- src/App/README.md | 4 +- src/App/bot/texts.py | 7 ++++ src/App/platforms.py | 53 ++++++++++++++++++++++++++- src/App/tests_platforms.py | 37 +++++++++++++++++-- src/serverus/README.md | 2 +- src/serverus/docker.py | 4 +- src/serverus/playbooks/app_deploy.yml | 19 ++++++++++ 7 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/App/README.md b/src/App/README.md index f985ecf..a9e39a2 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -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-сервер diff --git a/src/App/bot/texts.py b/src/App/bot/texts.py index ef23f2b..6d9a481 100644 --- a/src/App/bot/texts.py +++ b/src/App/bot/texts.py @@ -490,6 +490,13 @@ def deployed(name: str, data: dict) -> str: recreated = data.get('recreated') or [] if recreated: lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'{escape(s)}' for s in recreated)) + for item in data.get('foreign_domains') or []: + domain = f'{escape(item.domain)}' + if item.addresses: + where = f'указывает на {escape(", ".join(item.addresses))}, а сервер - {escape(item.server)}' + else: + where = 'не найден в DNS' + lines.append(f'⚠️ Домен {domain} {where}: сайт по нему не откроется, сертификат не выпустится.') return '\n'.join(lines) diff --git a/src/App/platforms.py b/src/App/platforms.py index ab133e4..640f9e6 100644 --- a/src/App/platforms.py +++ b/src/App/platforms.py @@ -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 = ''): diff --git a/src/App/tests_platforms.py b/src/App/tests_platforms.py index dfdbab7..2990441 100644 --- a/src/App/tests_platforms.py +++ b/src/App/tests_platforms.py @@ -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('git.example.com указывает на 93.184.215.14, а сервер - 10.0.1.1', message) + self.assertIn('none.example.com не найден в 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) diff --git a/src/serverus/README.md b/src/serverus/README.md index 8a9757c..ae9dd93 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -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 Мб. diff --git a/src/serverus/docker.py b/src/serverus/docker.py index 1d2c042..100fc0d 100644 --- a/src/serverus/docker.py +++ b/src/serverus/docker.py @@ -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: diff --git a/src/serverus/playbooks/app_deploy.yml b/src/serverus/playbooks/app_deploy.yml index e3bdfaf..f6ac444 100644 --- a/src/serverus/playbooks/app_deploy.yml +++ b/src/serverus/playbooks/app_deploy.yml @@ -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 }}"