import base64 import httpx import re from datetime import datetime # Legacy API (v9.5-v11) — used in x-api-version header for session auth API_VERSIONS_LEGACY = { "9.5": "1.0-rev1", "10": "1.0-rev1", "11": "1.0-rev1", } # v12+ OAuth2 API — used in x-api-version header for /api/oauth2/token and data requests # 1.0-rev2 is for v11 (legacy). For v12+, use 1.1-rev2 as safe default. API_VERSIONS_V12 = { "auto": "1.1-rev2", "12": "1.1-rev0", "12.1": "1.1-rev1", "12.2": "1.1-rev2", "12.3": "1.2-rev1", "13": "1.3-rev1", } def detect_version_from_host(host: str) -> str: h = host.lower() for v in ["12.3", "12.2", "12.1", "12", "11", "10", "9.5"]: if v in h: return v return "auto" def port_for_version(version: str) -> int: try: ver = float(version) except ValueError: return 9419 return 9419 if ver >= 12 else 9398 class VeeamClient: def __init__(self, host: str, port: int, username: str, password: str, version: str = "auto"): self.host = host self.port = port self.username = username self.password = password self.version = version self.base_url = f"https://{host}:{port}" self.token = None self.session_id = None self._v12_mode = False self._legacy_api_version = "1.0-rev1" self._v12_api_version = "1.1-rev2" self._http = httpx.AsyncClient(verify=False, timeout=30.0) async def close(self): if self.session_id: try: await self._http.delete( f"{self.base_url}/api/session/{self.session_id}", headers=self._legacy_headers() ) except Exception: pass await self._http.aclose() def _legacy_headers(self) -> dict: h = { "Accept": "application/json", "x-api-version": self._legacy_api_version, } if self.session_id: h["X-RestSvcSessionId"] = self.session_id return h def _v12_headers(self) -> dict: h = { "Accept": "application/json", "Content-Type": "application/json", "x-api-version": self._v12_api_version, } if self.token: h["Authorization"] = f"Bearer {self.token}" return h async def connect(self) -> str: return await self._try_connect() def _is_v12(self) -> bool: return self._v12_mode def _should_try_v12_first(self) -> bool: if self.version == "auto": return True try: return float(self.version) >= 12 except ValueError: return True def _resolve_v12_revision(self) -> str: return API_VERSIONS_V12.get(self.version, "1.0-rev2") def _resolve_legacy_revision(self) -> str: return API_VERSIONS_LEGACY.get(self.version, "1.0-rev1") async def _try_connect(self) -> str: combos = [] default_port = self.port or 9419 if self.version == "auto": combos = [ (9419, "v12"), (9398, "legacy"), ] elif self._should_try_v12_first(): combos = [ (default_port, "v12"), ((9398 if default_port == 9419 else 9419), "legacy"), ] else: combos = [ (default_port, "legacy"), ((9419 if default_port == 9398 else 9398), "v12"), ] last_error = "" for port, method in combos: self.port = port self.base_url = f"https://{self.host}:{self.port}" try: if method == "v12": self._v12_api_version = self._resolve_v12_revision() result = await self._connect_v12() self._v12_mode = True else: self._legacy_api_version = self._resolve_legacy_revision() result = await self._connect_legacy() self._v12_mode = False return result except Exception as e: last_error = f"{type(e).__name__}: {e}" continue raise ConnectionError( f"Cannot connect to {self.host} (tried ports {[c[0] for c in combos]}). " f"Last error: {last_error}" ) async def _connect_legacy(self) -> str: auth_str = base64.b64encode(f"{self.username}:{self.password}".encode()).decode() resp = await self._http.post( f"{self.base_url}/api/session", headers={ "Accept": "application/json", "x-api-version": self._legacy_api_version, "Authorization": f"Basic {auth_str}", }, ) data = self._parse(resp) sid = data.get("sessionId") or data.get("SessionId") if not sid: msg = data.get("_raw", resp.text[:300]) if isinstance(data, dict) else str(data)[:300] raise ConnectionError(f"Legacy auth failed ({resp.status_code}): {msg}") self.session_id = sid return data.get("userRole") or data.get("UserRole") or "unknown" async def _connect_v12(self) -> str: resp = await self._http.post( f"{self.base_url}/api/oauth2/token", data={ "grant_type": "password", "username": self.username, "password": self.password, }, headers={ "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded", "x-api-version": self._v12_api_version, }, ) if resp.status_code == 404: raise ConnectionError( f"v12 auth endpoint not found on {self.base_url} " f"(404 — REST API may be disabled or wrong port)") try: data = resp.json() except Exception: raise ConnectionError( f"v12 auth failed ({resp.status_code}): {resp.text[:200]}") self.token = data.get("access_token") if not self.token: raise ConnectionError( f"Token auth failed ({resp.status_code}): " f"{data.get('error_description', resp.text[:200])}") return data.get("user_role") or data.get("username", "unknown") def _resolve_version(self) -> str: if self.version != "auto": return self.version detected = detect_version_from_host(self.host) if detected != "auto": return detected return "12" async def fetch_jobs(self) -> list[dict]: if self._is_v12(): last_err = None for url in ("/api/v1/jobs/states", "/api/v1/jobs", "/api/v1/jobs/states?typeFilter=Backup"): try: return await self._fetch_json_list(url) except Exception as e: last_err = e continue raise ConnectionError(f"all job endpoints failed: {last_err}") return await self._fetch_legacy_jobs() async def fetch_sessions(self, limit: int = 50) -> list[dict]: if self._is_v12(): return await self._fetch_json_list(f"/api/v1/sessions?limit={limit}") return await self._fetch_legacy_sessions(limit) async def fetch_repositories(self) -> list[dict]: if self._is_v12(): return await self._fetch_json_list("/api/v1/backupInfrastructure/repositories") return await self._fetch_legacy_repos() async def fetch_repository_states(self) -> list[dict]: if self._is_v12(): return await self._fetch_json_list("/api/v1/backupInfrastructure/repositories/states") return [] async def fetch_alarms(self) -> list[dict]: if self._is_v12(): return await self._fetch_json_list("/api/v1/alarms") return [] async def fetch_backup_servers(self) -> list[dict]: if self._is_v12(): return await self._fetch_json_list("/api/v1/backupInfrastructure/managedServers") return await self._fetch_legacy_list("/api/backupServers", "BackupServer") async def fetch_agents(self) -> list[dict]: if self._is_v12(): return await self._fetch_json_list("/api/v1/agents/protectedComputers") return [] async def _fetch_json_list(self, path: str) -> list[dict]: resp = await self._http.get( f"{self.base_url}{path}", headers=self._v12_headers(), ) if resp.status_code >= 400: raise ConnectionError(f"GET {path}: {resp.status_code} {resp.text[:200]}") data = resp.json() return data.get("data") or data.get("items") or data.get("results") or data.get("jobs") or (data if isinstance(data, list) else []) async def _fetch_legacy_jobs(self) -> list[dict]: resp = await self._http.get( f"{self.base_url}/api/jobs", headers=self._legacy_headers(), ) if resp.status_code >= 400: raise ConnectionError(f"GET /api/jobs: {resp.status_code} {resp.text[:200]}") data = self._parse(resp) jobs = data.get("Jobs") or data.get("jobs") or data.get("Refs", {}).get("Job") or [] if isinstance(jobs, dict): jobs = [jobs] return [self._normalize_legacy_job(j) for j in jobs] async def _fetch_legacy_sessions(self, limit: int) -> list[dict]: resp = await self._http.get( f"{self.base_url}/api/sessions?limit={limit}", headers=self._legacy_headers(), ) if resp.status_code >= 400: return [] data = self._parse(resp) sessions = data.get("Sessions") or data.get("sessions") or [] if isinstance(sessions, dict): sessions = [sessions] return [self._normalize_legacy_session(s) for s in sessions] async def _fetch_legacy_repos(self) -> list[dict]: return await self._fetch_legacy_list("/api/repositories", "BackupRepository") async def _fetch_legacy_list(self, path: str, item_key: str) -> list[dict]: resp = await self._http.get( f"{self.base_url}{path}", headers=self._legacy_headers(), ) if resp.status_code >= 400: return [] data = self._parse(resp) items = data.get(f"{item_key}s") or data.get(item_key) or [] if isinstance(items, dict): items = [items] return items def _parse(self, resp: httpx.Response) -> dict: ct = resp.headers.get("content-type", "") if "json" in ct: try: return resp.json() except Exception: pass text = resp.text if text.strip().startswith("<"): return self._xml_to_dict_simple(text) try: return resp.json() except Exception: return {"_raw": text[:500]} def _xml_to_dict_simple(self, xml: str) -> dict: result = {} tags = re.findall(r"<(\w+)([^>]*)>(.*?)", xml, re.DOTALL) for tag_name, attrs_str, content in tags: attrs = dict(re.findall(r'(\w+)="([^"]*)"', attrs_str)) sub = self._xml_to_dict_simple(content) if "<" in content else content.strip() if attrs: entry = {"_text": sub} if sub else {} entry.update(attrs) else: entry = sub if tag_name not in result: result[tag_name] = entry else: existing = result[tag_name] if not isinstance(existing, list): result[tag_name] = [existing] result[tag_name].append(entry) return result def _normalize_legacy_job(self, job: dict) -> dict: return { "id": job.get("@Id") or job.get("id") or job.get("Id", ""), "name": job.get("@Name") or job.get("name") or job.get("Name", ""), "type": job.get("@Type") or job.get("type") or job.get("JobType", ""), "description": job.get("@Description") or job.get("description", ""), "schedule_enabled": job.get("@ScheduleEnabled") or job.get("scheduleEnabled", ""), "is_active": job.get("@IsBackupToD2D") or job.get("isActive", ""), "next_run": job.get("@NextRun") or job.get("nextRun", ""), } def _normalize_legacy_session(self, s: dict) -> dict: return { "id": s.get("@Id") or s.get("id", ""), "name": s.get("@Name") or s.get("name", ""), "job_name": s.get("@JobName") or s.get("jobName", ""), "job_id": s.get("@JobId") or s.get("jobId", ""), "type": s.get("@Type") or s.get("type", ""), "result": s.get("@Result") or s.get("result", ""), "state": s.get("@State") or s.get("state", ""), "creation_time": s.get("@CreationTime") or s.get("creationTime", ""), "end_time": s.get("@EndTime") or s.get("endTime", ""), } def __repr__(self): return f"VeeamClient({self.host}:{self.port} v{self.version})"