diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c51c997..5716769 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -36,10 +36,12 @@ 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 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="$!" @@ -62,6 +64,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 0000000..dc06415 Binary files /dev/null and b/img/muse-logo.png differ 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

+
- - + + 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..dfc6c7a --- /dev/null +++ b/ops/muse-status/test_muse_status.py @@ -0,0 +1,193 @@ +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_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( + 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()