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

@ -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)