#!/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())