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>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-09-30 10:31:38 +04:00
1 parent a8d1f286d1
commit fd4b05097a
16 files changed
+526 -13

No files matched your search

+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'))
+2 -1
View File
@@ -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
+31 -1
View File
@@ -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):
+51 -2
View File
@@ -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
+5 -3
View File
@@ -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 символов.
+5 -2
View File
@@ -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)
+8 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+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])
+1 -1
View File
@@ -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 ]
+38
View File
@@ -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'