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:
2026-06-16 20:18:24 +00:00
commit f283a371bb
253 changed files with 15975 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
name = "mumble"
+35
View File
@@ -0,0 +1,35 @@
# Mumble — First-Time Setup
## Prerequisites
- DNS: `mumble.<DOMAIN_SUFFIX>` should resolve to the server (needed for app identity and optional web client)
- Port 64738 TCP+UDP must be open on the server firewall (mumble protocol, published directly on the host)
## Steps
1. **Create the app:**
```bash
abra app new mumble --server <SERVER> --domain mumble.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate mumble.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/mumble.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy mumble.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Verify:** Connect with a Mumble client to `<SERVER>:64738`, or check that the service is running:
```bash
abra app ps mumble.<DOMAIN_SUFFIX>
```
## Notes
- Mumble uses its own protocol (not HTTP) — no Traefik routing needed for voice.
- The superuser password is stored as a Docker secret (`su_password`).
- To enable the web client, uncomment the `compose.mumbleweb.yml` line in the app's `.env` file.
+26
View File
@@ -0,0 +1,26 @@
# Mumble Tests
## Target
- **Server:** `<SERVER>:64738` (mumble protocol, TCP+UDP)
- **Web client:** `https://mumble.<DOMAIN_SUFFIX>` (requires `compose.mumbleweb.yml` overlay)
## Automated Checks
Run the scripts in `tests/` to perform automated testing:
### Core (always available)
- `health_check.py` — Confirms the mumble server is listening on port 64738 via TCP connection test.
- `mumble_connect.py` — Full protocol integration test: connects via TLS, authenticates, verifies server version, channel list, welcome text, and ServerSync handshake. Zero external dependencies.
### Web client (requires `compose.mumbleweb.yml` overlay)
- `web_client.py` — Verifies the web client is reachable via HTTPS, returns HTTP 200, and serves the Mumble Web UI with expected page content.
## Manual Verification
1. Connect with a Mumble desktop client to `<SERVER>` port 64738.
2. Confirm you can join the server and see the welcome message.
3. Open `https://mumble.<DOMAIN_SUFFIX>` in a browser and confirm the Mumble Web UI loads.
4. Check `abra app ps mumble.<DOMAIN_SUFFIX>` shows healthy replicas for both `app` and `web` services.
+31
View File
@@ -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()
+347
View File
@@ -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()
+74
View File
@@ -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()
+14
View File
@@ -0,0 +1,14 @@
# Mumble Upstream
## Main Project
- **Repository:** https://github.com/mumble-voip/mumble
- **Releases:** https://github.com/mumble-voip/mumble/releases
- **Website:** https://www.mumble.info
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `mumblevoip/mumble-server` | https://github.com/mumble-voip/mumble-docker/releases |
| web (optional) | `rankenstein/mumble-web` | https://github.com/nickvergessen/mumble-web |