enroll(rustdesk-server): test suite + bridge poll (status-key overlay, ws + punch-hole-amplification tests)
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-30 18:58:43 +00:00
parent 16ee27affd
commit d71a53cb62
9 changed files with 543 additions and 1 deletions
+1 -1
View File
@@ -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/<recipe>/ 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
+44
View File
@@ -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.
+53
View File
@@ -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"
@@ -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)"
)
+47
View File
@@ -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