From d71a53cb6209339fb152b83859413c45999afe4a Mon Sep 17 00:00:00 2001 From: cc-ci bot Date: Fri, 30 Oct 2026 18:58:43 +0000 Subject: [PATCH] enroll(rustdesk-server): test suite + bridge poll (status-key overlay, ws + punch-hole-amplification tests) --- nix/modules/bridge.nix | 2 +- tests/rustdesk-server/PARITY.md | 44 ++++++++ tests/rustdesk-server/compose.ccci.yml | 53 +++++++++ .../rustdesk-server/custom/_rustdesk_proto.py | 105 ++++++++++++++++++ .../custom/test_identity_key.py | 95 ++++++++++++++++ .../test_punch_hole_no_amplification.py | 93 ++++++++++++++++ .../custom/test_rendezvous_ports.py | 53 +++++++++ .../custom/test_websocket_handshake.py | 52 +++++++++ tests/rustdesk-server/recipe_meta.py | 47 ++++++++ 9 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 tests/rustdesk-server/PARITY.md create mode 100644 tests/rustdesk-server/compose.ccci.yml create mode 100644 tests/rustdesk-server/custom/_rustdesk_proto.py create mode 100644 tests/rustdesk-server/custom/test_identity_key.py create mode 100644 tests/rustdesk-server/custom/test_punch_hole_no_amplification.py create mode 100644 tests/rustdesk-server/custom/test_rendezvous_ports.py create mode 100644 tests/rustdesk-server/custom/test_websocket_handshake.py create mode 100644 tests/rustdesk-server/recipe_meta.py diff --git a/nix/modules/bridge.nix b/nix/modules/bridge.nix index bb527c0..3791282 100644 --- a/nix/modules/bridge.nix +++ b/nix/modules/bridge.nix @@ -41,7 +41,7 @@ let # admin-registered push optimization deduped against the poller (§4.1). Enrollment = add # the repo to POLL_REPOS (csv) + ensure tests// exists. - POLL_INTERVAL=30 - - POLL_REPOS=recipe-maintainers/cc-ci,recipe-maintainers/custom-html,recipe-maintainers/custom-html-tiny,recipe-maintainers/keycloak,recipe-maintainers/cryptpad,recipe-maintainers/matrix-synapse,recipe-maintainers/lasuite-docs,recipe-maintainers/lasuite-meet,recipe-maintainers/n8n,recipe-maintainers/hedgedoc,recipe-maintainers/uptime-kuma,recipe-maintainers/bluesky-pds,recipe-maintainers/discourse,recipe-maintainers/ghost,recipe-maintainers/immich,recipe-maintainers/lasuite-drive,recipe-maintainers/mailu,recipe-maintainers/mattermost-lts,recipe-maintainers/mumble,recipe-maintainers/plausible,recipe-maintainers/drone,recipe-maintainers/gitea,recipe-maintainers/wordpress + - POLL_REPOS=recipe-maintainers/cc-ci,recipe-maintainers/custom-html,recipe-maintainers/custom-html-tiny,recipe-maintainers/keycloak,recipe-maintainers/cryptpad,recipe-maintainers/matrix-synapse,recipe-maintainers/lasuite-docs,recipe-maintainers/lasuite-meet,recipe-maintainers/n8n,recipe-maintainers/hedgedoc,recipe-maintainers/uptime-kuma,recipe-maintainers/bluesky-pds,recipe-maintainers/discourse,recipe-maintainers/ghost,recipe-maintainers/immich,recipe-maintainers/lasuite-drive,recipe-maintainers/mailu,recipe-maintainers/mattermost-lts,recipe-maintainers/mumble,recipe-maintainers/plausible,recipe-maintainers/drone,recipe-maintainers/gitea,recipe-maintainers/wordpress,recipe-maintainers/rustdesk-server - HMAC_FILE=/run/secrets/webhook_hmac - DRONE_TOKEN_FILE=/run/secrets/drone_token - GITEA_TOKEN_FILE=/run/secrets/gitea_token diff --git a/tests/rustdesk-server/PARITY.md b/tests/rustdesk-server/PARITY.md new file mode 100644 index 0000000..eb7204d --- /dev/null +++ b/tests/rustdesk-server/PARITY.md @@ -0,0 +1,44 @@ +# Parity — rustdesk-server + +The recipe-maintainer corpus has **no** `recipe-info/rustdesk-server/` directory — rustdesk-server +was never in the recipe-maintainer parity suite (this is a fresh 2026-10-30 enrollment, requested +as a one-off upgrade of a single-client production deploy). So this PARITY.md documents the +cc-ci-authored suite as the baseline. + +## The recipe's shape (why the suite looks like this) + +rustdesk-server is **not HTTP-native**: hbbs (rendezvous/registry) + hbbr (relay) speak a +hand-rolled protobuf protocol on host-published ports 21115-21119 (21116 also UDP; 21118/21119 +websocket). There is no web UI, no HTTP API, and the stock compose's traefik labels point at a +port nothing serves. The cc-ci ENVIRONMENTAL overlay (`compose.ccci.yml`) adds a `status` +sidecar serving exactly hbbs's generated identity PUBLIC key at `/id_ed25519.pub` — the HTTP +health gate then asserts real app state (the key only exists because hbbs minted it), never a +canned page. The private key + sqlite peer DB in the same volume are asserted UNREACHABLE. +Host ports need no overlay: the stock compose publishes them mode:host, so protocol tests run +on-host at 127.0.0.1 (the mumble pattern). + +## Recipe-specific tests (≥2 beyond the generic floor) + +| cc-ci file | what's verified | why non-vacuous | +|---|---|---| +| `custom/test_rendezvous_ports.py` | All 5 stock host-published ports (21115-21119) accept TCP; 21116 holds 3 consecutive connects. | The recipe's entire function is serving those ports; a missing/failed bind (e.g. a compose port change upstream) is named per-port. | +| `custom/test_websocket_handshake.py` | Real `101 Switching Protocols` upgrades on hbbs :21118 and hbbr :21119. | These are the app's own client-facing protocol listeners; a bare GET gets NO response, so 101 can only come from a genuinely serving hbbs/hbbr (verified live on 1.1.12 and 1.1.16). | +| `custom/test_identity_key.py` | hbbs's ed25519 identity key read back through THREE independent surfaces — HTTPS status route (via Traefik), hbbs's own `Key:` startup log, the volume file — asserted EQUAL and a valid 32-byte key; plus the private key / DB paths asserted 404. | The §4.3-style round-trip of the recipe's defining generated state; a crash-looping hbbs (no key) fails, a routing gap fails, a surface mismatch (reading another deployment) fails, and an exposure fails. | +| `custom/test_punch_hole_no_amplification.py` | An unauthenticated UDP punch-hole request gets NO response on ≥1.1.16, while a register_peer sanity probe on the same socket IS answered. | The security regression test for the 1.1.16 amplification fix (GH #670, actively exploited pre-fix even with `-k _`). The in-test register_peer response proves silence means "fixed", not "broken"; a regression turns this RED naming the layer. Empirical baseline recorded in the file (1.1.12 answered 3/3, 1.1.16 silent 3/3). Version-gated per STYLE §2. | + +## Backup data-integrity + +The stock recipe declares no `backupbot.backup` labels — backup/restore tiers auto-skip +(intentional non-run, auto-detected). Nothing here weakens that; if the recipe grows backup +support upstream, the generic tiers pick it up automatically. + +## Deferred + +- A full register_pk/punch-hole EXCHANGE between two registered peers (proving the happy-path + connection flow, not just the refusal) — needs the client-side crypto handshake (signed pk + registration); the register_peer + ws + punch-hole surfaces already cover the server's + externally observable behavior. Revisit if upstream changes the rendezvous flow. +- Host-port contention: 21115-21119 are unique to this recipe, but two CONCURRENT rustdesk + deploys (e.g. a cold run racing a canonical reattach) would collide on them — same exposure + mailu (25/465/587) and lasuite-meet (7882) already carry as host-port recipes. Cadence makes + a collision rare; a failed deploy is loud and re-runnable. diff --git a/tests/rustdesk-server/compose.ccci.yml b/tests/rustdesk-server/compose.ccci.yml new file mode 100644 index 0000000..51646da --- /dev/null +++ b/tests/rustdesk-server/compose.ccci.yml @@ -0,0 +1,53 @@ +--- +version: "3.8" +# cc-ci ENVIRONMENTAL overlay for rustdesk-server — applies to ALL deploys (base + PR head). +# +# rustdesk-server has NO HTTP surface (hbbs/hbbr speak their own protobuf protocol on +# host-published 21115-21119; the stock traefik labels point at a dead :80). cc-ci's install +# tier gates on an HTTPS serving signal through Traefik, so this overlay adds a `status` +# sidecar serving exactly ONE real app artifact: hbbs's generated identity PUBLIC key at +# /id_ed25519.pub (mounted read-only from the shared rustdesk_data volume). The health gate +# then proves domain routing AND that hbbs booted far enough to mint its keypair. The private +# key and the sqlite peer DB in the same volume are never exposed: nginx serves only the +# exact .pub location, everything else 404s. +# +# Node-reality tweaks ONLY — no version-specific image pins, no changes to the app/signal +# services (the stock app's vestigial Host(`${DOMAIN}`) router on :80 is left untouched; the +# status router declares an explicit higher priority so it deterministically wins the tie). +services: + status: + image: nginx:1.31.8 + command: + - sh + - -c + - | + printf '%s\n' \ + 'user root;' \ + 'worker_processes 1;' \ + 'error_log /dev/stderr warn;' \ + 'pid /tmp/nginx.pid;' \ + 'events { worker_connections 128; }' \ + 'http { default_type text/plain; access_log off;' \ + ' server { listen 80;' \ + ' location = /id_ed25519.pub { alias /pub/id_ed25519.pub; }' \ + ' location / { return 404; }' \ + ' }' \ + '}' > /etc/nginx/nginx.conf + exec nginx -g 'daemon off;' + volumes: + # rustdesk_data is owned root:0700 (hbbs's /root), and id_ed25519.pub is 0644 — workers + # must run as root (user root; above) to traverse the volume dir. Read-only mount; the + # private key (id_ed25519) and db_v2.sqlite3 are unreachable through the config above. + - rustdesk_data:/pub:ro + networks: + - proxy + deploy: + labels: + - "traefik.enable=true" + - "traefik.http.routers.${STACK_NAME}-status.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.${STACK_NAME}-status.entrypoints=web-secure" + - "traefik.http.routers.${STACK_NAME}-status.tls.certresolver=${LETS_ENCRYPT_ENV}" + # Explicit priority: the stock app router claims the same Host rule against a dead + # :80 backend; without this the winner of the equal-default-priority tie is undefined. + - "traefik.http.routers.${STACK_NAME}-status.priority=100" + - "traefik.http.services.${STACK_NAME}-status.loadbalancer.server.port=80" diff --git a/tests/rustdesk-server/custom/_rustdesk_proto.py b/tests/rustdesk-server/custom/_rustdesk_proto.py new file mode 100644 index 0000000..115adea --- /dev/null +++ b/tests/rustdesk-server/custom/_rustdesk_proto.py @@ -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 diff --git a/tests/rustdesk-server/custom/test_identity_key.py b/tests/rustdesk-server/custom/test_identity_key.py new file mode 100644 index 0000000..1483aba --- /dev/null +++ b/tests/rustdesk-server/custom/test_identity_key.py @@ -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: ` 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: ' 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" diff --git a/tests/rustdesk-server/custom/test_punch_hole_no_amplification.py b/tests/rustdesk-server/custom/test_punch_hole_no_amplification.py new file mode 100644 index 0000000..19ce8af --- /dev/null +++ b/tests/rustdesk-server/custom/test_punch_hole_no_amplification.py @@ -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}" + ) diff --git a/tests/rustdesk-server/custom/test_rendezvous_ports.py b/tests/rustdesk-server/custom/test_rendezvous_ports.py new file mode 100644 index 0000000..4c6a308 --- /dev/null +++ b/tests/rustdesk-server/custom/test_rendezvous_ports.py @@ -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) diff --git a/tests/rustdesk-server/custom/test_websocket_handshake.py b/tests/rustdesk-server/custom/test_websocket_handshake.py new file mode 100644 index 0000000..77577b4 --- /dev/null +++ b/tests/rustdesk-server/custom/test_websocket_handshake.py @@ -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)" + ) diff --git a/tests/rustdesk-server/recipe_meta.py b/tests/rustdesk-server/recipe_meta.py new file mode 100644 index 0000000..65e6d1f --- /dev/null +++ b/tests/rustdesk-server/recipe_meta.py @@ -0,0 +1,47 @@ +# Per-recipe harness config for rustdesk-server (hbbs/hbbr rendezvous+relay — NOT HTTP-native). +# +# RustDesk's server speaks its own protobuf protocol on host-published ports +# (21115-21119 tcp + 21116 udp; 21118/21119 are its websocket listeners) — there is NO HTTP +# API and no web UI, and the stock compose's traefik labels point at a port nothing serves +# (vestigial). To fit cc-ci's HTTP-readiness model we deploy a cc-ci ENVIRONMENTAL overlay +# (compose.ccci.yml, auto-provided by lifecycle.provide_ccci_overlay): +# - a `status` sidecar (nginx) that serves EXACTLY ONE real artifact of the app — hbbs's +# generated identity key — at /id_ed25519.pub on the app domain via Traefik. The health +# gate then asserts the domain routes AND the app actually booted far enough to mint its +# keypair (a canned page would prove nothing; the key file only exists because hbbs +# created it). The private key and the sqlite peer DB in the same volume are NEVER +# exposed (nginx serves only the exact .pub location; everything else 404s). +# - host ports 21115-21119 need NO overlay: the stock compose already publishes them +# mode:host, so on-host protocol tests reach hbbs/hbbr at 127.0.0.1 directly. +# +# Distinctive config markers (read back by the recipe-specific tests — the app's own +# artifacts, not hard-coded upstream values): the identity key is asserted EQUAL across three +# independent surfaces — the HTTPS status route, hbbs's own startup log line ("Key: …"), and +# the volume file — proving our deploy actually produced the state we're reading. + +HEALTH_PATH = "/id_ed25519.pub" # real hbbs artifact served by the status sidecar +HEALTH_OK = (200,) + +DEPLOY_TIMEOUT = 600 # both images are tiny (~6 MB) and nginx is pre-pulled on the node +HTTP_TIMEOUT = 300 # key file appears seconds after hbbs starts; generous for a cold node + +# Environmental overlay for EVERY deploy (base install AND upgrade head): adds the status +# sidecar + its Traefik router (priority beats the stock app's dead :80 router, which is left +# untouched — no version-specific pins, no service add/drop beyond the CI-only sidecar). +EXTRA_ENV = { + "COMPOSE_FILE": "compose.yml:compose.ccci.yml", +} + +# Extra readiness beyond the HTTP gate: the rendezvous server must be STABLY listening on its +# core udp/tcp port 21116 (3 consecutive connects) before the harness proceeds — mirrors +# mumble's voice-port probe. The remaining ports + the websocket + protocol surfaces are +# asserted by the custom tier. + + +def READY_PROBE(ctx): + return [{"tcp_host": "127.0.0.1", "tcp_port": 21116, "stable": 3}] + + +# canon §2.B: enroll as a DATA-WARM canonical (all recipes enrolled — operator 2026-06-17). +# The weekly sweep promotes this recipe's canonical to its latest green RELEASE TAG. +WARM_CANONICAL = True