"""Synology DSM authentication module. Handles DSM login/logout, session ID (SID) management, and credential resolution. Supports optional SynoToken for CSRF protection on DSM 6+. Environment variable support (priority order per variable group): DSM_* variables (primary): DSM_URL, DSM_USERNAME, DSM_PASSWORD, DSM_PASSWORD_FILE, DSM_ALLOW_FROM, DSM_RATE_LIMIT SYNOLOGY_* variables (compatibility layer): SYNOLOGY_NAS_HOST (→ dsm_url), SYNOLOGY_CHAT_TOKEN, SYNOLOGY_CHAT_INCOMING_URL Generic: OPENCLAW_BOT_NAME """ from __future__ import annotations import asyncio import os import stat from pathlib import Path from typing import Any import httpx from yuxi.channels.adapters.synologychat.probe import resolve_api_version from yuxi.channels.exceptions import ChannelAuthenticationError from yuxi.utils.logging_config import logger _ENV_MAP = { "DSM_URL": "dsm_url", "DSM_USERNAME": "username", "DSM_PASSWORD": "password", "DSM_PASSWORD_FILE": "password_file", "DSM_ALLOW_FROM": "allow_from", "DSM_RATE_LIMIT": "rate_limit", } _SYNOLOGY_CHAT_ENV_MAP = { "SYNOLOGY_CHAT_TOKEN": "webhook_token", "SYNOLOGY_CHAT_INCOMING_URL": "incoming_webhook_url", } _OPENCLAW_BOT_NAME_ENV = "OPENCLAW_BOT_NAME" def apply_env_defaults(config: dict[str, Any]) -> dict[str, Any]: for env_key, config_key in _ENV_MAP.items(): env_val = os.environ.get(env_key) if env_val and config_key not in config: if config_key == "rate_limit": try: config[config_key] = {"max_per_minute": int(env_val)} except ValueError: pass elif config_key == "allow_from": config.setdefault("security", {})["allow_from"] = [v.strip() for v in env_val.split(",") if v.strip()] else: config[config_key] = env_val for env_key, config_key in _SYNOLOGY_CHAT_ENV_MAP.items(): env_val = os.environ.get(env_key) if env_val and config_key not in config: config[config_key] = env_val nas_host = os.environ.get("SYNOLOGY_NAS_HOST") if nas_host and "dsm_url" not in config: config["dsm_url"] = nas_host bot_name = os.environ.get(_OPENCLAW_BOT_NAME_ENV) if bot_name and "bot_name" not in config: config["bot_name"] = bot_name return config def _resolve_password(config: dict[str, Any]) -> str: password = config.get("password", "") password_file = config.get("password_file") if password_file: p = Path(password_file) _check_password_file_permissions(p) password = p.read_text().strip() return password def _check_password_file_permissions(file_path: Path) -> None: try: file_stat = file_path.stat() if os.name != "nt" and file_stat.st_mode & (stat.S_IRGRP | stat.S_IROTH): logger.warning( f"Password file '{file_path}' has group/other read permissions. " f"Consider restricting to owner-only (chmod 600)." ) except OSError: pass async def dsm_login( http_client: httpx.AsyncClient, base_url: str, config: dict[str, Any], api_info: dict[str, Any] | None, ) -> tuple[str, str | None]: username = config["username"] password = _resolve_password(config) if not password: raise ChannelAuthenticationError("DSM password not configured") auth_version = resolve_api_version(api_info, "SYNO.API.Auth") session_name = config.get("session_name", "Chat") params: dict[str, Any] = { "api": "SYNO.API.Auth", "version": str(auth_version), "method": "login", "account": username, "passwd": password, "session": session_name, "format": "sid", } if config.get("enable_syno_token", False): params["enable_syno_token"] = "yes" try: response = await http_client.get( f"{base_url}/webapi/auth.cgi", params=params, ) response.raise_for_status() result = response.json() if not result.get("success"): error_code = result.get("error", {}).get("code", "unknown") raise ChannelAuthenticationError(f"DSM login failed (code: {error_code})") data = result["data"] sid = data["sid"] synotoken = data.get("synotoken") logger.info(f"DSM login successful (session: {session_name})") return sid, synotoken except httpx.HTTPError as e: raise ChannelAuthenticationError(f"DSM login HTTP error: {e}") from e async def dsm_logout( http_client: httpx.AsyncClient, base_url: str, sid: str, api_info: dict[str, Any] | None, ) -> None: auth_version = resolve_api_version(api_info, "SYNO.API.Auth") try: response = await http_client.get( f"{base_url}/webapi/auth.cgi", params={ "api": "SYNO.API.Auth", "version": str(auth_version), "method": "logout", "_sid": sid, }, ) response.raise_for_status() logger.info("DSM logout successful") except Exception as e: logger.warning(f"DSM logout failed (non-critical): {e}") async def ensure_valid_sid( http_client: httpx.AsyncClient, base_url: str, config: dict[str, Any], sid: str | None, api_info: dict[str, Any] | None, max_retries: int = 2, ) -> tuple[str, str | None]: if sid is None: return await dsm_login(http_client, base_url, config, api_info) chat_version = resolve_api_version(api_info, "SYNO.Chat.External") for attempt in range(max_retries): try: response = await http_client.get( f"{base_url}/webapi/query.cgi", params={ "api": "SYNO.Chat.External", "version": str(chat_version), "method": "list", "_sid": sid, }, ) response.raise_for_status() result = response.json() error_code = result.get("error", {}).get("code", 0) if error_code in (106, 118, 119): logger.info("SID expired, re-authenticating...") return await dsm_login(http_client, base_url, config, api_info) return sid, None except httpx.HTTPStatusError as e: if 400 <= e.response.status_code < 500 and attempt < max_retries - 1: logger.warning(f"SID validation HTTP {e.response.status_code}, retrying (attempt {attempt + 1})") await asyncio.sleep(1 * (2**attempt)) elif e.response.status_code in (401, 403): logger.info(f"SID validation returned {e.response.status_code}, re-authenticating...") return await dsm_login(http_client, base_url, config, api_info) elif attempt >= max_retries - 1: logger.info("SID validation HTTP error, re-authenticating...") return await dsm_login(http_client, base_url, config, api_info) except httpx.HTTPError as e: logger.warning(f"SID validation error: {e}, attempt {attempt + 1}") if attempt < max_retries - 1: await asyncio.sleep(1) continue logger.info("SID validation failed after retries, re-authenticating...") return await dsm_login(http_client, base_url, config, api_info)