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 }}"