Initial commit: Synology DSM API skill
- Add synology_api.py: DSM API client library - Add synology_api_login.py: CLI tool for login and system info - Add SKILL.md: Skill definition with usage examples - Add CLAUDE.md: Developer documentation - Add references/: Example configs and usage guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
283
scripts/synology_api.py
Normal file
283
scripts/synology_api.py
Normal file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synology DSM API 共用 library。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from dataclasses import dataclass
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_dotenv(path: str = ".env") -> None:
|
||||
"""最小 .env 讀取器:只在環境變數尚未設定時補值。"""
|
||||
candidates = [
|
||||
Path(path),
|
||||
Path.cwd() / ".env",
|
||||
Path(__file__).resolve().parents[3] / ".env",
|
||||
]
|
||||
|
||||
seen: set[Path] = set()
|
||||
for p in candidates:
|
||||
p = p.resolve()
|
||||
if p in seen or not p.exists():
|
||||
continue
|
||||
seen.add(p)
|
||||
|
||||
for raw_line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class DSMLoginResult:
|
||||
success: bool
|
||||
data: dict[str, Any]
|
||||
meta: dict[str, Any]
|
||||
|
||||
|
||||
class DSMApiClient:
|
||||
"""可重用的 DSM API client。"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: int = 25, verify_ssl: bool = False) -> None:
|
||||
self.base_url = self.normalize_base(base_url)
|
||||
self.timeout = timeout
|
||||
self.verify_ssl = verify_ssl
|
||||
self.sid: str = ""
|
||||
|
||||
self.cookie_jar = CookieJar()
|
||||
self._opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=self._build_ssl_context()),
|
||||
urllib.request.HTTPCookieProcessor(self.cookie_jar),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_base(url: str) -> str:
|
||||
u = url.strip().rstrip("/")
|
||||
if not u.startswith("http://") and not u.startswith("https://"):
|
||||
u = "https://" + u
|
||||
return u
|
||||
|
||||
def _build_ssl_context(self) -> ssl.SSLContext:
|
||||
ctx = ssl.create_default_context()
|
||||
if not self.verify_ssl:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
def _collect_cookies(self) -> list[dict[str, Any]]:
|
||||
cookies: list[dict[str, Any]] = []
|
||||
for c in self.cookie_jar:
|
||||
cookies.append(
|
||||
{
|
||||
"name": c.name,
|
||||
"value": c.value,
|
||||
"domain": c.domain,
|
||||
"path": c.path,
|
||||
"secure": c.secure,
|
||||
}
|
||||
)
|
||||
return cookies
|
||||
|
||||
def login(
|
||||
self,
|
||||
user: str,
|
||||
password: str,
|
||||
session: str = "FileStation",
|
||||
fmt: str = "sid",
|
||||
endpoints: list[tuple[str, int]] | None = None,
|
||||
sessions: list[str] | None = None,
|
||||
) -> DSMLoginResult:
|
||||
if endpoints is None:
|
||||
endpoints = [
|
||||
("/webapi/auth.cgi", 7),
|
||||
("/webapi/auth.cgi", 6),
|
||||
("/webapi/entry.cgi", 7),
|
||||
("/webapi/entry.cgi", 6),
|
||||
("/webapi/entry.cgi", 3),
|
||||
]
|
||||
if sessions is None:
|
||||
sessions = [session, "DSM", "FileStation", "DownloadStation", "AudioStation", "VideoStation"]
|
||||
|
||||
last_err: dict[str, Any] = {}
|
||||
|
||||
for session_name in sessions:
|
||||
for path, version in endpoints:
|
||||
qs = {
|
||||
"api": "SYNO.API.Auth",
|
||||
"version": str(version),
|
||||
"method": "login",
|
||||
"account": user,
|
||||
"passwd": password,
|
||||
"session": session_name,
|
||||
"format": fmt,
|
||||
}
|
||||
url = f"{self.base_url}{path}?{urllib.parse.urlencode(qs)}"
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
try:
|
||||
with self._opener.open(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
status = resp.getcode()
|
||||
data = json.loads(raw)
|
||||
|
||||
if status == 200 and data.get("success") is True:
|
||||
self.sid = data.get("data", {}).get("sid", "")
|
||||
return DSMLoginResult(
|
||||
success=True,
|
||||
data=data,
|
||||
meta={
|
||||
"base": self.base_url,
|
||||
"endpoint": path,
|
||||
"version": version,
|
||||
"session": session_name,
|
||||
"status": status,
|
||||
"cookies": self._collect_cookies(),
|
||||
},
|
||||
)
|
||||
|
||||
last_err = {
|
||||
"endpoint": path,
|
||||
"version": version,
|
||||
"session": session_name,
|
||||
"status": status,
|
||||
"response": data,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = {
|
||||
"endpoint": path,
|
||||
"version": version,
|
||||
"session": session_name,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
return DSMLoginResult(
|
||||
success=False,
|
||||
data={},
|
||||
meta={"base": self.base_url, "last_error": last_err},
|
||||
)
|
||||
|
||||
def export_session(self, result: DSMLoginResult) -> dict[str, Any]:
|
||||
sid = result.data.get("data", {}).get("sid", "")
|
||||
return {
|
||||
"base": self.base_url,
|
||||
"sid": sid,
|
||||
"cookies": result.meta.get("cookies", []),
|
||||
}
|
||||
|
||||
def api_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""呼叫 DSM WebAPI(GET)。"""
|
||||
req_params = dict(params)
|
||||
if self.sid and "_sid" not in req_params:
|
||||
req_params["_sid"] = self.sid
|
||||
url = f"{self.base_url}{path}?{urllib.parse.urlencode(req_params)}"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with self._opener.open(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
return json.loads(raw)
|
||||
|
||||
def query_api(self, query: str = "all") -> dict[str, Any]:
|
||||
"""查詢 DSM API 能力表(SYNO.API.Info)。"""
|
||||
return self.api_get(
|
||||
"/webapi/query.cgi",
|
||||
{
|
||||
"api": "SYNO.API.Info",
|
||||
"version": "1",
|
||||
"method": "query",
|
||||
"query": query,
|
||||
},
|
||||
)
|
||||
|
||||
def _try_api_candidates(self, candidates: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
last_err: dict[str, Any] = {}
|
||||
for c in candidates:
|
||||
try:
|
||||
data = self.api_get(c["path"], c["params"])
|
||||
if data.get("success") is True:
|
||||
return {"success": True, "request": c, "response": data}
|
||||
last_err = {"request": c, "response": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = {"request": c, "error": str(e)}
|
||||
return {"success": False, "error": last_err}
|
||||
|
||||
def get_system_info(self) -> dict[str, Any]:
|
||||
"""取得 DSM 基本 System Information。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.System", "version": "3", "method": "info"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.System", "version": "2", "method": "info"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.System", "version": "1", "method": "info"}},
|
||||
]
|
||||
)
|
||||
|
||||
def get_utilization(self) -> dict[str, Any]:
|
||||
"""取得 CPU/記憶體使用率(若 NAS 支援)。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.System.Utilization", "version": "1", "method": "get"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.System.Utilization", "version": "1", "method": "load"}},
|
||||
]
|
||||
)
|
||||
|
||||
def get_volumes(self) -> dict[str, Any]:
|
||||
"""取得 Volume 清單(若 NAS 支援)。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Volume", "version": "2", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Volume", "version": "1", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Storage.CGI.Volume", "version": "1", "method": "list"}},
|
||||
]
|
||||
)
|
||||
|
||||
def get_storage_hms_disk(self) -> dict[str, Any]:
|
||||
"""取得硬碟健康 / S.M.A.R.T.(若 NAS 支援 SYNO.Storage.HMS.Disk)。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Storage.HMS.Disk", "version": "1", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Storage.HMS.Disk", "version": "1", "method": "get"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Storage.HMS.Disk", "version": "1", "method": "load_info"}},
|
||||
]
|
||||
)
|
||||
|
||||
def get_core_storage_disk(self) -> dict[str, Any]:
|
||||
"""取得硬碟列表 / 介面 / 類型(若 NAS 支援 SYNO.Core.Storage.Disk)。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Disk", "version": "2", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Disk", "version": "1", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Disk", "version": "1", "method": "get"}},
|
||||
]
|
||||
)
|
||||
|
||||
def get_core_storage_volume(self) -> dict[str, Any]:
|
||||
"""取得 Volume 邏輯儲存資訊(若 NAS 支援 SYNO.Core.Storage.Volume)。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Volume", "version": "2", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Volume", "version": "1", "method": "list"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Core.Storage.Volume", "version": "1", "method": "get"}},
|
||||
]
|
||||
)
|
||||
|
||||
def get_storage_cgi_storage(self) -> dict[str, Any]:
|
||||
"""取得 Storage Manager 綜合資訊(含 disks/volumes/pools)。"""
|
||||
return self._try_api_candidates(
|
||||
[
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Storage.CGI.Storage", "version": "1", "method": "load_info"}},
|
||||
{"path": "/webapi/entry.cgi", "params": {"api": "SYNO.Storage.CGI.Storage", "version": "1", "method": "load"}},
|
||||
]
|
||||
)
|
||||
Reference in New Issue
Block a user