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:
1 parent
a8d1f286d1
commit
fd4b05097a
16 files changed
+526
-13
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# сгенерированные ключи и .env - на месте, в репозиторий не попадают
|
||||
app/data/
|
||||
app/.env
|
||||
@@ -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:
|
||||
Executable
+169
@@ -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:]))
|
||||
@@ -0,0 +1,2 @@
|
||||
# адрес сервера, который hbbs сообщает клиентам как relay: ip или домен
|
||||
RELAY_HOST=1.2.3.4
|
||||
Executable
+38
@@ -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'))
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 символов.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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])
|
||||
@@ -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 ]
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user