ForcePilot/backend/package/yuxi/channels/adapters/synologychat/auth.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

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)