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'))
|
||||
Reference in new issue
Block a user