- 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>
432 lines
16 KiB
Python
432 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Synology DSM API 自動登入 CLI(使用 synology_api.py)
|
||
|
||
預設:
|
||
DS_URL=https://syno.lotimmy.com/
|
||
DS_USER, DS_PASSWORD 從環境變數或 .env 讀取
|
||
|
||
用法:
|
||
python3 scripts/synology_api_login.py
|
||
python3 scripts/synology_api_login.py --system-info
|
||
python3 scripts/synology_api_login.py --system-info --summary
|
||
python3 scripts/synology_api_login.py --system-info --json-compact
|
||
|
||
輸出:
|
||
- tmp/synology_api_login_result.json (回應摘要)
|
||
- tmp/synology_session.json (sid 與 cookie)
|
||
- tmp/synology_system_info.json (System Information,--system-info 時)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
HOSTS_FILE = Path(__file__).resolve().parents[1] / "references" / "hosts.json"
|
||
|
||
from synology_api import DSMApiClient, load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
DEFAULT_URL = os.getenv("DS_URL", "https://syno.lotimmy.com/")
|
||
DEFAULT_USER = os.getenv("DS_USER", "")
|
||
DEFAULT_PASSWORD = os.getenv("DS_PASSWORD", "")
|
||
OUT_RESULT = os.getenv("DS_LOGIN_RESULT", "tmp/synology_api_login_result.json")
|
||
OUT_SESSION = os.getenv("DS_SESSION_FILE", "tmp/synology_session.json")
|
||
OUT_SYSINFO = os.getenv("DS_SYSINFO_FILE", "tmp/synology_system_info.json")
|
||
|
||
|
||
def load_hosts() -> dict[str, Any]:
|
||
if not HOSTS_FILE.exists():
|
||
return {}
|
||
try:
|
||
return json.loads(HOSTS_FILE.read_text(encoding="utf-8"))
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
|
||
def resolve_profile(profile_name: str) -> tuple[str, str, str]:
|
||
hosts = load_hosts()
|
||
profile = hosts.get(profile_name)
|
||
if not isinstance(profile, dict):
|
||
raise SystemExit(f"[x] 找不到 profile:{profile_name}(檔案:{HOSTS_FILE})")
|
||
|
||
url = str(profile.get("url", "")).strip()
|
||
user = str(profile.get("user", "")).strip()
|
||
password = str(profile.get("password", "")).strip()
|
||
|
||
password_env = str(profile.get("password_env", "")).strip()
|
||
if not password and password_env:
|
||
password = os.getenv(password_env, "").strip()
|
||
|
||
if not url or not user or not password:
|
||
raise SystemExit(f"[x] profile {profile_name} 缺少 url/user/password(或 password_env 未設定)")
|
||
|
||
return url, user, password
|
||
|
||
|
||
def _find_key(obj: Any, keys: tuple[str, ...]) -> Any:
|
||
if isinstance(obj, dict):
|
||
for k, v in obj.items():
|
||
lk = k.lower()
|
||
if any(x in lk for x in keys):
|
||
return v
|
||
for v in obj.values():
|
||
hit = _find_key(v, keys)
|
||
if hit is not None:
|
||
return hit
|
||
elif isinstance(obj, list):
|
||
for item in obj:
|
||
hit = _find_key(item, keys)
|
||
if hit is not None:
|
||
return hit
|
||
return None
|
||
|
||
|
||
def _to_int(value: Any) -> int | None:
|
||
try:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return int(value)
|
||
v = str(value).strip()
|
||
if not v:
|
||
return None
|
||
return int(float(v))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _to_gib(value: int | None) -> float | None:
|
||
if value is None:
|
||
return None
|
||
return round(value / (1024 ** 3), 2)
|
||
|
||
|
||
def _build_compact_summary(bundle: dict[str, Any]) -> dict[str, Any]:
|
||
sysinfo = bundle.get("system_info", {})
|
||
util = bundle.get("utilization", {})
|
||
vols = bundle.get("volumes", {})
|
||
hms_disk = bundle.get("storage_hms_disk", {})
|
||
storage_cgi = bundle.get("storage_cgi_storage", {})
|
||
network_ethernet = bundle.get("network_ethernet", {})
|
||
|
||
sdata = sysinfo.get("response", {}).get("data", {})
|
||
udata = util.get("response", {}).get("data", {})
|
||
vdata = vols.get("response", {}).get("data", {})
|
||
hdata = hms_disk.get("response", {}).get("data", {})
|
||
scdata = storage_cgi.get("response", {}).get("data", {})
|
||
ndata = network_ethernet.get("response", {}).get("data", [])
|
||
if not ndata and isinstance(network_ethernet.get("data"), list):
|
||
ndata = network_ethernet.get("data", [])
|
||
|
||
cpu_obj = udata.get("cpu", {}) if isinstance(udata, dict) else {}
|
||
cpu_usage_pct = None
|
||
if isinstance(cpu_obj, dict):
|
||
cpu_usage_pct = cpu_obj.get("user_load", 0) + cpu_obj.get("system_load", 0) + cpu_obj.get("other_load", 0)
|
||
|
||
mem_obj = udata.get("memory", {}) if isinstance(udata, dict) else {}
|
||
mem_usage_pct = mem_obj.get("real_usage") if isinstance(mem_obj, dict) else None
|
||
|
||
volume_obj = vdata.get("volumes", vdata.get("volume", vdata))
|
||
if not volume_obj:
|
||
volume_obj = scdata.get("volumes", []) if isinstance(scdata, dict) else []
|
||
if not volume_obj:
|
||
volume_obj = udata.get("space", {}).get("volume", []) if isinstance(udata, dict) else []
|
||
|
||
volumes: list[str] = []
|
||
volume_usage: dict[str, Any] = {}
|
||
disk_size_usage: dict[str, Any] = {}
|
||
if isinstance(volume_obj, list) and volume_obj:
|
||
for v in volume_obj:
|
||
if not isinstance(v, dict):
|
||
continue
|
||
name = (
|
||
v.get("display_name")
|
||
or v.get("name")
|
||
or v.get("volume_path")
|
||
or v.get("path")
|
||
or (f"volume{v.get('num_id')}" if v.get('num_id') is not None else None)
|
||
or v.get("device")
|
||
or v.get("id")
|
||
)
|
||
if not name:
|
||
continue
|
||
volumes.append(name)
|
||
usage = v.get("utilization")
|
||
if usage is not None:
|
||
volume_usage[name] = {
|
||
"utilization_pct": usage,
|
||
"read_byte": v.get("read_byte"),
|
||
"write_byte": v.get("write_byte"),
|
||
}
|
||
|
||
size_obj = v.get("size") if isinstance(v.get("size"), dict) else {}
|
||
total_bytes = (
|
||
v.get("total")
|
||
or v.get("total_size")
|
||
or v.get("size_total")
|
||
or v.get("capacity")
|
||
or size_obj.get("total")
|
||
)
|
||
used_bytes = (
|
||
v.get("used")
|
||
or v.get("used_size")
|
||
or v.get("size_used")
|
||
or v.get("used_space")
|
||
or size_obj.get("used")
|
||
)
|
||
free_bytes = (
|
||
v.get("free")
|
||
or v.get("free_size")
|
||
or v.get("size_free")
|
||
or v.get("available")
|
||
or size_obj.get("free")
|
||
)
|
||
total_i = _to_int(total_bytes)
|
||
used_i = _to_int(used_bytes)
|
||
free_i = _to_int(free_bytes)
|
||
if free_i is None and total_i is not None and used_i is not None:
|
||
free_i = max(total_i - used_i, 0)
|
||
|
||
if total_i is not None or used_i is not None or free_i is not None:
|
||
disk_size_usage[name] = {
|
||
"total_bytes": total_i,
|
||
"used_bytes": used_i,
|
||
"free_bytes": free_i,
|
||
"total_gib": _to_gib(total_i),
|
||
"used_gib": _to_gib(used_i),
|
||
"free_gib": _to_gib(free_i),
|
||
}
|
||
elif isinstance(volume_obj, dict) and volume_obj:
|
||
volumes = list(volume_obj.keys())
|
||
|
||
disk_health = hdata.get("disk", hdata.get("disks", hdata))
|
||
if not disk_health:
|
||
disk_health = {}
|
||
for d in scdata.get("disks", []) if isinstance(scdata, dict) else []:
|
||
if not isinstance(d, dict):
|
||
continue
|
||
name = d.get("name") or d.get("device") or d.get("id")
|
||
if not name:
|
||
continue
|
||
vendor = d.get("vendor")
|
||
if isinstance(vendor, str):
|
||
vendor = vendor.strip()
|
||
|
||
disk_health[name] = {
|
||
"status": d.get("status"),
|
||
"smart_status": d.get("smart_status"),
|
||
"temp_c": d.get("temp"),
|
||
"model": d.get("model"),
|
||
"vendor": vendor,
|
||
"disk_type": d.get("diskType"),
|
||
}
|
||
|
||
if isinstance(disk_health, dict):
|
||
for _, info in disk_health.items():
|
||
if isinstance(info, dict) and isinstance(info.get("vendor"), str):
|
||
info["vendor"] = info["vendor"].strip()
|
||
|
||
primary_ip = None
|
||
if isinstance(ndata, list):
|
||
for nic in ndata:
|
||
if not isinstance(nic, dict):
|
||
continue
|
||
if nic.get("ifname") == "eth0" and nic.get("ip"):
|
||
primary_ip = nic.get("ip")
|
||
break
|
||
if primary_ip is None:
|
||
for nic in ndata:
|
||
if isinstance(nic, dict) and nic.get("ip") and nic.get("ip") != "":
|
||
primary_ip = nic.get("ip")
|
||
break
|
||
|
||
serial = sdata.get("serial")
|
||
|
||
result = {
|
||
"model": sdata.get("model"),
|
||
"serial": serial,
|
||
"ip": primary_ip,
|
||
"dsm_version": sdata.get("firmware_ver"),
|
||
"cpu_usage_pct": cpu_usage_pct,
|
||
"cpu_clock_mhz": sdata.get("cpu_clock_speed"),
|
||
"memory_total_mb": sdata.get("ram_size"),
|
||
"memory_usage_pct": mem_usage_pct,
|
||
"temperature_c": sdata.get("sys_temp", _find_key(sdata, ("temperature", "temp"))),
|
||
"volumes": volumes,
|
||
"disk_size_usage": disk_size_usage,
|
||
"disk_health": disk_health,
|
||
"time": sdata.get("time"),
|
||
"uptime": sdata.get("up_time"),
|
||
}
|
||
|
||
return result
|
||
|
||
|
||
def _safe_call(fn: Callable[[], dict[str, Any]], name: str) -> dict[str, Any]:
|
||
try:
|
||
return fn()
|
||
except Exception as exc: # noqa: BLE001
|
||
return {
|
||
"success": False,
|
||
"error": f"{name} failed: {exc}",
|
||
"response": {"data": {}},
|
||
}
|
||
|
||
|
||
def _load_cached_sid(session_file: str, expected_base: str) -> str:
|
||
p = Path(session_file)
|
||
if not p.exists():
|
||
return ""
|
||
try:
|
||
data = json.loads(p.read_text(encoding="utf-8"))
|
||
except Exception: # noqa: BLE001
|
||
return ""
|
||
|
||
base = str(data.get("base", "")).strip().rstrip("/")
|
||
sid = str(data.get("sid", "")).strip()
|
||
if not sid:
|
||
return ""
|
||
if base and base != expected_base.rstrip("/"):
|
||
return ""
|
||
return sid
|
||
|
||
|
||
def _is_sid_valid(client: DSMApiClient) -> bool:
|
||
probe = _safe_call(client.get_system_info, "get_system_info")
|
||
return bool(probe.get("success") is True)
|
||
|
||
|
||
def _print_summary(bundle: dict[str, Any]) -> None:
|
||
c = _build_compact_summary(bundle)
|
||
vol_summary = ", ".join(c.get("volumes", [])) if c.get("volumes") else "N/A"
|
||
|
||
print("[SUMMARY] System Information")
|
||
print(f"- CPU 使用率: {str(c.get('cpu_usage_pct')) + '%' if c.get('cpu_usage_pct') is not None else 'N/A'}")
|
||
print(f"- CPU 頻率: {str(c.get('cpu_clock_mhz')) + ' MHz' if c.get('cpu_clock_mhz') is not None else 'N/A'}")
|
||
print(f"- 記憶體(總量): {str(c.get('memory_total_mb')) + ' MB' if c.get('memory_total_mb') is not None else 'N/A'}")
|
||
print(f"- 記憶體(使用): {str(c.get('memory_usage_pct')) + '%' if c.get('memory_usage_pct') is not None else 'N/A'}")
|
||
print(f"- 溫度: {str(c.get('temperature_c')) + '°C' if c.get('temperature_c') is not None else 'N/A'}")
|
||
print(f"- Volume: {vol_summary}")
|
||
|
||
size_usage = c.get("disk_size_usage", {})
|
||
if isinstance(size_usage, dict) and size_usage:
|
||
print("- 容量:")
|
||
for vname, vinfo in size_usage.items():
|
||
if not isinstance(vinfo, dict):
|
||
continue
|
||
t = vinfo.get("total_gib")
|
||
u = vinfo.get("used_gib")
|
||
f = vinfo.get("free_gib")
|
||
print(f" - {vname}: 總 {t} GiB / 已用 {u} GiB / 可用 {f} GiB")
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="DSM API login (no Playwright)")
|
||
parser.add_argument("--profile", help="主機設定名稱(讀 references/hosts.json)")
|
||
parser.add_argument("--url", default=DEFAULT_URL, help="DSM URL")
|
||
parser.add_argument("--user", default=DEFAULT_USER, help="帳號(預設 DS_USER)")
|
||
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="密碼(預設 DS_PASSWORD)")
|
||
parser.add_argument("--system-info", action="store_true", help="登入後抓取 System Information")
|
||
parser.add_argument("--summary", action="store_true", help="搭配 --system-info 顯示人類可讀摘要")
|
||
parser.add_argument("--json-compact", action="store_true", help="搭配 --system-info 輸出精簡 JSON")
|
||
args = parser.parse_args()
|
||
|
||
if args.profile:
|
||
args.url, args.user, args.password = resolve_profile(args.profile)
|
||
|
||
client = DSMApiClient(base_url=args.url)
|
||
Path("tmp").mkdir(parents=True, exist_ok=True)
|
||
|
||
used_cached_sid = False
|
||
cached_sid = _load_cached_sid(OUT_SESSION, client.base_url)
|
||
if cached_sid:
|
||
client.sid = cached_sid
|
||
if _is_sid_valid(client):
|
||
used_cached_sid = True
|
||
|
||
result_obj: dict[str, Any]
|
||
if used_cached_sid:
|
||
result_obj = {
|
||
"success": True,
|
||
"meta": {
|
||
"base": client.base_url,
|
||
"source": "cached_sid",
|
||
"session_file": OUT_SESSION,
|
||
},
|
||
"data": {"sid": client.sid},
|
||
}
|
||
print("[✓] DSM API Session 可用(沿用既有 SID)")
|
||
print(f"[i] SID: {client.sid[:8]}... (來源 {OUT_SESSION})")
|
||
else:
|
||
if not args.user or not args.password:
|
||
print("[x] 缺少帳密:請設定 DS_USER / DS_PASSWORD 或傳 --user/--password", file=sys.stderr)
|
||
return 2
|
||
|
||
result = client.login(user=args.user, password=args.password)
|
||
result_obj = {
|
||
"success": result.success,
|
||
"meta": result.meta,
|
||
"data": result.data,
|
||
}
|
||
|
||
if not result.success:
|
||
Path(OUT_RESULT).write_text(json.dumps(result_obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
print("[x] DSM API 登入失敗", file=sys.stderr)
|
||
print(f"[i] 詳細結果:{OUT_RESULT}", file=sys.stderr)
|
||
return 1
|
||
|
||
session_obj = client.export_session(result)
|
||
Path(OUT_SESSION).write_text(json.dumps(session_obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
sid = session_obj.get("sid", "")
|
||
print("[✓] DSM API 登入成功")
|
||
print(f"[i] SID: {sid[:8]}... (已寫入 {OUT_SESSION})")
|
||
|
||
Path(OUT_RESULT).write_text(json.dumps(result_obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
if args.system_info:
|
||
sysinfo = _safe_call(client.get_system_info, "get_system_info")
|
||
util = _safe_call(client.get_utilization, "get_utilization")
|
||
vols = _safe_call(client.get_volumes, "get_volumes")
|
||
hms_disk = _safe_call(client.get_storage_hms_disk, "get_storage_hms_disk")
|
||
storage_cgi = _safe_call(client.get_storage_cgi_storage, "get_storage_cgi_storage")
|
||
network_ethernet = _safe_call(
|
||
lambda: client.api_get(
|
||
"/webapi/entry.cgi",
|
||
{"api": "SYNO.Core.Network.Ethernet", "version": "1", "method": "list"},
|
||
),
|
||
"get_network_ethernet",
|
||
)
|
||
bundle = {
|
||
"system_info": sysinfo,
|
||
"utilization": util,
|
||
"volumes": vols,
|
||
"storage_hms_disk": hms_disk,
|
||
"storage_cgi_storage": storage_cgi,
|
||
"network_ethernet": network_ethernet,
|
||
}
|
||
Path(OUT_SYSINFO).write_text(json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
if sysinfo.get("success"):
|
||
print(f"[i] System Information 已寫入:{OUT_SYSINFO}")
|
||
if args.summary:
|
||
_print_summary(bundle)
|
||
if args.json_compact:
|
||
print(json.dumps(_build_compact_summary(bundle), ensure_ascii=False))
|
||
else:
|
||
print(f"[!] System Information 取得失敗,已寫入:{OUT_SYSINFO}")
|
||
|
||
print(f"[i] 詳細結果:{OUT_RESULT}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|