From 433b4162a7e78de0e49c28ee01d3794792175562 Mon Sep 17 00:00:00 2001 From: Adiker Date: Sun, 13 Sep 2026 14:05:50 +0200 Subject: [PATCH 1/3] feat(muse): add Discord bot readiness status --- .github/workflows/pages.yml | 2 + README.md | 85 ++++++- img/muse-logo.png | Bin 0 -> 26341 bytes index.html | 41 +++- js/app.js | 19 +- ops/muse-status/Caddyfile | 40 ++++ ops/muse-status/muse-status.service | 39 ++++ ops/muse-status/muse_status.py | 343 ++++++++++++++++++++++++++++ ops/muse-status/test_muse_status.py | 183 +++++++++++++++ 9 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 img/muse-logo.png create mode 100644 ops/muse-status/Caddyfile create mode 100644 ops/muse-status/muse-status.service create mode 100644 ops/muse-status/muse_status.py create mode 100644 ops/muse-status/test_muse_status.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c51c997..ca68851 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -36,6 +36,7 @@ jobs: test -f css/styles.css test -f js/app.js test -f img/505675340-c40b22c9-33da-47b7-bc4c-ce69bb5cc174.png + test -f img/muse-logo.png test -f CNAME grep -qx 'adiker.eu' CNAME @@ -62,6 +63,7 @@ jobs: curl --fail --silent --output /dev/null http://127.0.0.1:18080/css/styles.css curl --fail --silent --output /dev/null http://127.0.0.1:18080/js/app.js curl --fail --silent --output /dev/null http://127.0.0.1:18080/img/505675340-c40b22c9-33da-47b7-bc4c-ce69bb5cc174.png + curl --fail --silent --output /dev/null http://127.0.0.1:18080/img/muse-logo.png deploy: name: Deploy to GitHub Pages diff --git a/README.md b/README.md index 64c9dd7..f77b4ab 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This repository contains a static single-page application used as a front page f - **FileBrowser Quantum** - **autobrr** - **OpenSpeedTest** +- **Muse** (Discord music bot) The page is intentionally framework-free: plain HTML, CSS, and vanilla JavaScript. That keeps it fast, portable, and easy to host on GitHub Pages or any static @@ -49,6 +50,7 @@ server. - Live service dashboard with online count, last check time, and latency labels - Browser-local recent status history with mini timelines and average latency - Health checks for all configured services +- Local Muse readiness probe with Discord gateway connectivity check - Polling with warm-up measurement and failure backoff - EN/PL language switcher - Dark / Light / OLED themes @@ -66,12 +68,15 @@ server. ├── css/ │ └── styles.css # Styling, themes, responsive rules ├── img/ -│ └── 505675340-c40b22c9-33da-47b7-bc4c-ce69bb5cc174.png -│ # Local FileBrowser Quantum logo asset +│ ├── 505675340-c40b22c9-33da-47b7-bc4c-ce69bb5cc174.png +│ │ # Local FileBrowser Quantum logo asset +│ └── muse-logo.png # Local Muse logo asset ├── index.html # Main page markup ├── index_old.html # Legacy page backup -└── js/ - └── app.js # UI logic, i18n, status polling +├── js/ +│ └── app.js # UI logic, i18n, status polling +└── ops/ + └── muse-status/ # Host probe, systemd/Caddy templates, and tests ``` ### Run Locally @@ -92,6 +97,36 @@ Service endpoints are defined in `js/app.js`: - `https://files.adiker.eu/health` - `https://autobrr.adiker.eu/api/healthz/liveness` - `https://speedtest.adiker.eu/health` +- `https://muse.adiker.eu/health` + +The Muse endpoint returns `200 {"status":"online"}` only when the local +container is running, has logged `Ready!` after its current start, and has an +established TCP connection to `gateway.discord.gg`. Otherwise it returns +`503 {"status":"offline"}`. It intentionally publishes no Discord, channel, +track, container, or failure details. + +The local logo is copied from the [official Muse repository](https://github.com/museofficial/muse/blob/master/.github/logo.png). + +The local bridge is installed from `ops/muse-status/`. It listens only on a +Unix socket and requires the `adiker` account to be a member of the Docker +group: + +```bash +sudo install -Dm755 ops/muse-status/muse_status.py /usr/local/lib/muse-status/muse_status.py +sudo install -Dm644 ops/muse-status/muse-status.service /etc/systemd/system/muse-status.service +# Back up an existing Caddy snippet before replacing it. +sudo install -Dm644 ops/muse-status/Caddyfile /etc/caddy/conf.d/muse-status.caddy +sudo systemctl daemon-reload +sudo systemctl enable --now muse-status.service +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +Run the probe tests with: + +```bash +python3 -m unittest discover -s ops/muse-status -p 'test_*.py' +``` User preferences are stored in `localStorage`: @@ -167,6 +202,7 @@ główna dla: - **FileBrowser Quantum** - **autobrr** - **OpenSpeedTest** +- **Muse** (bot muzyczny Discorda) Strona celowo nie używa frameworków: to czysty HTML, CSS i vanilla JavaScript. Dzięki temu pozostaje szybka, przenośna i łatwa w hostowaniu na GitHub Pages @@ -178,6 +214,7 @@ albo dowolnym serwerze statycznym. - Dashboard statusów z liczbą usług online, czasem ostatniego sprawdzenia i opóźnieniami - Lokalna historia ostatnich statusów z mini-timeline i średnim opóźnieniem - Health checki dla wszystkich skonfigurowanych usług +- Lokalna sonda gotowości Muse ze sprawdzeniem połączenia z gateway Discorda - Odpytywanie z pomiarem rozgrzewkowym i backoffem przy błędach - Przełącznik języka EN/PL - Motywy Dark / Light / OLED @@ -195,12 +232,15 @@ albo dowolnym serwerze statycznym. ├── css/ │ └── styles.css # Style, motywy, reguły responsywne ├── img/ -│ └── 505675340-c40b22c9-33da-47b7-bc4c-ce69bb5cc174.png -│ # Lokalny zasób logo FileBrowser Quantum +│ ├── 505675340-c40b22c9-33da-47b7-bc4c-ce69bb5cc174.png +│ │ # Lokalny zasób logo FileBrowser Quantum +│ └── muse-logo.png # Lokalny zasób logo Muse ├── index.html # Główny markup strony ├── index_old.html # Kopia starszej wersji strony -└── js/ - └── app.js # Logika UI, i18n, odpytywanie statusów +├── js/ +│ └── app.js # Logika UI, i18n, odpytywanie statusów +└── ops/ + └── muse-status/ # Sonda hosta, szablony systemd/Caddy i testy ``` ### Uruchom lokalnie @@ -221,6 +261,35 @@ Endpointy usług są zdefiniowane w `js/app.js`: - `https://files.adiker.eu/health` - `https://autobrr.adiker.eu/api/healthz/liveness` - `https://speedtest.adiker.eu/health` +- `https://muse.adiker.eu/health` + +Endpoint Muse zwraca `200 {"status":"online"}` tylko wtedy, gdy lokalny +kontener działa, po bieżącym uruchomieniu zalogował `Ready!` i ma aktywne +połączenie TCP z `gateway.discord.gg`. W przeciwnym razie zwraca +`503 {"status":"offline"}`. Publiczna odpowiedź nie ujawnia serwera Discord, +kanału, utworu, kontenera ani przyczyny awarii. + +Lokalne logo pochodzi z [oficjalnego repozytorium Muse](https://github.com/museofficial/muse/blob/master/.github/logo.png). + +Lokalny most instaluje się z `ops/muse-status/`. Nasłuchuje wyłącznie na +sockecie Unix, a konto `adiker` musi należeć do grupy `docker`: + +```bash +sudo install -Dm755 ops/muse-status/muse_status.py /usr/local/lib/muse-status/muse_status.py +sudo install -Dm644 ops/muse-status/muse-status.service /etc/systemd/system/muse-status.service +# Przed zastąpieniem istniejącego snippetu Caddy wykonaj jego kopię zapasową. +sudo install -Dm644 ops/muse-status/Caddyfile /etc/caddy/conf.d/muse-status.caddy +sudo systemctl daemon-reload +sudo systemctl enable --now muse-status.service +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +Testy sondy uruchomisz poleceniem: + +```bash +python3 -m unittest discover -s ops/muse-status -p 'test_*.py' +``` Preferencje użytkownika zapisywane są w `localStorage`: diff --git a/img/muse-logo.png b/img/muse-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..dc0641529d1c2da8834c688adb057544ebe96a61 GIT binary patch literal 26341 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 Tb&szRcyRcj_c!8yGBf`NOBrU3 literal 0 HcmV?d00001 diff --git a/index.html b/index.html index 86d5ef7..531f991 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,10 @@ adiker.eu — Homepage - + - + adiker.eu type="button">Autobrr +
@@ -60,12 +62,13 @@

adiker.eu

📊 Service dashboard

Live status overview

- 0/4 online + 0/5 online Last check: -- Jellyfin: -- ms FileBrowser: -- ms autobrr: -- ms OpenSpeedTest: -- ms + Muse: -- ms

Recent checks

@@ -106,6 +109,15 @@

Recent checks

Avg: -- ms
+
+
+ Muse + No data yet +
+ + Avg: -- ms +
@@ -226,6 +238,25 @@

🚀 OpenSpeedTest

+
© adiker.eu • @@ -240,8 +271,8 @@

🚀 OpenSpeedTest

- - + + diff --git a/js/app.js b/js/app.js index 4a54d3d..6c66e4a 100644 --- a/js/app.js +++ b/js/app.js @@ -16,14 +16,16 @@ const SERVICE_STATE = { jf: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, fb: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, ab: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - st: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } + st: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + mu: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } }; const SERVICES = [ { key: 'jf', url: 'https://jellyfin.adiker.eu/health' }, { key: 'fb', url: 'https://files.adiker.eu/health' }, { key: 'ab', url: 'https://autobrr.adiker.eu/api/healthz/liveness' }, - { key: 'st', url: 'https://speedtest.adiker.eu/health' } + { key: 'st', url: 'https://speedtest.adiker.eu/health' }, + { key: 'mu', url: 'https://muse.adiker.eu/health' } ]; const STR = { @@ -33,6 +35,7 @@ const STR = { filebrowser: 'Filebrowser', autobrr: 'Autobrr', openspeedtest: 'OpenSpeedTest', + muse: 'Muse', more: 'More services (soon)' }, subtitle: 'My Playground', @@ -63,6 +66,7 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Your file manager', open: 'Open FileBrowser Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automated torrent management', open: 'Open autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Network speed test', open: 'Open OpenSpeedTest', short: 'OpenSpeedTest' }, + mu: { title: 'Muse', sub: 'Discord music bot', short: 'Muse' }, status: { online: 'Online', offline: 'Offline' }, pc: { on: 'My PC is on :)', @@ -109,6 +113,7 @@ const STR = { filebrowser: 'Filebrowser', autobrr: 'Autobrr', openspeedtest: 'OpenSpeedTest', + muse: 'Muse', more: 'Więcej usług (wkrótce)' }, subtitle: 'Mój plac zabaw', @@ -139,6 +144,7 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Twój menedżer plików', open: 'Otwórz FileBrowsera Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automatyzacja torrentów', open: 'Otwórz autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Test prędkości sieci', open: 'Otwórz OpenSpeedTest', short: 'OpenSpeedTest' }, + mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', short: 'Muse' }, status: { online: 'Online', offline: 'Offline' }, pc: { on: 'Mój PC jest włączony :)', @@ -480,6 +486,7 @@ function renderDashboard(lang = getLang()) { setText('dash-fb-latency', `${L.fb.short}: ${SERVICE_STATE.fb.latency ?? '--'} ms`); setText('dash-ab-latency', `${L.ab.short}: ${SERVICE_STATE.ab.latency ?? '--'} ms`); setText('dash-st-latency', `${L.st.short}: ${SERVICE_STATE.st.latency ?? '--'} ms`); + setText('dash-mu-latency', `${L.mu.short}: ${SERVICE_STATE.mu.latency ?? '--'} ms`); setText('history-title', L.dashboard.historyTitle); SERVICES.forEach((service) => renderHistoryTimeline(service.key, lang, L)); @@ -501,6 +508,8 @@ function applyLang(lang) { setText('st-title', '🚀 ' + L.st.title); setText('st-sub', L.st.sub); setText('st-btn-text', L.st.open); + setText('mu-title', '🎧 ' + L.mu.title); + setText('mu-sub', L.mu.sub); setText('subtitle', L.subtitle); setText('availability', L.availability); @@ -519,6 +528,7 @@ function applyLang(lang) { setText('tab-filebrowser', L.nav.filebrowser); setText('tab-autobrr', L.nav.autobrr); setText('tab-speedtest', L.nav.openspeedtest); + setText('tab-muse', L.nav.muse); setText('tab-more', L.nav.more); const tabsNav = document.querySelector('nav.tabs'); @@ -650,12 +660,13 @@ async function refreshService(service) { } function initTabs() { - const tabIds = ['tab-jellyfin', 'tab-filebrowser', 'tab-autobrr', 'tab-speedtest']; + const tabIds = ['tab-jellyfin', 'tab-filebrowser', 'tab-autobrr', 'tab-speedtest', 'tab-muse']; const map = { 'tab-jellyfin': 'section-jf', 'tab-filebrowser': 'section-fb', 'tab-autobrr': 'section-ab', - 'tab-speedtest': 'section-st' + 'tab-speedtest': 'section-st', + 'tab-muse': 'section-muse' }; const tabs = tabIds.map((id) => document.getElementById(id)).filter(Boolean); diff --git a/ops/muse-status/Caddyfile b/ops/muse-status/Caddyfile new file mode 100644 index 0000000..ba1397b --- /dev/null +++ b/ops/muse-status/Caddyfile @@ -0,0 +1,40 @@ +muse.adiker.eu { + @health-options { + path /health + method OPTIONS + } + + handle @health-options { + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, HEAD, OPTIONS" + Access-Control-Allow-Headers "Content-Type" + Cache-Control "no-store" + } + respond "" 204 + } + + @health path /health + handle @health { + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, HEAD, OPTIONS" + Access-Control-Allow-Headers "Content-Type" + Cache-Control "no-store" + } + reverse_proxy unix//run/muse-status/status.sock + } + + respond 404 + + handle_errors { + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, HEAD, OPTIONS" + Access-Control-Allow-Headers "Content-Type" + Cache-Control "no-store" + Content-Type "application/json; charset=utf-8" + } + respond "{\"status\":\"offline\"}" 503 + } +} diff --git a/ops/muse-status/muse-status.service b/ops/muse-status/muse-status.service new file mode 100644 index 0000000..b081b63 --- /dev/null +++ b/ops/muse-status/muse-status.service @@ -0,0 +1,39 @@ +[Unit] +Description=Muse Discord readiness status probe +After=docker.service network-online.target +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=adiker +Group=caddy +SupplementaryGroups=docker +Environment=MUSE_COMPOSE_PROJECT=docker-compose +Environment=MUSE_COMPOSE_SERVICE=muse +Environment=MUSE_STATUS_SOCKET=/run/muse-status/status.sock +Environment=MUSE_POLL_INTERVAL=5 +Environment=MUSE_STATUS_MAX_AGE=15 +ExecStart=/usr/bin/python3 /usr/local/lib/muse-status/muse_status.py +Restart=on-failure +RestartSec=5s +RuntimeDirectory=muse-status +RuntimeDirectoryMode=0750 +UMask=0007 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectKernelLogs=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +CapabilityBoundingSet= +ReadWritePaths=/run/muse-status + +[Install] +WantedBy=multi-user.target diff --git a/ops/muse-status/muse_status.py b/ops/muse-status/muse_status.py new file mode 100644 index 0000000..97598ee --- /dev/null +++ b/ops/muse-status/muse_status.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Expose a small, read-only readiness check for the local Muse container.""" + +from __future__ import annotations + +import http.server +import ipaddress +import json +import logging +import os +from pathlib import Path +import socket +import socketserver +import stat +import subprocess +import threading +import time +from dataclasses import dataclass +from datetime import datetime +from urllib.parse import urlsplit + + +READY_MARKER = b"Ready!" +ESTABLISHED = "01" +GATEWAY_PORT = 443 +DEFAULT_SOCKET = "/run/muse-status/status.sock" +DEFAULT_PROJECT = "docker-compose" +DEFAULT_SERVICE = "muse" +DEFAULT_GATEWAY = "gateway.discord.gg" +DEFAULT_INTERVAL = 5.0 +DEFAULT_MAX_AGE = 15.0 +COMMAND_TIMEOUT = 3.0 + + +class ProbeError(RuntimeError): + """A probe dependency could not be checked safely.""" + + +def _as_bytes(value: bytes | str | None) -> bytes: + if value is None: + return b"" + return value if isinstance(value, bytes) else value.encode() + + +def _run_command(argv: list[str], timeout: float = COMMAND_TIMEOUT) -> subprocess.CompletedProcess[bytes]: + return subprocess.run(argv, check=False, capture_output=True, timeout=timeout) + + +def _parse_started_at(value: str) -> float: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError as exc: + raise ProbeError("invalid container start timestamp") from exc + + +class DockerClient: + def __init__(self, runner=_run_command, timeout: float = COMMAND_TIMEOUT): + self._runner = runner + self._timeout = timeout + + def _call(self, *args: str) -> bytes: + try: + result = self._runner(["docker", *args], self._timeout) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ProbeError("docker command failed") from exc + + if result.returncode != 0: + raise ProbeError("docker command returned an error") + return _as_bytes(result.stdout) + + def running_container(self, project: str, service: str) -> str: + output = self._call( + "ps", + "--filter", + f"label=com.docker.compose.project={project}", + "--filter", + f"label=com.docker.compose.service={service}", + "--format", + "{{.ID}}", + ) + container_ids = [line.strip() for line in output.decode(errors="replace").splitlines() if line.strip()] + if len(container_ids) != 1: + raise ProbeError("expected exactly one running Muse container") + return container_ids[0] + + def state(self, container_id: str) -> dict: + output = self._call("inspect", "--format", "{{json .State}}", container_id) + try: + state = json.loads(output.decode()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProbeError("invalid Docker state") from exc + if not isinstance(state, dict): + raise ProbeError("invalid Docker state shape") + return state + + def logs_since(self, container_id: str, started_at: str) -> bytes: + try: + result = self._runner( + ["docker", "logs", "--since", started_at, "--tail", "500", container_id], + self._timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ProbeError("docker logs failed") from exc + if result.returncode != 0: + raise ProbeError("docker logs returned an error") + return _as_bytes(result.stdout) + _as_bytes(result.stderr) + + +def resolve_gateway(hostname: str) -> set[str]: + try: + records = socket.getaddrinfo(hostname, GATEWAY_PORT, type=socket.SOCK_STREAM) + except OSError as exc: + raise ProbeError("gateway DNS lookup failed") from exc + + addresses = {str(ipaddress.ip_address(record[4][0].split("%", 1)[0])) for record in records} + if not addresses: + raise ProbeError("gateway DNS lookup returned no addresses") + return addresses + + +def _decode_proc_address(value: str, ipv6: bool) -> str: + raw = bytes.fromhex(value) + if ipv6: + if len(raw) != 16: + raise ValueError("invalid IPv6 address") + raw = b"".join(raw[index:index + 4][::-1] for index in range(0, 16, 4)) + return str(ipaddress.IPv6Address(raw)) + if len(raw) != 4: + raise ValueError("invalid IPv4 address") + return str(ipaddress.IPv4Address(raw[::-1])) + + +def _has_gateway_socket(table: str, gateway_addresses: set[str], ipv6: bool) -> bool: + for line in table.splitlines()[1:]: + fields = line.split() + if len(fields) < 4 or fields[3] != ESTABLISHED: + continue + try: + remote_address, remote_port = fields[2].rsplit(":", 1) + if int(remote_port, 16) != GATEWAY_PORT: + continue + address = _decode_proc_address(remote_address, ipv6) + except (ValueError, IndexError): + continue + if address in gateway_addresses: + return True + return False + + +def has_gateway_connection(pid: int, gateway_addresses: set[str], reader=Path.read_text) -> bool: + if pid <= 0: + raise ProbeError("invalid Muse process id") + + tables = [] + for suffix in ("tcp", "tcp6"): + path = Path(f"/proc/{pid}/net/{suffix}") + try: + tables.append((reader(path), suffix == "tcp6")) + except OSError as exc: + raise ProbeError("cannot read Muse network namespace") from exc + + return any(_has_gateway_socket(table, gateway_addresses, ipv6) for table, ipv6 in tables) + + +@dataclass(frozen=True) +class ProbeConfig: + project: str = DEFAULT_PROJECT + service: str = DEFAULT_SERVICE + gateway: str = DEFAULT_GATEWAY + + +class MuseProbe: + def __init__(self, docker: DockerClient, config: ProbeConfig, resolver=resolve_gateway): + self._docker = docker + self._config = config + self._resolver = resolver + self._ready_key: tuple[str, str] | None = None + + def _gateway_ips(self) -> set[str]: + return set(self._resolver(self._config.gateway)) + + def _is_ready(self, container_id: str, started_at: str) -> bool: + key = (container_id, started_at) + if self._ready_key == key: + return True + logs = self._docker.logs_since(container_id, started_at) + if READY_MARKER not in logs: + return False + self._ready_key = key + return True + + def check(self, now: float | None = None) -> bool: + container_id = self._docker.running_container(self._config.project, self._config.service) + state = self._docker.state(container_id) + if not state.get("Running"): + self._ready_key = None + return False + + started_at = state.get("StartedAt") + pid = state.get("Pid") + if not isinstance(started_at, str) or not isinstance(pid, int): + raise ProbeError("incomplete Docker state") + _parse_started_at(started_at) + + if not self._is_ready(container_id, started_at): + return False + gateway_ips = self._gateway_ips() + return has_gateway_connection(pid, gateway_ips) + + +class StatusStore: + def __init__(self, max_age: float = DEFAULT_MAX_AGE): + self._max_age = max_age + self._online = False + self._checked_at = 0.0 + self._lock = threading.Lock() + + def update(self, online: bool, now: float | None = None) -> None: + with self._lock: + self._online = bool(online) + self._checked_at = time.monotonic() if now is None else now + + def online(self, now: float | None = None) -> bool: + current = time.monotonic() if now is None else now + with self._lock: + return self._online and current - self._checked_at <= self._max_age + + +class StatusHandler(http.server.BaseHTTPRequestHandler): + server_version = "muse-status" + sys_version = "" + + def _respond(self, status: int, payload: bytes = b"", head: bool = False) -> None: + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if not head and payload: + self.wfile.write(payload) + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if urlsplit(self.path).path != "/health": + self._respond(404, b'{"status":"not_found"}\n') + return + online = self.server.status_store.online() + payload = (b'{"status":"online"}\n' if online else b'{"status":"offline"}\n') + self._respond(200 if online else 503, payload) + + def do_HEAD(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if urlsplit(self.path).path != "/health": + self._respond(404, b"", head=True) + return + self._respond(200 if self.server.status_store.online() else 503, b"", head=True) + + def do_OPTIONS(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if urlsplit(self.path).path == "/health": + self._respond(204) + return + self._respond(404, b'{"status":"not_found"}\n') + + def log_message(self, _format: str, *_args) -> None: + return + + +class UnixHTTPServer(socketserver.UnixStreamServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self, socket_path: str, status_store: StatusStore): + self.status_store = status_store + super().__init__(socket_path, StatusHandler) + + +def _prepare_socket(socket_path: str) -> None: + path = Path(socket_path) + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + if not stat.S_ISSOCK(path.stat().st_mode): + raise ProbeError("status socket path is not a socket") + path.unlink() + + +def _env_float(name: str, default: float) -> float: + try: + value = float(os.environ.get(name, default)) + except ValueError: + return default + return value if value > 0 else default + + +def _run_probe_loop(probe: MuseProbe, store: StatusStore, interval: float, stop: threading.Event) -> None: + while not stop.is_set(): + try: + online = probe.check() + except Exception as exc: # fail closed; do not expose probe details publicly + logging.warning("Muse probe unavailable: %s", type(exc).__name__) + online = False + store.update(online) + stop.wait(interval) + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + socket_path = os.environ.get("MUSE_STATUS_SOCKET", DEFAULT_SOCKET) + config = ProbeConfig( + project=os.environ.get("MUSE_COMPOSE_PROJECT", DEFAULT_PROJECT), + service=os.environ.get("MUSE_COMPOSE_SERVICE", DEFAULT_SERVICE), + gateway=os.environ.get("MUSE_GATEWAY_HOST", DEFAULT_GATEWAY), + ) + interval = _env_float("MUSE_POLL_INTERVAL", DEFAULT_INTERVAL) + max_age = _env_float("MUSE_STATUS_MAX_AGE", DEFAULT_MAX_AGE) + store = StatusStore(max_age=max_age) + probe = MuseProbe(DockerClient(), config) + _prepare_socket(socket_path) + + try: + try: + store.update(probe.check()) + except Exception as exc: + logging.warning("Muse probe unavailable: %s", type(exc).__name__) + store.update(False) + + stop = threading.Event() + thread = threading.Thread( + target=_run_probe_loop, + args=(probe, store, interval, stop), + name="muse-probe", + daemon=True, + ) + thread.start() + with UnixHTTPServer(socket_path, store) as server: + server.serve_forever(poll_interval=0.5) + except KeyboardInterrupt: + return + finally: + try: + Path(socket_path).unlink(missing_ok=True) + except OSError: + pass + + +if __name__ == "__main__": + main() diff --git a/ops/muse-status/test_muse_status.py b/ops/muse-status/test_muse_status.py new file mode 100644 index 0000000..cf1c9d4 --- /dev/null +++ b/ops/muse-status/test_muse_status.py @@ -0,0 +1,183 @@ +import ipaddress +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + + +sys.path.insert(0, str(Path(__file__).parent)) +import muse_status + + +STARTED_AT = "2026-09-13T10:52:12.960900197Z" +GATEWAY_IP = "162.159.130.234" + + +def proc_ipv4(value: str) -> str: + return bytes(reversed(ipaddress.IPv4Address(value).packed)).hex().upper() + + +def tcp_table(remote_ip: str = GATEWAY_IP, remote_port: int = 443, state: str = "01") -> str: + remote = f"{proc_ipv4(remote_ip)}:{remote_port:04X}" + return ( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" + f" 0: 050013AC:B2BA {remote} {state} 00000000:00000000 00:00000000 00000000 0 0 29451 1\n" + ) + + +class FakeDocker: + def __init__(self, running=True, ready=True): + self.running = running + self.ready = ready + self.started_at = STARTED_AT + self.log_calls = 0 + + def running_container(self, _project, _service): + if not self.running: + raise muse_status.ProbeError("not running") + return "container-id" + + def state(self, _container_id): + return {"Running": self.running, "Pid": 123, "StartedAt": self.started_at} + + def logs_since(self, _container_id, _started_at): + self.log_calls += 1 + return b"Ready!" if self.ready else b"connecting to Discord" + + +class MuseStatusTests(unittest.TestCase): + def test_gateway_socket_matching_discord_address(self): + tables = {"tcp": tcp_table(), "tcp6": tcp_table(remote_ip="192.0.2.10")} + result = muse_status.has_gateway_connection( + 123, + {GATEWAY_IP}, + reader=lambda path: tables[path.name], + ) + self.assertTrue(result) + + def test_gateway_socket_missing(self): + tables = {"tcp": tcp_table(remote_ip="192.0.2.10"), "tcp6": tcp_table(remote_ip="192.0.2.11")} + result = muse_status.has_gateway_connection( + 123, + {GATEWAY_IP}, + reader=lambda path: tables[path.name], + ) + self.assertFalse(result) + + def test_probe_requires_running_ready_container_and_gateway(self): + docker = FakeDocker() + probe = muse_status.MuseProbe( + docker, + muse_status.ProbeConfig(), + resolver=lambda _host: {GATEWAY_IP}, + ) + with patch.object(muse_status, "has_gateway_connection", return_value=True): + self.assertTrue(probe.check(now=100)) + self.assertEqual(docker.log_calls, 1) + + docker.ready = False + probe = muse_status.MuseProbe( + docker, + muse_status.ProbeConfig(), + resolver=lambda _host: {GATEWAY_IP}, + ) + with patch.object(muse_status, "has_gateway_connection", return_value=True): + self.assertFalse(probe.check(now=100)) + + docker.ready = True + docker.started_at = "2026-09-13T11:00:00.000000000Z" + with patch.object(muse_status, "has_gateway_connection", return_value=False): + self.assertFalse(probe.check(now=101)) + self.assertEqual(docker.log_calls, 3) + + def test_probe_fails_closed_for_dns_and_docker_errors(self): + probe = muse_status.MuseProbe( + FakeDocker(), + muse_status.ProbeConfig(), + resolver=lambda _host: (_ for _ in ()).throw(muse_status.ProbeError("dns")), + ) + with patch.object(muse_status, "has_gateway_connection", return_value=True): + with self.assertRaises(muse_status.ProbeError): + probe.check(now=100) + + def failed_runner(_argv, _timeout): + return subprocess.CompletedProcess([], 1, b"", b"error") + + with self.assertRaises(muse_status.ProbeError): + muse_status.DockerClient(runner=failed_runner).running_container("docker-compose", "muse") + + def test_docker_logs_include_stderr(self): + def runner(_argv, _timeout): + return subprocess.CompletedProcess([], 0, b"stdout", b" Ready!") + + logs = muse_status.DockerClient(runner=runner).logs_since("id", STARTED_AT) + self.assertEqual(logs, b"stdout Ready!") + + def test_status_store_expires(self): + store = muse_status.StatusStore(max_age=15) + store.update(True, now=100) + self.assertTrue(store.online(now=115)) + self.assertFalse(store.online(now=116)) + + def test_http_contract(self): + with tempfile.TemporaryDirectory() as directory: + socket_path = str(Path(directory) / "status.sock") + store = muse_status.StatusStore(max_age=15) + store.update(True) + server = muse_status.UnixHTTPServer(socket_path, store) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + status, headers, body = self.request(socket_path, b"GET /health?x=1 HTTP/1.1\r\nHost: local\r\n\r\n") + self.assertEqual(status, 200) + self.assertEqual(json.loads(body), {"status": "online"}) + self.assertEqual(headers[b"content-type"], b"application/json; charset=utf-8") + + store.update(False) + status, _headers, body = self.request(socket_path, b"GET /health HTTP/1.1\r\nHost: local\r\n\r\n") + self.assertEqual(status, 503) + self.assertEqual(json.loads(body), {"status": "offline"}) + + status, _headers, body = self.request(socket_path, b"HEAD /health HTTP/1.1\r\nHost: local\r\n\r\n") + self.assertEqual(status, 503) + self.assertEqual(body, b"") + + status, _headers, _body = self.request(socket_path, b"OPTIONS /health HTTP/1.1\r\nHost: local\r\n\r\n") + self.assertEqual(status, 204) + + status, _headers, body = self.request(socket_path, b"GET / HTTP/1.1\r\nHost: local\r\n\r\n") + self.assertEqual(status, 404) + self.assertEqual(json.loads(body), {"status": "not_found"}) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + @staticmethod + def request(socket_path: str, request: bytes): + connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + connection.connect(socket_path) + connection.sendall(request) + chunks = [] + while True: + chunk = connection.recv(4096) + if not chunk: + break + chunks.append(chunk) + connection.close() + response = b"".join(chunks) + head, body = response.split(b"\r\n\r\n", 1) + lines = head.splitlines() + status = int(lines[0].split()[1]) + headers = {line.split(b": ", 1)[0].lower(): line.split(b": ", 1)[1] for line in lines[1:]} + return status, headers, body + + +if __name__ == "__main__": + unittest.main() From c704d1c2200ae28943ecb5cee9f6e9d48f8e1dcd Mon Sep 17 00:00:00 2001 From: Adiker Date: Sun, 13 Sep 2026 14:07:32 +0200 Subject: [PATCH 2/3] test(muse): cover stopped container state --- ops/muse-status/test_muse_status.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ops/muse-status/test_muse_status.py b/ops/muse-status/test_muse_status.py index cf1c9d4..dfc6c7a 100644 --- a/ops/muse-status/test_muse_status.py +++ b/ops/muse-status/test_muse_status.py @@ -51,6 +51,16 @@ def logs_since(self, _container_id, _started_at): class MuseStatusTests(unittest.TestCase): + def test_probe_reports_stopped_container_offline(self): + docker = FakeDocker() + probe = muse_status.MuseProbe( + docker, + muse_status.ProbeConfig(), + resolver=lambda _host: {GATEWAY_IP}, + ) + with patch.object(docker, "state", return_value={"Running": False, "Pid": 0, "StartedAt": STARTED_AT}): + self.assertFalse(probe.check(now=100)) + def test_gateway_socket_matching_discord_address(self): tables = {"tcp": tcp_table(), "tcp6": tcp_table(remote_ip="192.0.2.10")} result = muse_status.has_gateway_connection( From 2f666f8c901d286a56c81d072dd674d04dcd79ca Mon Sep 17 00:00:00 2001 From: Adiker Date: Sun, 13 Sep 2026 14:08:52 +0200 Subject: [PATCH 3/3] ci(muse): run probe tests in Pages validation --- .github/workflows/pages.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ca68851..5716769 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -41,6 +41,7 @@ jobs: grep -qx 'adiker.eu' CNAME node --check js/app.js + python3 -m unittest discover -s ops/muse-status -p 'test_*.py' python3 -m http.server 18080 --bind 127.0.0.1 > /tmp/adiker-http.log 2>&1 & server_pid="$!"