Compare commits

..
7 Commits
Author SHA1 Message Date
protokey f766242c44 chore: update 2026-10-05 18:40:15 +04:00
protokeyandClaude Opus 5.5 adcfb48750 feat: предупреждение о доменах caddy.conf, которые не указывают на сервер
app_deploy возвращает домены сайтов из caddy.conf (caddy adapt), App после
деплоя сверяет их A/AAAA с адресом сервера, бот предупреждает: сайт не
откроется, сертификат не выпустится. Со своим CA сверки нет.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:22:05 +04:00
protokeyandClaude Opus 5.5 803a6bf9f2 fix: файлы своего Bot API сервера без TELEGRAM_FILES_URL и токен в ошибке
Без TELEGRAM_FILES_URL адрес скачивания был голым путем, а текст ошибки с путем
(в нем токен бота) уходил в чат. Теперь сайт не стартует без TELEGRAM_FILES_URL
при заданном TELEGRAM_API_URL, а в ошибке скачивания только тип исключения.
env_example: 127.0.0.1 вместо localhost, чтобы не пробовать ::1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:22:05 +04:00
protokeyandClaude Opus 5.5 a90c265ff3 fix: воркер ждет готовности rabbitmq, локально один процесс
healthcheck rabbitmq по check_port_connectivity: web и worker стартуют после
service_healthy, make dev поднимает сервисы с --wait. Иначе docker-proxy
принимает TCP до старта брокера и pika спамит IncompatibleProtocolError.
dj_worker: --processes 1 --threads 1 вместо процесса на каждое ядро.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:28:11 +04:00
protokey deaa5b04db chore: update 2026-10-01 08:16:15 +04:00
protokeyandClaude Opus 5.5 fd4b05097a feat: публикация портов не-HTTP сервисами, проверка портов до деплоя, пример RustDesk
serverus: app_ports.py проверяет опубликованные порты приложения до
изменений - по docker compose config (переменные из .env подставлены)
против слушающих сокетов сервера и портов контейнеров других приложений.
Занятый порт больше не роняет up после замены файлов. Конвенция: только
мост; не-HTTP сервисы публикуют свои порты, 80 и 443 - у caddy;
bind-mount только своих файлов из data/<сервис>/ с :ro. Описано, что
упавший up после синхронизации файлы не откатывает.

App: uploads.check предупреждает о 80/443 вместо любых портов и о
bind-mount из папки приложения вне data/ или без :ro.

docs/apps/rustdesk: приложение (hbbs, hbbr, ключ генерируем сами),
genkey.py и check.py - проверка сервера по протоколу RustDesk снаружи.
Проверено на стенде Debian 13: деплой, повторный деплой, отказ второго
приложения на тех же портах до изменений, перезагрузка сервера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:31:38 +04:00
protokeyandClaude Opus 5.5 a8d1f286d1 feat: docker-серверы - приложения в ~/apps под управлением бота
serverus: DockerHost - установка Docker и ingress Caddy, deploy приложения
с проверкой compose и caddy.conf до изменений и пересозданием только
изменившихся сервисов, status, control, logs, remove. nftables
перезапускается только при изменении конфига, Docker следом
восстанавливает свои правила; release восстанавливает конфиг Debian
вместо остановки nftables.

Telegram: прием документов и отправка файлов, раздача файлов своего
Bot API сервера через nginx (telegram-files).

App: общая модель Managed для Point и Platform, DockerApp и версии
файлов AppVersion, разбор архивов (uploads), сценарии платформы
(platforms), задачи и меню docker-серверов в боте.

Проверено на стенде Debian 13 с local_certs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:27:05 +04:00
51 changed files with 4194 additions and 240 deletions

No files matched your search

+1
View File
@@ -6,3 +6,4 @@ src/design/static_root/
__pycache__/
wheels/
.env
docker-services-data/
+29 -2
View File
@@ -19,7 +19,7 @@ SERVERUS_CA = $(if $(filter yes,$(ca)),--ca $(SERVERUS_CA_DIR))
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker dj_secrets dj_backup dj_restore dj_check_points docker_build docker_up serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker dj_secrets dj_backup dj_restore dj_check_points docker_build docker_up dev dev_stop serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release serverus_docker serverus_app
help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -31,12 +31,16 @@ help:
@echo "make dj_secrets - сгенерировать недостающие секреты в .env"
@echo "make dj_backup [keep=14] - бэкап базы в extra/backups, keep - сколько последних оставить"
@echo "make dj_restore file=<dump> - восстановить базу из бэкапа (сайт и воркер остановлены)"
@echo "make dev - разработка: инфраструктура в docker, миграции, сайт и воркер из $(VENV) (Ctrl+C - остановить оба)"
@echo "make dev_stop - остановить инфраструктуру разработки"
@echo "make docker_build / docker_up - собрать образ и поднять все в docker (профиль app)"
@echo "make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] [ca=yes] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
@echo "make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
@echo "make serverus_release ip=<ip> [password=<new_root_password>] - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
@echo "make serverus_docker ip=<ip> password=<root_password> [local_certs=yes] - настроить свежий сервер и поставить Docker с ingress Caddy"
@echo "make serverus_app ip=<ip> action=deploy|status|logs|start|stop|restart|pull|remove [name=<app>] [dir=<local_app_dir>] [service=<service>] [volumes=yes] - приложение на docker-сервере"
venv:
@test -d $(VENV) || python3 -m venv $(VENV)
@@ -87,8 +91,9 @@ dj_run:
dj_test:
$(MANAGE) test --settings=MyPointVPN.settings.test
# по умолчанию dramatiq берет процессы по числу ядер: для разработки хватает одного потока
dj_worker:
$(MANAGE) rundramatiq
$(MANAGE) rundramatiq --processes 1 --threads 1
# секреты в .env: дописывает только отсутствующие и пустые, FIELD_ENCRYPTION_KEYS не перезаписывает
dj_secrets:
@@ -115,6 +120,20 @@ docker_build: wheels
docker_up:
docker compose --profile app up -d
# разработка: в docker только то, что нужно сайту и воркеру; сами они - из venv, с перезагрузкой runserver
DEV_SERVICES := postgres rabbitmq telegram telegram-files
dev:
@# --wait: rabbitmq до healthy, иначе воркер стучится в еще не открытый порт
docker compose up -d --wait $(DEV_SERVICES)
@until docker compose exec -T postgres pg_isready -q; do sleep 1; done
$(MANAGE) migrate --noinput
@# Ctrl+C получают оба процесса
$(MAKE) --no-print-directory -j2 dj_run dj_worker
dev_stop:
docker compose stop $(DEV_SERVICES)
dj_startapp:
$(MANAGE) startapp $(app)
@@ -142,3 +161,11 @@ serverus_outbound:
serverus_release:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(password),--password '$(password)')
serverus_docker:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_docker ip=<ip> password=<root_password>"; exit 1; }
$(SERVERUS) docker $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(filter yes,$(local_certs)),--local-certs)
serverus_app:
@test -n "$(ip)" -a -n "$(action)" || { echo "Укажите приложение: make serverus_app ip=<ip> action=deploy|status|... [name=<app>] [dir=<local_app_dir>]"; exit 1; }
$(SERVERUS) app $(action) $(name) $(if $(dir),$(abspath $(dir))) --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(service),--service '$(service)') $(if $(filter yes,$(volumes)),--volumes)
+3 -3
View File
@@ -300,9 +300,9 @@ https://1.2.3.4:12839/9g72PmSGAQ3V/<subId>
### Запуск
1. `cp env_example .env` и заполнить: база, RabbitMQ, `FIELD_ENCRYPTION_KEYS`, `SERVERUS_CERTIFICATES`, `TELEGRAM_WEBHOOK_URL`, `ALLOWED_HOSTS`.
2. `docker compose up -d` - Postgres, RabbitMQ, свой Bot API сервер.
3. `make sync`, `make dj_migrate`, `make dj_superuser`.
4. Два процесса: `make dj_run` (сайт, админка и webhook ботов), `make dj_worker` (установка и возврат серверов).
2. `make sync`.
3. `make dev` - Postgres, RabbitMQ и свой Bot API сервер в docker, миграции, затем сайт (`dj_run`: админка и webhook ботов) и воркер (`dj_worker`: установка и возврат серверов) в одном терминале. Ctrl+C останавливает оба, `make dev_stop` - инфраструктуру. По отдельности: `docker compose up -d postgres rabbitmq telegram telegram-files`, `make dj_migrate`, `make dj_run`, `make dj_worker`.
4. При первом запуске - `make dj_superuser`.
5. В админке добавить бота с токеном, выбрать интерфейс MyPoint и опубликовать.
Подробности: `src/App/README.md` (сценарии, секреты, ручной вход `make serve_ssh`), `src/serverus/README.md` (плейбуки, ручная проверка на сервере), `src/Telegram/README.md` (боты).
+38 -2
View File
@@ -25,6 +25,18 @@ services:
# - "127.0.0.1:8081:8081"
# - "127.0.0.1:8082:8082"
telegram-files:
# файлы, присланные ботам документами: Bot API сервер в --local режиме не отдает их по HTTP, а пишет на диск
# с правами 0600 от uid 101. Воркеры nginx работают от того же uid 101. Отдаются только documents/
image: docker.local/nginx:1.30.5-alpine-slim
configs:
- source: telegram-files
target: /etc/nginx/conf.d/default.conf
volumes:
- telegram-bot-api-data:/var/lib/telegram-bot-api:ro
network_mode: host
restart: unless-stopped
redis:
image: docker.local/redis:8.8.3-alpine
ports:
@@ -49,6 +61,13 @@ services:
- "127.0.0.1:15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
# docker-proxy принимает TCP еще до старта брокера и сразу рвет соединение: ждем, когда AMQP-порт реально слушается
healthcheck:
test: [ "CMD", "rabbitmq-diagnostics", "-q", "check_port_connectivity" ]
interval: 30s
timeout: 10s
start_period: 60s
start_interval: 1s
postgres:
image: docker.local/postgres:16.15-alpine3.23
@@ -75,6 +94,7 @@ services:
DB_PORT: "5432"
RABBITMQ_ADDRESS: rabbitmq:5672
TELEGRAM_API_URL: http://telegram:8081
TELEGRAM_FILES_URL: http://telegram-files:8083
# Bot API сервер ходит на webhook по имени сервиса сайта, домен не нужен
TELEGRAM_WEBHOOK_URL: http://web:8000
ALLOWED_HOSTS: web,127.0.0.1,localhost
@@ -95,9 +115,11 @@ services:
migrate:
condition: service_completed_successfully
rabbitmq:
condition: service_started
condition: service_healthy
telegram:
condition: service_started
telegram-files:
condition: service_started
worker:
<<: *app
@@ -110,10 +132,24 @@ services:
migrate:
condition: service_completed_successfully
rabbitmq:
condition: service_started
condition: service_healthy
volumes:
telegram-bot-api-data:
redis_data:
rabbitmq_data:
postgres_data:
configs:
telegram-files:
content: |
server {
listen 127.0.0.1:8083;
access_log off;
location ~ ^/[0-9]+:[A-Za-z0-9_-]+/documents/[^/]+$$ {
root /var/lib/telegram-bot-api;
}
location / {
return 404;
}
}
+3
View File
@@ -0,0 +1,3 @@
# сгенерированные ключи и .env - на месте, в репозиторий не попадают
app/data/
app/.env
+58
View File
@@ -0,0 +1,58 @@
# RustDesk на docker-сервере
Свой сервер для [RustDesk](https://rustdesk.com) - удаленного доступа к компьютерам. Пример приложения по конвенции `serverus.DockerHost` (см. `src/serverus/README.md`): не-HTTP сервис, который публикует свои порты через мост.
## Что внутри
Сервер RustDesk - две программы из образа `rustdesk/rustdesk-server`:
- `hbbs` - сервер ID. Клиенты регистрируются в нем под своим ID; при подключении hbbs сводит двух клиентов и помогает им соединиться напрямую (пробивает NAT). Трафик сеанса через него не идет. Порты: 21116/tcp+udp, 21115/tcp (NAT-тест).
- `hbbr` - relay. Если напрямую соединиться не вышло, весь трафик сеанса идет через него. Порт 21117/tcp.
hbbs сообщает клиентам адрес relay (`-r ${RELAY_HOST}:21117`). Оба запущены с `-k _`: работают только клиенты с нашим ключом. Websocket-порты (21118, 21119) не публикуются: они нужны только веб-клиенту.
Ключ генерируем мы и кладем в `data/<сервис>/` (bind-mount `:ro`): он хранится в версии приложения и переживает переустановку ОС. Сгенерированный hbbs ключ жил бы в volume и пропал бы вместе с ним, а клиентов пришлось бы перенастраивать. База hbbs (`db_v2.sqlite3`) - в named volume.
## Файлы
```
app/docker-compose.yaml приложение
app/.env RELAY_HOST, из env_example
app/data/hbbs|hbbr/ ключ, из genkey.py
genkey.py ключ в формате hbbs/hbbr
check.py проверка сервера по протоколу RustDesk
```
`app/.env` и `app/data/` в git не попадают.
## Развернуть
```bash
cd docs/apps/rustdesk
cp env_example app/.env # RELAY_HOST - ip или домен сервера
../../../.venv/bin/python genkey.py # печатает публичный ключ
make -C ../../.. serverus_app ip=<ip> action=deploy name=rustdesk dir=docs/apps/rustdesk/app
```
Через бота - архив содержимого `app/` (вместе с `.env` и `data/`).
## Проверить
```bash
python3 check.py <ip> "$(cat app/data/hbbs/id_ed25519.pub)"
```
Статус `running` и открытый порт ничего не доказывают: docker-proxy принимает tcp, даже когда контейнер лежит. `check.py` говорит с сервером по его протоколу снаружи, как клиент:
1. UDP 21116: регистрация клиента - ответ `RegisterPeerResponse`: udp доходит до hbbs и обратно.
2. TCP 21115: NAT-тест - hbbs возвращает порт, с которого мы пришли. Совпадает с нашим - hbbs видит настоящий адрес клиента, прямые соединения возможны.
3. TCP 21116: запрос соединения с несуществующим ID - с нашим ключом `ID_NOT_EXIST`, с чужим `LICENSE_MISMATCH`: сервер работает с нашим ключом.
4. TCP 21117: два соединения с одним uuid - hbbr сводит их, байты идут в обе стороны. С чужим ключом соединение закрывается.
Выход 0 - все прошло. В логах hbbs (`make serverus_app ... action=logs name=rustdesk`) должно быть `Private key comes from id_ed25519` и `Key:` с нашим публичным ключом.
Проверено на стенде (Debian 13): деплой, повторный деплой без изменений, отказ второго приложения на тех же портах до изменений, перезагрузка сервера.
## Клиент
Настройки - Сеть - ID-сервер: адрес сервера, Key: публичный ключ. Relay клиент получит от hbbs.
@@ -0,0 +1,30 @@
# RustDesk: hbbs - сервер ID (знакомит клиентов), hbbr - relay (трафик, когда напрямую не вышло).
# Ключ сгенерирован нами и лежит в data/<сервис>/: переживает переустановку ОС. База hbbs - в named volume.
services:
hbbs:
image: rustdesk/rustdesk-server:1.1.16
command: hbbs -r ${RELAY_HOST}:21117 -k _
restart: unless-stopped
ports:
- "21115:21115"
- "21116:21116"
- "21116:21116/udp"
volumes:
- hbbs:/root
- ./data/hbbs/id_ed25519:/root/id_ed25519:ro
- ./data/hbbs/id_ed25519.pub:/root/id_ed25519.pub:ro
depends_on: [ hbbr ]
hbbr:
image: rustdesk/rustdesk-server:1.1.16
command: hbbr -k _
restart: unless-stopped
ports:
- "21117:21117"
volumes:
- hbbr:/root
- ./data/hbbr/id_ed25519:/root/id_ed25519:ro
- ./data/hbbr/id_ed25519.pub:/root/id_ed25519.pub:ro
volumes:
hbbs:
hbbr:
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Проверка сервера RustDesk по его протоколу, снаружи: check.py <host> <публичный ключ>.
1. UDP 21116 (hbbs): RegisterPeer -> RegisterPeerResponse - UDP доходит до контейнера и обратно.
2. TCP 21115 (hbbs): TestNatRequest -> TestNatResponse с нашим портом - NAT-тест работает, адрес клиента виден.
3. TCP 21116 (hbbs): PunchHoleRequest с нашим ключом -> ID_NOT_EXIST, с чужим -> LICENSE_MISMATCH:
сервер работает именно с нашим ключом.
4. TCP 21117 (hbbr): два соединения RequestRelay с одним uuid сводятся, байты идут насквозь: relay работает.
С чужим ключом hbbr соединение закрывает.
"""
import socket
import sys
import time
import uuid
# RendezvousMessage (hbb_common/protos/rendezvous.proto)
REGISTER_PEER, REGISTER_PEER_RESPONSE = 6, 7
PUNCH_HOLE_REQUEST, PUNCH_HOLE_RESPONSE = 8, 11
REQUEST_RELAY = 18
TEST_NAT_REQUEST, TEST_NAT_RESPONSE = 20, 21
FAILURES = {0: 'ID_NOT_EXIST', 2: 'OFFLINE', 3: 'LICENSE_MISMATCH', 4: 'LICENSE_OVERUSE'}
TIMEOUT = 5
def varint(value):
out = bytearray()
while True:
byte, value = value & 0x7F, value >> 7
out.append(byte | (0x80 if value else 0))
if not value:
return bytes(out)
def field(number, value):
if isinstance(value, int):
return varint(number << 3) + varint(value)
value = value.encode() if isinstance(value, str) else value
return varint(number << 3 | 2) + varint(len(value)) + value
def message(number, *fields):
return field(number, b''.join(field(n, v) for n, v in fields))
def parse(data):
"""{номер поля: значение} одного уровня: varint - int, length-delimited - bytes."""
fields, i = {}, 0
while i < len(data):
key, i = read_varint(data, i)
number, wire = key >> 3, key & 7
if wire == 0:
fields[number], i = read_varint(data, i)
elif wire == 2:
size, i = read_varint(data, i)
fields[number], i = data[i:i + size], i + size
else:
raise ValueError(f'wire type {wire}')
return fields
def read_varint(data, i):
value = shift = 0
while True:
byte = data[i]
value |= (byte & 0x7F) << shift
i, shift = i + 1, shift + 7
if not byte & 0x80:
return value, i
def frame(payload):
"""BytesCodec hbb_common: длина << 2 | (байт заголовка - 1), little endian."""
n = len(payload)
size = 1 if n <= 0x3F else 2 if n <= 0x3FFF else 3 if n <= 0x3FFFFF else 4
return ((n << 2) | (size - 1)).to_bytes(size, 'little') + payload
def recv_exact(sock, n):
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError('сервер закрыл соединение')
data += chunk
return data
def recv_frame(sock):
first = recv_exact(sock, 1)
size = (first[0] & 0x3) + 1
head = first + recv_exact(sock, size - 1)
return recv_exact(sock, int.from_bytes(head, 'little') >> 2)
def tcp(host, port, payload):
with socket.create_connection((host, port), TIMEOUT) as sock:
sock.settimeout(TIMEOUT)
sock.sendall(frame(payload))
return parse(recv_frame(sock)), sock.getsockname()[1]
def check_udp(host):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(TIMEOUT)
sock.sendto(message(REGISTER_PEER, (1, f'probe{uuid.uuid4().hex[:8]}'), (2, 0)), (host, 21116))
reply = parse(sock.recv(4096))
assert REGISTER_PEER_RESPONSE in reply, reply
return f'RegisterPeerResponse request_pk={parse(reply[REGISTER_PEER_RESPONSE]).get(2, 0)}'
def check_nat(host):
reply, port = tcp(host, 21115, message(TEST_NAT_REQUEST, (1, 0)))
seen = parse(reply[TEST_NAT_RESPONSE]).get(1)
return f'TestNatResponse port={seen} (наш {port})'
def punch_hole(host, key):
reply, _ = tcp(host, 21116, message(PUNCH_HOLE_REQUEST, (1, 'probe-nonexistent'), (3, key)))
return FAILURES[parse(reply[PUNCH_HOLE_RESPONSE]).get(3, 0)]
def check_key(host, key):
ours, other = punch_hole(host, key), punch_hole(host, 'wrong-key')
assert (ours, other) == ('ID_NOT_EXIST', 'LICENSE_MISMATCH'), (ours, other)
return f'наш ключ -> {ours}, чужой -> {other}'
def check_relay(host, key):
session = uuid.uuid4().hex
request = frame(message(REQUEST_RELAY, (2, session), (6, key)))
with socket.create_connection((host, 21117), TIMEOUT) as a, socket.create_connection((host, 21117), TIMEOUT) as b:
a.settimeout(TIMEOUT)
b.settimeout(TIMEOUT)
a.sendall(request)
# второй должен прийти, когда первый уже ждет пару
time.sleep(0.5)
b.sendall(request)
time.sleep(0.5)
a.sendall(b'ping-from-a')
got_b = recv_exact(b, len(b'ping-from-a'))
b.sendall(b'pong-from-b')
got_a = recv_exact(a, len(b'pong-from-b'))
assert (got_a, got_b) == (b'pong-from-b', b'ping-from-a'), (got_a, got_b)
with socket.create_connection((host, 21117), TIMEOUT) as c:
c.settimeout(TIMEOUT)
c.sendall(frame(message(REQUEST_RELAY, (2, uuid.uuid4().hex), (6, 'wrong-key'))))
closed = c.recv(1) == b''
assert closed, 'чужой ключ не отклонен'
return 'два клиента сведены, байты прошли в обе стороны; чужой ключ - соединение закрыто'
def main(host, key):
failed = 0
for name, check in (
('UDP 21116 hbbs', lambda: check_udp(host)),
('TCP 21115 hbbs', lambda: check_nat(host)),
('TCP 21116 hbbs', lambda: check_key(host, key)),
('TCP 21117 hbbr', lambda: check_relay(host, key)),
):
try:
print(f'OK {name}: {check()}')
except Exception as error:
failed += 1
print(f'FAIL {name}: {type(error).__name__}: {error}')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main(*sys.argv[1:]))
+2
View File
@@ -0,0 +1,2 @@
# адрес сервера, который hbbs сообщает клиентам как relay: ip или домен
RELAY_HOST=1.2.3.4
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Ключ RustDesk для приложения: genkey.py [<папка приложения>], по умолчанию app/ рядом со скриптом.
Пара ed25519 в формате hbbs/hbbr: id_ed25519 - base64 64 байт (seed + публичный ключ), id_ed25519.pub - base64
32 байт публичного ключа. Пишется в data/hbbs/ и data/hbbr/. Существующий ключ не перезаписывается: новый ключ
отключит всех настроенных клиентов. Печатает публичный ключ - его вводят в клиентах.
Нужен пакет cryptography (есть в .venv проекта).
"""
import base64
import sys
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
SERVICES = ('hbbs', 'hbbr')
def main(app_dir: Path) -> int:
paths = [app_dir / 'data' / service / 'id_ed25519' for service in SERVICES]
existing = [path for path in paths if path.exists()]
if existing:
print(f'ключ уже есть: {existing[0]}, публичный: {existing[0].with_suffix(".pub").read_text().strip()}')
return 1
key = Ed25519PrivateKey.generate()
seed = key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
for path in paths:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(base64.b64encode(seed + public).decode())
path.chmod(0o600)
path.with_suffix('.pub').write_text(base64.b64encode(public).decode())
print(base64.b64encode(public).decode())
return 0
if __name__ == '__main__':
sys.exit(main(Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent / 'app'))
+4 -2
View File
@@ -2,9 +2,11 @@
TG_APP_ID=
TG_APP_HASH=
# Адрес своего Bot API сервера для Django. Пусто - api.telegram.org
TELEGRAM_API_URL=http://localhost:8081
TELEGRAM_API_URL=http://127.0.0.1:8081
# Файлы, присланные ботам документами (сервис telegram-files): свой Bot API сервер не отдает их по HTTP
TELEGRAM_FILES_URL=http://127.0.0.1:8083
# Адрес сайта для webhook ботов, как его видит Bot API сервер (он в сети хоста, network_mode: host)
TELEGRAM_WEBHOOK_URL=http://localhost:8000
TELEGRAM_WEBHOOK_URL=http://127.0.0.1:8000
# Хосты сайта через запятую, в том числе хост из TELEGRAM_WEBHOOK_URL
ALLOWED_HOSTS=127.0.0.1,localhost
+21 -1
View File
@@ -7,6 +7,10 @@ Django приложение связывающее все части сайта
-[ ] Настройки домена и прокси у готовой точки
-[x] Клиенты и статистика через API панели 3x-ui
-[x] Супервизор долгих задач и ежедневная проверка точек
-[x] Docker-серверы: приложения в `~/apps` из бота
-[ ] Бэкапы named volumes docker-приложений: сейчас данные сервисов (база Gitea, RustDesk и т.п.) теряются при переустановке ОС и `remove` с `volumes`
-[ ] Проверка `reverse_proxy` из `caddy.conf` приложения: проверять не только домен, но и доступность самого docker-сервиса за прокси
-[ ] Опционально свой registry для образов docker-приложений
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
@@ -14,6 +18,11 @@ Django приложение связывающее все части сайта
- `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, прокси на выходе и доступ к панели.
- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh, а также пароль root, присланный пользователем при добавлении (`default_root_password`).
- `Platform` - docker-сервер: один сервер с ролью `docker`, приложения и снимок их состояния (`status_snapshot`, `status_at`). Владелец - `Telegram.User`.
- `DockerApp` - приложение платформы: папка `~/apps/<name>/`, compose-проект `name`. Имя `caddy` занято ingress.
- `AppVersion` - полный набор файлов приложения (зашифрован: в нем `.env`), вид (`full`, `update`, `rollback`) и состояние (`draft`, `applied`, `failed`). Текущая версия - последняя примененная, хранятся 10 последних.
- `Point` и `Platform` наследуют абстрактную `Managed`: владелец, имя, статус, ошибка и поля долгой задачи. Менеджер `ManagedManager` - `start_task`, `claim_task`, `beat`, `finish_task`, `abort_task`, `unique_name`, запросы `owned_by`, `find_by_name`, `find_by_pk`, `find_last`.
- `Server` принадлежит точке или платформе, ровно чему-то одному (`CheckConstraint`).
- `Key` - ключ клиента на панели точки: имя (оно же email клиента в панели), `uuid` и `sub_id`. Клиенту выдается `key.subscription_url`, после переустановки ключ восстанавливается с теми же ссылками.
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен.
@@ -34,12 +43,23 @@ Django приложение связывающее все части сайта
- `fields.py` - `EncryptedTextField`: секреты в базе зашифрованы Fernet ключами `FIELD_ENCRYPTION_KEYS` из `.env`.
- `management/commands/serve_ssh.py` - ручной вход на сервер: `make serve_ssh ip=<ip>`.
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- Долгая задача запускается через `tasks.start(actor, point, status, chat, *args)`: точка получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в точке (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `Point.INTERRUPTED`, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
- Долгая задача запускается через `tasks.start(actor, obj, status, chat, *args)` для точки или платформы: запись получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в записи (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `INTERRUPTED` модели, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
- `check_point` - ежедневная проверка точки (`services.check_point`): своя цепочка у каждой точки, запускается после установки и командой `check_points` (ее выполняет воркер в docker при старте). Сутки проходятся перескоками по 15 минут: RabbitMQ не держит неподтвержденное отложенное сообщение дольше `consumer_timeout`.
- `backups.py` и команды `backup`, `restore` - бэкап базы `pg_dump`/`pg_restore`, `generate_secrets` - секреты для `.env`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `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` (после деплоя сверяет домены из `caddy.conf` с адресом сервера: `foreign_domains`, бот предупреждает о не указывающих на него), `control`, `logs`, `remove_app`, `refresh`.
## Docker-сервер
Приложение живет в App версиями файлов. "Залить апдейт" сливает присланное с текущей версией, "перезалить все" заменяет ее архивом целиком. В обоих случаях сервер получает полный набор файлов, бот перед применением показывает разницу и предупреждения. Удалить сервис из приложения - залить compose-файл без него: локальная копия пользователя - источник правды, бот compose-файл не правит.
Операции, которые меняют сервер (установка, применение файлов, старт/стоп, удаление), идут задачами под супервизором по одной: платформа `updating`, пока операция не кончится. Прерванная операция оставляет платформу `ready` с текстом ошибки. Статус и логи - быстрые задачи без занятости (`refresh_platform`, `send_logs`), периодической проверки нет: "проверить" в меню сервера.
Файлы приходят документом через `Telegram` (`ctx.download_document`), логи уходят файлом (`send_document`).
## Установка точки
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
+47 -4
View File
@@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm
from django.utils.translation import gettext_lazy as _
from .models import Key, Point, Server, User
from .models import AppVersion, DockerApp, Key, Platform, Point, Server, User
class UserCreationForm(AdminUserCreationForm):
@@ -55,13 +55,56 @@ class PointAdmin(admin.ModelAdmin):
inlines = [ServerInline]
@admin.register(Platform)
class PlatformAdmin(admin.ModelAdmin):
list_display = ('name', 'owner', 'status', 'created_at')
list_filter = ('status',)
search_fields = ('name', 'servers__address')
readonly_fields = (
'owner', 'name', 'status', 'error', 'status_at', 'status_snapshot', 'task_started_at', 'task_heartbeat',
'created_at',
)
inlines = [ServerInline]
class AppVersionInline(admin.TabularInline):
"""Только история: файлы не показываются, в них .env с секретами."""
model = AppVersion
extra = 0
fields = ('created_at', 'kind', 'state', 'error')
readonly_fields = fields
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(DockerApp)
class DockerAppAdmin(admin.ModelAdmin):
"""Только просмотр: приложения заливаются и удаляются через бота (platforms), иначе база и сервер разойдутся."""
list_display = ('name', 'platform', 'created_at')
list_filter = ('platform',)
search_fields = ('name', 'platform__name')
fields = ('name', 'platform', 'created_at')
readonly_fields = fields
inlines = [AppVersionInline]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(Server)
class ServerAdmin(admin.ModelAdmin):
list_display = ('address', 'role', 'point', 'ssh_port', 'has_fingerprint')
list_display = ('address', 'role', 'point', 'platform', 'ssh_port', 'has_fingerprint')
list_filter = ('role',)
search_fields = ('address', 'point__name')
search_fields = ('address', 'point__name', 'platform__name')
exclude = ('private_key',)
readonly_fields = ('point', 'role', 'public_key', 'uname')
readonly_fields = ('point', 'platform', 'role', 'public_key', 'uname')
actions = ('reset_fingerprint',)
@admin.display(boolean=True, description='отпечаток')
+355 -10
View File
@@ -2,10 +2,11 @@ import ipaddress
import re
import serverus
from Telegram import api as bot_api
from Telegram.interfaces import Interface, command, register, state, text
from .. import fields, services, tasks
from ..models import Credentials, Point, Server
from .. import fields, platforms, services, tasks, uploads
from ..models import AppVersion, Credentials, DockerApp, Platform, Point, Server
from . import texts
# состояния диалога
@@ -23,6 +24,17 @@ POINT_PROXY_EDIT = 'point.proxy'
POINT_KEYS = 'point.keys'
POINT_KEY_ADD = 'point.keys.add'
POINT_KEY_REVOKE = 'point.keys.revoke'
DOCKER_ADD = 'docker.add'
DOCKER_SELECT = 'docker.select'
PLATFORM = 'platform'
PLATFORM_RELEASE = 'platform.release'
PLATFORM_REINSTALL = 'platform.reinstall'
APP_NEW = 'platform.app.new'
APP = 'app'
APP_UPLOAD = 'app.upload'
APP_CONFIRM = 'app.confirm'
APP_REMOVE = 'app.remove'
SERVICE = 'app.service'
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
@@ -62,10 +74,15 @@ class MyPointInterface(Interface):
def main_menu(self, ctx):
ctx.reset()
points = list(Point.objects.owned_by(ctx.user))
if points:
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
else:
ctx.reply(texts.GREETING, keyboard=texts.MAIN_KEYBOARD)
platform_list = list(Platform.objects.owned_by(ctx.user))
lines = [texts.points_list(points)] if points else []
lines += [texts.platforms_list(platform_list)] if platform_list else []
ctx.reply('\n'.join(lines) or texts.GREETING, keyboard=self.main_keyboard(ctx), parse_mode=HTML)
def main_keyboard(self, ctx):
return texts.main_keyboard(
Point.objects.owned_by(ctx.user).exists(), Platform.objects.owned_by(ctx.user).exists(),
)
@text(texts.FAQ)
def faq(self, ctx):
@@ -188,7 +205,7 @@ class MyPointInterface(Interface):
)
tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.INSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
# управление сервером
@@ -245,7 +262,7 @@ class MyPointInterface(Interface):
return
tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=POINT)
def ask_reinstall(self, ctx):
@@ -274,7 +291,7 @@ class MyPointInterface(Interface):
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
tasks.start(tasks.reinstall_point, point, Point.Status.INSTALLING, ctx.chat, address, password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_RELEASE)
@@ -347,7 +364,7 @@ class MyPointInterface(Interface):
def start_change(self, ctx, point: Point, actor, value: str):
tasks.start(actor, point, Point.Status.UPDATING, ctx.chat, value)
ctx.reset()
ctx.reply(texts.CHANGE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.CHANGE_STARTED, keyboard=self.main_keyboard(ctx))
# ключи
@@ -419,3 +436,331 @@ class MyPointInterface(Interface):
return ctx.reply(texts.PANEL_ERROR.format(error=error))
ctx.reply(texts.KEY_REVOKED.format(name=key.name))
self.show_keys(ctx)
# docker-серверы
@text(texts.ADD_DOCKER)
def ask_docker_server(self, ctx):
ctx.reset()
self.ask(ctx, DOCKER_ADD, texts.DOCKER_CREDENTIALS_PROMPT)
@text(texts.CANCEL, state=DOCKER_ADD)
@text(texts.CANCEL, state=DOCKER_SELECT)
def cancel_docker(self, ctx):
self.main_menu(ctx)
@state(DOCKER_ADD)
def add_docker_server(self, ctx):
# в сообщении пароль root, в чате ему не место
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
if Server.objects.is_address_taken(credentials.address):
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
platform = Platform.objects.create_platform(ctx.user, credentials)
tasks.start(tasks.install_platform, platform, Platform.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.DOCKER_INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.DOCKER_SERVERS)
def ask_platform(self, ctx):
ctx.reset()
platform_list = Platform.objects.owned_by(ctx.user)
self.ask(ctx, DOCKER_SELECT, f'{texts.PLATFORM_SELECT_PROMPT}\n{texts.platforms_list(platform_list)}')
@state(DOCKER_SELECT)
def select_platform(self, ctx):
platform = Platform.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
if platform is None:
return ctx.reply(texts.PLATFORM_NOT_FOUND)
self.show_platform(ctx, platform)
@text(texts.PLATFORM_MENU)
def platform_menu(self, ctx):
"""Кнопка из уведомления: последний открытый docker-сервер или последний созданный."""
platform = self.current_platform(ctx) or Platform.objects.owned_by(ctx.user).find_last()
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
def current_platform(self, ctx) -> Platform | None:
platform_id = ctx.data.get('platform')
return Platform.objects.owned_by(ctx.user).find_by_pk(platform_id) if platform_id else None
def show_platform(self, ctx, platform: Platform):
ctx.reset()
ctx.state = PLATFORM
ctx.data['platform'] = platform.pk
ctx.reply(texts.platform_card(platform), keyboard=texts.PLATFORM_KEYBOARD, parse_mode=HTML)
def idle_platform(self, ctx, ready: bool = True) -> Platform | None:
"""Текущий docker-сервер, если с ним можно работать. Иначе отвечает пользователю и возвращает None.
ready - сервер должен быть установлен."""
platform = self.current_platform(ctx)
if platform is None:
self.main_menu(ctx)
elif platform.is_busy:
ctx.reply(texts.POINT_BUSY.format(status=platform.get_status_display()))
return None
elif ready and platform.status != Platform.Status.READY:
ctx.reply(texts.PLATFORM_NOT_READY)
return None
return platform
@text(texts.CHECK, state=PLATFORM)
def check_platform(self, ctx):
platform = self.idle_platform(ctx)
if platform:
tasks.refresh_platform.send(platform.pk, ctx.chat.pk)
ctx.reply(texts.CHECK_STARTED)
@text(texts.RELEASE, state=PLATFORM)
def ask_platform_release(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_RELEASE, texts.PLATFORM_RELEASE_CONFIRM, [[texts.YES, texts.NO]])
@text(texts.YES, state=PLATFORM_RELEASE)
def release_platform(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
tasks.start(tasks.release_platform, platform, Platform.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=PLATFORM)
def ask_platform_reinstall(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_REINSTALL, texts.PLATFORM_REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
@state(PLATFORM_REINSTALL)
def reinstall_platform_with_password(self, ctx):
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
if platform.server.address != credentials.address:
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_platform_reinstall(ctx, platform, fields.encrypt(credentials.password))
@text(texts.USE_OLD, state=PLATFORM_REINSTALL)
def reinstall_platform_with_old_password(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform:
self.start_platform_reinstall(ctx, platform, '')
def start_platform_reinstall(self, ctx, platform: Platform, encrypted_password: str):
tasks.start(tasks.reinstall_platform, platform, Platform.Status.INSTALLING, ctx.chat, encrypted_password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_REINSTALL)
@text(texts.CANCEL, state=APP_NEW)
def back_to_platform(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
# приложения
@state(PLATFORM)
def select_app(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
app = platform.apps.find_by_name(ctx.text.strip())
if app is None:
return ctx.reply(texts.APP_NOT_FOUND)
self.show_app(ctx, app)
def current_app(self, ctx) -> DockerApp | None:
platform = self.current_platform(ctx)
app_id = ctx.data.get('app')
return platform.apps.filter(pk=app_id).first() if platform and app_id else None
def show_app(self, ctx, app: DockerApp):
ctx.state = APP
ctx.data['app'] = app.pk
ctx.data.pop('service', None)
ctx.reply(texts.app_card(app), keyboard=texts.APP_KEYBOARD, parse_mode=HTML)
@text(texts.APP_MENU)
@text(texts.CANCEL, state=APP_REMOVE)
def app_menu(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
self.show_app(ctx, app)
@text(texts.NEW_APP, state=PLATFORM)
def ask_app_name(self, ctx):
if self.idle_platform(ctx):
self.ask(ctx, APP_NEW, texts.APP_NAME_PROMPT)
@state(APP_NEW)
def create_app(self, ctx):
platform = self.idle_platform(ctx)
if platform is None:
return
try:
app = platforms.create_app(platform, ctx.text.strip())
except ValueError as error:
return ctx.reply(str(error))
ctx.data['app'] = app.pk
ctx.data['new_app'] = True
self.ask_upload(ctx, AppVersion.Kind.FULL)
@text(texts.UPLOAD_UPDATE, state=APP)
def ask_update(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
self.ask_upload(ctx, AppVersion.Kind.UPDATE)
@text(texts.UPLOAD_FULL, state=APP)
def ask_full(self, ctx):
if self.current_app(ctx) is None:
return self.platform_menu(ctx)
self.ask_upload(ctx, AppVersion.Kind.FULL)
def ask_upload(self, ctx, kind: str):
ctx.data['kind'] = kind
prompt = texts.UPLOAD_UPDATE_PROMPT if kind == AppVersion.Kind.UPDATE else texts.UPLOAD_FULL_PROMPT
self.ask(ctx, APP_UPLOAD, prompt)
@state(APP_UPLOAD)
def receive_upload(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
document = ctx.document
if document is None:
return ctx.reply(texts.UPLOAD_NOT_DOCUMENT)
try:
content = ctx.download_document(uploads.ARCHIVE_MAX)
upload = platforms.prepare_upload(app, document.name, content, ctx.data.get('kind', AppVersion.Kind.UPDATE))
except (bot_api.ApiError, uploads.UploadError) as error:
return ctx.reply(texts.upload_failed(error), parse_mode=HTML)
self.show_upload(ctx, upload)
def show_upload(self, ctx, upload: platforms.Upload):
if upload.version is None:
# ошибки - ждем исправленные файлы, без изменений - назад к приложению
ctx.reply(texts.upload_report(upload), parse_mode=HTML)
if not upload.errors:
self.app_menu(ctx)
return
ctx.state = APP_CONFIRM
ctx.data['version'] = upload.version.pk
ctx.reply(texts.upload_report(upload), keyboard=texts.CONFIRM_KEYBOARD, parse_mode=HTML)
@text(texts.CANCEL, state=APP_UPLOAD)
@text(texts.CANCEL, state=APP_CONFIRM)
def cancel_upload(self, ctx):
app = self.current_app(ctx)
version_id = ctx.data.pop('version', None)
if app and version_id:
app.versions.filter(pk=version_id, state=AppVersion.State.DRAFT).delete()
if app and ctx.data.pop('new_app', False):
platforms.discard_new_app(app)
return self.platform_menu(ctx)
self.app_menu(ctx)
@text(texts.APPLY, state=APP_CONFIRM)
def apply_upload(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
version = app.versions.filter(pk=ctx.data.get('version'), state=AppVersion.State.DRAFT).first()
if version is None:
ctx.reply(texts.VERSION_GONE)
return self.app_menu(ctx)
tasks.start(tasks.deploy_version, platform, Platform.Status.UPDATING, ctx.chat, version.pk)
ctx.data.pop('version', None)
ctx.data.pop('new_app', None)
ctx.state = APP
ctx.reply(texts.APPLY_STARTED, keyboard=texts.APP_KEYBOARD)
@text(texts.ROLLBACK, state=APP)
def rollback(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
try:
upload = platforms.prepare_rollback(app)
except ValueError:
return ctx.reply(texts.ROLLBACK_NONE)
self.show_upload(ctx, upload)
# управление приложением и сервисом
@text(texts.START, state=APP)
@text(texts.STOP, state=APP)
@text(texts.RESTART, state=APP)
@text(texts.PULL, state=APP)
@text(texts.START, state=SERVICE)
@text(texts.STOP, state=SERVICE)
@text(texts.RESTART, state=SERVICE)
def control_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.start(tasks.control_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, texts.ACTIONS[ctx.text], service)
ctx.reply(texts.OPERATION_STARTED)
@text(texts.LOGS, state=APP)
@text(texts.LOGS, state=SERVICE)
def send_logs(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.send_logs.send(app.pk, service, ctx.chat.pk)
ctx.reply(texts.LOGS_STARTED)
@state(APP)
def select_service(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.text.strip()
services = app.platform.app_status(app.name).get('services', {})
if not services:
return ctx.reply(texts.NO_SNAPSHOT)
if service not in services:
return ctx.reply(texts.APP_NOT_FOUND)
ctx.state = SERVICE
ctx.data['service'] = service
ctx.reply(texts.service_card(app, service), keyboard=texts.SERVICE_KEYBOARD, parse_mode=HTML)
@text(texts.REMOVE_APP, state=APP)
def ask_remove_app(self, ctx):
if self.idle_platform(ctx) and self.current_app(ctx):
self.ask(ctx, APP_REMOVE, texts.REMOVE_APP_CONFIRM, texts.REMOVE_APP_KEYBOARD)
@text(texts.REMOVE_KEEP_DATA, state=APP_REMOVE)
@text(texts.REMOVE_WITH_DATA, state=APP_REMOVE)
def remove_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
volumes = ctx.text == texts.REMOVE_WITH_DATA
tasks.start(tasks.remove_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, volumes)
ctx.data.pop('app', None)
ctx.state = PLATFORM
ctx.reply(texts.OPERATION_STARTED, keyboard=texts.PLATFORM_KEYBOARD)
+256 -6
View File
@@ -1,9 +1,11 @@
"""Кнопки и тексты бота. Сообщения размечены HTML, данные пользователя экранируются."""
import datetime
from html import escape
from django.utils import timezone
from urllib.parse import urlsplit, urlunsplit
from ..models import Key, Point, Server
from ..models import AppVersion, DockerApp, Key, Managed, Platform, Point, Server
# главное меню
MAIN_MENU = 'главное меню'
@@ -39,8 +41,45 @@ USE_OLD = 'Использовать старый'
ADD_KEY = 'выпустить ключ'
REVOKE_KEY = 'отозвать ключ'
MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]]
MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]]
# docker-серверы
ADD_DOCKER = 'добавить docker-сервер'
DOCKER_SERVERS = 'docker-серверы'
PLATFORM_MENU = 'меню docker-сервера'
NEW_APP = 'новое приложение'
CHECK = 'проверить'
APP_MENU = 'меню приложения'
START = 'старт'
STOP = 'стоп'
RESTART = 'рестарт'
LOGS = 'логи'
PULL = 'обновить образы'
UPLOAD_UPDATE = 'залить апдейт'
UPLOAD_FULL = 'перезалить все'
ROLLBACK = 'откатить'
REMOVE_APP = 'удалить'
APPLY = 'Применить'
REMOVE_KEEP_DATA = 'удалить, данные оставить'
REMOVE_WITH_DATA = 'удалить с данными'
ACTIONS = {START: 'start', STOP: 'stop', RESTART: 'restart', PULL: 'pull'}
ACTION_DONE = {'start': 'запущено', 'stop': 'остановлено', 'restart': 'перезапущено', 'pull': 'образы обновлены'}
def main_keyboard(has_points: bool = False, has_platforms: bool = False) -> list[list[str]]:
rows = [[FAQ, ADD_SERVER, HELP]]
if has_points:
rows.append([MANAGE])
rows.append([ADD_DOCKER, DOCKER_SERVERS] if has_platforms else [ADD_DOCKER])
return rows
MAIN_KEYBOARD = main_keyboard()
MAIN_KEYBOARD_WITH_POINTS = main_keyboard(has_points=True)
PLATFORM_KEYBOARD = [[NEW_APP, CHECK], [REINSTALL, RELEASE], [MAIN_MENU]]
APP_KEYBOARD = [[START, STOP, RESTART], [LOGS, PULL], [UPLOAD_UPDATE, UPLOAD_FULL], [ROLLBACK, REMOVE_APP], [PLATFORM_MENU]]
SERVICE_KEYBOARD = [[START, STOP, RESTART], [LOGS], [APP_MENU]]
CONFIRM_KEYBOARD = [[APPLY, CANCEL]]
REMOVE_APP_KEYBOARD = [[REMOVE_KEEP_DATA, REMOVE_WITH_DATA], [CANCEL]]
DRAFT_KEYBOARD = [[DOMAIN, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]]
POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY], [CLIENTS], [MAIN_MENU]]
KEYS_KEYBOARD = [[ADD_KEY, REVOKE_KEY], [SERVER_MENU]]
@@ -62,6 +101,9 @@ FAQ_TEXT = '''<b>Что это за бот?</b>
<b>Что бот делает с сервером?</b>
Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы.
<b>Что такое docker-сервер?</b>
Сервер под ваши docker-приложения. Бот настраивает его так же, ставит Docker и Caddy, который выпускает сертификаты для ваших доменов. Приложение - папка <code>~/apps/&lt;имя&gt;</code>: <code>docker-compose.yaml</code>, <code>.env</code>, <code>caddy.conf</code> для веб-морды и <code>data/&lt;сервис&gt;/</code> с настройками сервисов. Файлы вы держите у себя и присылаете боту архивом, данные сервисов живут в volumes.
<b>Как забрать сервер назад?</b>
Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся.
@@ -75,6 +117,12 @@ HELP_TEXT = '''<b>Добавить сервер</b>
4. по желанию "домен", "прокси"
5. "установить" - установка идет в фоне, по окончании придет уведомление
<b>Docker-сервер</b>
1. "добавить docker-сервер" и <code>ip:пароль</code>
2. "docker-серверы", имя сервера, "новое приложение", имя и архив папки приложения
3. в приложении: "залить апдейт" - файлы поверх текущих (архив или один файл), "перезалить все" - архив целиком, лишнее удаляется. Перед применением бот покажет, что изменится
4. "старт", "стоп", "рестарт", "логи" - для всего приложения или, если прислать имя сервиса, только для него
<b>Управлять сервером</b>
"управление сервером" и имя сервера из главного меню.
@@ -141,7 +189,7 @@ def point_card(point: Point) -> str:
f'Сервер: <code>{escape(point.name)}</code>',
f'Состояние: {point.get_status_display()}',
]
for role in Server.Role:
for role in (Server.Role.PANEL, Server.Role.MASK):
server = servers.get(role)
lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}')
lines += [
@@ -267,8 +315,8 @@ def change_failed(point: Point, error: Exception) -> str:
)
def task_interrupted(point: Point) -> str:
return f'<code>{escape(point.name)}</code>: {escape(point.error)}.'
def task_interrupted(obj: Managed) -> str:
return f'<code>{escape(obj.name)}</code>: {escape(obj.error)}.'
def health_problem(point: Point, problem: str) -> str:
@@ -277,3 +325,205 @@ def health_problem(point: Point, problem: str) -> str:
def health_restored(point: Point) -> str:
return f'Проверка <code>{escape(point.name)}</code>: снова все в порядке.'
# docker-серверы
DOCKER_CREDENTIALS_PROMPT = (
'отправь данные для подключения к серверу под docker\n<code>ip:root_password</code>\n'
'Сервер будет настроен так же, как сервер точки, и получит Docker и Caddy для веб-приложений.'
)
DOCKER_INSTALL_STARTED = 'Запускаю установку docker-сервера. Отправлю уведомление когда все будет готово.'
PLATFORM_SELECT_PROMPT = 'Пришлите имя docker-сервера'
PLATFORM_NOT_FOUND = 'Docker-сервер не найден. Пришлите имя из списка.'
PLATFORM_NOT_READY = 'Docker-сервер еще не установлен.'
PLATFORM_REINSTALL_PROMPT = '''Сервер будет настроен заново, приложения развернуты с текущими версиями файлов.
Вызывайте после переустановки ОС в панели хостинг провайдера.
Пришлите <code>ip:новый_пароль</code> или нажмите "Использовать старый".'''
PLATFORM_RELEASE_CONFIRM = '''Сервер будет освобожден: случайный пароль root, мои доступы убраны.
Приложения на сервере продолжат работать, но управлять ими из бота будет нельзя. Продолжить?'''
APP_NAME_PROMPT = '''Пришли имя приложения: латиница в нижнем регистре, цифры, - и _. Например <code>site</code>.
Это имя папки <code>~/apps/&lt;имя&gt;</code> и compose-проекта.'''
APP_NOT_FOUND = 'Нет такого приложения или сервиса. Пришли имя из списка.'
UPLOAD_UPDATE_PROMPT = '''Пришли архив (zip, tar, tar.gz) или один файл документом.
Файлы лягут поверх текущих, остальные останутся. Корень архива - корень приложения.'''
UPLOAD_FULL_PROMPT = '''Пришли архив всей папки приложения (zip, tar, tar.gz) документом.
Файлы, которых нет в архиве, будут удалены. Корень архива - корень приложения:
<code>docker-compose.yaml</code>, <code>.env</code>, <code>caddy.conf</code>, <code>data/&lt;сервис&gt;/</code>.'''
UPLOAD_NOT_DOCUMENT = 'Пришли файл документом или нажми "отмена".'
UPLOAD_UNCHANGED = 'Файлы не изменились, применять нечего.'
VERSION_GONE = 'Эти файлы уже применены или заменены новыми. Пришлите их заново.'
APP_GONE = 'Приложение уже удалено.'
APPLY_STARTED = 'Применяю. Отправлю уведомление когда все будет готово.'
ROLLBACK_NONE = 'Предыдущей версии нет, откатываться некуда.'
APP_NOT_DEPLOYED = 'Приложение еще не залито: "перезалить все".'
REMOVE_APP_CONFIRM = '''Приложение будет остановлено, папка и блок Caddy удалены.
"удалить с данными" удалит и volumes приложения, восстановить их будет нельзя.'''
OPERATION_STARTED = 'Выполняю. Отправлю уведомление когда все будет готово.'
CHECK_STARTED = 'Проверяю сервер.'
LOGS_STARTED = 'Собираю логи.'
NO_SNAPSHOT = 'Состояние неизвестно: нажмите "проверить" в меню docker-сервера.'
def platforms_list(platforms) -> str:
return '\n'.join(
f'<code>{escape(platform.name)}</code>\t{platform.get_status_display()}\tdocker' for platform in platforms
)
def when(moment) -> str:
return f'{timezone.localtime(moment):%d.%m %H:%M}' if moment else EMPTY
def service_line(name: str, state: dict) -> str:
health = state.get('health')
mark = '✅' if state.get('state') == 'running' and health in ('', None, 'healthy') else '⚠️'
details = state.get('status') or state.get('state', '')
return f'{mark} <code>{escape(name)}</code> {escape(details)}' + (f' ({escape(health)})' if health else '')
def platform_card(platform: Platform) -> str:
lines = [
f'Docker-сервер: <code>{escape(platform.name)}</code>',
f'Состояние: {platform.get_status_display()}',
f'Адрес: {escape(platform.server.address)}',
]
if platform.error:
lines.append(f'Ошибка: {escape(platform.error)}')
apps = list(platform.apps.all())
lines.append(f'\n<b>Приложения</b> (проверено {when(platform.status_at)}):')
if not apps:
lines.append('Приложений пока нет: "новое приложение".')
for app in apps:
status = platform.app_status(app.name)
services = status.get('services', {})
if app.current_version() is None:
summary = 'не залито'
elif status.get('error'):
summary = f'ошибка: {escape(status["error"])}'
elif not services:
summary = 'нет данных'
else:
running = sum(1 for state in services.values() if state.get('state') == 'running')
summary = f'работает {running} из {len(services)}'
lines.append(f'<code>{escape(app.name)}</code> {summary}')
if apps:
lines.append('\nПришли имя приложения, чтобы управлять им.')
return '\n'.join(lines)
def version_line(version: AppVersion | None) -> str:
if version is None:
return 'не залито'
return f'{when(version.created_at)}, {version.get_kind_display()}'
def app_card(app: DockerApp) -> str:
platform = app.platform
status = platform.app_status(app.name)
lines = [
f'Приложение: <code>{escape(app.name)}</code> на <code>{escape(platform.name)}</code>',
f'Файлы: {version_line(app.current_version())}',
f'Сервисы (проверено {when(platform.status_at)}):',
]
if status.get('error'):
lines.append(f'ошибка: {escape(status["error"])}')
services = status.get('services', {})
lines += [service_line(name, state) for name, state in sorted(services.items())] or [EMPTY]
if services:
lines.append('\nПришли имя сервиса, чтобы управлять только им.')
return '\n'.join(lines)
def service_card(app: DockerApp, service: str) -> str:
state = app.platform.app_status(app.name).get('services', {}).get(service, {})
return '\n'.join([
f'Сервис <code>{escape(service)}</code> приложения <code>{escape(app.name)}</code>',
service_line(service, state) if state else EMPTY,
])
def upload_report(upload) -> str:
"""Разница файлов, ошибки и предупреждения присланной версии."""
diff = upload.diff
lines = []
for title, paths in (('Добавлено', diff.added), ('Изменено', diff.changed), ('Удалено', diff.removed)):
if paths:
shown = ', '.join(f'<code>{escape(path)}</code>' for path in paths[:20])
lines.append(f'{title}: {shown}' + (f' и еще {len(paths) - 20}' if len(paths) > 20 else ''))
if upload.errors:
lines.append('\n<b>Нельзя применить:</b>')
lines += [f'- {escape(error)}' for error in upload.errors]
if upload.warnings:
lines.append('\n<b>Предупреждения:</b>')
lines += [f'- {escape(warning)}' for warning in upload.warnings]
if upload.version:
lines.append('\nПрименить?')
elif upload.errors:
lines.append('\nИсправь и пришли файлы заново.')
return '\n'.join(lines) or UPLOAD_UNCHANGED
def upload_failed(error: Exception) -> str:
return f'Не удалось разобрать файл: {escape(str(error))}'
def platform_installed(platform: Platform) -> str:
return (
f'docker-сервер {escape(platform.server.address)} - проименован как <code>{escape(platform.name)}</code>, '
'готов к работе.'
)
def platform_install_failed(platform: Platform, error: Exception) -> str:
return f'Не удалось настроить <code>{escape(platform.name)}</code>:\n{escape(str(error))}'
def platform_released(name: str, address: str, password: str, error: str) -> str:
if error:
return f'<code>{escape(name)}</code>: не удалось вернуть {escape(address)}: {escape(error)}'
return f'Docker-сервер <code>{escape(name)}</code> возвращен.\n{escape(address)} - пароль root <code>{escape(password)}</code>'
def deployed(name: str, data: dict) -> str:
lines = [f'<code>{escape(name)}</code>: файлы применены.']
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)
def deploy_failed(name: str, error: Exception) -> str:
return f'<code>{escape(name)}</code>: не удалось применить файлы:\n{escape(str(error))}'
def app_controlled(name: str, action: str, service: str) -> str:
target = f'<code>{escape(name)}</code>' + (f' / <code>{escape(service)}</code>' if service else '')
return f'{target}: {ACTION_DONE.get(action, action)}.'
def app_removed(name: str, volumes: bool) -> str:
return f'<code>{escape(name)}</code> удалено' + (' вместе с данными.' if volumes else ', volumes остались на сервере.')
def app_operation_failed(name: str, error: Exception) -> str:
return f'<code>{escape(name)}</code>: не удалось:\n{escape(str(error))}'
def refresh_failed(platform: Platform, error: Exception) -> str:
return f'<code>{escape(platform.name)}</code>: не удалось проверить сервер:\n{escape(str(error))}'
def logs_caption(name: str, service: str) -> str:
return f'Логи {name}' + (f' / {service}' if service else '')
def logs_failed(name: str, error: Exception) -> str:
return f'<code>{escape(name)}</code>: не удалось получить логи:\n{escape(str(error))}'
+100
View File
@@ -0,0 +1,100 @@
# Generated by Django 6.1.1 on 2026-09-29 08:24
import App.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('App', '0009_point_task_supervisor'),
('Telegram', '0003_bot_webhook'),
]
operations = [
migrations.CreateModel(
name='DockerApp',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'приложение',
'verbose_name_plural': 'приложения',
'ordering': ['name'],
},
),
migrations.AlterField(
model_name='point',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='Telegram.user'),
),
migrations.AlterField(
model_name='server',
name='point',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.point'),
),
migrations.AlterField(
model_name='server',
name='role',
field=models.CharField(choices=[('panel', 'панель'), ('mask', 'маскировка'), ('docker', 'docker')], max_length=8),
),
migrations.CreateModel(
name='AppVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('files', App.fields.EncryptedTextField(help_text='base64(zlib(json {путь: base64}))')),
('kind', models.CharField(choices=[('full', 'перезалито все'), ('update', 'апдейт'), ('rollback', 'откат')], max_length=8)),
('state', models.CharField(choices=[('draft', 'черновик'), ('applied', 'применена'), ('failed', 'не применилась')], default='draft', max_length=8)),
('error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='App.dockerapp')),
],
options={
'verbose_name': 'версия приложения',
'verbose_name_plural': 'версии приложений',
'ordering': ['-pk'],
},
),
migrations.CreateModel(
name='Platform',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64, unique=True)),
('status', models.CharField(choices=[('installing', 'устанавливается'), ('updating', 'настраивается'), ('ready', 'активен'), ('error', 'ошибка'), ('releasing', 'возвращается')], default='installing', max_length=16)),
('error', models.TextField(blank=True)),
('task_token', models.CharField(blank=True, editable=False, max_length=32)),
('task_started_at', models.DateTimeField(blank=True, editable=False, null=True)),
('task_heartbeat', models.DateTimeField(blank=True, editable=False, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('status_snapshot', models.JSONField(blank=True, default=dict, editable=False, help_text='Последний DockerHost.status(): состояние приложений')),
('status_at', models.DateTimeField(blank=True, editable=False, null=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='Telegram.user')),
('task_chat', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='Telegram.chat')),
],
options={
'verbose_name': 'docker-сервер',
'verbose_name_plural': 'docker-серверы',
},
),
migrations.AddField(
model_name='dockerapp',
name='platform',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='apps', to='App.platform'),
),
migrations.AddField(
model_name='server',
name='platform',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.platform'),
),
migrations.AddConstraint(
model_name='server',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('platform__isnull', True), ('point__isnull', False)), models.Q(('platform__isnull', False), ('point__isnull', True)), _connector='OR'), name='app_server_one_parent'),
),
migrations.AddConstraint(
model_name='dockerapp',
constraint=models.UniqueConstraint(fields=('platform', 'name'), name='app_dockerapp_name_unique'),
),
]
+319 -102
View File
@@ -1,12 +1,15 @@
import base64
import json
import re
import secrets
import zlib
from dataclasses import dataclass
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.db import models, transaction
from django.utils import timezone
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
from serverus import APP_NAME_RE, CF_WARP, Host, generate_password, generate_port, generate_username
from .fields import EncryptedTextField
@@ -68,29 +71,131 @@ class Credentials:
# find_* возвращают None, и это отражено в типе. Вызывающая сторона видит по имени и типу, нужна ли проверка на None.
class PointQuerySet(models.QuerySet):
def owned_by(self, owner) -> 'PointQuerySet':
"""Точки владельца от старых к новым."""
class ManagedQuerySet(models.QuerySet):
def owned_by(self, owner) -> 'ManagedQuerySet':
"""Записи владельца от старых к новым."""
return self.filter(owner=owner).order_by('created_at')
def find_by_pk(self, pk) -> 'Point | None':
def find_by_pk(self, pk):
return self.filter(pk=pk).first()
def find_by_name(self, name: str) -> 'Point | None':
def find_by_name(self, name: str):
return self.filter(name=name).first()
def find_last(self) -> 'Point | None':
"""Последняя созданная точка."""
def find_last(self):
"""Последняя созданная запись."""
return self.order_by('created_at').last()
def is_name_taken(self, name: str) -> bool:
return self.filter(name=name).exists()
def busy(self) -> 'PointQuerySet':
return self.filter(status__in=Point.BUSY_STATUSES)
def busy(self) -> 'ManagedQuerySet':
return self.filter(status__in=self.model.BUSY_STATUSES)
class PointManager(models.Manager.from_queryset(PointQuerySet)):
class ManagedManager(models.Manager.from_queryset(ManagedQuerySet)):
"""Долгая задача записи: token связывает запись, задачу и ее супервизора (App.tasks)."""
def start_task(self, obj: 'Managed', status: str, token: str, chat=None):
"""Запись занята задачей token. chat - куда сообщить, если задача прервется."""
obj.status = status
obj.error = ''
obj.task_token = token
obj.task_chat = chat
obj.task_started_at = None
obj.task_heartbeat = timezone.now()
obj.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
def claim_task(self, pk, token: str) -> bool:
"""Задача token начала работу. False - запись уже не ее или это повтор после падения воркера."""
now = timezone.now()
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
.update(task_started_at=now, task_heartbeat=now))
def beat(self, pk, token: str):
"""Задача token жива."""
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
def finish_task(self, pk, token: str):
"""Задача token закончилась, статус она выставила сама."""
self.filter(pk=pk, task_token=token).update(**Managed.NO_TASK)
def abort_task(self, pk, token: str) -> bool:
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
with transaction.atomic():
obj = self.select_for_update().filter(pk=pk, task_token=token).first()
if obj is None:
return False
if obj.status not in self.model.INTERRUPTED:
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
self.filter(pk=pk).update(**Managed.NO_TASK)
return False
status, error = self.model.INTERRUPTED[obj.status]
self.filter(pk=pk).update(status=status, error=error, **Managed.NO_TASK)
return True
def unique_name(self, prefix: str) -> str:
while True:
name = f'{prefix}-{secrets.token_hex(3)}'
if not self.is_name_taken(name):
return name
class Managed(models.Model):
"""Запись, которой управляют долгие задачи (App.tasks): точка доступа или docker-сервер.
Пока запись занята задачей (BUSY_STATUSES), с ней ничего не сделать. Прерванная задача оставляет статус
по INTERRUPTED.
"""
class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается'
UPDATING = 'updating', 'настраивается'
READY = 'ready', 'активен'
ERROR = 'error', 'ошибка'
RELEASING = 'releasing', 'возвращается'
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
INTERRUPTED: dict[str, tuple[str, str]] = {}
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='%(class)ss')
name = models.CharField(max_length=64, unique=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
error = models.TextField(blank=True)
# долгая задача и ее супервизор, см. App.tasks
task_token = models.CharField(max_length=32, blank=True, editable=False)
task_chat = models.ForeignKey(
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
)
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
def __str__(self):
return self.name
@property
def is_busy(self) -> bool:
return self.status in self.BUSY_STATUSES
def set_status(self, status: str, error: str = ''):
self.status = status
self.error = error
self.save(update_fields=['status', 'error'])
class PointQuerySet(ManagedQuerySet):
pass
class PointManager(ManagedManager.from_queryset(PointQuerySet)):
def create_point(
self, owner, panel: Credentials, mask: Credentials | None = None, *, domain: str = '', outbound_proxy: str = '',
) -> 'Point':
@@ -102,7 +207,7 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
with transaction.atomic():
point = self.create(
owner=owner,
name=self._unique_name(),
name=self.unique_name('point'),
domain=domain,
outbound_proxy=outbound_proxy,
panel_username=generate_username(),
@@ -117,54 +222,8 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
Server.objects.create_server(point, Server.Role.MASK, mask)
return point
# долгая задача точки: token связывает точку, задачу и ее супервизора (App.tasks)
def start_task(self, point: 'Point', status: str, token: str, chat=None):
"""Точка занята задачей token. chat - куда сообщить, если задача прервется."""
point.status = status
point.error = ''
point.task_token = token
point.task_chat = chat
point.task_started_at = None
point.task_heartbeat = timezone.now()
point.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
def claim_task(self, pk, token: str) -> bool:
"""Задача token начала работу. False - точка уже не ее или это повтор после падения воркера."""
now = timezone.now()
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
.update(task_started_at=now, task_heartbeat=now))
def beat(self, pk, token: str):
"""Задача token жива."""
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
def finish_task(self, pk, token: str):
"""Задача token закончилась, статус она выставила сама."""
self.filter(pk=pk, task_token=token).update(**Point.NO_TASK)
def abort_task(self, pk, token: str) -> bool:
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
with transaction.atomic():
point = self.select_for_update().filter(pk=pk, task_token=token).first()
if point is None:
return False
if point.status not in Point.INTERRUPTED:
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
self.filter(pk=pk).update(**Point.NO_TASK)
return False
status, error = Point.INTERRUPTED[point.status]
self.filter(pk=pk).update(status=status, error=error, **Point.NO_TASK)
return True
def _unique_name(self) -> str:
while True:
name = f'point-{secrets.token_hex(3)}'
if not self.is_name_taken(name):
return name
class Point(models.Model):
class Point(Managed):
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
@@ -174,27 +233,12 @@ class Point(models.Model):
CF_WARP = CF_WARP
class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается'
UPDATING = 'updating', 'настраивается'
READY = 'ready', 'активен'
ERROR = 'error', 'ошибка'
RELEASING = 'releasing', 'возвращается'
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
INTERRUPTED = {
Status.INSTALLING: (Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
Managed.Status.INSTALLING: (Managed.Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
# домен и прокси serverus применяет в самом конце, прежние настройки почти наверняка на месте
Status.UPDATING: (Status.READY, 'смена настройки прервалась, настройки прежние'),
Status.RELEASING: (Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
Managed.Status.UPDATING: (Managed.Status.READY, 'смена настройки прервалась, настройки прежние'),
Managed.Status.RELEASING: (Managed.Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
}
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='points')
name = models.CharField(max_length=64, unique=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
error = models.TextField(blank=True)
domain = models.CharField(max_length=253, blank=True)
outbound_proxy = EncryptedTextField(blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}')
@@ -208,34 +252,17 @@ class Point(models.Model):
sub_port = models.PositiveIntegerField(help_text='Порт подписки панели')
sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей')
# долгая задача (установка, смена настройки, возврат) и ее супервизор, см. App.tasks
task_token = models.CharField(max_length=32, blank=True, editable=False)
task_chat = models.ForeignKey(
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
)
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
# ежедневная проверка, см. App.tasks.check_point
check_token = models.CharField(max_length=32, blank=True, editable=False)
checked_at = models.DateTimeField(null=True, blank=True, editable=False)
health_error = models.TextField(blank=True, editable=False, help_text='Что не так по последней проверке')
created_at = models.DateTimeField(auto_now_add=True)
objects = PointManager()
class Meta:
verbose_name = 'точка доступа'
verbose_name_plural = 'точки доступа'
def __str__(self):
return self.name
@property
def is_busy(self) -> bool:
return self.status in self.BUSY_STATUSES
@property
def panel_host(self) -> str:
"""Адрес панели для https и подписки: домен, без него - ip панели. По ssh ходим только по ip."""
@@ -247,11 +274,6 @@ class Point(models.Model):
def subscription_url(self, sub_id: str) -> str:
return f'https://{self.panel_host}:{self.sub_port}/{self.sub_path}/{sub_id}'
def set_status(self, status: str, error: str = ''):
self.status = status
self.error = error
self.save(update_fields=['status', 'error'])
def server(self, role: str) -> 'Server':
"""Сервер с ролью role. Бросает Server.DoesNotExist, если его нет."""
return self.servers.get(role=role)
@@ -294,19 +316,35 @@ class ServerManager(models.Manager.from_queryset(ServerQuerySet)):
default_root_password=credentials.password,
)
def create_docker_server(self, platform: 'Platform', credentials: Credentials) -> 'Server':
"""Сервер docker-платформы. Вызывать через Server.objects, обычно в составе Platform.objects.create_platform."""
return self.create(
platform=platform,
role=Server.Role.DOCKER,
address=credentials.address,
root_password=credentials.password,
default_root_password=credentials.password,
)
class Server(models.Model):
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера.
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point. Поиск: методы ServerQuerySet,
Принадлежит точке доступа (роли panel, mask) или docker-платформе (роль docker), ровно чему-то одному.
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point, и
Server.objects.create_docker_server в составе Platform.objects.create_platform. Поиск: методы ServerQuerySet,
в том числе в пределах точки: point.servers.find_by_address(address).
"""
class Role(models.TextChoices):
PANEL = 'panel', 'панель'
MASK = 'mask', 'маскировка'
DOCKER = 'docker', 'docker'
point = models.ForeignKey(Point, on_delete=models.CASCADE, related_name='servers')
point = models.ForeignKey(Point, on_delete=models.CASCADE, null=True, blank=True, related_name='servers')
platform = models.ForeignKey(
'Platform', on_delete=models.CASCADE, null=True, blank=True, related_name='servers',
)
role = models.CharField(max_length=8, choices=Role.choices)
address = models.GenericIPAddressField(unique=True)
ssh_port = models.PositiveIntegerField(default=22)
@@ -325,7 +363,14 @@ class Server(models.Model):
class Meta:
verbose_name = 'сервер'
verbose_name_plural = 'серверы'
constraints = [models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique')]
constraints = [
models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique'),
models.CheckConstraint(
condition=models.Q(point__isnull=False, platform__isnull=True)
| models.Q(point__isnull=True, platform__isnull=False),
name='app_server_one_parent',
),
]
def __str__(self):
return f'{self.address} ({self.get_role_display()})'
@@ -378,3 +423,175 @@ class Key(models.Model):
@property
def subscription_url(self) -> str:
return self.point.subscription_url(self.sub_id)
class PlatformQuerySet(ManagedQuerySet):
pass
class PlatformManager(ManagedManager.from_queryset(PlatformQuerySet)):
def create_platform(self, owner, credentials: Credentials) -> 'Platform':
"""Создает docker-платформу вместе с ее сервером. Вызывать через Platform.objects. Имя генерируется."""
with transaction.atomic():
platform = self.create(owner=owner, name=self.unique_name('docker'))
Server.objects.create_docker_server(platform, credentials)
return platform
class Platform(Managed):
"""Docker-сервер: приложения в ~/apps под управлением бота (serverus.DockerHost).
Операции с сервером идут по одной: пока платформа занята задачей, с ней ничего не сделать.
Создание: Platform.objects.create_platform. Поиск: методы PlatformQuerySet. Удаление: platform.delete()
удаляет и сервер, и приложения.
"""
INTERRUPTED = {
Managed.Status.INSTALLING: (Managed.Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
Managed.Status.UPDATING: (Managed.Status.READY, 'операция прервалась, проверьте состояние кнопкой "Проверить"'),
Managed.Status.RELEASING: (Managed.Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
}
status_snapshot = models.JSONField(
default=dict, blank=True, editable=False, help_text='Последний DockerHost.status(): состояние приложений',
)
status_at = models.DateTimeField(null=True, blank=True, editable=False)
objects = PlatformManager()
class Meta:
verbose_name = 'docker-сервер'
verbose_name_plural = 'docker-серверы'
@property
def server(self) -> Server:
"""Сервер платформы. Есть всегда: create_platform создает его вместе с платформой."""
return self.servers.get(role=Server.Role.DOCKER)
def app_status(self, name: str) -> dict:
"""Состояние приложения из последнего снимка: {'services': {...}, 'error': ''}, пусто - в снимке его нет."""
return self.status_snapshot.get('apps', {}).get(name, {})
def save_snapshot(self, snapshot: dict):
self.status_snapshot = snapshot
self.status_at = timezone.now()
self.save(update_fields=['status_snapshot', 'status_at'])
class DockerAppQuerySet(models.QuerySet):
def find_by_name(self, name: str) -> 'DockerApp | None':
return self.filter(name=name).first()
def is_name_taken(self, name: str) -> bool:
return self.filter(name=name).exists()
class DockerApp(models.Model):
"""Приложение на docker-платформе: папка ~/apps/<name>/, compose-проект name.
Файлы приложения хранятся версиями (AppVersion): текущая - последняя примененная. Сервер всегда получает полный
набор файлов текущей версии. Создание и удаление: App.platforms. Поиск: platform.apps.find_by_name(name).
"""
NAME_RE = APP_NAME_RE
platform = models.ForeignKey(Platform, on_delete=models.CASCADE, related_name='apps')
name = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
objects = DockerAppQuerySet.as_manager()
class Meta:
verbose_name = 'приложение'
verbose_name_plural = 'приложения'
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['platform', 'name'], name='app_dockerapp_name_unique')]
def __str__(self):
return f'{self.name} ({self.platform})'
def current_version(self) -> 'AppVersion | None':
return self.versions.applied().first()
def previous_version(self) -> 'AppVersion | None':
"""Версия перед текущей: на нее откатывается приложение."""
return self.versions.applied()[1:2].first()
class AppVersionQuerySet(models.QuerySet):
def applied(self) -> 'AppVersionQuerySet':
"""Примененные версии от новых к старым."""
return self.filter(state=AppVersion.State.APPLIED).order_by('-pk')
def find_by_pk(self, pk) -> 'AppVersion | None':
return self.filter(pk=pk).first()
class AppVersionManager(models.Manager.from_queryset(AppVersionQuerySet)):
def create_draft(self, app: DockerApp, files: dict[str, bytes], kind: str) -> 'AppVersion':
"""Черновик версии: файлы, которые пользователь прислал и еще не применил. Прежние черновики удаляются."""
with transaction.atomic():
app.versions.filter(state=AppVersion.State.DRAFT).delete()
return self.create(app=app, files=AppVersion.encode(files), kind=kind)
class AppVersion(models.Model):
"""Полный набор файлов приложения. Файлы хранятся зашифрованными: в них .env с секретами.
Черновик (draft) становится примененной версией (applied) или неудачной (failed). Хранятся KEEP последних
примененных версий. Создание: AppVersion.objects.create_draft, дальше App.platforms.
"""
KEEP = 10
class Kind(models.TextChoices):
FULL = 'full', 'перезалито все'
UPDATE = 'update', 'апдейт'
ROLLBACK = 'rollback', 'откат'
class State(models.TextChoices):
DRAFT = 'draft', 'черновик'
APPLIED = 'applied', 'применена'
FAILED = 'failed', 'не применилась'
app = models.ForeignKey(DockerApp, on_delete=models.CASCADE, related_name='versions')
files = EncryptedTextField(help_text='base64(zlib(json {путь: base64}))')
kind = models.CharField(max_length=8, choices=Kind.choices)
state = models.CharField(max_length=8, choices=State.choices, default=State.DRAFT)
error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
objects = AppVersionManager()
class Meta:
verbose_name = 'версия приложения'
verbose_name_plural = 'версии приложений'
ordering = ['-pk']
def __str__(self):
return f'{self.app} #{self.pk} ({self.get_state_display()})'
@staticmethod
def encode(files: dict[str, bytes]) -> str:
data = {path: base64.b64encode(content).decode() for path, content in sorted(files.items())}
return base64.b64encode(zlib.compress(json.dumps(data).encode())).decode()
def get_files(self) -> dict[str, bytes]:
data = json.loads(zlib.decompress(base64.b64decode(self.files)))
return {path: base64.b64decode(content) for path, content in data.items()}
def mark_applied(self):
"""Версия применена: старые примененные версии сверх KEEP и неудачные попытки удаляются."""
with transaction.atomic():
self.state = self.State.APPLIED
self.error = ''
self.save(update_fields=['state', 'error'])
versions = self.app.versions
versions.filter(state=self.State.FAILED).delete()
stale = versions.applied().values_list('pk', flat=True)[self.KEEP:]
versions.filter(pk__in=list(stale)).delete()
def mark_failed(self, error: str):
self.state = self.State.FAILED
self.error = error
self.save(update_fields=['state', 'error'])
+215
View File
@@ -0,0 +1,215 @@
"""Сценарии docker-платформы поверх serverus.DockerHost: установка, приложения и их версии, возврат сервера.
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
"""
import ipaddress
import logging
import socket
from dataclasses import dataclass
import serverus
from . import uploads
from .models import AppVersion, DockerApp, Platform
from .services import (
ProvisionError, bootstrap_server, check_result, installing, local_ca, release_server, reset_for_reinstall, updating,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Upload:
"""Итог разбора присланных файлов. version - черновик для применения, None - применять нечего или нельзя."""
version: AppVersion | None
diff: uploads.Diff
errors: 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:
return serverus.DockerHost(platform.server.host())
def install_platform(platform: Platform):
"""bootstrap сервера, Docker и ingress, затем приложения с текущими версиями (после переустановки ОС)."""
with installing(platform):
server = platform.server
bootstrap_server(server)
check_result(server, docker(platform).install(local_certs=local_ca() is not None))
server.uname = check_result(server, serverus.Server(server.host()).facts()).data.get('uname', '')
server.save(update_fields=['uname'])
for app in platform.apps.all():
version = app.current_version()
if version:
check_result(server, docker(platform).deploy(app.name, version.get_files()))
_refresh_after(platform)
def reinstall(platform: Platform, password: str | None = None):
"""Повторная установка после переустановки ОС у провайдера. password - новый пароль root от провайдера,
без него - присланный при добавлении сервера. Приложения разворачиваются заново с текущими версиями."""
reset_for_reinstall(platform.server, password)
install_platform(platform)
def release_platform(platform: Platform) -> tuple[str, str]:
"""Возвращает сервер владельцу: (новый пароль root, ошибка). Приложения на сервере продолжают работать.
Платформа удаляется из базы вместе с версиями приложений, только если сервер возвращен.
"""
server = platform.server
error = release_server(server)
if error:
platform.set_status(Platform.Status.ERROR, f'{server.address}: {error}')
return '', error
password = server.root_password
platform.delete()
return password, ''
def create_app(platform: Platform, name: str) -> DockerApp:
"""Новое приложение без файлов. ValueError - имя не подходит, занято или зарезервировано."""
if not DockerApp.NAME_RE.match(name):
raise ValueError('имя приложения: латиница в нижнем регистре, цифры, - и _, до 64 символов')
if name == serverus.INGRESS or platform.apps.is_name_taken(name):
raise ValueError(f'приложение {name} уже есть')
return DockerApp.objects.create(platform=platform, name=name)
def discard_new_app(app: DockerApp):
"""Отмена создания: приложение, которое ни разу не применялось, удаляется."""
if app.current_version() is None:
app.delete()
def prepare_upload(app: DockerApp, filename: str, content: bytes, kind: str) -> Upload:
"""Разбирает присланный файл в черновик версии. kind - AppVersion.Kind.UPDATE (поверх текущих файлов)
или FULL (архив целиком, отсутствующее удаляется). uploads.UploadError - файл не разобрать."""
new = uploads.read(filename, content)
if kind == AppVersion.Kind.FULL and not uploads.is_archive(filename):
raise uploads.UploadError('перезалить все можно только архивом: zip, tar, tar.gz')
current = app.current_version()
old = current.get_files() if current else {}
files = uploads.merge(old, new) if kind == AppVersion.Kind.UPDATE else new
return _draft(app, old, files, kind)
def prepare_rollback(app: DockerApp) -> Upload:
"""Черновик отката на предыдущую версию. ValueError - откатываться некуда."""
previous = app.previous_version()
if previous is None:
raise ValueError('предыдущей версии нет')
return _draft(app, app.current_version().get_files(), previous.get_files(), AppVersion.Kind.ROLLBACK)
def _draft(app: DockerApp, old: dict[str, bytes], files: dict[str, bytes], kind: str) -> Upload:
errors, warnings = uploads.check(files)
diff = uploads.diff(old, files)
version = None
if not errors and (diff or app.current_version() is None):
version = AppVersion.objects.create_draft(app, files, kind)
return Upload(version, diff, errors, warnings)
def apply(version: AppVersion) -> dict:
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services,
domains, и foreign_domains - домены из caddy.conf, которые не указывают на сервер (см. foreign_domains).
Ошибка - версия отмечается неудачной. Ошибка проверки compose-файла, его портов или caddy.conf - на сервере остается
прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются.
"""
app = version.app
platform = app.platform
with updating(platform):
try:
result = check_result(platform.server, docker(platform).deploy(app.name, version.get_files()))
except ProvisionError as error:
version.mark_failed(str(error))
raise
version.mark_applied()
_refresh_after(platform)
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 = ''):
"""start, stop, restart или pull приложения или одного сервиса."""
platform = app.platform
with updating(platform):
check_result(platform.server, docker(platform).control(app.name, action, service))
_refresh_after(platform)
def remove_app(app: DockerApp, volumes: bool = False):
"""Удаляет приложение с сервера (volumes=True - вместе с данными) и из базы."""
platform = app.platform
with updating(platform):
check_result(platform.server, docker(platform).remove(app.name, volumes))
app.delete()
_refresh_after(platform)
def logs(app: DockerApp, service: str = '', lines: int = 500) -> str:
return check_result(app.platform.server, docker(app.platform).logs(app.name, service, lines)).data.get('logs', '')
def refresh(platform: Platform) -> dict:
"""Снимок состояния приложений с сервера в platform.status_snapshot."""
snapshot = check_result(platform.server, docker(platform).status()).data
platform.save_snapshot(snapshot)
return snapshot
def _refresh_after(platform: Platform):
"""Снимок после операции: операция уже прошла, недоступный сервер ее не отменяет."""
try:
refresh(platform)
except ProvisionError:
logger.warning('Platform %s: status refresh failed', platform, exc_info=True)
+61 -46
View File
@@ -7,7 +7,7 @@ from contextlib import contextmanager
import serverus
from django.conf import settings
from .models import KEY_NAME_RE, Key, Point, Server
from .models import KEY_NAME_RE, Key, Managed, Point, Server
class ProvisionError(Exception):
@@ -16,7 +16,7 @@ class ProvisionError(Exception):
def install_point(point: Point):
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
with _installing(point):
with installing(point):
for server in point.servers.all():
provision(server)
restore_keys(point)
@@ -29,12 +29,9 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера.
"""
servers = [point.servers.get(address=address)] if address else point.servers.all()
with _installing(point):
with installing(point):
for server in servers:
server.known_hosts = ''
server.ssh_port = 22
server.root_password = password or server.default_root_password
server.save()
reset_for_reinstall(server, password)
provision(server)
# панель доступна по домену только через маскировку, поэтому после всех серверов
restore_keys(point)
@@ -47,22 +44,11 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
"""
passwords, errors = {}, {}
for server in point.servers.all():
if not server.private_key:
# возвращен прошлой попыткой: ключа на сервере больше нет, пароль уже новый
passwords[server.address] = server.root_password
continue
password = serverus.generate_password()
result = serverus.Server(server.host()).release(password)
if result.ok:
# release убирает ключ и возвращает ssh на 22 порт
server.root_password = password
server.ssh_port = 22
server.private_key = ''
server.public_key = ''
server.save(update_fields=['root_password', 'ssh_port', 'private_key', 'public_key'])
passwords[server.address] = password
error = release_server(server)
if error:
errors[server.address] = error
else:
errors[server.address] = result.error
passwords[server.address] = server.root_password
if errors:
point.set_status(Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items()))
else:
@@ -77,8 +63,8 @@ def set_domain(point: Point, domain: str):
"""
if not domain and point.mask:
raise ValueError('маскировка работает только с доменом')
with _updating(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
with updating(point):
check_result(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
point.domain = domain
point.save(update_fields=['domain'])
@@ -87,8 +73,8 @@ def set_outbound_proxy(point: Point, proxy: str):
"""Меняет исходящий прокси готовой точки: protocol://user:password@host:port, Point.CF_WARP или '' - напрямую."""
if proxy and proxy != Point.CF_WARP:
serverus.parse_proxy(proxy)
with _updating(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
with updating(point):
check_result(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
point.outbound_proxy = proxy
point.save(update_fields=['outbound_proxy'])
@@ -182,16 +168,45 @@ def xui_api(point: Point) -> serverus.XuiApi:
def provision(server: Server):
"""Базовая настройка сервера и специализация по его роли."""
_bootstrap(server)
bootstrap_server(server)
if server.role == Server.Role.PANEL:
_install_panel(server)
else:
_redirect_to_panel(server)
server.uname = _check(server, serverus.Server(server.host()).facts()).data.get('uname', '')
server.uname = check_result(server, serverus.Server(server.host()).facts()).data.get('uname', '')
server.save(update_fields=['uname'])
def _bootstrap(server: Server):
def reset_for_reinstall(server: Server, password: str | None = None):
"""Сервер переустановлен у провайдера: отпечаток новый, ssh на 22 порту, пароль от провайдера.
password - новый пароль root, без него - присланный при добавлении сервера.
"""
server.known_hosts = ''
server.ssh_port = 22
server.root_password = password or server.default_root_password
server.save()
def release_server(server: Server) -> str:
"""Возвращает сервер владельцу с новым паролем root (server.root_password). Возвращает ошибку или ''."""
if not server.private_key:
# возвращен прошлой попыткой: ключа на сервере больше нет, пароль уже новый
return ''
password = serverus.generate_password()
result = serverus.Server(server.host()).release(password)
if not result.ok:
return result.error
# release убирает ключ и возвращает ssh на 22 порт
server.root_password = password
server.ssh_port = 22
server.private_key = ''
server.public_key = ''
server.save(update_fields=['root_password', 'ssh_port', 'private_key', 'public_key'])
return ''
def bootstrap_server(server: Server):
if not server.private_key:
keys = serverus.generate_keypair(f'mypoint-{server.address}')
server.private_key, server.public_key = keys.private_key, keys.public_key
@@ -199,7 +214,7 @@ def _bootstrap(server: Server):
new_password = serverus.generate_password()
ssh_port = serverus.generate_ssh_port()
# при ошибке остается старый пароль, но ключ уже может быть прописан: повторный запуск войдет по ключу
_check(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
check_result(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
server.root_password = new_password
server.ssh_port = ssh_port
server.save(update_fields=['root_password', 'ssh_port'])
@@ -208,7 +223,7 @@ def _bootstrap(server: Server):
def _install_panel(server: Server):
point = server.point
panel = serverus.XuiPanel(server.host())
result = _check(server, panel.install(
result = check_result(server, panel.install(
username=point.panel_username,
password=point.panel_password,
panel_port=point.panel_port,
@@ -220,21 +235,21 @@ def _install_panel(server: Server):
))
point.panel_api_token = result.data.get('api_token', '')
point.save(update_fields=['panel_api_token'])
result = _check(server, panel.create_inbound())
result = check_result(server, panel.create_inbound())
point.panel_inbound_id = result.data.get('inbound_id')
point.save(update_fields=['panel_inbound_id'])
if point.outbound_proxy:
_check(server, panel.set_outbound(point.outbound_proxy))
check_result(server, panel.set_outbound(point.outbound_proxy))
def _redirect_to_panel(server: Server):
point = server.point
# 80 - выпуск и продление сертификата (HTTP-01), 443 - подключения клиентов
ports = (80, 443, point.panel_port, point.sub_port)
_check(server, serverus.RedirectProxy(server.host()).redirect(point.panel.address, ports))
check_result(server, serverus.RedirectProxy(server.host()).redirect(point.panel.address, ports))
def _check(server: Server, result: serverus.Result) -> serverus.Result:
def check_result(server: Server, result: serverus.Result) -> serverus.Result:
if result.known_hosts and result.known_hosts != server.known_hosts:
server.known_hosts = result.known_hosts
server.save(update_fields=['known_hosts'])
@@ -244,28 +259,28 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result:
@contextmanager
def _installing(point: Point):
def installing(obj: Managed):
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
point.set_status(Point.Status.INSTALLING)
obj.set_status(Managed.Status.INSTALLING)
try:
yield
except Exception as error:
point.set_status(Point.Status.ERROR, str(error))
obj.set_status(Managed.Status.ERROR, str(error))
raise
point.set_status(Point.Status.READY)
obj.set_status(Managed.Status.READY)
@contextmanager
def _updating(point: Point):
"""Статус UPDATING на время смены настройки готовой точки, затем READY.
def updating(obj: Managed):
"""Статус UPDATING на время изменения готовой записи, затем READY.
При ошибке точка тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси только после
успешного выпуска и проверки, так что панель работает с прежними настройками.
При ошибке запись тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси точки только после
успешного выпуска и проверки, приложение docker-платформы - только после проверки compose-файла.
"""
point.set_status(Point.Status.UPDATING)
obj.set_status(Managed.Status.UPDATING)
try:
yield
except Exception as error:
point.set_status(Point.Status.READY, str(error))
obj.set_status(Managed.Status.READY, str(error))
raise
point.set_status(Point.Status.READY)
obj.set_status(Managed.Status.READY)
+189 -48
View File
@@ -1,9 +1,10 @@
"""Фоновые задачи: долгие операции с серверами, их супервизор и ежедневная проверка точек.
Долгая задача запускается через start(): точка получает статус и token задачи, вместе с задачей запускается
супервизор. Пока задача работает, она раз в HEARTBEAT_SECONDS отмечается в точке. Супервизор просыпается
раз в минуту, пока задача не закончится, и снимает ее, если отметки прекратились: воркер упал, его убили,
сообщение потерялось. Точка получает статус по Point.INTERRUPTED, пользователь - уведомление.
Долгая задача запускается через start() для точки доступа или docker-платформы (Managed): запись получает статус
и token задачи, вместе с задачей запускается супервизор. Пока задача работает, она раз в HEARTBEAT_SECONDS
отмечается в записи. Супервизор просыпается раз в минуту, пока задача не закончится, и снимает ее, если отметки
прекратились: воркер упал, его убили, сообщение потерялось. Запись получает статус по INTERRUPTED своей модели,
пользователь - уведомление.
Повторная доставка сообщения (RabbitMQ возвращает в очередь сообщение упавшего воркера) задачу не повторяет,
а снимает: повторять половину работы с серверами опасно, пользователь сам решит, переустанавливать ли.
@@ -15,19 +16,22 @@ import threading
import time
import dramatiq
from django.apps import apps
from django.db import connection
from django.utils import timezone
from Telegram.messages import send
from Telegram.messages import send, send_document
from Telegram.models import Chat
from . import fields, services
from . import fields, platforms, services
from .bot import texts
from .models import Point
from .models import AppVersion, DockerApp, Managed, Platform, Point
logger = logging.getLogger(__name__)
# установка обновляет систему и ставит панель, это долго
TIME_LIMIT_MS = 60 * 60 * 1000
# статус и логи docker-платформы: один короткий плейбук
QUICK_TIME_LIMIT_MS = 5 * 60 * 1000
HEARTBEAT_SECONDS = 30
SUPERVISE_DELAY_MS = 60 * 1000
# без отметок дольше - задача мертва
@@ -49,73 +53,86 @@ def _notify(chat_id: int | None, text: str, keyboard=None):
# долгие задачи и супервизор
def start(actor, point: Point, status: str, chat, *args):
"""Запускает долгую задачу точки вместе с супервизором. chat - куда сообщить о результате."""
def _model(label: str) -> type[Managed]:
return apps.get_model(label)
def _menu_keyboard(obj: Managed):
return [[texts.PLATFORM_MENU if isinstance(obj, Platform) else texts.SERVER_MENU]]
def start(actor, obj: Managed, status: str, chat, *args):
"""Запускает долгую задачу записи вместе с супервизором. chat - куда сообщить о результате.
actor получает (pk, token, *args)."""
model = type(obj)
token = secrets.token_hex(16)
Point.objects.start_task(point, status, token, chat)
actor.send(point.pk, token, *args)
logger.info('Point %s: %s queued, task %s', point, actor.actor_name, token[:8])
supervise.send_with_options(args=(point.pk, token), delay=SUPERVISE_DELAY_MS)
model.objects.start_task(obj, status, token, chat)
actor.send(obj.pk, token, *args)
logger.info('%s %s: %s queued, task %s', model.__name__, obj, actor.actor_name, token[:8])
supervise.send_with_options(args=(model._meta.label, obj.pk, token), delay=SUPERVISE_DELAY_MS)
@dramatiq.actor(max_retries=3)
def supervise(point_id: int, token: str):
point = Point.objects.filter(pk=point_id, task_token=token).first()
if point is None:
def supervise(label: str, pk: int, token: str):
model = _model(label)
obj = model.objects.filter(pk=pk, task_token=token).first()
if obj is None:
# задача закончилась, супервизор больше не нужен
return
limit = TASK_STALE if point.task_started_at else QUEUE_TIMEOUT
if timezone.now() - point.task_heartbeat > limit:
logger.error('Point %s task is stuck: no heartbeat since %s', point, point.task_heartbeat)
_abort(point_id, token)
limit = TASK_STALE if obj.task_started_at else QUEUE_TIMEOUT
if timezone.now() - obj.task_heartbeat > limit:
logger.error('%s %s task is stuck: no heartbeat since %s', model.__name__, obj, obj.task_heartbeat)
_abort(model, pk, token)
return
logger.debug('Point %s task %s alive, last heartbeat %s', point, token[:8], point.task_heartbeat)
supervise.send_with_options(args=(point_id, token), delay=SUPERVISE_DELAY_MS)
logger.debug('%s %s task %s alive, last heartbeat %s', model.__name__, obj, token[:8], obj.task_heartbeat)
supervise.send_with_options(args=(label, pk, token), delay=SUPERVISE_DELAY_MS)
def _run(what: str, point_id: int, token: str, work):
"""Выполняет work(point) как задачу token: с отметками для супервизора и снятием, если она прервется.
def _run(model: type[Managed], what: str, pk: int, token: str, work):
"""Выполняет work(obj) как задачу token: с отметками для супервизора и снятием, если она прервется.
what - название задачи для лога."""
if not Point.objects.claim_task(point_id, token):
# точка удалена, задачу уже снял супервизор или это повторная доставка после падения воркера
logger.warning('Point %s: %s task %s is not claimable, dropped', point_id, what, token[:8])
_abort(point_id, token)
name = model.__name__
if not model.objects.claim_task(pk, token):
# запись удалена, задачу уже снял супервизор или это повторная доставка после падения воркера
logger.warning('%s %s: %s task %s is not claimable, dropped', name, pk, what, token[:8])
_abort(model, pk, token)
return
logger.info('Point %s: %s started, task %s', point_id, what, token[:8])
logger.info('%s %s: %s started, task %s', name, pk, what, token[:8])
started = time.monotonic()
stop = threading.Event()
heartbeat = threading.Thread(target=_heartbeat, args=(point_id, token, stop), daemon=True)
heartbeat = threading.Thread(target=_heartbeat, args=(model, pk, token, stop), daemon=True)
heartbeat.start()
try:
work(Point.objects.get(pk=point_id))
work(model.objects.get(pk=pk))
except BaseException:
# лимит времени dramatiq и остановка воркера - не Exception, сценарий их не ловит
logger.exception('Point %s task interrupted', point_id)
_abort(point_id, token)
logger.exception('%s %s task interrupted', name, pk)
_abort(model, pk, token)
raise
finally:
stop.set()
heartbeat.join()
Point.objects.finish_task(point_id, token)
point = Point.objects.filter(pk=point_id).first()
model.objects.finish_task(pk, token)
obj = model.objects.filter(pk=pk).first()
logger.info(
'Point %s: %s finished in %.0fs, %s', point_id, what, time.monotonic() - started,
f'status {point.status}' + (f': {point.error}' if point.error else '') if point else 'point deleted',
'%s %s: %s finished in %.0fs, %s', name, pk, what, time.monotonic() - started,
f'status {obj.status}' + (f': {obj.error}' if obj.error else '') if obj else 'deleted',
)
def _heartbeat(point_id: int, token: str, stop: threading.Event):
def _heartbeat(model: type[Managed], pk: int, token: str, stop: threading.Event):
try:
while not stop.wait(HEARTBEAT_SECONDS):
Point.objects.beat(point_id, token)
model.objects.beat(pk, token)
finally:
connection.close()
def _abort(point_id: int, token: str):
chat_id = Point.objects.filter(pk=point_id).values_list('task_chat', flat=True).first()
if Point.objects.abort_task(point_id, token):
_notify(chat_id, texts.task_interrupted(Point.objects.get(pk=point_id)), keyboard=[[texts.SERVER_MENU]])
def _abort(model: type[Managed], pk: int, token: str):
chat_id = model.objects.filter(pk=pk).values_list('task_chat', flat=True).first()
if model.objects.abort_task(pk, token):
obj = model.objects.get(pk=pk)
_notify(chat_id, texts.task_interrupted(obj), keyboard=_menu_keyboard(obj))
def _install(point: Point, operation, *args):
@@ -150,29 +167,153 @@ def _release(point: Point):
# повтор не нужен: пользователь сам решает, переустанавливать ли после ошибки
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def install_point(point_id: int, token: str):
_run('install', point_id, token, lambda point: _install(point, services.install_point))
_run(Point, 'install', point_id, token, lambda point: _install(point, services.install_point))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def reinstall_point(point_id: int, token: str, address: str | None = None, password: str | None = None):
_run('reinstall', point_id, token, lambda point: _install(point, services.reinstall, address, password))
_run(Point, 'reinstall', point_id, token, lambda point: _install(point, services.reinstall, address, password))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def release_point(point_id: int, token: str):
_run('release', point_id, token, _release)
_run(Point, 'release', point_id, token, _release)
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def set_domain(point_id: int, token: str, domain: str):
_run('set_domain', point_id, token, lambda point: _change(point, services.set_domain, domain, texts.domain_changed))
_run(Point, 'set_domain', point_id, token, lambda point: _change(point, services.set_domain, domain, texts.domain_changed))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def set_outbound_proxy(point_id: int, token: str, encrypted_proxy: str):
"""encrypted_proxy - fields.encrypt(proxy): в прокси логин и пароль, а сообщения брокера лежат на диске."""
proxy = fields.decrypt(encrypted_proxy) if encrypted_proxy else ''
_run('set_outbound_proxy', point_id, token, lambda point: _change(point, services.set_outbound_proxy, proxy, texts.proxy_changed))
_run(Point, 'set_outbound_proxy', point_id, token, lambda point: _change(point, services.set_outbound_proxy, proxy, texts.proxy_changed))
# docker-платформа: операции с сервером идут по одной, пока платформа занята задачей
def _platform_operation(platform: Platform, what: str, operation, done, failed):
"""operation(platform) и уведомление: done(результат) или failed(ошибка)."""
chat_id = platform.task_chat_id
try:
result = operation(platform)
except Exception as error:
logger.exception('Platform %s %s failed', platform, what)
_notify(chat_id, failed(error), keyboard=[[texts.PLATFORM_MENU]])
return
_notify(chat_id, done(result), keyboard=[[texts.PLATFORM_MENU]])
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def install_platform(platform_id: int, token: str):
_run(Platform, 'install', platform_id, token, lambda platform: _platform_operation(
platform, 'install', platforms.install_platform,
lambda _: texts.platform_installed(platform), lambda error: texts.platform_install_failed(platform, error),
))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def reinstall_platform(platform_id: int, token: str, encrypted_password: str = ''):
"""encrypted_password - fields.encrypt(пароль root от провайдера), пусто - присланный при добавлении."""
password = fields.decrypt(encrypted_password) if encrypted_password else None
_run(Platform, 'reinstall', platform_id, token, lambda platform: _platform_operation(
platform, 'reinstall', lambda platform: platforms.reinstall(platform, password),
lambda _: texts.platform_installed(platform), lambda error: texts.platform_install_failed(platform, error),
))
def _release_platform(platform: Platform):
chat_id, name, address = platform.task_chat_id, platform.name, platform.server.address
password, error = platforms.release_platform(platform)
_notify(chat_id, texts.platform_released(name, address, password, error))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def release_platform(platform_id: int, token: str):
_run(Platform, 'release', platform_id, token, _release_platform)
def _deploy(platform: Platform, version_id: int):
version = AppVersion.objects.filter(pk=version_id, app__platform=platform, state=AppVersion.State.DRAFT).first()
if version is None:
_notify(platform.task_chat_id, texts.VERSION_GONE, keyboard=[[texts.PLATFORM_MENU]])
return
name = version.app.name
_platform_operation(
platform, 'deploy', lambda _: platforms.apply(version),
lambda data: texts.deployed(name, data), lambda error: texts.deploy_failed(name, error),
)
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def deploy_version(platform_id: int, token: str, version_id: int):
_run(Platform, 'deploy', platform_id, token, lambda platform: _deploy(platform, version_id))
def _app_operation(platform: Platform, app_id: int, what: str, operation, done):
app = platform.apps.filter(pk=app_id).first()
if app is None:
_notify(platform.task_chat_id, texts.APP_GONE, keyboard=[[texts.PLATFORM_MENU]])
return
name = app.name
_platform_operation(
platform, what, lambda _: operation(app),
lambda _: done(name), lambda error: texts.app_operation_failed(name, error),
)
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def control_app(platform_id: int, token: str, app_id: int, action: str, service: str = ''):
_run(Platform, action, platform_id, token, lambda platform: _app_operation(
platform, app_id, action, lambda app: platforms.control(app, action, service),
lambda name: texts.app_controlled(name, action, service),
))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def remove_app(platform_id: int, token: str, app_id: int, volumes: bool = False):
_run(Platform, 'remove', platform_id, token, lambda platform: _app_operation(
platform, app_id, 'remove', lambda app: platforms.remove_app(app, volumes),
lambda name: texts.app_removed(name, volumes),
))
# быстрые операции без занятости: только читают сервер
@dramatiq.actor(max_retries=0, time_limit=QUICK_TIME_LIMIT_MS)
def refresh_platform(platform_id: int, chat_id: int):
"""Снимок состояния приложений и карточка платформы в чат."""
platform = Platform.objects.find_by_pk(platform_id)
if platform is None:
return
try:
platforms.refresh(platform)
except Exception as error:
logger.exception('Platform %s status refresh failed', platform)
_notify(chat_id, texts.refresh_failed(platform, error))
return
_notify(chat_id, texts.platform_card(platform))
@dramatiq.actor(max_retries=0, time_limit=QUICK_TIME_LIMIT_MS)
def send_logs(app_id: int, service: str, chat_id: int):
"""Логи приложения или сервиса файлом в чат."""
app = DockerApp.objects.filter(pk=app_id).select_related('platform').first()
if app is None:
return
try:
content = platforms.logs(app, service)
except Exception as error:
logger.exception('App %s logs failed', app)
_notify(chat_id, texts.logs_failed(app.name, error))
return
stamp = timezone.now().strftime('%Y%m%d-%H%M%S')
filename = f"{app.name}{'-' + service if service else ''}-{stamp}.log"
send_document(
Chat.objects.get(pk=chat_id), filename, content.encode() or b'(empty)\n', caption=texts.logs_caption(app.name, service),
)
# ежедневная проверка: своя цепочка у каждой точки, живет, пока жива точка
+2 -2
View File
@@ -392,12 +392,12 @@ class SupervisorTests(TestCase):
def start(self, actor=tasks.install_point, status=Point.Status.INSTALLING, *args):
with mock.patch.object(actor, 'send') as send, mock.patch.object(tasks.supervise, 'send_with_options') as sup:
tasks.start(actor, self.point, status, self.chat, *args)
sup.assert_called_once_with(args=(self.point.pk, send.call_args.args[1]), delay=tasks.SUPERVISE_DELAY_MS)
sup.assert_called_once_with(args=('App.Point', self.point.pk, send.call_args.args[1]), delay=tasks.SUPERVISE_DELAY_MS)
return send.call_args.args
def supervise(self, token):
with mock.patch.object(tasks.supervise, 'send_with_options') as again:
tasks.supervise.fn(self.point.pk, token)
tasks.supervise.fn('App.Point', self.point.pk, token)
self.point.refresh_from_db()
return again.called
+570
View File
@@ -0,0 +1,570 @@
import io
import socket
import tarfile
import zipfile
from unittest import mock
import serverus
from django.db import IntegrityError
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
from . import platforms, tasks, uploads
from .bot import texts
from .bot.interface import MyPointInterface
from .models import AppVersion, Credentials, DockerApp, Platform, Server
from .tests_services import FakeRunner, create_point, patch_runner
COMPOSE = b'''services:
web:
image: nginx
restart: unless-stopped
volumes: [ "./data/web:/etc/nginx/conf.d:ro" ]
'''
SNAPSHOT = {'apps': {'site': {'services': {'web': {'state': 'running', 'health': '', 'status': 'Up 1 hour'}}, 'error': ''}}}
def make_zip(files: dict[str, bytes], symlink: str = '') -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w') as archive:
for path, content in files.items():
archive.writestr(path, content)
if symlink:
info = zipfile.ZipInfo(symlink)
info.external_attr = 0o120777 << 16
archive.writestr(info, '/etc/passwd')
return buffer.getvalue()
def make_tar(files: dict[str, bytes], symlink: str = '') -> bytes:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode='w:gz') as archive:
for path, content in files.items():
info = tarfile.TarInfo(path)
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
if symlink:
info = tarfile.TarInfo(symlink)
info.type = tarfile.SYMTYPE
info.linkname = '/etc/passwd'
archive.addfile(info)
return buffer.getvalue()
class DockerRunner(FakeRunner):
"""FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy."""
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)
if playbook == 'app_deploy':
with tarfile.open(extravars['archive']) as archive:
self.deployed[extravars['app']] = {
member.name: archive.extractfile(member).read() for member in archive.getmembers()
}
if not result.ok:
return result
data = {
'app_status': self.snapshot,
'app_deploy': {'changed': [], 'removed': [], 'recreated': ['web'], 'services': ['web'], 'domains': self.domains},
'app_logs': {'logs': 'line 1\nline 2'},
}.get(playbook)
if data is None:
return result
return serverus.Result(ok=True, status='successful', data=data, known_hosts=result.known_hosts)
def create_platform(owner=None) -> Platform:
owner = owner or TelegramUser.objects.create(tg_id=1)
return Platform.objects.create_platform(owner, Credentials('10.0.1.1', 'docker-pw'))
def ready_platform(owner=None) -> Platform:
platform = create_platform(owner)
server = platform.server
server.private_key = 'key'
server.save()
platform.set_status(Platform.Status.READY)
return platform
def deployed_app(platform: Platform, name='site', files=None) -> DockerApp:
app = DockerApp.objects.create(platform=platform, name=name)
version = AppVersion.objects.create_draft(app, files or {'docker-compose.yaml': COMPOSE}, AppVersion.Kind.FULL)
version.mark_applied()
return app
class UploadsTests(TestCase):
def test_zip_and_tar(self):
files = {'docker-compose.yaml': COMPOSE, 'data/web/default.conf': b'server {}'}
self.assertEqual(uploads.read('site.zip', make_zip(files)), files)
self.assertEqual(uploads.read('site.tar.gz', make_tar(files)), files)
self.assertEqual(uploads.read('site.tgz', make_tar(files)), files)
def test_single_file(self):
self.assertEqual(uploads.read('docker-compose.yaml', COMPOSE), {'docker-compose.yaml': COMPOSE})
self.assertFalse(uploads.is_archive('docker-compose.yaml'))
def test_skips_junk(self):
archive = make_zip({
'.env': b'A=1', '__MACOSX/._x': b'', 'data/.DS_Store': b'', 'data/web/._a.conf': b'', 'Thumbs.db': b'',
})
self.assertEqual(uploads.read('a.zip', archive), {'.env': b'A=1'})
def test_rejects_unsafe_paths_and_links(self):
for name, content in (
('a.zip', make_zip({'../etc/passwd': b''})),
('a.zip', make_zip({'/etc/passwd': b''})),
('a.zip', make_zip({'.env': b''}, symlink='data/link')),
('a.tar.gz', make_tar({'data/../../x': b''})),
('a.tar.gz', make_tar({'.env': b''}, symlink='data/link')),
('a.zip', b'not a zip'),
('a.zip', make_zip({})),
):
with self.subTest(name=name), self.assertRaises(uploads.UploadError):
uploads.read(name, content)
def test_limits(self):
with mock.patch.object(uploads, 'FILES_MAX', 2), self.assertRaisesRegex(uploads.UploadError, 'файлов'):
uploads.read('a.zip', make_zip({'a': b'', 'b': b'', 'c': b''}))
with mock.patch.object(uploads, 'UNPACKED_MAX', 10), self.assertRaisesRegex(uploads.UploadError, 'Мб'):
uploads.read('a.tar.gz', make_tar({'a': b'x' * 11}))
with mock.patch.object(uploads, 'ARCHIVE_MAX', 10), self.assertRaises(uploads.UploadError):
uploads.read('a.zip', b'x' * 11)
def test_merge_and_diff(self):
old = {'a': b'1', 'b': b'2', 'c': b'3'}
new = uploads.merge(old, {'b': b'22', 'd': b'4'})
self.assertEqual(new, {'a': b'1', 'b': b'22', 'c': b'3', 'd': b'4'})
diff = uploads.diff(old, {'b': b'22', 'd': b'4'})
self.assertEqual((diff.added, diff.changed, diff.removed), (['d'], ['b'], ['a', 'c']))
self.assertFalse(uploads.diff(old, old))
def test_check(self):
self.assertEqual(uploads.check({'compose.yaml': COMPOSE})[0], ['нет docker-compose.yaml в корне (compose.yaml нужно переименовать)'])
self.assertIn('не разбирается', uploads.check({'docker-compose.yaml': b'services: [x'})[0][0])
self.assertEqual(uploads.check({'docker-compose.yaml': b'x: 1'})[0], ['в docker-compose.yaml нет services'])
errors, warnings = uploads.check({
'docker-compose.yaml': b'services:\n db:\n image: postgres\n ports: ["5432:5432"]\n',
'data/web/x.conf': b'', 'notes.txt': b'', '.env': b'', 'caddy.conf': b'',
})
self.assertEqual(errors, [])
self.assertEqual(warnings, [
'db: нет restart, после перезагрузки сервера не поднимется',
'data/web/: нет сервиса web',
'вне конвенции: notes.txt',
])
self.assertEqual(uploads.check({'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b''}), ([], []))
def test_check_ports_and_mounts(self):
# RustDesk: свои порты, ключ, сгенерированный нами, из data/ с :ro, остальное в named volume
rustdesk = b'''services:
hbbs:
image: rustdesk/rustdesk-server
restart: unless-stopped
ports: ["21115:21115", "21116:21116", "21116:21116/udp", 21118]
volumes: ["keys:/root", "./data/hbbs/id_ed25519:/root/id_ed25519:ro"]
hbbr:
image: rustdesk/rustdesk-server
restart: unless-stopped
ports: [{target: 21117, published: "21117"}]
volumes: [{type: bind, source: ./data/hbbr/id_ed25519, target: /root/id_ed25519, read_only: true}]
'''
files = {'docker-compose.yaml': rustdesk, 'data/hbbs/id_ed25519': b'', 'data/hbbr/id_ed25519': b''}
self.assertEqual(uploads.check(files), ([], []))
errors, warnings = uploads.check({'docker-compose.yaml': b'''services:
web:
image: nginx
restart: unless-stopped
ports: ["127.0.0.1:80:80", "8443-8444:443", {published: 443, target: 443}, "${PORT}:80"]
volumes: ["./data:/data", "./data/web/site:/site", "../x:/x:ro", "/var/run/docker.sock:/sock", "cache:/cache"]
'''})
self.assertEqual(errors, [])
self.assertEqual(warnings, [
'web: публикует 80, 443, их занимает caddy, веб-морда - через caddy.conf',
'web: монтирует data, из папки приложения монтируется только data/<сервис>/, состояние - в named volume',
'web: data/web/site без :ro, то, что сервис туда запишет, удалит следующая заливка',
'web: монтирует ../x, из папки приложения монтируется только data/<сервис>/, состояние - в named volume',
])
class ModelsTests(TestCase):
def test_create_platform(self):
platform = create_platform()
self.assertTrue(platform.name.startswith('docker-'))
self.assertEqual(platform.status, Platform.Status.INSTALLING)
self.assertEqual((platform.server.address, platform.server.role), ('10.0.1.1', Server.Role.DOCKER))
self.assertEqual(platform.server.default_root_password, 'docker-pw')
self.assertEqual(Platform.objects.owned_by(platform.owner).find_last(), platform)
self.assertEqual(list(platform.owner.platforms.all()), [platform])
def test_server_has_one_parent(self):
platform = create_platform()
point = create_point(TelegramUser.objects.create(tg_id=2), mask=None)
server = platform.server
server.point = point
with self.assertRaises(IntegrityError):
server.save()
def test_point_card_skips_docker_role(self):
point = create_point(mask=None)
self.assertNotIn('Docker', texts.point_card(point))
def test_versions(self):
app = DockerApp.objects.create(platform=create_platform(), name='site')
self.assertIsNone(app.current_version())
files = {'docker-compose.yaml': COMPOSE, '.env': b'SECRET=1', 'data/web/bin': bytes(range(256))}
first = AppVersion.objects.create_draft(app, files, AppVersion.Kind.FULL)
self.assertEqual(first.get_files(), files)
self.assertNotIn(b'SECRET', AppVersion.objects.filter(pk=first.pk).values_list('files', flat=True)[0].encode())
first.mark_applied()
versions = []
for index in range(AppVersion.KEEP + 1):
version = AppVersion.objects.create_draft(app, {'docker-compose.yaml': b'%d' % index}, AppVersion.Kind.UPDATE)
version.mark_applied()
versions.append(version)
self.assertEqual(app.current_version(), versions[-1])
self.assertEqual(app.previous_version(), versions[-2])
self.assertEqual(app.versions.applied().count(), AppVersion.KEEP)
self.assertFalse(app.versions.filter(pk=first.pk).exists())
def test_new_draft_replaces_old_draft(self):
app = DockerApp.objects.create(platform=create_platform(), name='site')
AppVersion.objects.create_draft(app, {'a': b''}, AppVersion.Kind.FULL)
AppVersion.objects.create_draft(app, {'b': b''}, AppVersion.Kind.FULL)
self.assertEqual(app.versions.count(), 1)
class PlatformScenarioTests(TestCase):
def test_install(self):
platform = create_platform()
runner = DockerRunner()
with patch_runner(runner):
platforms.install_platform(platform)
platform.refresh_from_db()
self.assertEqual(platform.status, Platform.Status.READY)
self.assertEqual(runner.playbooks('10.0.1.1'), ['bootstrap', 'docker', 'facts', 'app_status'])
self.assertEqual(runner.call('docker', '10.0.1.1')[1]['local_certs'], False)
self.assertEqual(platform.server.uname, 'Linux test')
self.assertEqual(platform.status_snapshot, SNAPSHOT)
def test_install_failure(self):
platform = create_platform()
with patch_runner(DockerRunner(fail='docker')), self.assertRaises(platforms.ProvisionError):
platforms.install_platform(platform)
platform.refresh_from_db()
self.assertEqual((platform.status, platform.error), (Platform.Status.ERROR, '10.0.1.1: boom'))
def test_reinstall_redeploys_apps(self):
platform = ready_platform()
deployed_app(platform)
runner = DockerRunner()
with patch_runner(runner):
platforms.reinstall(platform, 'new-pw')
self.assertEqual(runner.playbooks('10.0.1.1'), ['bootstrap', 'docker', 'facts', 'app_deploy', 'app_status'])
self.assertEqual(runner.call('bootstrap', '10.0.1.1')[1]['login_password'], 'new-pw')
self.assertEqual(runner.deployed['site'], {'docker-compose.yaml': COMPOSE})
def test_release(self):
platform = ready_platform()
deployed_app(platform)
with patch_runner(DockerRunner()):
password, error = platforms.release_platform(platform)
self.assertTrue(password)
self.assertEqual(error, '')
self.assertFalse(Platform.objects.exists() or DockerApp.objects.exists() or Server.objects.exists())
def test_create_app(self):
platform = ready_platform()
self.assertEqual(platforms.create_app(platform, 'site').name, 'site')
for name in ('site', 'caddy', 'Site', '../x', ''):
with self.subTest(name=name), self.assertRaises(ValueError):
platforms.create_app(platform, name)
def test_update_merges_and_full_replaces(self):
app = deployed_app(ready_platform(), files={'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b'a'})
upload = platforms.prepare_upload(app, 'b.conf', b'b', AppVersion.Kind.UPDATE)
self.assertEqual((upload.errors, upload.diff.added), ([], ['b.conf']))
self.assertEqual(upload.version.get_files(), {'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b'a', 'b.conf': b'b'})
upload = platforms.prepare_upload(app, 'app.zip', make_zip({'docker-compose.yaml': COMPOSE}), AppVersion.Kind.FULL)
self.assertEqual(upload.diff.removed, ['data/web/a.conf'])
self.assertEqual(app.versions.filter(state=AppVersion.State.DRAFT).count(), 1)
def test_upload_without_changes_or_with_errors(self):
app = deployed_app(ready_platform())
upload = platforms.prepare_upload(app, 'docker-compose.yaml', COMPOSE, AppVersion.Kind.UPDATE)
self.assertIsNone(upload.version)
self.assertFalse(upload.diff)
upload = platforms.prepare_upload(app, 'docker-compose.yaml', b'x: 1', AppVersion.Kind.UPDATE)
self.assertIsNone(upload.version)
self.assertTrue(upload.errors)
with self.assertRaisesRegex(uploads.UploadError, 'только архивом'):
platforms.prepare_upload(app, 'docker-compose.yaml', COMPOSE, AppVersion.Kind.FULL)
def test_apply(self):
platform = ready_platform()
app = DockerApp.objects.create(platform=platform, name='site')
upload = platforms.prepare_upload(app, 'app.tar.gz', make_tar({'docker-compose.yaml': COMPOSE}), AppVersion.Kind.FULL)
runner = DockerRunner()
with patch_runner(runner):
data = platforms.apply(upload.version)
self.assertEqual(data['recreated'], ['web'])
self.assertEqual(runner.deployed['site'], {'docker-compose.yaml': COMPOSE})
self.assertEqual(app.current_version(), upload.version)
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)
current = app.current_version()
upload = platforms.prepare_upload(app, 'data/web/a.conf', b'a', AppVersion.Kind.UPDATE)
with patch_runner(DockerRunner(fail='app_deploy')), self.assertRaises(platforms.ProvisionError):
platforms.apply(upload.version)
upload.version.refresh_from_db()
self.assertEqual(upload.version.state, AppVersion.State.FAILED)
self.assertEqual(app.current_version(), current)
platform.refresh_from_db()
self.assertEqual((platform.status, platform.error), (Platform.Status.READY, '10.0.1.1: boom'))
def test_rollback(self):
app = deployed_app(ready_platform())
with self.assertRaises(ValueError):
platforms.prepare_rollback(app)
version = platforms.prepare_upload(app, 'data/web/a.conf', b'a', AppVersion.Kind.UPDATE).version
version.mark_applied()
upload = platforms.prepare_rollback(app)
self.assertEqual(upload.diff.removed, ['data/web/a.conf'])
self.assertEqual(upload.version.kind, AppVersion.Kind.ROLLBACK)
def test_control_logs_remove(self):
platform = ready_platform()
app = deployed_app(platform)
runner = DockerRunner()
with patch_runner(runner):
platforms.control(app, 'restart', 'web')
self.assertEqual(platforms.logs(app, 'web'), 'line 1\nline 2')
platforms.remove_app(app, volumes=True)
self.assertEqual(runner.call('app_control', '10.0.1.1')[1], {
'apps_dir': '/root/apps', 'app': 'site', 'action': 'restart', 'service': 'web',
})
self.assertTrue(runner.call('app_remove', '10.0.1.1')[1]['volumes'])
self.assertFalse(DockerApp.objects.exists())
def test_refresh_failure_after_operation_is_not_an_error(self):
platform = ready_platform()
app = deployed_app(platform)
with patch_runner(DockerRunner(fail='app_status')), self.assertLogs('App.platforms', 'WARNING'):
platforms.control(app, 'stop')
platform.refresh_from_db()
self.assertEqual((platform.status, platform.error), (Platform.Status.READY, ''))
class PlatformTasksTests(TestCase):
def setUp(self):
bot = Bot.objects.create(token='1:test')
owner = TelegramUser.objects.create(tg_id=1)
self.chat = Chat.objects.create(bot=bot, chat_id=1, type='private', user=owner)
self.platform = ready_platform(owner)
self.api = FakeApi()
patcher = mock.patch.object(Bot, 'api', return_value=self.api)
patcher.start()
self.addCleanup(patcher.stop)
def run_task(self, actor, *args, status=Platform.Status.UPDATING, runner=None):
self.platform.refresh_from_db()
with mock.patch.object(actor, 'send') as send, mock.patch.object(tasks.supervise, 'send_with_options') as sup:
tasks.start(actor, self.platform, status, self.chat, *args)
self.assertEqual(sup.call_args.kwargs['args'][0], 'App.Platform')
with patch_runner(runner or DockerRunner()):
actor.fn(*send.call_args.args)
return self.api.last_text
def test_install(self):
text = self.run_task(tasks.install_platform, status=Platform.Status.INSTALLING)
self.assertIn('готов к работе', text)
self.assertEqual(self.api.last_keyboard, [[texts.PLATFORM_MENU]])
def test_deploy(self):
app = DockerApp.objects.create(platform=self.platform, name='site')
version = AppVersion.objects.create_draft(app, {'docker-compose.yaml': COMPOSE}, AppVersion.Kind.FULL)
text = self.run_task(tasks.deploy_version, version.pk)
self.assertIn('файлы применены', text)
self.assertIn('web', text)
with self.assertLogs('App.tasks', 'ERROR'):
version = AppVersion.objects.create_draft(app, {'docker-compose.yaml': b'x'}, AppVersion.Kind.FULL)
text = self.run_task(tasks.deploy_version, version.pk, runner=DockerRunner(fail='app_deploy'))
self.assertIn('не удалось применить', text)
self.assertIn('уже применены', self.run_task(tasks.deploy_version, version.pk))
def test_control_and_remove(self):
app = deployed_app(self.platform)
self.assertIn('перезапущено', self.run_task(tasks.control_app, app.pk, 'restart', 'web'))
self.assertIn('вместе с данными', self.run_task(tasks.remove_app, app.pk, True))
self.assertIn('уже удалено', self.run_task(tasks.remove_app, app.pk, True))
def test_release(self):
text = self.run_task(tasks.release_platform, status=Platform.Status.RELEASING)
self.assertIn('10.0.1.1 - пароль root', text)
self.assertFalse(Platform.objects.exists())
def test_refresh_and_logs(self):
app = deployed_app(self.platform)
with patch_runner(DockerRunner()):
tasks.refresh_platform.fn(self.platform.pk, self.chat.pk)
self.assertIn('работает 1 из 1', self.api.last_text)
tasks.send_logs.fn(app.pk, 'web', self.chat.pk)
document = self.api.documents[-1]
self.assertTrue(document.file_name.startswith('site-web-'))
self.assertEqual(document.content, b'line 1\nline 2')
with patch_runner(DockerRunner(fail='app_status')), self.assertLogs('App.tasks', 'ERROR'):
tasks.refresh_platform.fn(self.platform.pk, self.chat.pk)
self.assertIn('не удалось проверить', self.api.last_text)
class PlatformBotTests(TestCase):
def setUp(self):
self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(MyPointInterface))
self.chat = Conversation(self.bot)
self.api = self.chat.api
self.say('/start')
self.owner = TelegramUser.objects.get()
for actor in (tasks.install_platform, tasks.deploy_version, tasks.control_app, tasks.remove_app,
tasks.refresh_platform, tasks.send_logs, tasks.supervise):
patcher = mock.patch.object(actor, 'send' if actor is not tasks.supervise else 'send_with_options')
setattr(self, actor.actor_name, patcher.start())
self.addCleanup(patcher.stop)
def say(self, text):
return self.chat.say(text)
def open_platform(self) -> Platform:
platform = ready_platform(self.owner)
platform.save_snapshot(SNAPSHOT)
self.say(texts.DOCKER_SERVERS)
self.assertIn(platform.name, self.say(platform.name))
return platform
def test_add_docker_server(self):
self.assertIn('ip:root_password', self.say(texts.ADD_DOCKER))
self.assertEqual(self.say('10.0.1.1:secret'), texts.DOCKER_INSTALL_STARTED)
self.assertEqual(len(self.api.deleted), 1)
platform = Platform.objects.get()
self.assertEqual(platform.status, Platform.Status.INSTALLING)
self.install_platform.assert_called_once()
self.assertEqual(self.api.last_keyboard, texts.main_keyboard(has_platforms=True))
self.assertEqual(self.say(texts.ADD_DOCKER) and self.say('10.0.1.1:x'), texts.ADDRESS_TAKEN.format(address='10.0.1.1'))
self.assertIn(platform.name, self.say(texts.MAIN_MENU))
def test_new_app_upload_and_apply(self):
platform = self.open_platform()
self.say(texts.NEW_APP)
self.assertIn('уже есть', self.say('caddy'))
self.assertIn('архив всей папки', self.say('site'))
self.assertEqual(self.say('text'), texts.UPLOAD_NOT_DOCUMENT)
reply = self.chat.send_document('site.zip', make_zip({'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b'a'}))
self.assertIn('Добавлено', reply)
self.assertIn('Применить?', reply)
self.assertEqual(self.say(texts.APPLY), texts.APPLY_STARTED)
version = AppVersion.objects.get()
self.assertEqual(self.deploy_version.call_args.args[2], version.pk)
platform.refresh_from_db()
self.assertEqual(platform.status, Platform.Status.UPDATING)
# занятый сервер не принимает новые операции
self.assertIn('Дождитесь', self.say(texts.RESTART))
def test_cancel_new_app_discards_it(self):
self.open_platform()
self.say(texts.NEW_APP)
self.say('site')
self.chat.send_document('site.zip', make_zip({'docker-compose.yaml': COMPOSE}))
self.assertIn('Приложений пока нет', self.say(texts.CANCEL))
self.assertFalse(DockerApp.objects.exists() or AppVersion.objects.exists())
def test_update_errors_and_unchanged(self):
platform = self.open_platform()
deployed_app(platform)
self.assertIn('Up 1 hour', self.say('site'))
self.say(texts.UPLOAD_UPDATE)
reply = self.chat.send_document('docker-compose.yaml', b'x: 1')
self.assertIn('Нельзя применить', reply)
# без изменений - сообщение и назад к приложению
self.assertIn('Up 1 hour', self.chat.send_document('docker-compose.yaml', COMPOSE))
self.assertEqual(self.api.sent[-2].text, texts.UPLOAD_UNCHANGED)
self.say(texts.UPLOAD_FULL)
self.assertIn('только архивом', self.chat.send_document('docker-compose.yaml', COMPOSE))
def test_service_control_and_logs(self):
platform = self.open_platform()
app = deployed_app(platform)
self.say('site')
self.assertEqual(self.say('ghost'), texts.APP_NOT_FOUND)
self.assertIn('Up 1 hour', self.say('web'))
self.assertEqual(self.api.last_keyboard, texts.SERVICE_KEYBOARD)
self.assertEqual(self.say(texts.LOGS), texts.LOGS_STARTED)
self.assertEqual(self.send_logs.call_args.args, (app.pk, 'web', self.chat_pk()))
self.assertEqual(self.say(texts.STOP), texts.OPERATION_STARTED)
self.assertEqual(self.control_app.call_args.args[2:], (app.pk, 'stop', 'web'))
def test_remove_app(self):
platform = self.open_platform()
app = deployed_app(platform)
self.say('site')
self.assertEqual(self.say(texts.REMOVE_APP), texts.REMOVE_APP_CONFIRM)
self.assertIn('site', self.say(texts.CANCEL))
self.say(texts.REMOVE_APP)
self.assertEqual(self.say(texts.REMOVE_WITH_DATA), texts.OPERATION_STARTED)
self.assertEqual(self.remove_app.call_args.args[2:], (app.pk, True))
def test_check_and_rollback(self):
platform = self.open_platform()
deployed_app(platform)
self.assertEqual(self.say(texts.CHECK), texts.CHECK_STARTED)
self.refresh_platform.assert_called_once_with(platform.pk, self.chat_pk())
self.say('site')
self.assertEqual(self.say(texts.ROLLBACK), texts.ROLLBACK_NONE)
def test_other_users_platform_is_hidden(self):
platform = ready_platform(TelegramUser.objects.create(tg_id=99))
self.say(texts.DOCKER_SERVERS)
self.assertEqual(self.say(platform.name), texts.PLATFORM_NOT_FOUND)
def chat_pk(self):
return Chat.objects.get().pk
+214
View File
@@ -0,0 +1,214 @@
"""Разбор присланных боту файлов приложения: архивы, слияние версий, разница и проверка по конвенции.
Корень архива - корень приложения. Мусор архиваторов пропускается, пути наружу и ссылки - отказ.
Конвенция приложения (serverus.DockerHost): docker-compose.yaml, .env, caddy.conf, data/<сервис>/.
"""
import io
import posixpath
import stat
import tarfile
import zipfile
from dataclasses import dataclass
from pathlib import PurePosixPath
import yaml
from serverus import CADDY_FILE, COMPOSE_FILE, DATA_DIR, ENV_FILE
ARCHIVE_MAX = 5 * 1024 * 1024
UNPACKED_MAX = 20 * 1024 * 1024
FILES_MAX = 500
JUNK_NAMES = {'.DS_Store', 'Thumbs.db', 'desktop.ini'}
JUNK_DIRS = {'__MACOSX'}
ARCHIVE_SUFFIXES = ('.zip', '.tar', '.tar.gz', '.tgz')
# так compose-файл могли назвать по привычке
OTHER_COMPOSE_NAMES = ('docker-compose.yml', 'compose.yaml', 'compose.yml')
# порты ingress: остальные приложения публикуют только свои, не-HTTP порты
INGRESS_PORTS = {80, 443}
class UploadError(ValueError):
pass
@dataclass(frozen=True)
class Diff:
added: list[str]
changed: list[str]
removed: list[str]
def __bool__(self):
return bool(self.added or self.changed or self.removed)
def is_archive(filename: str) -> bool:
return filename.lower().endswith(ARCHIVE_SUFFIXES)
def read(filename: str, content: bytes) -> dict[str, bytes]:
"""Файлы из присланного документа: {путь: содержимое}. Не архив - один файл в корне приложения.
UploadError - архив не читается, выходит за лимиты или содержит ссылки и пути за пределы приложения.
"""
if len(content) > ARCHIVE_MAX:
raise UploadError(f'файл больше {ARCHIVE_MAX // 1024 // 1024} Мб')
lower = filename.lower()
try:
if lower.endswith('.zip'):
files = _read_zip(content)
elif is_archive(lower):
files = _read_tar(content)
else:
files = {_clean_path(filename): content}
except (zipfile.BadZipFile, tarfile.TarError, EOFError, OSError) as error:
raise UploadError(f'архив не читается: {error}') from error
if not files:
raise UploadError('в архиве нет файлов')
return files
def _read_zip(content: bytes) -> dict[str, bytes]:
files, total = {}, 0
with zipfile.ZipFile(io.BytesIO(content)) as archive:
for info in archive.infolist():
if info.is_dir():
continue
# тип файла есть не у всех архиваторов: без него это обычный файл
file_type = stat.S_IFMT(info.external_attr >> 16)
if file_type and file_type != stat.S_IFREG:
raise UploadError(f'{info.filename}: ссылки и специальные файлы не поддерживаются')
path = _clean_path(info.filename)
if _is_junk(path):
continue
# zipfile читает не больше размера из заголовка, так что лимит по заголовку честный
total = _count(files, total, info.file_size)
files[path] = archive.read(info)
return files
def _read_tar(content: bytes) -> dict[str, bytes]:
files, total = {}, 0
with tarfile.open(fileobj=io.BytesIO(content), mode='r:*') as archive:
for member in archive:
if member.isdir():
continue
if not member.isfile():
raise UploadError(f'{member.name}: ссылки и специальные файлы не поддерживаются')
path = _clean_path(member.name)
if _is_junk(path):
continue
total = _count(files, total, member.size)
files[path] = archive.extractfile(member).read()
return files
def _count(files: dict, total: int, size: int) -> int:
if len(files) >= FILES_MAX:
raise UploadError(f'больше {FILES_MAX} файлов')
total += size
if total > UNPACKED_MAX:
raise UploadError(f'распакованные файлы больше {UNPACKED_MAX // 1024 // 1024} Мб')
return total
def _clean_path(path: str) -> str:
path = path.replace('\\', '/')
while path.startswith('./'):
path = path[2:]
parts = path.split('/')
if not path or path.startswith('/') or any(part in ('', '.', '..') for part in parts):
raise UploadError(f'недопустимый путь: {path!r}')
return str(PurePosixPath(path))
def _is_junk(path: str) -> bool:
parts = path.split('/')
return parts[-1] in JUNK_NAMES or parts[-1].startswith('._') or any(part in JUNK_DIRS for part in parts)
def merge(old: dict[str, bytes], new: dict[str, bytes]) -> dict[str, bytes]:
"""Апдейт: новые файлы поверх текущих, остальные остаются."""
return {**old, **new}
def diff(old: dict[str, bytes], new: dict[str, bytes]) -> Diff:
return Diff(
added=sorted(new.keys() - old.keys()),
changed=sorted(path for path in new.keys() & old.keys() if new[path] != old[path]),
removed=sorted(old.keys() - new.keys()),
)
def check(files: dict[str, bytes]) -> tuple[list[str], list[str]]:
"""Проверка полного набора файлов приложения: (ошибки, предупреждения). С ошибками применять нельзя."""
errors, warnings = [], []
if COMPOSE_FILE not in files:
other = next((name for name in OTHER_COMPOSE_NAMES if name in files), None)
hint = f' ({other} нужно переименовать)' if other else ''
return [f'нет {COMPOSE_FILE} в корне{hint}'], warnings
try:
compose = yaml.safe_load(files[COMPOSE_FILE])
except yaml.YAMLError as error:
return [f'{COMPOSE_FILE} не разбирается: {error}'], warnings
services = compose.get('services') if isinstance(compose, dict) else None
if not isinstance(services, dict) or not services:
return [f'в {COMPOSE_FILE} нет services'], warnings
for name, service in services.items():
service = service if isinstance(service, dict) else {}
taken = sorted(INGRESS_PORTS & {port for entry in _list(service.get('ports')) for port in _host_ports(entry)})
if taken:
warnings.append(f'{name}: публикует {", ".join(map(str, taken))}, их занимает caddy, веб-морда - через caddy.conf')
for source, read_only in _app_mounts(service):
if not source.startswith(f'{DATA_DIR}/'):
warnings.append(f'{name}: монтирует {source}, из папки приложения монтируется только {DATA_DIR}/<сервис>/, '
'состояние - в named volume')
elif not read_only:
warnings.append(f'{name}: {source} без :ro, то, что сервис туда запишет, удалит следующая заливка')
if not service.get('restart'):
warnings.append(f'{name}: нет restart, после перезагрузки сервера не поднимется')
folders = {path.split('/')[1] for path in files if path.startswith(f'{DATA_DIR}/') and path.count('/') >= 2}
for folder in sorted(folders - set(map(str, services))):
warnings.append(f'{DATA_DIR}/{folder}/: нет сервиса {folder}')
known = {COMPOSE_FILE, ENV_FILE, CADDY_FILE}
stray = sorted(path for path in files if path not in known and not path.startswith(f'{DATA_DIR}/'))
if stray:
warnings.append('вне конвенции: ' + ', '.join(stray[:10]) + (' ...' if len(stray) > 10 else ''))
return errors, warnings
def _list(value) -> list:
return value if isinstance(value, list) else []
def _host_ports(entry) -> set[int]:
"""Порты хоста из записи ports: "[ip:]хост:контейнер[/протокол]" или длинная форма. Без порта хоста - пусто."""
if isinstance(entry, dict):
published = entry.get('published')
return _port_range(str(published)) if published is not None else set()
text = str(entry).split('/')[0]
if ':' not in text:
return set()
return _port_range(text.rsplit(':', 1)[0].rsplit(':', 1)[-1])
def _port_range(text: str) -> set[int]:
"""80 или 8000-8010; переменные и мусор - пусто: порты после подстановки переменных проверяет сервер при деплое."""
first, _, last = text.partition('-')
if not first.isdigit() or (last and not last.isdigit()):
return set()
return set(range(int(first), int(last or first) + 1))
def _app_mounts(service: dict):
"""Bind-mount из папки приложения: (путь относительно приложения, только чтение)."""
for volume in _list(service.get('volumes')):
if isinstance(volume, dict):
if volume.get('type') != 'bind':
continue
source, read_only = str(volume.get('source', '')), bool(volume.get('read_only'))
else:
parts = str(volume).split(':')
if len(parts) < 2:
continue
source, read_only = parts[0], len(parts) > 2 and 'ro' in parts[2].split(',')
if source.startswith('.'):
yield posixpath.normpath(source), read_only
+2
View File
@@ -38,6 +38,8 @@ INSTALLED_APPS += [
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '')
# Раздача файлов своего Bot API сервера (сервис telegram-files): в --local режиме он пишет их на диск
TELEGRAM_FILES_URL = os.environ.get('TELEGRAM_FILES_URL', '')
# Адрес этого сайта, как его видит Bot API сервер: на него ставится webhook ботов
TELEGRAM_WEBHOOK_URL = os.environ.get('TELEGRAM_WEBHOOK_URL', '')
+4
View File
@@ -59,6 +59,8 @@ class Shop(Interface):
Порядок разбора сообщения: команда, кнопка текущего состояния (`@text(..., state=...)`), кнопка, состояние диалога, `fallback`.
Файлы: `ctx.document` - присланный документ (`file_id`, `name`, `size`, `mime_type`) или `None`, `ctx.text` у документа - подпись. `ctx.download_document(max_size)` возвращает содержимое, `api.FileTooLarge` - файл больше `max_size`, `api.ApiError` - не скачался. Документ ловит обработчик состояния (`@state`). `ctx.reply_document(filename, content, caption)` и `messages.send_document(chat, ...)` отправляют файл документом.
`ctx.delete_message()` удаляет входящее сообщение из чата, например с паролем. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`.
## Запуск
@@ -69,6 +71,8 @@ class Shop(Interface):
3. Отдельный процесс не нужен: обновления принимает сайт. Смена интерфейса применяется без перезапуска.
`TELEGRAM_API_URL` направляет бота на свой Bot API сервер (сервис `telegram` в `docker-compose.yaml`).
`TELEGRAM_FILES_URL` - раздача файлов своего Bot API сервера (сервис `telegram-files`). В `--local` режиме сервер не отдает файлы по HTTP, а пишет их в свою папку (`api.WORK_DIR`) с правами 0600 от uid 101. Рядом с ним работает nginx, воркеры которого тоже от uid 101: он отдает только `documents/` и слушает только локальный адрес. При заданном `TELEGRAM_API_URL` переменная обязательна: без нее сайт не запустится (`ImproperlyConfigured`).
`TELEGRAM_WEBHOOK_URL` - адрес сайта, как его видит Bot API сервер, к нему дописывается путь webhook. Сервер в docker запущен с `--local` (`TELEGRAM_LOCAL`), поэтому webhook может быть на http и на любом порту. Хост из этого адреса нужно добавить в `ALLOWED_HOSTS`.
Состояние webhook (адрес, очередь, последняя ошибка доставки) видно на странице бота в админке. Ошибка обработчика пишется в `last_error`, а телеграму отвечаем 200, чтобы он не повторял то же обновление.
+43 -1
View File
@@ -1,25 +1,40 @@
import logging
import requests
import telebot
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from telebot import apihelper
logger = logging.getLogger(__name__)
def configure_api_server():
"""Направляет pyTelegramBotAPI на свой инстанс Bot API сервера, если он задан в TELEGRAM_API_URL."""
if uses_own_server():
url = settings.TELEGRAM_API_URL.rstrip('/')
apihelper.API_URL = url + '/bot{0}/{1}'
apihelper.FILE_URL = url + '/file/bot{0}/{1}'
# в --local режиме сервер файлы не отдает, их раздает TELEGRAM_FILES_URL по пути внутри WORK_DIR
files_url = getattr(settings, 'TELEGRAM_FILES_URL', '')
if not files_url:
raise ImproperlyConfigured('TELEGRAM_API_URL is set, so TELEGRAM_FILES_URL is required')
apihelper.FILE_URL = files_url.rstrip('/') + '{1}'
CLOUD_API_URL = 'https://api.telegram.org'
ALLOWED_UPDATES = ['message', 'my_chat_member']
# папка данных своего Bot API сервера (TELEGRAM_WORK_DIR образа): в --local режиме getFile отдает путь внутри нее
WORK_DIR = '/var/lib/telegram-bot-api'
class ApiError(Exception):
pass
class FileTooLarge(ApiError):
pass
def uses_own_server() -> bool:
return bool(getattr(settings, 'TELEGRAM_API_URL', ''))
@@ -34,3 +49,30 @@ def log_out_from_cloud(token: str):
def make_api(token: str) -> telebot.TeleBot:
return telebot.TeleBot(token, threaded=False)
def download_file(api: telebot.TeleBot, file_id: str, max_size: int) -> bytes:
"""Содержимое файла из сообщения. FileTooLarge - больше max_size байт, ApiError - не удалось скачать.
Свой Bot API сервер в --local режиме файлы по HTTP не отдает: getFile возвращает путь на его диске,
а раздает их TELEGRAM_FILES_URL (nginx рядом с сервером, см. configure_api_server).
"""
try:
file = api.get_file(file_id)
if file.file_size and file.file_size > max_size:
raise FileTooLarge(f'file is larger than {max_size} bytes')
path = file.file_path
if uses_own_server():
if not path.startswith(WORK_DIR + '/'):
raise ApiError('unexpected file path from Bot API server')
path = path[len(WORK_DIR):]
content = api.download_file(path)
except ApiError:
raise
except Exception as error:
# путь к файлу и URL содержат токен бота: текст ошибки уходит в чат, поэтому подробности только в лог
logger.exception('Failed to download file %s', file_id)
raise ApiError(f'download: {type(error).__name__}') from error
if len(content) > max_size:
raise FileTooLarge(f'file is larger than {max_size} bytes')
return content
+35 -1
View File
@@ -1,9 +1,21 @@
from dataclasses import dataclass
from telebot import types
from . import api as bot_api
from . import interfaces, messages
from .models import Bot, Chat, User
@dataclass(frozen=True)
class Document:
"""Файл, присланный документом."""
file_id: str
name: str
size: int
mime_type: str
class Context:
"""Всё, что нужно обработчику интерфейса: входящее сообщение, собеседник, состояние диалога и ответ."""
@@ -16,7 +28,21 @@ class Context:
@property
def text(self) -> str:
return self.message.text or ''
"""Текст сообщения, у документа - подпись."""
return self.message.text or self.message.caption or ''
@property
def document(self) -> Document | None:
document = self.message.document
if document is None:
return None
return Document(document.file_id, document.file_name or '', document.file_size or 0, document.mime_type or '')
def download_document(self, max_size: int) -> bytes:
"""Содержимое присланного документа. api.FileTooLarge - больше max_size байт, api.ApiError - не скачался."""
if self.document is None:
raise ValueError('message has no document')
return bot_api.download_file(self.api, self.document.file_id, max_size)
@property
def command(self) -> str | None:
@@ -65,3 +91,11 @@ class Context:
def reply(self, text: str, keyboard: messages.Keyboard | None = None, parse_mode: str | None = None):
return messages.send(self.chat, text, keyboard=keyboard, parse_mode=parse_mode, api=self.api)
def reply_document(
self, filename: str, content: bytes, caption: str = '', keyboard: messages.Keyboard | None = None,
parse_mode: str | None = None,
):
return messages.send_document(
self.chat, filename, content, caption=caption, keyboard=keyboard, parse_mode=parse_mode, api=self.api,
)
+10 -1
View File
@@ -33,7 +33,7 @@ def _handle_message(api, bot: Bot, message: types.Message):
user = _save_user(message.from_user)
chat = _save_chat(bot, message.chat, user)
Message.objects.create(
chat=chat, user=user, direction=Message.Direction.IN, message_id=message.message_id, text=message.text or '',
chat=chat, user=user, direction=Message.Direction.IN, message_id=message.message_id, text=_message_text(message),
)
interface = bot.get_interface()
@@ -44,6 +44,15 @@ def _handle_message(api, bot: Bot, message: types.Message):
chat.save(update_fields=['state', 'data'])
def _message_text(message: types.Message) -> str:
"""Текст для истории: у документа - подпись или имя файла."""
if message.text:
return message.text
if message.document:
return message.caption or message.document.file_name or ''
return message.caption or ''
def _save_user(tg_user: types.User) -> User:
user, _ = User.objects.update_or_create(
tg_id=tg_user.id,
+17
View File
@@ -1,3 +1,5 @@
import io
from telebot import types
from .models import Chat, Message
@@ -22,3 +24,18 @@ def send(chat: Chat, text: str, keyboard: Keyboard | None = None, parse_mode: st
api = api or chat.bot.api()
sent = api.send_message(chat.chat_id, text, reply_markup=build_keyboard(keyboard), parse_mode=parse_mode)
return Message.objects.create(chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=text)
def send_document(
chat: Chat, filename: str, content: bytes, caption: str = '', keyboard: Keyboard | None = None,
parse_mode: str | None = None, api=None,
) -> Message:
"""Отправляет файл документом и сохраняет в историю подпись или имя файла."""
api = api or chat.bot.api()
sent = api.send_document(
chat.chat_id, types.InputFile(io.BytesIO(content), file_name=filename), caption=caption or None,
reply_markup=build_keyboard(keyboard), parse_mode=parse_mode,
)
return Message.objects.create(
chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=caption or filename,
)
+45 -1
View File
@@ -8,11 +8,32 @@ CHAT_ID = 42
class FakeApi:
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения."""
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения, отдает присланные документы."""
def __init__(self):
self.sent = []
self.deleted = []
self.documents = []
self.files = {}
"""file_id -> содержимое присланного документа."""
def get_file(self, file_id):
from .api import WORK_DIR, uses_own_server
# свой сервер отдает путь на своем диске, облако - относительный
path = f'{WORK_DIR}/1:token/documents/{file_id}' if uses_own_server() else f'documents/{file_id}'
return SimpleNamespace(file_id=file_id, file_path=path, file_size=len(self.files[file_id]))
def download_file(self, file_path):
return self.files[file_path.rsplit('/', 1)[-1]]
def send_document(self, chat_id, document, caption=None, reply_markup=None, parse_mode=None):
content = document.file.read()
self.documents.append(SimpleNamespace(
chat_id=chat_id, file_name=document.file_name, content=content, caption=caption, reply_markup=reply_markup,
))
self.sent.append(SimpleNamespace(chat_id=chat_id, text=caption, reply_markup=reply_markup, parse_mode=parse_mode))
return SimpleNamespace(message_id=len(self.sent))
def send_message(self, chat_id, text, reply_markup=None, parse_mode=None):
self.sent.append(SimpleNamespace(chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode))
@@ -51,6 +72,19 @@ def message_update_json(update_id: int, text: str, chat_type: str = 'private', u
}
def document_update_json(
update_id: int, file_id: str, file_name: str, size: int, caption: str = '', user_id: int = USER_ID,
) -> dict:
"""Обновление с документом в том виде, в каком его присылает Bot API."""
update = message_update_json(update_id, '', user_id=user_id)
message = update['message']
del message['text']
message['document'] = {'file_id': file_id, 'file_unique_id': file_id, 'file_name': file_name, 'file_size': size}
if caption:
message['caption'] = caption
return update
class Conversation:
"""Диалог пользователя с ботом: say() отправляет сообщение и возвращает ответ бота."""
@@ -68,3 +102,13 @@ class Conversation:
sent_before = len(self.api.sent)
self._handle_update(self.api, self.bot, message_update(self._update_id, text, user_id=self.user_id))
return self.api.last_text if len(self.api.sent) > sent_before else None
def send_document(self, file_name: str, content: bytes, caption: str = '') -> str | None:
"""Присылает боту документ и возвращает ответ бота."""
self._update_id += 1
file_id = f'file{self._update_id}'
self.api.files[file_id] = content
sent_before = len(self.api.sent)
update = document_update_json(self._update_id, file_id, file_name, len(content), caption, self.user_id)
self._handle_update(self.api, self.bot, types.Update.de_json(update))
return self.api.last_text if len(self.api.sent) > sent_before else None
+86 -1
View File
@@ -5,11 +5,12 @@ from unittest import mock
from django.test import TestCase, override_settings
from telebot import types
from . import api as bot_api
from . import interfaces
from .dispatcher import handle_update
from .interfaces import Interface, command, register, state, text
from .models import Bot, Chat, Message, User
from .testing import FakeApi, message_update, message_update_json
from .testing import Conversation, FakeApi, message_update, message_update_json
@register
@@ -42,6 +43,22 @@ class EchoInterface(Interface):
ctx.state = ''
ctx.reply(f'hi {ctx.text}')
@text('upload')
def ask_file(self, ctx):
ctx.state = 'await_file'
ctx.reply('file?')
@state('await_file')
def file(self, ctx):
if ctx.document is None:
return ctx.reply('not a file')
try:
content = ctx.download_document(max_size=10)
except bot_api.FileTooLarge:
return ctx.reply('too large')
ctx.state = ''
ctx.reply_document(f'echo-{ctx.document.name}', content, caption=f'got {ctx.text}')
def fallback(self, ctx):
ctx.reply(f'echo {ctx.text}')
@@ -128,6 +145,74 @@ class DispatcherTests(TestCase):
self.assertEqual(Chat.objects.count(), 1)
class DocumentTests(TestCase):
def setUp(self):
self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(EchoInterface))
self.conversation = Conversation(self.bot)
self.conversation.say('upload')
def check_echo(self):
self.assertEqual(self.conversation.send_document('a.zip', b'zip', caption='caption'), 'got caption')
document = self.conversation.api.documents[-1]
self.assertEqual((document.file_name, document.content), ('echo-a.zip', b'zip'))
self.assertEqual(
list(Message.objects.order_by('pk').values_list('direction', 'text'))[-2:],
[('in', 'caption'), ('out', 'got caption')],
)
@override_settings(TELEGRAM_API_URL='')
def test_cloud_document(self):
self.check_echo()
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
def test_own_server_document(self):
self.check_echo()
def test_too_large(self):
self.assertEqual(self.conversation.send_document('big.zip', b'x' * 11), 'too large')
self.assertEqual(Message.objects.filter(direction='in').order_by('pk').last().text, 'big.zip')
def test_text_in_file_state(self):
self.assertEqual(self.conversation.say('hello'), 'not a file')
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
def test_own_server_rejects_foreign_path(self):
api = mock.Mock()
api.get_file.return_value = SimpleNamespace(file_path='/etc/passwd', file_size=1)
with self.assertRaises(bot_api.ApiError):
bot_api.download_file(api, 'id', 10)
api.download_file.assert_not_called()
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081', TELEGRAM_FILES_URL='http://127.0.0.1:8083/')
def test_own_server_file_url(self):
from telebot import apihelper
with mock.patch.object(apihelper, 'API_URL', None), mock.patch.object(apihelper, 'FILE_URL', None):
bot_api.configure_api_server()
self.assertEqual(
apihelper.FILE_URL.format('1:t', '/1:t/documents/file_0.zip'),
'http://127.0.0.1:8083/1:t/documents/file_0.zip',
)
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081', TELEGRAM_FILES_URL='')
def test_own_server_requires_file_url(self):
from django.core.exceptions import ImproperlyConfigured
from telebot import apihelper
with mock.patch.object(apihelper, 'API_URL', None), mock.patch.object(apihelper, 'FILE_URL', None):
with self.assertRaises(ImproperlyConfigured):
bot_api.configure_api_server()
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
def test_download_error_hides_token(self):
api = mock.Mock()
api.get_file.return_value = SimpleNamespace(file_path=f'{bot_api.WORK_DIR}/1:secret/documents/a.zip', file_size=1)
api.download_file.side_effect = ValueError('No connection adapters were found for /1:secret/documents/a.zip')
with self.assertLogs('Telegram.api', 'ERROR'), self.assertRaises(bot_api.ApiError) as raised:
bot_api.download_file(api, 'id', 10)
self.assertNotIn('secret', str(raised.exception))
@override_settings(TELEGRAM_WEBHOOK_URL='http://localhost:8000/')
class PublishTests(TestCase):
def setUp(self):
+42 -1
View File
@@ -54,7 +54,7 @@ server.release(generate_password())
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
- `bootstrap` - проверяет Debian 12 или 13, прописывает ключ, меняет пароль root, обновляет систему (если что-то обновилось - перезагружает в конце), включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
- `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `release` - ставит новый пароль root, возвращает конфиг nftables Debian по умолчанию (все разрешено, nftables остается включенным, Docker перезапускается, чтобы вернуть свои правила), убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `facts` - `uname -a` и версия ОС.
Специализации - наследники `Server`:
@@ -65,6 +65,41 @@ server.release(generate_password())
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
- `RedirectProxy(host).redirect(target_ip, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip> <ports>`, который через nftables перенаправляет tcp-порты `ports` на те же порты target_ip с masquerade и MSS clamping. Точка передает 80, 443, порт панели и порт подписки; ssh остается на самом сервере, скрипт откажется перенаправлять его порт. Правила - `/etc/nftables.d/serverus-redirect.nft`, подключаются include из `/etc/nftables.conf`. Заодно включает bbr, fq и увеличенные буферы и таблицу conntrack.
- `DockerHost(host).install(local_certs=False)` - после `bootstrap`: фаервол (ssh, 80, 443/tcp+udp, транзитный трафик пропускается - его фильтрует Docker), Docker из репозитория Docker, логи контейнеров с ротацией (`log-driver: local`), автоматические обновления безопасности (`unattended-upgrades`), папка приложений `APPS_DIR` (`/root/apps`), сеть `web` и встроенное приложение `INGRESS` (`caddy`, образ `CADDY_IMAGE` закреплен, обновлять вручную). `local_certs` - сертификаты собственного CA Caddy вместо Let's Encrypt, для стенда.
## Docker-приложения
Приложение - папка `/root/apps/<name>/`, compose-проект с именем `name`:
```
docker-compose.yaml COMPOSE_FILE, обязателен
.env ENV_FILE, секреты, на сервере 0600
caddy.conf CADDY_FILE, необязательно: блок Caddy для веб-морды
data/<сервис>/ DATA_DIR, настройки сервиса, имя папки = имя сервиса в compose
```
Сервисы ничего не пишут в папку приложения, состояние - только в named volumes. Поэтому папка целиком повторяет присланные файлы, лишние файлы удаляются. Bind-mount - только для своих файлов из `data/<сервис>/` и только `:ro` (например, ключи, которые сгенерировали мы сами); то, что генерирует сервис, живет в named volume. Named volumes не переживают переустановку ОС: ключ, который клиенты должны знать заранее, лучше сгенерировать самим и положить в `data/`.
Сеть - только мост, `network_mode: host` не используется: фаервол открывает только ssh, 80 и 443. HTTP-сервисы порты не публикуют: веб-морды подключаются к внешней сети `web`, а их домены описываются в `caddy.conf`. Не-HTTP сервисы (например, RustDesk) публикуют свои порты через `ports:`: трафик к ним Docker пропускает через `forward` своими правилами, фаервол открывать не нужно. 80 и 443 заняты `caddy`. Пример - `docs/apps/rustdesk/`: приложение, свой ключ в `data/` и проверка по протоколу.
```caddyfile
app.example.com {
reverse_proxy site-web-1:8080
}
```
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`, `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 Мб.
- `remove(name, volumes=False)` - контейнеры, папка и блок Caddy; `volumes=True` - вместе с данными.
Контейнерам нужен `restart: unless-stopped`: при изменении конфига nftables перезапускается и стирает правила Docker, Docker перезапускается следом, чтобы их вернуть. Без изменений конфига nftables не перезапускается.
Скрипты на сервере (`playbooks/files/app_sync.py`, `app_status.py`, `app_ports.py`) копируются в `/root/apps/.serverus/` на каждом запуске.
Порты и логины: `generate_port(exclude=...)` - порт сервиса из 10000-20000 (ssh - 20000-60000), `generate_username()` - 6 символов.
Плейбуки ходят в API панели с самого сервера на `127.0.0.1` со своим токеном `serverus`, который перевыпускается на каждом запуске. Токен `api_token`, отданный вызывающей стороне, при этом не меняется.
@@ -106,6 +141,9 @@ make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на па
make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] # сертификат на домен или на ip
make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую
make serverus_release ip=<ip> # вернуть сервер
make serverus_docker ip=<ip> password=<root_password> [local_certs=yes] # bootstrap + Docker + ingress
make serverus_app ip=<ip> action=deploy name=<app> dir=<папка> # залить приложение из локальной папки
make serverus_app ip=<ip> action=status|logs|start|stop|restart|pull|remove [name=<app>] [service=<service>] [volumes=yes]
```
То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>`, `python -m serverus key add <name> --out <dir>` и `python -m serverus release --out <dir>`.
@@ -134,3 +172,6 @@ make serverus_release ip=<ip> # вернуть се
- [x] `release` на локальном стенде
- [ ] проверить на сервере с публичным адресом: сертификат Let's Encrypt на домен и на ip (продление короткого сертификата по cron), `release`, Debian 12
- [x] выпуск и отзыв ключей в боте поверх `XuiApi`
- [x] `DockerHost`: плейбуки приложений проверены на локальном Docker (deploy, выборочное пересоздание, отказ на битом compose, откат блока Caddy, статус, логи, удаление)
- [x] `DockerHost` на стенде (Debian 13): установка, https через `local_certs`, выборочное пересоздание, отказ битого `caddy.conf` и дубля домена до изменений, удаление сервиса заливкой, `release` с работающими приложениями, повторный bootstrap сервера с Docker
- [ ] `DockerHost` на сервере с публичным адресом: сертификаты Let's Encrypt через Caddy
+12
View File
@@ -1,6 +1,9 @@
from .credentials import (
KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username,
)
from .docker import (
APP_NAME_RE, CADDY_FILE, CADDY_IMAGE, COMPOSE_FILE, DATA_DIR, ENV_FILE, INGRESS, SERVICE_NAME_RE, DockerHost,
)
from .host import Host
from .local_ca import Certificate, LocalCA
from .proxy import RedirectProxy
@@ -10,13 +13,22 @@ from .xui import CF_WARP, XUI_VERSION, XuiPanel, parse_proxy
from .xui_api import XuiApi, XuiApiError
__all__ = [
'APP_NAME_RE',
'CADDY_FILE',
'CADDY_IMAGE',
'CF_WARP',
'COMPOSE_FILE',
'Certificate',
'DATA_DIR',
'DockerHost',
'ENV_FILE',
'Host',
'INGRESS',
'KeyPair',
'LocalCA',
'RedirectProxy',
'Result',
'SERVICE_NAME_RE',
'Server',
'XUI_VERSION',
'XuiApi',
+87 -1
View File
@@ -5,6 +5,9 @@
python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir> [--password <new_root_password>]
python -m serverus docker <ip> <root_password> --out <dir> [--local-certs]
python -m serverus app deploy <name> <app_dir> --out <dir>
python -m serverus app status|logs|start|stop|restart|pull|remove [<name>] --out <dir> [--service <service>] [--volumes]
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
bootstrap меняет пароль и порт ssh, без этого файла доступ к серверу можно потерять.
@@ -15,7 +18,7 @@ import sys
from pathlib import Path
from . import (
Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
DockerHost, Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username,
)
@@ -53,7 +56,25 @@ def main(argv=None) -> int:
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
release.add_argument('--password', default='', help='новый пароль root, без него случайный')
docker = commands.add_parser('docker', help='bootstrap свежего сервера и установка Docker с ingress Caddy')
docker.add_argument('address')
docker.add_argument('password', help='текущий пароль root')
docker.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
docker.add_argument('--local-certs', action='store_true', help='сертификаты своего CA Caddy: для стенда без публичного адреса')
app = commands.add_parser('app', help='приложение на сервере от команды docker')
app.add_argument('action', choices=['deploy', 'status', 'logs', 'start', 'stop', 'restart', 'pull', 'remove'])
app.add_argument('name', nargs='?', default='', help='имя приложения, для status не нужно')
app.add_argument('app_dir', nargs='?', type=Path, help='для deploy: локальная папка приложения')
app.add_argument('--out', type=Path, required=True, help='папка с доступами от команды docker')
app.add_argument('--service', default='', help='один сервис приложения')
app.add_argument('--volumes', action='store_true', help='remove: удалить и данные')
args = parser.parse_args(argv)
if args.command == 'docker':
return install_docker(args.address, args.password, args.out, args.local_certs)
if args.command == 'app':
return manage_app(args.action, args.name, args.app_dir, args.out, args.service, args.volumes)
if args.command == 'xui':
return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl, args.ca)
if args.command == 'key':
@@ -133,6 +154,71 @@ def install_panel(access: dict, out: Path, ca_dir: Path | None) -> int:
return 0
def install_docker(address: str, password: str, out: Path, local_certs: bool) -> int:
access_file = out / ACCESS_FILE
access = json.loads(access_file.read_text()) if access_file.exists() else {}
if access.get('bootstrapped'):
# повторный запуск после ошибки установки: сервер уже на ключе и новом порту
print('bootstrap: skipped, already done')
else:
keys = generate_keypair()
access = {
'address': address,
'ssh_port': generate_ssh_port(),
'root_password': generate_password(),
'private_key': keys.private_key,
'known_hosts': '',
}
save(out, access)
result = Server(Host(address, private_key=keys.private_key)).bootstrap(
password, keys.public_key, access['root_password'], access['ssh_port'],
)
access['known_hosts'] = result.known_hosts
access['bootstrapped'] = result.ok
save(out, access)
report('bootstrap', result)
if not result.ok:
return 1
result = DockerHost(load_host(access)).install(local_certs=local_certs)
report('docker', result)
if not result.ok:
return 1
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{access['address']}")
return 0
def manage_app(action: str, name: str, app_dir: Path | None, out: Path, service: str, volumes: bool) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
docker = DockerHost(load_host(access))
try:
if action == 'deploy':
if app_dir is None or not app_dir.is_dir():
print('deploy: укажите локальную папку приложения', file=sys.stderr)
return 1
files = {
path.relative_to(app_dir).as_posix(): path.read_bytes()
for path in sorted(app_dir.rglob('*')) if path.is_file()
}
result = docker.deploy(name, files)
elif action == 'status':
result = docker.status()
elif action == 'logs':
result = docker.logs(name, service)
elif action == 'remove':
result = docker.remove(name, volumes)
else:
result = docker.control(name, action, service)
except ValueError as error:
print(error, file=sys.stderr)
return 1
report(action, result)
if action == 'logs':
print(result.data.get('logs', ''))
elif result.data:
print(json.dumps(result.data, indent=2, ensure_ascii=False))
return 0 if result.ok else 1
def manage_key(action: str, name: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
api = XuiApi(panel_url(access), access['api_token'], ca_file=access.get('ca_file') or None)
+132
View File
@@ -0,0 +1,132 @@
import io
import re
import tarfile
import tempfile
from collections.abc import Mapping
from pathlib import Path, PurePosixPath
from .runner import Result
from .server import Server
CADDY_IMAGE = 'caddy:2.10.2-alpine'
"""Образ Caddy на docker-сервере. Обновлять вручную после проверки."""
INGRESS = 'caddy'
"""Встроенное приложение: публикует 80 и 443, HTTP-сервисы остальных приложений ходят через него. Заливать и удалять
его нельзя."""
APPS_DIR = '/root/apps'
COMPOSE_FILE = 'docker-compose.yaml'
CADDY_FILE = 'caddy.conf'
"""Блок Caddy приложения с веб-мордой: лежит в корне приложения, на сервере подключается к ingress."""
DATA_DIR = 'data'
"""Настройки сервисов: data/<сервис>/. Изменения в них пересоздают только этот сервис."""
ENV_FILE = '.env'
APP_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
SERVICE_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$')
ACTIONS = ('start', 'stop', 'restart', 'pull')
LOG_LINES_MAX = 5000
class DockerHost(Server):
"""Сервер для docker-приложений.
Приложение - папка APPS_DIR/<name>/ (compose-проект с именем name): COMPOSE_FILE, ENV_FILE, CADDY_FILE (необязательно)
и DATA_DIR/<сервис>/ с настройками сервисов. Сервисы ничего не пишут в папку приложения, состояние - в named volumes,
поэтому папка целиком повторяет присланные файлы.
"""
def install(self, local_certs: bool = False) -> Result:
"""Ставит Docker и ingress на сервер после bootstrap.
Открывает 80 и 443 (tcp, udp для http/3), логи контейнеров ротируются, обновления безопасности ставятся сами.
Создает APPS_DIR, сеть web и приложение INGRESS: Caddy, который подключает caddy.conf приложений и сам выпускает
для их доменов сертификаты Let's Encrypt. local_certs - сертификаты от собственного CA Caddy, для стенда без
публичного адреса.
"""
return self._run('docker', caddy_image=CADDY_IMAGE, local_certs=local_certs)
def deploy(self, name: str, files: Mapping[str, bytes]) -> Result:
"""Приводит приложение name к files ({путь: содержимое}, полный набор файлов) и запускает его.
Сначала проверяет compose-файл, не заняты ли его порты на сервере, и CADDY_FILE: ошибка - на сервере ничего не
изменилось. Затем обновляет файлы по месту,
лишние удаляет, делает docker compose up -d --remove-orphans (compose сам пересоздает сервисы с изменившейся
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные.
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...], 'domains': [...]}.
domains - хосты сайтов из CADDY_FILE как их видит Caddy, в том числе wildcard: Caddy выпустит для них сертификаты,
только если они указывают на сервер.
"""
_check_app(name)
if name == INGRESS:
raise ValueError(f'{INGRESS} is managed by install')
paths = [_check_path(path) for path in files]
if COMPOSE_FILE not in paths:
raise ValueError(f'{COMPOSE_FILE} is required')
with tempfile.TemporaryDirectory(prefix='serverus-app-') as directory:
archive = Path(directory) / f'{name}.tar.gz'
archive.write_bytes(_pack(dict(zip(paths, files.values()))))
return self._run('app_deploy', app=name, archive=str(archive))
def status(self) -> Result:
"""Состояние всех приложений: сервисы из compose-файла, сверенные с docker ps -a.
Возвращает data: {'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}.
state - running, exited, restarting... или missing, если контейнера сервиса нет.
error - текст, если compose-файл приложения не читается.
"""
return self._run('app_status')
def control(self, name: str, action: str, service: str = '') -> Result:
"""start (up -d), stop, restart или pull (новые образы и up -d) приложения или одного его сервиса."""
_check_app(name)
if action not in ACTIONS:
raise ValueError(f'action must be one of {ACTIONS}')
return self._run('app_control', app=name, action=action, service=_check_service(service))
def logs(self, name: str, service: str = '', lines: int = 500) -> Result:
"""Последние lines строк логов приложения или одного сервиса. Возвращает data: {'logs': текст}."""
_check_app(name)
lines = max(1, min(int(lines), LOG_LINES_MAX))
return self._run('app_logs', app=name, service=_check_service(service), lines=lines)
def remove(self, name: str, volumes: bool = False) -> Result:
"""Останавливает и удаляет приложение: контейнеры, папку и блок Caddy. volumes=True - вместе с данными."""
_check_app(name)
if name == INGRESS:
raise ValueError(f'{INGRESS} is managed by install')
return self._run('app_remove', app=name, volumes=volumes)
def _run(self, playbook: str, **extravars) -> Result:
return self.run(playbook, apps_dir=APPS_DIR, **extravars)
def _check_app(name: str):
if not APP_NAME_RE.match(name):
raise ValueError('app name: a-z, 0-9, - and _, up to 64 characters')
def _check_service(name: str) -> str:
if name and not SERVICE_NAME_RE.match(name):
raise ValueError('service name: a-z, A-Z, 0-9, ., - and _, up to 64 characters')
return name
def _check_path(path: str) -> str:
"""Относительный путь внутри приложения без . и .."""
pure = PurePosixPath(path)
if not path or pure.is_absolute() or any(part in ('', '.', '..') for part in path.split('/')):
raise ValueError(f'bad path: {path!r}')
return str(pure)
def _pack(files: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode='w:gz') as tar:
for path, content in sorted(files.items()):
info = tarfile.TarInfo(path)
info.size = len(content)
info.mode = 0o600 if PurePosixPath(path).name == ENV_FILE else 0o644
tar.addfile(info, io.BytesIO(content))
return buffer.getvalue()
+29
View File
@@ -0,0 +1,29 @@
# extravars: apps_dir, app, action (start, stop, restart, pull), service (optional, без него - все приложение)
- name: Control app
hosts: target
gather_facts: false
vars:
app_dir: "{{ apps_dir }}/{{ app }}"
services: "{{ [service] if service | default('') else [] }}"
commands:
start: [ [ up, -d ] ]
stop: [ [ stop ] ]
restart: [ [ restart ] ]
pull: [ [ pull ], [ up, -d ] ]
tasks:
- name: Check app
ansible.builtin.stat:
path: "{{ app_dir }}/docker-compose.yaml"
register: compose_file
- name: Require app
ansible.builtin.assert:
that: compose_file.stat.exists
fail_msg: "app {{ app }} not found"
quiet: true
- name: Run docker compose
ansible.builtin.command:
argv: "{{ ['docker', 'compose'] + item + services }}"
chdir: "{{ app_dir }}"
loop: "{{ commands[action] }}"
+202
View File
@@ -0,0 +1,202 @@
# extravars: apps_dir, app, archive (tar.gz на управляющей машине: полный набор файлов приложения)
#
# 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 сам пересоздает сервисы с изменившейся конфигурацией.
# Упал (например, образ не скачался) - файлы уже новые, отката нет.
# 4. Сервисы, у которых изменилось что-то в data/<сервис>/, пересоздаются: конфиги они читают при старте.
# У нового приложения - нет: up только что создал все контейнеры.
# 5. caddy.conf приложения становится блоком ingress. Caddy все же не принял - прежний блок возвращается, ошибка.
- name: Deploy app
hosts: target
gather_facts: false
vars:
app_dir: "{{ apps_dir }}/{{ app }}"
stage: "{{ apps_dir }}/.staging/{{ app }}"
snippet: "{{ apps_dir }}/caddy/data/caddy/sites/{{ app }}.caddy"
caddy_check: "{{ apps_dir }}/caddy/data/caddy/.check"
tasks:
- ansible.builtin.import_tasks: tasks/app_scripts.yml
- name: Deploy
block:
- name: Clean staging
ansible.builtin.file:
path: "{{ stage }}"
state: absent
- name: Create staging
ansible.builtin.file:
path: "{{ stage }}"
state: directory
mode: "0700"
- name: Unpack files
ansible.builtin.unarchive:
src: "{{ archive }}"
dest: "{{ stage }}"
- name: Validate compose file
ansible.builtin.command: docker compose -p {{ app | quote }} config -q
args:
chdir: "{{ stage }}"
changed_when: false
# переменные из .env подставлены compose: занятый порт иначе уронил бы up уже после замены файлов
- name: Check published ports
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_ports.py {{ stage | quote }} {{ app | quote }}
changed_when: false
- name: Check new caddy.conf
ansible.builtin.stat:
path: "{{ stage }}/caddy.conf"
register: new_caddy_conf
# копия конфигурации Caddy с новым блоком вместо прежнего: дубли доменов и ошибки видны до изменений
- name: Validate caddy.conf
ansible.builtin.shell: |
set -e
rm -rf {{ caddy_check | quote }}
mkdir -p {{ caddy_check | quote }}/sites
cp {{ apps_dir | quote }}/caddy/data/caddy/Caddyfile {{ caddy_check | quote }}/
find {{ apps_dir | quote }}/caddy/data/caddy/sites -maxdepth 1 -name '*.caddy' ! -name {{ (app + '.caddy') | quote }} \
-exec cp {} {{ caddy_check | quote }}/sites/ \;
cp {{ stage | quote }}/caddy.conf {{ caddy_check | quote }}/sites/{{ (app + '.caddy') | quote }}
cd {{ apps_dir | quote }}/caddy
# ошибку показываем без информационных строк Caddy
rc=0
out=$(docker compose exec -T caddy caddy validate --config /etc/caddy/.check/Caddyfile --adapter caddyfile 2>&1) || rc=$?
printf '%s\n' "$out" | grep -v '"level":"info"' >&2 || true
exit $rc
args:
executable: /bin/bash
register: caddy_validate
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
changed_when: sync_out.rc == 0 and ((sync_out.stdout | from_json).changed + (sync_out.stdout | from_json).removed) | length > 0
always:
- name: Remove staging
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop: [ "{{ stage }}", "{{ caddy_check }}" ]
- name: Start app
ansible.builtin.command: docker compose up -d --remove-orphans
args:
chdir: "{{ app_dir }}"
- name: Read services
ansible.builtin.command: docker compose config --services
args:
chdir: "{{ app_dir }}"
register: services_out
changed_when: false
- name: Find services with changed settings
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
((sync_out.stdout | from_json).changed + (sync_out.stdout | from_json).removed)
| select('match', '^data/[^/]+/') | map('regex_replace', '^data/([^/]+)/.*$', '\1')
| unique | intersect(services_out.stdout_lines) | list }}
- name: Recreate services with changed settings
ansible.builtin.command:
argv: [ docker, compose, up, -d, --force-recreate, --no-deps, "{{ item }}" ]
chdir: "{{ app_dir }}"
loop: "{{ recreate }}"
- name: Check caddy.conf
ansible.builtin.stat:
path: "{{ app_dir }}/caddy.conf"
register: caddy_conf
- name: Check current Caddy block
ansible.builtin.stat:
path: "{{ snippet }}"
register: old_snippet
- name: Save current Caddy block
ansible.builtin.slurp:
src: "{{ snippet }}"
register: old_snippet_content
when: old_snippet.stat.exists
- name: Update Caddy block
block:
- name: Install Caddy block
ansible.builtin.copy:
src: "{{ app_dir }}/caddy.conf"
dest: "{{ snippet }}"
remote_src: true
mode: "0644"
register: snippet_copy
when: caddy_conf.stat.exists
- name: Remove Caddy block
ansible.builtin.file:
path: "{{ snippet }}"
state: absent
register: snippet_remove
when: not caddy_conf.stat.exists
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
when: snippet_copy.changed or snippet_remove.changed
rescue:
- name: Restore previous Caddy block
ansible.builtin.copy:
content: "{{ old_snippet_content.content | b64decode }}"
dest: "{{ snippet }}"
mode: "0644"
when: old_snippet.stat.exists
- name: Remove rejected Caddy block
ansible.builtin.file:
path: "{{ snippet }}"
state: absent
when: not old_snippet.stat.exists
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
- name: Report rejected caddy.conf
ansible.builtin.fail:
msg: >-
app is deployed, but Caddy rejected caddy.conf, previous block is kept:
{{ (ansible_failed_result.stderr | default(ansible_failed_result.msg | default(''))) | trim | truncate(800) }}
- name: Return result
ansible.builtin.set_stats:
aggregate: false
data:
changed: "{{ sync.changed }}"
removed: "{{ sync.removed }}"
recreated: "{{ recreate }}"
services: "{{ services }}"
domains: "{{ domains }}"
+21
View File
@@ -0,0 +1,21 @@
# extravars: apps_dir, app, service (optional), lines
- name: App logs
hosts: target
gather_facts: false
vars:
app_dir: "{{ apps_dir }}/{{ app }}"
# лог уходит через события ansible: больше мегабайта не нужно
logs_limit: 1000000
tasks:
- name: Read logs
ansible.builtin.command:
argv: "{{ ['docker', 'compose', 'logs', '--no-color', '--timestamps', '--tail', lines | string] + ([service] if service | default('') else []) }}"
chdir: "{{ app_dir }}"
register: logs_out
changed_when: false
- name: Return logs
ansible.builtin.set_stats:
aggregate: false
data:
logs: "{{ logs_out.stdout[-logs_limit:] }}"
+27
View File
@@ -0,0 +1,27 @@
# extravars: apps_dir, app, volumes (true - удалить и данные)
- name: Remove app
hosts: target
gather_facts: false
vars:
app_dir: "{{ apps_dir }}/{{ app }}"
snippet: "{{ apps_dir }}/caddy/data/caddy/sites/{{ app }}.caddy"
tasks:
# по имени проекта: контейнеры удаляются, даже если папки уже нет
- name: Stop and remove containers
ansible.builtin.command:
argv: "{{ ['docker', 'compose', '-p', app, 'down', '--remove-orphans'] + (['--volumes'] if volumes | bool else []) }}"
chdir: "{{ apps_dir }}"
- name: Remove app directory
ansible.builtin.file:
path: "{{ app_dir }}"
state: absent
- name: Remove Caddy block
ansible.builtin.file:
path: "{{ snippet }}"
state: absent
register: snippet_remove
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
when: snippet_remove.changed
+16
View File
@@ -0,0 +1,16 @@
# extravars: apps_dir
- name: Apps status
hosts: target
gather_facts: false
tasks:
- ansible.builtin.import_tasks: tasks/app_scripts.yml
- name: Read status
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_status.py {{ apps_dir | quote }}
register: status_out
changed_when: false
- name: Return status
ansible.builtin.set_stats:
aggregate: false
data: "{{ status_out.stdout | from_json }}"
+135
View File
@@ -0,0 +1,135 @@
# extravars: apps_dir, caddy_image, local_certs
#
# Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений,
# сеть web и ingress: приложение caddy, которое публикует 80 и 443. Caddy подключает блоки приложений
# из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy.
# Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх.
- name: Install Docker platform
hosts: target
gather_facts: true
vars:
caddy_dir: "{{ apps_dir }}/caddy"
tasks:
- ansible.builtin.import_tasks: tasks/firewall.yml
vars:
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443 ]
firewall_udp_ports: [ 443 ]
# трафик контейнеров идет через forward: его фильтрует Docker
firewall_forward: true
- name: Install prerequisites
ansible.builtin.apt:
name: [ ca-certificates, curl, python3, unattended-upgrades ]
state: present
update_cache: true
- name: Enable automatic security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
- name: Create keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Download Docker key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker repository
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.sources
mode: "0644"
content: |
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: {{ ansible_facts.distribution_release }}
Components: stable
Architectures: {{ 'arm64' if ansible_facts.architecture == 'aarch64' else 'amd64' }}
Signed-By: /etc/apt/keyrings/docker.asc
- name: Create Docker config directory
ansible.builtin.file:
path: /etc/docker
state: directory
mode: "0755"
- name: Configure Docker daemon
ansible.builtin.copy:
dest: /etc/docker/daemon.json
mode: "0644"
content: |
{
"log-driver": "local",
"log-opts": {"max-size": "10m", "max-file": "3"}
}
register: docker_daemon
- name: Install Docker
ansible.builtin.apt:
name: [ docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin ]
state: present
update_cache: true
# после частых перезапусков (фаервол, release) systemd может держать Docker остановленным по лимиту запусков
- name: Reset Docker start limit
ansible.builtin.command: systemctl reset-failed docker.service
changed_when: false
failed_when: false
- name: Start Docker
ansible.builtin.systemd_service:
name: docker
enabled: true
state: "{{ 'restarted' if docker_daemon.changed else 'started' }}"
- name: Create apps directory
ansible.builtin.file:
path: "{{ apps_dir }}"
state: directory
mode: "0700"
- name: Create web network
ansible.builtin.shell: docker network inspect web >/dev/null 2>&1 || { docker network create web && echo created; }
register: web_network
changed_when: "'created' in web_network.stdout"
- name: Create ingress directories
ansible.builtin.file:
path: "{{ caddy_dir }}/data/caddy/sites"
state: directory
mode: "0700"
- name: Write ingress compose file
ansible.builtin.template:
src: templates/caddy-compose.yaml.j2
dest: "{{ caddy_dir }}/docker-compose.yaml"
mode: "0600"
# Caddyfile смонтирован папкой: файл, замененный целиком, контейнер увидел бы только после пересоздания
- name: Write Caddyfile
ansible.builtin.template:
src: templates/Caddyfile.j2
dest: "{{ caddy_dir }}/data/caddy/Caddyfile"
mode: "0644"
register: caddyfile
- name: Start ingress
ansible.builtin.command: docker compose up -d --remove-orphans
args:
chdir: "{{ caddy_dir }}"
register: caddy_up
changed_when: "'Started' in caddy_up.stderr or 'Recreated' in caddy_up.stderr"
- name: Reload Caddyfile
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
args:
chdir: "{{ caddy_dir }}"
when: caddyfile.changed and not caddy_up.changed
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Проверка портов приложения до изменений: app_ports.py <stage> <app>.
Порты берутся из docker compose config (переменные из .env подставлены, диапазоны раскрыты) и сверяются с занятыми
на сервере: слушающие сокеты (ss) и порты контейнеров других приложений. Порты, которые держит само приложение,
занятыми не считаются: при деплое их контейнеры пересоздаются.
Занят - печатает причины в stderr и выходит с кодом 1.
"""
import json
import re
import subprocess
import sys
# 0.0.0.0:9000-9001->9000-9001/udp, [::]:80->80/tcp
DOCKER_PORT_RE = re.compile(r':(\d+)(?:-(\d+))?->[^/,]+/(tcp|udp)')
def run(*args, cwd=None):
result = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
return result.stdout
def published(stage, app):
"""{(порт, протокол): [сервисы]} из отрендеренного compose-файла."""
config = json.loads(run('docker', 'compose', '-p', app, 'config', '--format', 'json', cwd=stage))
ports = {}
for name, service in (config.get('services') or {}).items():
for entry in service.get('ports') or []:
if entry.get('published'):
key = (int(entry['published']), entry.get('protocol', 'tcp'))
ports.setdefault(key, []).append(name)
return ports
def listening():
"""Все слушающие порты сервера, включая docker-proxy опубликованных портов."""
ports = set()
for line in run('ss', '-Hlntu').splitlines():
fields = line.split()
if len(fields) >= 5:
port = fields[4].rsplit(':', 1)[-1]
if port.isdigit():
ports.add((int(port), fields[0]))
return ports
def containers():
"""[(проект, контейнер, {(порт, протокол)})] всех контейнеров с опубликованными портами."""
result = []
for line in run('docker', 'ps', '--format', '{{json .}}').splitlines():
container = json.loads(line)
ports = set()
for first, last, protocol in DOCKER_PORT_RE.findall(container.get('Ports', '')):
ports.update((port, protocol) for port in range(int(first), int(last or first) + 1))
labels = dict(label.split('=', 1) for label in container.get('Labels', '').split(',') if '=' in label)
result.append((labels.get('com.docker.compose.project', ''), container.get('Names', ''), ports))
return result
def main(stage, app):
wanted = published(stage, app)
if not wanted:
return
own, owners = set(), {}
for project, name, ports in containers():
if project == app:
own |= ports
else:
owners.update(dict.fromkeys(ports, name))
busy = (listening() - own) | owners.keys()
errors = [
f'{port}/{protocol} ({", ".join(services)}): ' + (f'занят контейнером {owners[port, protocol]}'
if (port, protocol) in owners else 'занят на сервере')
for (port, protocol), services in sorted(wanted.items()) if (port, protocol) in busy
]
if errors:
print('порты заняты: ' + '; '.join(errors), file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Состояние docker-приложений: app_status.py <apps_dir> [<app>].
Сервисы берутся из compose-файла и сверяются с docker compose ps -a: описан, но контейнера нет - state missing.
Контейнеры, которых нет в compose-файле (остались от прошлой версии), тоже попадают в список.
Печатает JSON: {"apps": {app: {"services": {service: {"state", "health", "status"}}, "error": ""}}}.
"""
import json
import os
import subprocess
import sys
COMPOSE_FILE = 'docker-compose.yaml'
def compose(app_dir, *args):
result = subprocess.run(
['docker', 'compose', *args], cwd=app_dir, capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
return result.stdout
def containers(output):
"""ps --format json: старый compose печатает массив, новый - объект на строку."""
output = output.strip()
if not output:
return []
if output.startswith('['):
return json.loads(output)
return [json.loads(line) for line in output.splitlines() if line.strip()]
def app_status(app_dir):
services = {
name: {'state': 'missing', 'health': '', 'status': ''}
for name in compose(app_dir, 'config', '--services').split()
}
for container in containers(compose(app_dir, 'ps', '-a', '--format', 'json')):
state = {
'state': container.get('State', ''),
'health': container.get('Health', ''),
'status': container.get('Status', ''),
}
name = container.get('Service', '')
# несколько реплик: показываем проблемную
if services.get(name, {}).get('state') in (None, 'missing', 'running'):
services[name] = state
return services
def main(apps_dir, only=''):
apps = {}
names = [only] if only else sorted(os.listdir(apps_dir)) if os.path.isdir(apps_dir) else []
for name in names:
app_dir = os.path.join(apps_dir, name)
if name.startswith('.') or not os.path.isfile(os.path.join(app_dir, COMPOSE_FILE)):
continue
try:
apps[name] = {'services': app_status(app_dir), 'error': ''}
except (RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
apps[name] = {'services': {}, 'error': str(error)}
print(json.dumps({'apps': apps}))
if __name__ == '__main__':
main(*sys.argv[1:])
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Приводит папку приложения к присланной копии: app_sync.py <stage> <app_dir>.
Файлы переписываются по месту (тот же inode): смонтированный в контейнер файл остается тем же файлом.
Лишние файлы удаляются, пустые папки за ними тоже.
Печатает JSON: {"changed": [...], "removed": [...], "created": true, если папки приложения не было}.
"""
import json
import os
import shutil
import stat
import sys
def files(root):
result = set()
for directory, _, names in os.walk(root):
for name in names:
result.add(os.path.relpath(os.path.join(directory, name), root))
return result
def same(src, dst):
if not os.path.isfile(dst) or os.path.islink(dst):
return False
if stat.S_IMODE(os.stat(src).st_mode) != stat.S_IMODE(os.stat(dst).st_mode):
return False
with open(src, 'rb') as a, open(dst, 'rb') as b:
return a.read() == b.read()
def clear(path):
"""Убирает то, что мешает положить файл: папку или ссылку на его месте."""
if os.path.islink(path) or (os.path.exists(path) and not os.path.isdir(path)):
os.remove(path)
elif os.path.isdir(path):
shutil.rmtree(path)
def ensure_dir(app_dir, relative):
"""Создает папки до файла; файл или ссылка на месте папки удаляется."""
current = app_dir
for part in relative.split(os.sep)[:-1]:
current = os.path.join(current, part)
if os.path.islink(current) or (os.path.exists(current) and not os.path.isdir(current)):
os.remove(current)
os.makedirs(current, exist_ok=True)
def main(stage, app_dir):
created = not os.path.isdir(app_dir)
os.makedirs(app_dir, mode=0o700, exist_ok=True)
new, old = files(stage), files(app_dir)
changed, removed = [], []
for relative in sorted(old - new):
os.remove(os.path.join(app_dir, relative))
removed.append(relative)
for relative in sorted(new):
src, dst = os.path.join(stage, relative), os.path.join(app_dir, relative)
if same(src, dst):
continue
ensure_dir(app_dir, relative)
if os.path.isdir(dst) or os.path.islink(dst):
clear(dst)
with open(src, 'rb') as source, open(dst, 'wb') as target:
shutil.copyfileobj(source, target)
os.chmod(dst, stat.S_IMODE(os.stat(src).st_mode))
changed.append(relative)
for directory, _, _ in os.walk(app_dir, topdown=False):
if directory != app_dir and not os.listdir(directory):
os.rmdir(directory)
print(json.dumps({'changed': changed, 'removed': removed, 'created': created}))
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
+24 -3
View File
@@ -5,19 +5,40 @@
tasks:
- ansible.builtin.import_tasks: tasks/root_password.yml
# конфиг Debian по умолчанию: все разрешено. nftables остается включенным
- name: Restore default nftables config
ansible.builtin.copy:
dest: /etc/nftables.conf
mode: "0755"
content: |
#!/usr/sbin/nft -f
flush ruleset
- name: Flush and disable nftables
table inet filter {
chain input {
type filter hook input priority filter;
}
chain forward {
type filter hook forward priority filter;
}
chain output {
type filter hook output priority filter;
}
}
- name: Apply default nftables rules
ansible.builtin.systemd_service:
name: nftables
enabled: false
state: stopped
state: restarted
# flush ruleset стер и правила Docker, приложения на сервере остаются работать
- name: Restore Docker rules
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
register: docker_restart
changed_when: false
# rc 5 - Docker не установлен
failed_when: docker_restart.rc not in [0, 5]
- name: Remove traffic redirect
ansible.builtin.file:
@@ -0,0 +1,13 @@
# Скрипты приложений на сервере: копируются на каждом запуске, чтобы соответствовать версии serverus
- name: Create serverus scripts directory
ansible.builtin.file:
path: "{{ apps_dir }}/.serverus"
state: directory
mode: "0700"
- name: Install app scripts
ansible.builtin.copy:
src: "files/{{ item }}"
dest: "{{ apps_dir }}/.serverus/{{ item }}"
mode: "0755"
loop: [ app_sync.py, app_status.py, app_ports.py ]
@@ -0,0 +1,4 @@
- name: Reload Caddy
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
args:
chdir: "{{ apps_dir }}/caddy"
+15 -1
View File
@@ -1,4 +1,8 @@
# vars: firewall_tcp_ports, firewall_udp_ports (optional), firewall_forward (optional, пропускать транзитный трафик)
#
# Конфиг начинается с flush ruleset: при загрузке nftables стартует раньше Docker, и Docker добавляет свои правила
# поверх. Перезапуск nftables на работающем сервере стирает правила Docker, поэтому nftables перезапускается только
# при изменении конфига, а Docker следом восстанавливает свои правила (контейнеры с restart поднимутся сами).
- name: Install nftables
ansible.builtin.apt:
name: nftables
@@ -10,9 +14,19 @@
dest: /etc/nftables.conf
mode: "0755"
validate: /usr/sbin/nft -c -f %s
register: firewall_config
- name: Apply nftables
ansible.builtin.systemd_service:
name: nftables
enabled: true
state: restarted
state: "{{ 'restarted' if firewall_config.changed else 'started' }}"
# bootstrap меняет конфиг дважды подряд: reset-failed сбрасывает счетчик запусков, иначе systemd откажет по лимиту
- name: Restore Docker rules
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
when: firewall_config.changed
register: docker_restart
changed_when: false
# rc 5 - Docker не установлен
failed_when: docker_restart.rc not in [0, 5]
@@ -0,0 +1,9 @@
# {{ ansible_managed }}
{% if local_certs | bool %}
{
# стенд без публичного адреса: сертификаты от собственного CA Caddy вместо Let's Encrypt
local_certs
}
{% endif %}
import sites/*.caddy
@@ -0,0 +1,23 @@
# {{ ansible_managed }}
# ingress: единственное приложение, которое публикует порты. Блоки приложений - data/caddy/sites/<app>.caddy
services:
caddy:
image: {{ caddy_image }}
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./data/caddy:/etc/caddy:ro
- data:/data
- config:/config
networks: [ web ]
networks:
web:
external: true
volumes:
data:
config:
+209 -1
View File
@@ -1,13 +1,19 @@
import importlib.util
import io
import json
import os
import ssl
import subprocess
import sys
import tarfile
import tempfile
import unittest
from pathlib import Path
import urllib.error
from unittest import mock
from . import (
CF_WARP, XUI_VERSION, Host, LocalCA, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
CADDY_IMAGE, CF_WARP, XUI_VERSION, DockerHost, Host, LocalCA, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username, parse_proxy, run_playbook,
)
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
@@ -154,6 +160,208 @@ class XuiPanelTests(unittest.TestCase):
int(short_id, 16)
class RecordingRunner(FakeRunner):
"""Запоминает и архив deploy: после запуска временная папка с ним удаляется."""
def __call__(self, playbook, host, extravars):
if 'archive' in extravars:
with tarfile.open(extravars['archive']) as tar:
self.files = {
member.name: (tar.extractfile(member).read(), member.mode) for member in tar.getmembers()
}
return super().__call__(playbook, host, extravars)
class DockerHostTests(unittest.TestCase):
def setUp(self):
self.runner = RecordingRunner()
self.docker = DockerHost(Host('1.2.3.4'), runner=self.runner)
def test_install(self):
self.docker.install(local_certs=True)
self.assertEqual(self.runner.calls[0][0::2], ('docker', {
'apps_dir': '/root/apps', 'caddy_image': CADDY_IMAGE, 'local_certs': True,
}))
def test_deploy_packs_files(self):
self.docker.deploy('site', {'docker-compose.yaml': b'services: {}', '.env': b'A=1', 'data/nginx/n.conf': b'x'})
playbook, _, extravars = self.runner.calls[0]
self.assertEqual((playbook, extravars['app'], extravars['apps_dir']), ('app_deploy', 'site', '/root/apps'))
self.assertFalse(os.path.exists(extravars['archive']))
self.assertEqual(self.runner.files, {
'.env': (b'A=1', 0o600),
'data/nginx/n.conf': (b'x', 0o644),
'docker-compose.yaml': (b'services: {}', 0o644),
})
def test_deploy_rejects_bad_input(self):
compose = {'docker-compose.yaml': b''}
for name, files in (
('Site', compose), ('../x', compose), ('caddy', compose), ('site', {'compose.yaml': b''}),
('site', {**compose, '../etc/passwd': b''}), ('site', {**compose, '/etc/passwd': b''}),
('site', {**compose, 'data/./x': b''}),
):
with self.subTest(name=name, files=list(files)), self.assertRaises(ValueError):
self.docker.deploy(name, files)
self.assertEqual(self.runner.calls, [])
def test_operations(self):
self.docker.status()
self.docker.control('site', 'restart', 'nginx')
self.docker.logs('site', lines=10**6)
self.docker.remove('site', volumes=True)
self.assertEqual([(call[0], call[2]) for call in self.runner.calls], [
('app_status', {'apps_dir': '/root/apps'}),
('app_control', {'apps_dir': '/root/apps', 'app': 'site', 'action': 'restart', 'service': 'nginx'}),
('app_logs', {'apps_dir': '/root/apps', 'app': 'site', 'service': '', 'lines': 5000}),
('app_remove', {'apps_dir': '/root/apps', 'app': 'site', 'volumes': True}),
])
def test_control_rejects_bad_input(self):
for args in (('site', 'kill'), ('site', 'stop', 'a b'), ('caddy!', 'stop')):
with self.subTest(args=args), self.assertRaises(ValueError):
self.docker.control(*args)
with self.assertRaises(ValueError):
self.docker.remove('caddy')
SCRIPTS = Path(__file__).resolve().parent / 'playbooks' / 'files'
class AppSyncTests(unittest.TestCase):
def sync(self, stage, app_dir):
out = subprocess.run(
[sys.executable, str(SCRIPTS / 'app_sync.py'), str(stage), str(app_dir)],
check=True, capture_output=True, text=True,
)
return json.loads(out.stdout)
def write(self, root, files):
for path, content in files.items():
target = root / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
def test_sync_in_place(self):
with tempfile.TemporaryDirectory() as directory:
stage, app_dir = Path(directory, 'stage'), Path(directory, 'app')
self.write(stage, {'docker-compose.yaml': 'a', 'data/nginx/n.conf': 'n', 'data/old/o.conf': 'o'})
self.assertEqual(self.sync(stage, app_dir), {
'changed': ['data/nginx/n.conf', 'data/old/o.conf', 'docker-compose.yaml'], 'removed': [], 'created': True,
})
inode = (app_dir / 'data/nginx/n.conf').stat().st_ino
for path in stage.rglob('*'):
if path.is_file():
path.unlink()
self.write(stage, {'docker-compose.yaml': 'a', 'data/nginx/n.conf': 'new'})
self.assertEqual(self.sync(stage, app_dir), {
'changed': ['data/nginx/n.conf'], 'removed': ['data/old/o.conf'], 'created': False,
})
self.assertEqual((app_dir / 'data/nginx/n.conf').read_text(), 'new')
# тот же inode: смонтированный в контейнер файл остается тем же файлом
self.assertEqual((app_dir / 'data/nginx/n.conf').stat().st_ino, inode)
self.assertFalse((app_dir / 'data/old').exists())
self.assertEqual(self.sync(stage, app_dir), {'changed': [], 'removed': [], 'created': False})
def test_sync_mode_change(self):
with tempfile.TemporaryDirectory() as directory:
stage, app_dir = Path(directory, 'stage'), Path(directory, 'app')
self.write(stage, {'.env': 'A=1'})
self.sync(stage, app_dir)
os.chmod(stage / '.env', 0o600)
self.assertEqual(self.sync(stage, app_dir)['changed'], ['.env'])
self.assertEqual((app_dir / '.env').stat().st_mode & 0o777, 0o600)
def test_sync_replaces_directory_with_file(self):
with tempfile.TemporaryDirectory() as directory:
stage, app_dir = Path(directory, 'stage'), Path(directory, 'app')
self.write(app_dir, {'data/x/y': 'old'})
self.write(stage, {'data/x': 'file'})
self.assertEqual(self.sync(stage, app_dir), {'changed': ['data/x'], 'removed': ['data/x/y'], 'created': False})
self.assertEqual((app_dir / 'data/x').read_text(), 'file')
class AppStatusTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location('app_status', SCRIPTS / 'app_status.py')
self.module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.module)
def test_containers_formats(self):
container = {'Service': 'web', 'State': 'running'}
self.assertEqual(self.module.containers(json.dumps([container])), [container])
self.assertEqual(self.module.containers(json.dumps(container) + '\n' + json.dumps(container)), [container] * 2)
self.assertEqual(self.module.containers(''), [])
def test_services_compared_with_containers(self):
outputs = {
('config', '--services'): 'web\ndb\n',
('ps', '-a', '--format', 'json'): '\n'.join(json.dumps(c) for c in (
{'Service': 'web', 'State': 'running', 'Health': 'healthy', 'Status': 'Up 1 hour'},
{'Service': 'web', 'State': 'exited', 'Health': '', 'Status': 'Exited (1)'},
{'Service': 'old', 'State': 'running', 'Health': '', 'Status': 'Up'},
)),
}
with mock.patch.object(self.module, 'compose', lambda app_dir, *args: outputs[args]):
status = self.module.app_status('/apps/site')
self.assertEqual(status, {
'web': {'state': 'exited', 'health': '', 'status': 'Exited (1)'},
'db': {'state': 'missing', 'health': '', 'status': ''},
'old': {'state': 'running', 'health': '', 'status': 'Up'},
})
class AppPortsTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location('app_ports', SCRIPTS / 'app_ports.py')
self.module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.module)
def check(self, ports):
config = {'services': {
'hbbs': {'ports': [{'target': 21116, 'published': '21116', 'protocol': p} for p in ('tcp', 'udp')]},
'hbbr': {'ports': [{'target': 21117, 'published': str(ports), 'protocol': 'tcp'}, {'target': 5000}]},
}}
containers = [
{'Names': 'rustdesk-hbbs-1', 'Labels': 'com.docker.compose.project=rustdesk,x=y',
'Ports': '0.0.0.0:21116->21116/tcp, [::]:21116->21116/tcp, 0.0.0.0:21116->21116/udp'},
{'Names': 'caddy-caddy-1', 'Labels': 'com.docker.compose.project=caddy',
'Ports': '0.0.0.0:80->80/tcp, 0.0.0.0:442-443->442-443/tcp'},
]
outputs = {
'docker': lambda args: json.dumps(config) if 'config' in args else '\n'.join(map(json.dumps, containers)),
'ss': lambda args: 'tcp LISTEN 0 4096 0.0.0.0:21116 0.0.0.0:*\nudp UNCONN 0 0 0.0.0.0:21116 0.0.0.0:*\n'
'tcp LISTEN 0 128 [::]:30022 [::]:*\ntcp LISTEN 0 4096 0.0.0.0:80 0.0.0.0:*\n',
}
with mock.patch.object(self.module, 'run', lambda *args, cwd=None: outputs[args[0]](args)), \
mock.patch('sys.stderr', new_callable=io.StringIO) as stderr:
try:
self.module.main('/stage', 'rustdesk')
except SystemExit:
return stderr.getvalue()
return ''
def test_own_ports_are_free(self):
self.assertEqual(self.check(21117), '')
def test_busy_ports(self):
self.assertEqual(self.check(443), 'порты заняты: 443/tcp (hbbr): занят контейнером caddy-caddy-1\n')
self.assertEqual(self.check(30022), 'порты заняты: 30022/tcp (hbbr): занят на сервере\n')
class PlaybookSyntaxTests(unittest.TestCase):
def test_docker_playbooks(self):
playbooks = Path(__file__).resolve().parent / 'playbooks'
ansible = Path(sys.executable).parent / 'ansible-playbook'
for name in ('docker', 'app_deploy', 'app_status', 'app_control', 'app_logs', 'app_remove', 'release'):
with self.subTest(playbook=name):
out = subprocess.run(
[str(ansible), '--syntax-check', '-i', 'localhost,', f'{name}.yml'],
cwd=playbooks, capture_output=True, text=True,
)
self.assertEqual(out.returncode, 0, out.stderr)
class LocalCATests(unittest.TestCase):
def test_ca_is_created_once_and_verifies_certificates(self):
from cryptography import x509