enroll(rustdesk-server): test suite + bridge poll (status-key overlay, ws + punch-hole-amplification tests)
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Minimal RustDesk rendezvous protocol encoder/probe (stdlib only, zero deps).
|
||||
|
||||
hbbs speaks `RendezvousMessage` protobuf over UDP :21116 (one message per datagram, no
|
||||
framing). The wire format is hand-encoded here from the upstream proto
|
||||
(rustdesk/hbb_common protos/rendezvous.proto — field numbers are IDENTICAL between the
|
||||
1.1.12-vendored and the current shared-proto era, verified against both tags):
|
||||
|
||||
message RendezvousMessage {
|
||||
oneof union {
|
||||
RegisterPeer register_peer = 6;
|
||||
RegisterPeerResponse register_peer_response = 7;
|
||||
PunchHoleRequest punch_hole_request = 8;
|
||||
PunchHoleResponse punch_hole_response = 11;
|
||||
...
|
||||
}
|
||||
}
|
||||
message RegisterPeer { string id = 1; int32 serial = 2; }
|
||||
message PunchHoleRequest { string id = 1; ... }
|
||||
|
||||
This is the surface a real RustDesk client uses to register and request connections; it is
|
||||
the app's own interface (STYLE.md §1), exercised from the cc-ci host against the
|
||||
host-published :21116 (the stock compose publishes 21115-21119 mode:host).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
RENDEZVOUS_PORT = 21116
|
||||
|
||||
|
||||
def varint(n: int) -> bytes:
|
||||
out = bytearray()
|
||||
while n > 0x7F:
|
||||
out.append((n & 0x7F) | 0x80)
|
||||
n >>= 7
|
||||
out.append(n)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def field_str(field: int, s: str) -> bytes:
|
||||
b = s.encode()
|
||||
return varint((field << 3) | 2) + varint(len(b)) + b
|
||||
|
||||
|
||||
def field_varint(field: int, n: int) -> bytes:
|
||||
return varint((field << 3) | 0) + varint(n)
|
||||
|
||||
|
||||
def wrap(union_field: int, inner: bytes) -> bytes:
|
||||
"""Wrap an inner message as the given oneof field of RendezvousMessage."""
|
||||
return varint((union_field << 3) | 2) + varint(len(inner)) + inner
|
||||
|
||||
|
||||
def register_peer(peer_id: str) -> bytes:
|
||||
"""RendezvousMessage{ register_peer { id, serial=0 } } — a plain peer registration."""
|
||||
return wrap(6, field_str(1, peer_id) + field_varint(2, 0))
|
||||
|
||||
|
||||
def punch_hole_request(target_id: str) -> bytes:
|
||||
"""RendezvousMessage{ punch_hole_request { id } } — an UNAUTHENTICATED connection request
|
||||
for `target_id` (no licence_key/token — exactly the spoofable request shape from the
|
||||
CVE-2026-30784 / GH #670 amplification report)."""
|
||||
return wrap(8, field_str(1, target_id))
|
||||
|
||||
|
||||
def parse_union_field(data: bytes) -> int | None:
|
||||
"""Which RendezvousMessage oneof field the server answered with (None if unparseable)."""
|
||||
if not data:
|
||||
return None
|
||||
key = data[0]
|
||||
field, wire = key >> 3, key & 0x7
|
||||
return field if wire == 2 else field # field number regardless of wire type
|
||||
|
||||
|
||||
def udp_probe(
|
||||
msg: bytes, host: str = "127.0.0.1", port: int = RENDEZVOUS_PORT, wait: float = 2.0
|
||||
) -> bytes | None:
|
||||
"""Send one datagram, return the server's reply (or None after `wait` seconds of silence)."""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(wait)
|
||||
try:
|
||||
s.sendto(msg, (host, port))
|
||||
try:
|
||||
data, _ = s.recvfrom(65535)
|
||||
return data
|
||||
except TimeoutError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def retry_register_peer(peer_id: str, attempts: int = 6, interval: float = 5.0) -> bytes | None:
|
||||
"""Poll register_peer until the server answers (post-deploy settle), or give up.
|
||||
|
||||
Returns the raw register_peer_response datagram."""
|
||||
deadline = time.time() + attempts * interval
|
||||
last: bytes | None = None
|
||||
while time.time() < deadline:
|
||||
last = udp_probe(register_peer(peer_id))
|
||||
if last is not None:
|
||||
return last
|
||||
time.sleep(interval)
|
||||
return last
|
||||
@@ -0,0 +1,95 @@
|
||||
"""rustdesk-server — identity-key round-trip across three independent surfaces (§4.3-style).
|
||||
|
||||
hbbs mints an ed25519 identity keypair on first boot into the shared rustdesk_data volume;
|
||||
the PUBLIC key is what clients configure to pin the server. This is the recipe's defining
|
||||
generated state, and we read it back through three genuinely different paths:
|
||||
|
||||
1. HTTPS through Traefik + the cc-ci status sidecar: GET /id_ed25519.pub (the serving path
|
||||
a remote client could observe),
|
||||
2. hbbs's OWN startup telemetry: the `Key: <b64>` log line (docker service logs),
|
||||
3. the volume file itself, read inside the status sidecar (cat /pub/id_ed25519.pub).
|
||||
|
||||
All three must AGREE and be a valid base64-encoded 32-byte ed25519 public key. A wedged or
|
||||
crash-looping hbbs fails (1) with 404 forever (no key file); a key mismatch across surfaces
|
||||
would mean we are reading different deployments' state — either way this test names the
|
||||
broken layer. Non-vacuous: every surface carries app-generated state, nothing is echoed from
|
||||
config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import lifecycle # noqa: E402
|
||||
|
||||
_KEY_RE = re.compile(rb"Key: ([A-Za-z0-9+/]{43}=)")
|
||||
|
||||
|
||||
def _stack(domain: str) -> str:
|
||||
return domain.replace(".", "_")
|
||||
|
||||
|
||||
def _https_key(domain: str) -> tuple[int, str]:
|
||||
return lifecycle.http_fetch(domain, "/id_ed25519.pub")
|
||||
|
||||
|
||||
def _log_key(domain: str) -> bytes | None:
|
||||
out = subprocess.run(
|
||||
["docker", "service", "logs", "--tail", "200", f"{_stack(domain)}_app"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
).stdout
|
||||
m = _KEY_RE.search(out.encode())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _volume_key(domain: str) -> str:
|
||||
return lifecycle.exec_in_app(domain, ["cat", "/pub/id_ed25519.pub"], service="status").strip()
|
||||
|
||||
|
||||
def test_identity_key_agrees_across_https_log_and_volume(live_app):
|
||||
https_status, https_body = _https_key(live_app)
|
||||
assert https_status == 200, (
|
||||
f"status route /id_ed25519.pub not serving (HTTP {https_status}) — hbbs never minted "
|
||||
"its key, or the status sidecar/routing is broken"
|
||||
)
|
||||
https_key = https_body.strip().encode()
|
||||
|
||||
log_key = _log_key(live_app)
|
||||
assert log_key is not None, "hbbs startup log carries no 'Key: <b64>' line (telemetry)"
|
||||
|
||||
vol_key = _volume_key(live_app).encode()
|
||||
|
||||
assert https_key == log_key == vol_key, (
|
||||
"identity key disagrees across surfaces — "
|
||||
f"https={https_key[:20]!r} log={log_key[:20]!r} volume={vol_key[:20]!r} "
|
||||
"(reading different deployments' state?)"
|
||||
)
|
||||
|
||||
raw = base64.b64decode(https_key, validate=True)
|
||||
assert len(raw) == 32, f"not a 32-byte ed25519 public key ({len(raw)} bytes)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/",
|
||||
"/id_ed25519", # the PRIVATE key lives in the same volume — must never be served
|
||||
"/db_v2.sqlite3",
|
||||
"/db_v2.sqlite3-wal",
|
||||
],
|
||||
)
|
||||
def test_status_route_serves_only_the_public_key(live_app, path):
|
||||
"""Anything but the exact .pub location 404s (default surface = deny). The private key and
|
||||
the sqlite peer DB share the status sidecar's volume — a 200 on any of these paths would
|
||||
be a real exposure."""
|
||||
status, _ = lifecycle.http_fetch(live_app, path)
|
||||
assert status == 404, f"status route served {path} (HTTP {status}) — exposure, not health"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""rustdesk-server — security regression: the unauthenticated punch-hole amplification fix.
|
||||
|
||||
Upstream 1.1.16 fixed GH rustdesk/rustdesk-server#670 (reserved-then-withdrawn
|
||||
CVE-2026-30784): hbbs used to answer ANY UDP punch-hole request with a punch-hole response —
|
||||
no check the requester was a registered peer or that the exchange was solicited — so spoofed
|
||||
source IPs turned public servers into DDoS reflection/amplifiers. This happened EVEN with
|
||||
`-k _` enforced (which this recipe uses), because the key gate never covered the punch-hole
|
||||
path.
|
||||
|
||||
Empirical baseline (cc-ci dev deploy, 2026-10-30, hand-encoded RendezvousMessage protobufs):
|
||||
1.1.12: register_peer -> 3a021001 (register_peer_response, request_pk=true)
|
||||
punch_hole_request x3 -> 5a021803 EVERY time (punch_hole_response, failure=3)
|
||||
1.1.16: register_peer -> 3a021001 (unchanged — the channel still answers)
|
||||
punch_hole_request x3 -> SILENCE (the fix)
|
||||
|
||||
The register_peer sanity check inside the same test is what makes "silence" meaningful: it
|
||||
proves the server is up, our encoding is right, and UDP 21116 is answering — so a silent
|
||||
punch-hole exchange can only be the FIX, not a broken server or a bad probe. Non-vacuous: a
|
||||
regression of the fix makes hbbs answer again and this test goes RED naming the layer.
|
||||
|
||||
Version-gated (STYLE.md §2): the fixed behavior only exists from 1.1.16; older versions are
|
||||
exercised by the upgrade tier's base deploy and are EXPECTED to answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import _rustdesk_proto as proto # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
|
||||
|
||||
def _deployed_app_version(domain: str) -> tuple[int, int, int] | None:
|
||||
"""The running app service's image tag (e.g. rustdesk/rustdesk-server:1.1.16 -> (1,1,16))."""
|
||||
out = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"service",
|
||||
"inspect",
|
||||
f"{domain.replace('.', '_')}_app",
|
||||
"--format",
|
||||
"{{.Spec.TaskTemplate.ContainerSpec.Image}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
).stdout.strip()
|
||||
tag = out.rsplit(":", 1)[-1]
|
||||
try:
|
||||
return tuple(int(x) for x in tag.split(".")[:3])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def test_unauthenticated_punch_hole_is_not_answered(live_app):
|
||||
version = _deployed_app_version(live_app)
|
||||
if version is not None and version < (1, 1, 16):
|
||||
pytest.skip(
|
||||
f"amplification fix landed upstream in 1.1.16 (running {version}) — pre-fix "
|
||||
"answering is expected behavior, covered by the upgrade tier's base deploy"
|
||||
)
|
||||
|
||||
# 1) channel sanity: a plain register_peer MUST be answered (register_peer_response,
|
||||
# RendezvousMessage field 7 -> first varint key 0x3A). Without this, later silence
|
||||
# could mean "server down / encoding wrong", not "fixed".
|
||||
reg = proto.retry_register_peer("ccci-sanity-probe")
|
||||
assert reg is not None, (
|
||||
"register_peer sanity probe unanswered — hbbs UDP 21116 not serving; cannot judge "
|
||||
"the punch-hole path"
|
||||
)
|
||||
assert proto.parse_union_field(reg) == 7, (
|
||||
f"expected register_peer_response (oneof field 7), got {proto.parse_union_field(reg)}: {reg[:40].hex()}"
|
||||
)
|
||||
|
||||
# 2) the actual security property: an UNAUTHENTICATED punch-hole request (no
|
||||
# licence_key/token, spoofable shape) for a nonexistent target must get NO response.
|
||||
# Pre-fix (1.1.12) every request was answered with punch_hole_response (field 11) —
|
||||
# the reflection/amplification vector. 3 independent attempts, all silent.
|
||||
answered = []
|
||||
for attempt in range(3):
|
||||
resp = proto.udp_probe(proto.punch_hole_request(f"ccci-no-such-peer-{attempt}"))
|
||||
if resp is not None:
|
||||
answered.append((attempt, resp[:40].hex()))
|
||||
assert not answered, (
|
||||
"hbbs answered unauthenticated punch-hole request(s) — the 1.1.16 amplification fix "
|
||||
f"(GH #670) has REGRESSED: {answered}"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""rustdesk-server — rendezvous/relay port presence (the recipe's whole purpose).
|
||||
|
||||
The stock compose publishes hbbs's and hbbr's ports mode:host on the cc-ci node, so these
|
||||
assertions run on-host against 127.0.0.1. A TCP accept on each port is the minimal real
|
||||
signal that the corresponding listener is bound; the protocol layers above them are asserted
|
||||
by the websocket-handshake and punch-hole tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
# service -> port map from the stock compose (hbbs: 21115/21116(+udp)/21118, hbbr: 21117/21119)
|
||||
PORTS = {
|
||||
21115: "hbbs NAT-test",
|
||||
21116: "hbbs rendezvous (tcp)",
|
||||
21117: "hbbr relay",
|
||||
21118: "hbbs websocket",
|
||||
21119: "hbbr websocket",
|
||||
}
|
||||
|
||||
|
||||
def _connect(port: int, timeout: float = 5.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def test_all_ports_listening(meta):
|
||||
"""Every stock host-published port accepts a TCP connection (poll: freshly rolled tasks
|
||||
rebind host-mode ports with a short window — mumble's READY_PROBE pattern, §5)."""
|
||||
deadline = time.time() + meta.HTTP_TIMEOUT
|
||||
pending = dict(PORTS)
|
||||
while pending and time.time() < deadline:
|
||||
for port in list(pending):
|
||||
if _connect(port):
|
||||
del pending[port]
|
||||
if pending:
|
||||
time.sleep(3)
|
||||
assert not pending, (
|
||||
f"ports not listening on 127.0.0.1: {pending} "
|
||||
f"(waited {meta.HTTP_TIMEOUT}s; hbbs/hbbr host-mode binds)"
|
||||
)
|
||||
|
||||
|
||||
def test_rendezvous_port_stably_listening():
|
||||
"""The core rendezvous port 21116 holds 3 consecutive accepts (no flapping rebind)."""
|
||||
for _ in range(3):
|
||||
assert _connect(21116), "hbbs rendezvous 21116 dropped a consecutive connect"
|
||||
time.sleep(1)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""rustdesk-server — real websocket handshakes against hbbs/hbbr's ws listeners.
|
||||
|
||||
21118 (hbbs) and 21119 (hbbr) are the servers' websocket endpoints — the surface a real
|
||||
RustDesk web client uses. A completed `101 Switching Protocols` upgrade is the app's own
|
||||
protocol answering (verified live against 1.1.12 and 1.1.16 during enrollment); a bare GET
|
||||
without upgrade headers gets NO response, so this can only pass against a genuinely serving
|
||||
listener, never a proxy or placeholder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
WS_PORTS = {21118: "hbbs", 21119: "hbbr"}
|
||||
|
||||
_REQ = (
|
||||
b"GET / HTTP/1.1\r\n"
|
||||
b"Host: rustdesk\r\n"
|
||||
b"Upgrade: websocket\r\n"
|
||||
b"Connection: Upgrade\r\n"
|
||||
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
b"Sec-WebSocket-Version: 13\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
|
||||
|
||||
def _handshake(port: int, timeout: float = 5.0) -> bytes:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as s:
|
||||
s.sendall(_REQ)
|
||||
s.settimeout(timeout)
|
||||
return s.recv(200)
|
||||
|
||||
|
||||
def test_websocket_upgrades(meta):
|
||||
"""Both ws endpoints complete an HTTP->websocket upgrade with 101 (poll per §5)."""
|
||||
deadline = time.time() + meta.HTTP_TIMEOUT
|
||||
pending = dict(WS_PORTS)
|
||||
while pending and time.time() < deadline:
|
||||
for port in list(pending):
|
||||
try:
|
||||
data = _handshake(port)
|
||||
except OSError:
|
||||
data = b""
|
||||
if data.startswith(b"HTTP/1.1 101"):
|
||||
del pending[port]
|
||||
if pending:
|
||||
time.sleep(3)
|
||||
assert not pending, (
|
||||
f"websocket endpoints did not complete 101 upgrades: {pending} "
|
||||
f"(waited {meta.HTTP_TIMEOUT}s)"
|
||||
)
|
||||
Reference in New Issue
Block a user