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'))