这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
227 lines
7.4 KiB
Python
227 lines
7.4 KiB
Python
"""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)
|