diff --git a/docs/apps/rustdesk/.gitignore b/docs/apps/rustdesk/.gitignore new file mode 100644 index 0000000..ef9e7f0 --- /dev/null +++ b/docs/apps/rustdesk/.gitignore @@ -0,0 +1,3 @@ +# сгенерированные ключи и .env - на месте, в репозиторий не попадают +app/data/ +app/.env diff --git a/docs/apps/rustdesk/README.md b/docs/apps/rustdesk/README.md new file mode 100644 index 0000000..ab29967 --- /dev/null +++ b/docs/apps/rustdesk/README.md @@ -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= action=deploy name=rustdesk dir=docs/apps/rustdesk/app +``` + +Через бота - архив содержимого `app/` (вместе с `.env` и `data/`). + +## Проверить + +```bash +python3 check.py "$(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. diff --git a/docs/apps/rustdesk/app/docker-compose.yaml b/docs/apps/rustdesk/app/docker-compose.yaml new file mode 100644 index 0000000..b0148ef --- /dev/null +++ b/docs/apps/rustdesk/app/docker-compose.yaml @@ -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: diff --git a/docs/apps/rustdesk/check.py b/docs/apps/rustdesk/check.py new file mode 100755 index 0000000..25a5364 --- /dev/null +++ b/docs/apps/rustdesk/check.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Проверка сервера RustDesk по его протоколу, снаружи: check.py <публичный ключ>. + +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:])) diff --git a/docs/apps/rustdesk/env_example b/docs/apps/rustdesk/env_example new file mode 100644 index 0000000..eea932f --- /dev/null +++ b/docs/apps/rustdesk/env_example @@ -0,0 +1,2 @@ +# адрес сервера, который hbbs сообщает клиентам как relay: ip или домен +RELAY_HOST=1.2.3.4 diff --git a/docs/apps/rustdesk/genkey.py b/docs/apps/rustdesk/genkey.py new file mode 100755 index 0000000..f5ff8ed --- /dev/null +++ b/docs/apps/rustdesk/genkey.py @@ -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')) diff --git a/src/App/platforms.py b/src/App/platforms.py index e2fce7e..ab133e4 100644 --- a/src/App/platforms.py +++ b/src/App/platforms.py @@ -114,7 +114,8 @@ def _draft(app: DockerApp, old: dict[str, bytes], files: dict[str, bytes], kind: def apply(version: AppVersion) -> dict: """Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services. - Ошибка - версия отмечается неудачной, на сервере остается прежняя (compose-файл проверяется до изменений). + Ошибка - версия отмечается неудачной. Ошибка проверки compose-файла, его портов или caddy.conf - на сервере остается + прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются. """ app = version.app platform = app.platform diff --git a/src/App/tests_platforms.py b/src/App/tests_platforms.py index 9004745..dfdbab7 100644 --- a/src/App/tests_platforms.py +++ b/src/App/tests_platforms.py @@ -156,13 +156,43 @@ class UploadsTests(TestCase): }) self.assertEqual(errors, []) self.assertEqual(warnings, [ - 'db: публикует порты, наружу должен смотреть только caddy', '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): diff --git a/src/App/uploads.py b/src/App/uploads.py index 426c43a..0bc5c98 100644 --- a/src/App/uploads.py +++ b/src/App/uploads.py @@ -4,6 +4,7 @@ Конвенция приложения (serverus.DockerHost): docker-compose.yaml, .env, caddy.conf, data/<сервис>/. """ import io +import posixpath import stat import tarfile import zipfile @@ -21,6 +22,8 @@ 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): @@ -151,8 +154,15 @@ def check(files: dict[str, bytes]) -> tuple[list[str], list[str]]: return [f'в {COMPOSE_FILE} нет services'], warnings for name, service in services.items(): service = service if isinstance(service, dict) else {} - if service.get('ports'): - warnings.append(f'{name}: публикует порты, наружу должен смотреть только caddy') + 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} @@ -163,3 +173,42 @@ def check(files: dict[str, bytes]) -> tuple[list[str], list[str]]: 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 diff --git a/src/serverus/README.md b/src/serverus/README.md index 9bda2f7..8a9757c 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -78,7 +78,9 @@ caddy.conf CADDY_FILE, необязательно: блок Caddy д data/<сервис>/ DATA_DIR, настройки сервиса, имя папки = имя сервиса в compose ``` -Сервисы ничего не пишут в папку приложения, состояние - только в named volumes. Поэтому папка целиком повторяет присланные файлы, лишние файлы удаляются. Порты публикует только `caddy`; остальные сервисы общаются через docker-сеть, веб-морды подключаются к внешней сети `web`, а их домены описываются в `caddy.conf`: +Сервисы ничего не пишут в папку приложения, состояние - только в 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 { @@ -88,7 +90,7 @@ app.example.com { Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер. -- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`. +- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл, его опубликованные порты (после подстановки переменных из `.env`: не слушает ли их уже сервер или контейнер другого приложения) и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Упал сам `up` (например, образ не скачался) - файлы на сервере уже новые, отката нет: исправленные файлы нужно залить заново. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`. - `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 Мб. @@ -96,7 +98,7 @@ Caddy выпускает сертификат для каждого домена Контейнерам нужен `restart: unless-stopped`: при изменении конфига nftables перезапускается и стирает правила Docker, Docker перезапускается следом, чтобы их вернуть. Без изменений конфига nftables не перезапускается. -Скрипты на сервере (`playbooks/files/app_sync.py`, `app_status.py`) копируются в `/root/apps/.serverus/` на каждом запуске. +Скрипты на сервере (`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 символов. diff --git a/src/serverus/docker.py b/src/serverus/docker.py index 6eb3df1..1d2c042 100644 --- a/src/serverus/docker.py +++ b/src/serverus/docker.py @@ -11,7 +11,8 @@ from .server import Server CADDY_IMAGE = 'caddy:2.10.2-alpine' """Образ Caddy на docker-сервере. Обновлять вручную после проверки.""" INGRESS = 'caddy' -"""Встроенное приложение: единственное, которое публикует порты (80, 443). Заливать и удалять его нельзя.""" +"""Встроенное приложение: публикует 80 и 443, HTTP-сервисы остальных приложений ходят через него. Заливать и удалять +его нельзя.""" APPS_DIR = '/root/apps' COMPOSE_FILE = 'docker-compose.yaml' CADDY_FILE = 'caddy.conf' @@ -47,10 +48,12 @@ class DockerHost(Server): def deploy(self, name: str, files: Mapping[str, bytes]) -> Result: """Приводит приложение name к files ({путь: содержимое}, полный набор файлов) и запускает его. - Сначала проверяет compose-файл: ошибка - на сервере ничего не изменилось. Затем обновляет файлы по месту, + Сначала проверяет compose-файл, не заняты ли его порты на сервере, и CADDY_FILE: ошибка - на сервере ничего не + изменилось. Затем обновляет файлы по месту, лишние удаляет, делает docker compose up -d --remove-orphans (compose сам пересоздает сервисы с изменившейся конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE - перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой. + Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные. Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...]}. """ _check_app(name) diff --git a/src/serverus/playbooks/app_deploy.yml b/src/serverus/playbooks/app_deploy.yml index dd082a5..e3bdfaf 100644 --- a/src/serverus/playbooks/app_deploy.yml +++ b/src/serverus/playbooks/app_deploy.yml @@ -1,9 +1,10 @@ # extravars: apps_dir, app, archive (tar.gz на управляющей машине: полный набор файлов приложения) # -# 1. Файлы распаковываются во временную папку, compose-файл и caddy.conf (caddy validate на копии конфигурации -# с новым блоком) проверяются там: ошибка - приложение не тронуто. +# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере) +# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто. # 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются. # 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией. +# Упал (например, образ не скачался) - файлы уже новые, отката нет. # 4. Сервисы, у которых изменилось что-то в data/<сервис>/, пересоздаются: конфиги они читают при старте. # У нового приложения - нет: up только что создал все контейнеры. # 5. caddy.conf приложения становится блоком ingress. Caddy все же не принял - прежний блок возвращается, ошибка. @@ -42,6 +43,11 @@ 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" diff --git a/src/serverus/playbooks/docker.yml b/src/serverus/playbooks/docker.yml index 4701492..6b79689 100644 --- a/src/serverus/playbooks/docker.yml +++ b/src/serverus/playbooks/docker.yml @@ -1,7 +1,7 @@ # extravars: apps_dir, caddy_image, local_certs # # Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений, -# сеть web и ingress: приложение caddy, единственное, которое публикует порты. Caddy подключает блоки приложений +# сеть web и ingress: приложение caddy, которое публикует 80 и 443. Caddy подключает блоки приложений # из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy. # Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх. - name: Install Docker platform diff --git a/src/serverus/playbooks/files/app_ports.py b/src/serverus/playbooks/files/app_ports.py new file mode 100644 index 0000000..e6583c3 --- /dev/null +++ b/src/serverus/playbooks/files/app_ports.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Проверка портов приложения до изменений: app_ports.py . + +Порты берутся из 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]) diff --git a/src/serverus/playbooks/tasks/app_scripts.yml b/src/serverus/playbooks/tasks/app_scripts.yml index 8b4fa7f..f74d00f 100644 --- a/src/serverus/playbooks/tasks/app_scripts.yml +++ b/src/serverus/playbooks/tasks/app_scripts.yml @@ -10,4 +10,4 @@ src: "files/{{ item }}" dest: "{{ apps_dir }}/.serverus/{{ item }}" mode: "0755" - loop: [ app_sync.py, app_status.py ] + loop: [ app_sync.py, app_status.py, app_ports.py ] diff --git a/src/serverus/tests.py b/src/serverus/tests.py index e1d192c..fe166f5 100644 --- a/src/serverus/tests.py +++ b/src/serverus/tests.py @@ -311,6 +311,44 @@ class AppStatusTests(unittest.TestCase): }) +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'