recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)
Sanitized single-commit public mirror of recipe-maintainer. - Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders. - Removed plans/ and planned-updates/ (deployment-planning docs) so no client/ deployment domains appear in the public repo. - All other secret stores were already gitignored. - docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Health check for Mumble — tests TCP connectivity to port 64738."""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
from utils.tests.helpers import resolve_server, run
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--server', default=os.environ.get('TEST_SERVER'))
|
||||
args = parser.parse_args()
|
||||
server = args.server or resolve_server()
|
||||
|
||||
print(f"Checking Mumble TCP port 64738 on {server} ...")
|
||||
result = run(
|
||||
f"ssh {server} 'bash -c \"echo > /dev/tcp/localhost/64738\"'",
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f"PASS: Mumble is listening on {server}:64738")
|
||||
else:
|
||||
print(f"FAIL: Could not connect to {server}:64738")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mumble protocol integration test.
|
||||
|
||||
Connects to the Mumble server using the raw TCP/TLS protocol, performs
|
||||
a full client handshake, and verifies the server responds correctly.
|
||||
|
||||
Checks:
|
||||
- TLS connection succeeds on port 64738
|
||||
- Server sends a valid Version message
|
||||
- Authentication is accepted (no Reject)
|
||||
- Server sends ChannelState (at least a root channel exists)
|
||||
- Server sends ServerSync (handshake completes)
|
||||
- Welcome text is present
|
||||
|
||||
No external dependencies — uses only Python stdlib (ssl, socket, struct).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
from utils.tests.helpers import resolve_server
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal protobuf encoding/decoding (no external deps)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _encode_varint(value):
|
||||
result = bytearray()
|
||||
while value > 0x7F:
|
||||
result.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
result.append(value & 0x7F)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def _encode_field_varint(field_number, value):
|
||||
tag = (field_number << 3) | 0
|
||||
return _encode_varint(tag) + _encode_varint(value)
|
||||
|
||||
|
||||
def _encode_field_string(field_number, value):
|
||||
tag = (field_number << 3) | 2
|
||||
encoded = value.encode("utf-8") if isinstance(value, str) else value
|
||||
return _encode_varint(tag) + _encode_varint(len(encoded)) + encoded
|
||||
|
||||
|
||||
def _decode_varint(data, offset):
|
||||
result = 0
|
||||
shift = 0
|
||||
while offset < len(data):
|
||||
byte = data[offset]
|
||||
result |= (byte & 0x7F) << shift
|
||||
offset += 1
|
||||
if not (byte & 0x80):
|
||||
break
|
||||
shift += 7
|
||||
return result, offset
|
||||
|
||||
|
||||
def _decode_fields(data):
|
||||
"""Decode protobuf message into {field_number: value}."""
|
||||
fields = {}
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
tag, offset = _decode_varint(data, offset)
|
||||
field_number = tag >> 3
|
||||
wire_type = tag & 0x07
|
||||
if wire_type == 0: # varint
|
||||
value, offset = _decode_varint(data, offset)
|
||||
elif wire_type == 1: # 64-bit fixed
|
||||
value = struct.unpack_from("<Q", data, offset)[0]
|
||||
offset += 8
|
||||
elif wire_type == 2: # length-delimited
|
||||
length, offset = _decode_varint(data, offset)
|
||||
raw = data[offset:offset + length]
|
||||
offset += length
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
value = raw
|
||||
elif wire_type == 5: # 32-bit fixed
|
||||
value = struct.unpack_from("<I", data, offset)[0]
|
||||
offset += 4
|
||||
else:
|
||||
raise ValueError(f"Unknown wire type {wire_type}")
|
||||
fields[field_number] = value
|
||||
return fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mumble protocol constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MSG_VERSION = 0
|
||||
MSG_AUTHENTICATE = 2
|
||||
MSG_PING = 3
|
||||
MSG_REJECT = 4
|
||||
MSG_SERVERSYNC = 5
|
||||
MSG_CHANNELSTATE = 7
|
||||
MSG_USERSTATE = 9
|
||||
|
||||
HEADER_FMT = ">HI"
|
||||
HEADER_SIZE = 6
|
||||
|
||||
MSG_NAMES = {
|
||||
0: "Version", 1: "UDPTunnel", 2: "Authenticate", 3: "Ping",
|
||||
4: "Reject", 5: "ServerSync", 6: "ChannelRemove", 7: "ChannelState",
|
||||
8: "UserRemove", 9: "UserState", 15: "CryptSetup", 24: "ServerConfig",
|
||||
}
|
||||
|
||||
REJECT_TYPES = {
|
||||
0: "None", 1: "WrongVersion", 2: "InvalidUsername",
|
||||
3: "WrongUserPW", 4: "WrongServerPW", 5: "UsernameInUse",
|
||||
6: "ServerFull", 7: "NoCertificate", 8: "AuthenticatorFail",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def send_msg(sock, msg_type, payload=b""):
|
||||
sock.sendall(struct.pack(HEADER_FMT, msg_type, len(payload)) + payload)
|
||||
|
||||
|
||||
def recv_msg(sock, timeout=10.0):
|
||||
sock.settimeout(timeout)
|
||||
header = b""
|
||||
while len(header) < HEADER_SIZE:
|
||||
chunk = sock.recv(HEADER_SIZE - len(header))
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed reading header")
|
||||
header += chunk
|
||||
msg_type, length = struct.unpack(HEADER_FMT, header)
|
||||
payload = b""
|
||||
while len(payload) < length:
|
||||
chunk = sock.recv(length - len(payload))
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed reading payload")
|
||||
payload += chunk
|
||||
return msg_type, payload
|
||||
|
||||
|
||||
def build_version():
|
||||
# Pretend to be client version 1.5.0
|
||||
v1 = (1 << 16) | (5 << 8) | 0
|
||||
return (_encode_field_varint(1, v1)
|
||||
+ _encode_field_string(2, "CoopCloudHealthCheck 1.0")
|
||||
+ _encode_field_string(3, "Linux"))
|
||||
|
||||
|
||||
def build_authenticate(username, password=""):
|
||||
payload = _encode_field_string(1, username)
|
||||
if password:
|
||||
payload += _encode_field_string(2, password)
|
||||
payload += _encode_field_varint(5, 1) # opus = true
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mumble_connect_test(host, port=64738, password="", timeout=15.0):
|
||||
"""Connect to a Mumble server and verify the handshake completes.
|
||||
|
||||
Returns a dict with results for each check.
|
||||
"""
|
||||
results = {
|
||||
"tls_connect": False,
|
||||
"server_version": None,
|
||||
"auth_accepted": False,
|
||||
"channels": [],
|
||||
"users": [],
|
||||
"server_sync": False,
|
||||
"welcome_text": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
raw_sock = None
|
||||
tls_sock = None
|
||||
|
||||
try:
|
||||
# 1. TLS connection
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(timeout)
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
|
||||
tls_sock = ctx.wrap_socket(raw_sock, server_hostname=host)
|
||||
tls_sock.connect((host, port))
|
||||
results["tls_connect"] = True
|
||||
|
||||
# 2. Send Version + Authenticate
|
||||
send_msg(tls_sock, MSG_VERSION, build_version())
|
||||
send_msg(tls_sock, MSG_AUTHENTICATE,
|
||||
build_authenticate("health-check-bot", password))
|
||||
|
||||
# 3. Read responses until ServerSync or Reject
|
||||
channels = {}
|
||||
users = {}
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
msg_type, payload = recv_msg(tls_sock, timeout=remaining)
|
||||
|
||||
if msg_type == MSG_VERSION:
|
||||
fields = _decode_fields(payload)
|
||||
v1 = fields.get(1, 0)
|
||||
results["server_version"] = {
|
||||
"string": f"{(v1 >> 16) & 0xFF}.{(v1 >> 8) & 0xFF}.{v1 & 0xFF}",
|
||||
"release": fields.get(2, ""),
|
||||
"os": fields.get(3, ""),
|
||||
}
|
||||
|
||||
elif msg_type == MSG_REJECT:
|
||||
fields = _decode_fields(payload)
|
||||
rtype = REJECT_TYPES.get(fields.get(1, 0), "Unknown")
|
||||
reason = fields.get(2, "")
|
||||
results["error"] = f"Rejected: {rtype} — {reason}"
|
||||
return results
|
||||
|
||||
elif msg_type == MSG_CHANNELSTATE:
|
||||
fields = _decode_fields(payload)
|
||||
ch_id = fields.get(1, 0)
|
||||
channels[ch_id] = fields.get(3, f"channel-{ch_id}")
|
||||
|
||||
elif msg_type == MSG_USERSTATE:
|
||||
fields = _decode_fields(payload)
|
||||
name = fields.get(3, "")
|
||||
if name:
|
||||
users[fields.get(1, 0)] = name
|
||||
|
||||
elif msg_type == MSG_SERVERSYNC:
|
||||
fields = _decode_fields(payload)
|
||||
results["welcome_text"] = fields.get(3, "")
|
||||
results["server_sync"] = True
|
||||
results["auth_accepted"] = True
|
||||
break
|
||||
|
||||
results["channels"] = list(channels.values())
|
||||
results["users"] = list(users.values())
|
||||
|
||||
if not results["server_sync"]:
|
||||
results["error"] = "Timed out waiting for ServerSync"
|
||||
|
||||
except ConnectionRefusedError:
|
||||
results["error"] = "Connection refused"
|
||||
except socket.timeout:
|
||||
results["error"] = "Connection timed out"
|
||||
except ssl.SSLError as e:
|
||||
results["error"] = f"TLS error: {e}"
|
||||
except ConnectionError as e:
|
||||
results["error"] = f"Connection error: {e}"
|
||||
except Exception as e:
|
||||
results["error"] = f"{type(e).__name__}: {e}"
|
||||
finally:
|
||||
if tls_sock:
|
||||
try:
|
||||
tls_sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
tls_sock.close()
|
||||
elif raw_sock:
|
||||
raw_sock.close()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mumble server integration test")
|
||||
parser.add_argument('--server', default=os.environ.get('TEST_SERVER'))
|
||||
parser.add_argument('--port', type=int, default=64738)
|
||||
parser.add_argument('--password', default="",
|
||||
help="Server password (if configured)")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = args.server or resolve_server()
|
||||
|
||||
print(f"Mumble integration test: {server}:{args.port}")
|
||||
print()
|
||||
|
||||
r = mumble_connect_test(server, port=args.port, password=args.password)
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
nonlocal passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(f" PASS: {name}{f' — {detail}' if detail else ''}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" FAIL: {name}{f' — {detail}' if detail else ''}")
|
||||
|
||||
# Check 1: TLS connection
|
||||
check("TLS connection on port 64738", r["tls_connect"])
|
||||
|
||||
# Check 2: Server version
|
||||
v = r["server_version"]
|
||||
check("Server sent Version message",
|
||||
v is not None,
|
||||
f"v{v['string']} ({v['release']})" if v else "no version received")
|
||||
|
||||
# Check 3: Authentication accepted (no Reject, got ServerSync)
|
||||
check("Authentication accepted",
|
||||
r["auth_accepted"],
|
||||
r.get("error", "") if not r["auth_accepted"] else "")
|
||||
|
||||
# Check 4: At least one channel exists (root channel)
|
||||
check("Server has channels",
|
||||
len(r["channels"]) > 0,
|
||||
f"{len(r['channels'])} channel(s): {r['channels']}")
|
||||
|
||||
# Check 5: ServerSync received (handshake complete)
|
||||
check("ServerSync handshake complete", r["server_sync"])
|
||||
|
||||
# Check 6: Welcome text present
|
||||
wt = r["welcome_text"] or ""
|
||||
check("Welcome text configured",
|
||||
len(wt.strip()) > 0,
|
||||
f"'{wt[:80]}...'" if len(wt) > 80 else f"'{wt}'" if wt else "empty")
|
||||
|
||||
print()
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
|
||||
if r["error"]:
|
||||
print(f"Error: {r['error']}")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test for mumble-web client overlay.
|
||||
|
||||
Verifies the web client is reachable via HTTPS and serves the expected
|
||||
Mumble Web UI. Requires the compose.mumbleweb.yml overlay to be enabled.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
from utils.tests.helpers import http_get, resolve_domain, wait_for_http
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mumble web client test")
|
||||
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
|
||||
args = parser.parse_args()
|
||||
domain = args.domain or resolve_domain('mumble')
|
||||
url = f"https://{domain}"
|
||||
|
||||
print(f"Mumble web client test: {url}")
|
||||
print()
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
nonlocal passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(f" PASS: {name}{f' — {detail}' if detail else ''}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" FAIL: {name}{f' — {detail}' if detail else ''}")
|
||||
|
||||
# Check 1: HTTP 200
|
||||
status, _ = http_get(url)
|
||||
check("Web client returns HTTP 200", status == 200, f"got {status}")
|
||||
|
||||
# Check 2: Page contains Mumble Web UI markers
|
||||
if status == 200:
|
||||
import urllib.request
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
|
||||
has_mumble_meta = "Mumble" in body
|
||||
check("Page contains 'Mumble' content", has_mumble_meta)
|
||||
|
||||
has_config_js = "config.js" in body
|
||||
check("Page loads config.js", has_config_js)
|
||||
|
||||
is_html = body.strip().startswith("<!DOCTYPE html>")
|
||||
check("Response is valid HTML", is_html)
|
||||
else:
|
||||
check("Page contains 'Mumble' content", False, "skipped — no 200")
|
||||
check("Page loads config.js", False, "skipped — no 200")
|
||||
check("Response is valid HTML", False, "skipped — no 200")
|
||||
|
||||
print()
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user