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>
170 lines
5.7 KiB
Python
Executable File
170 lines
5.7 KiB
Python
Executable File
#!/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:]))
|