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).
348 lines
11 KiB
Python
348 lines
11 KiB
Python
#!/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()
|