feat: Add Uptime Kuma version check via Socket.IO

Retrieve the running Uptime Kuma version by authenticating over
Socket.IO and reading the info event. This adds a new login_socket
dataset field, the fetch_socket_info helper, and the python-socketio
and websocket-client dependencies.
This commit is contained in:
Jeena 2026-03-19 04:17:18 +00:00
parent 501a84e0a9
commit f34e2f2d08
6 changed files with 90 additions and 0 deletions

View file

@ -15,3 +15,7 @@ MATRIX_ACCESS_TOKEN=replace-with-access-token
# Uptime Kuma push URL
UPTIME_KUMA_PUSH_URL=https://uptime.example.net/api/push/your-token
# Uptime Kuma login for version check
UPTIME_KUMA_USERNAME=your-username
UPTIME_KUMA_PASSWORD=your-password

View file

@ -152,3 +152,12 @@ Acceptance criteria:
- The script sends a request to the push URL when it runs.
- The push URL is configured via `.env`.
- Failures to reach the push URL are logged without crashing the run.
## US-17 - Uptime Kuma Service Check
As a maintainer, I want to track the running version of Uptime Kuma so updates are reported like other services.
Acceptance criteria:
- The dataset includes Uptime Kuma with a current version endpoint and extraction rule.
- The upstream latest version uses the official GitHub releases.
- Any required authentication is documented in the dataset.

View file

@ -26,6 +26,8 @@ export MATRIX_HOMESERVER=...
export MATRIX_ROOM_ID=...
export MATRIX_ACCESS_TOKEN=...
export UPTIME_KUMA_PUSH_URL=...
export UPTIME_KUMA_USERNAME=...
export UPTIME_KUMA_PASSWORD=...
```
The script also reads `.env` automatically if present.

View file

@ -12,6 +12,7 @@ from urllib.parse import urlencode, quote
from urllib.request import HTTPCookieProcessor, Request, build_opener, urlopen
import bcrypt
import socketio
import yaml
@ -34,6 +35,7 @@ class ServiceConfig:
upstream_latest_headers: Optional[Dict[str, str]]
notes: Optional[str]
login: Optional[Dict[str, Any]]
login_socket: Optional[Dict[str, Any]]
def load_yaml(path: str) -> Dict[str, Any]:
@ -109,6 +111,10 @@ def load_services(config: Dict[str, Any]) -> Dict[str, ServiceConfig]:
if login is not None and not isinstance(login, dict):
raise ValueError("login must be a mapping")
login_socket = entry.get("login_socket")
if login_socket is not None and not isinstance(login_socket, dict):
raise ValueError("login_socket must be a mapping")
loaded[name] = ServiceConfig(
name=name,
base_url=base_url,
@ -120,6 +126,7 @@ def load_services(config: Dict[str, Any]) -> Dict[str, ServiceConfig]:
upstream_latest_headers=upstream_headers,
notes=entry.get("notes"),
login=login,
login_socket=login_socket,
)
return loaded
@ -414,6 +421,53 @@ def authenticate_service(service: ServiceConfig, timeout: float, user_agent: str
return "; ".join(cookies)
def fetch_socket_info(service: ServiceConfig, timeout: float) -> Optional[str]:
if not service.login_socket:
return None
base_url = service.login_socket.get("url")
username_env = service.login_socket.get("username_env")
password_env = service.login_socket.get("password_env")
if not base_url or not username_env or not password_env:
raise ValueError(f"Login socket config incomplete for {service.name}")
username = os.getenv(username_env)
password = os.getenv(password_env)
if not username or not password:
raise ValueError(f"Missing credentials for {service.name}")
info_payload: Dict[str, Any] = {}
sio = socketio.Client(reconnection=False, request_timeout=timeout)
@sio.on("info")
def on_info(data):
nonlocal info_payload
info_payload = data or {}
sio.connect(base_url, transports=["websocket"], wait_timeout=timeout)
response = sio.call(
"login",
{
"username": username,
"password": password,
"token": "",
},
timeout=timeout,
)
if not response or not response.get("ok"):
sio.disconnect()
raise ValueError(f"Socket login failed for {service.name}")
if not info_payload.get("version"):
try:
info_payload = sio.call("info", timeout=timeout) or info_payload
except Exception:
pass
sio.disconnect()
return info_payload.get("version")
def check_service(service: ServiceConfig, timeout: float, user_agent: str) -> Dict[str, Any]:
result: Dict[str, Any] = {
"name": service.name,
@ -444,6 +498,11 @@ def check_service(service: ServiceConfig, timeout: float, user_agent: str) -> Di
)
except Exception as exc:
result["current_error"] = str(exc)
elif service.login_socket:
try:
result["current"] = fetch_socket_info(service, timeout)
except Exception as exc:
result["current_error"] = str(exc)
if service.upstream_latest_version_url and service.upstream_latest_extract:
headers = build_headers(service.upstream_latest_headers)

View file

@ -10,6 +10,9 @@ requires-python = ">=3.10"
dependencies = [
"PyYAML>=6.0",
"bcrypt>=4.0",
"python-socketio>=5.11",
"requests>=2.31",
"websocket-client>=1.7",
]
[project.optional-dependencies]

View file

@ -83,6 +83,19 @@ services:
upstream_latest_extract:
type: jsonpath
value: $.tag_name
- name: Uptime Kuma
base_url: https://uptime.jeena.net/
current_version_url:
current_version_extract:
login_socket:
url: https://uptime.jeena.net/
username_env: UPTIME_KUMA_USERNAME
password_env: UPTIME_KUMA_PASSWORD
upstream_latest_version_url: https://api.github.com/repos/louislam/uptime-kuma/releases/latest
upstream_latest_extract:
type: jsonpath
value: $.tag_name
notes: Version retrieved via authenticated Socket.IO info event.
- name: FreshRSS
base_url: https://rss.jeena.net/
current_version_url: https://rss.jeena.net/i/?a=about