#!/usr/bin/env python3 """Shared helpers for the backup-verification skills (seafile / owncloud / datto-workplace). Three sibling skills answer the same GPS-audit question from different backends: "which customers/machines are actually backing up here?" Each skill has its own server-side client (Seafile Web API, ownCloud occ over SSH, Datto Workplace file API). What they SHARE — and what lives here — is: * ClaudeTools repo-root resolution (env -> identity.json -> derived). * A one-field SOPS vault read via the canonical vault.sh wrapper. * An RmmClient for the "endpoint" half of the "server + RMM endpoint" cross-check: log in to GuruRMM, list agents, and run a detection PowerShell on an agent to prove a given sync client (SeaDrive / ownCloud / Datto Workplace) is actually installed and signed in on the machine. Standalone: stdlib-only transport (urllib), no third-party dependency. Skills add this file's directory to sys.path and `import backup_common`. """ from __future__ import annotations import json import os import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any, Optional # backup_common.py lives at /.claude/scripts/backup_common.py _THIS = Path(__file__).resolve() _DERIVED_ROOT = _THIS.parent.parent.parent # scripts -> .claude -> repo root RMM_BASE = os.environ.get("GURURMM_API", "http://172.16.3.30:3001") RMM_VAULT_ENTRY = "infrastructure/gururmm-server.sops.yaml" RMM_FIELD_EMAIL = "credentials.gururmm-api.admin-email" RMM_FIELD_PASSWORD = "credentials.gururmm-api.admin-password" HTTP_TIMEOUT = 60.0 class BackupError(RuntimeError): """Transport / auth / API error, with an optional HTTP status.""" def __init__(self, message: str, *, status: Optional[int] = None): super().__init__(message) self.status = status # --- repo-root + vault -------------------------------------------------------- def resolve_root() -> Path: """Resolve the ClaudeTools repo root: env var, then identity.json, then derived.""" env_root = os.environ.get("CLAUDETOOLS_ROOT") if env_root: return Path(env_root) identity = _DERIVED_ROOT / ".claude" / "identity.json" if identity.exists(): try: data = json.loads(identity.read_text(encoding="utf-8")) root = data.get("claudetools_root") if root: return Path(root) except (json.JSONDecodeError, OSError): pass return _DERIVED_ROOT def vault_get_field(entry: str, field: str) -> str: """Read one field from a SOPS vault entry via the canonical vault.sh wrapper.""" root = resolve_root() vault = root / ".claude" / "scripts" / "vault.sh" if not vault.exists(): raise BackupError(f"vault wrapper not found at {vault}") try: done = subprocess.run( ["bash", str(vault), "get-field", entry, field], capture_output=True, text=True, timeout=60, ) except FileNotFoundError as exc: raise BackupError("'bash' not found on PATH (need Git Bash for vault reads).") from exc except subprocess.TimeoutExpired as exc: raise BackupError(f"vault read timed out for {entry}:{field}") from exc if done.returncode != 0: raise BackupError( f"vault read failed for {entry}:{field} (exit {done.returncode}): {done.stderr.strip()}" ) value = done.stdout.strip() if not value: raise BackupError(f"vault returned empty for {entry}:{field}") return value # --- minimal JSON HTTP -------------------------------------------------------- def http_json(method: str, url: str, *, headers: Optional[dict] = None, body: Optional[dict] = None, form: Optional[dict] = None, timeout: float = HTTP_TIMEOUT, basic: Optional[tuple[str, str]] = None) -> tuple[int, Any]: """Do an HTTP request and parse a JSON response. Returns (status, parsed). `body` sends a JSON payload; `form` sends application/x-www-form-urlencoded (list values expand via doseq, e.g. repeated fields the Seafile admin share endpoint reads with getlist). Pass at most one. 4xx/5xx are returned (not raised) so callers can inspect the error body; transport failures raise. """ hdrs = dict(headers or {}) data = None if body is not None and form is not None: raise BackupError("http_json: pass body OR form, not both") if body is not None: data = json.dumps(body).encode("utf-8") hdrs.setdefault("Content-Type", "application/json") elif form is not None: data = urllib.parse.urlencode(form, doseq=True).encode("utf-8") hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded") if basic is not None: import base64 tok = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode("ascii") hdrs["Authorization"] = f"Basic {tok}" req = urllib.request.Request(url, data=data, method=method, headers=hdrs) try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", errors="replace") return resp.getcode(), _parse(raw) except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") return exc.code, _parse(raw) except urllib.error.URLError as exc: raise BackupError(f"request to {url} failed: {exc}") from exc def _parse(text: str) -> Any: if not text: return {} try: return json.loads(text) except json.JSONDecodeError: return {"_raw": text[:600]} # --- GuruRMM client (endpoint cross-check) ------------------------------------ # One detection script, parametrized by the product name-fragments to look for. # Returns a single JSON line describing installed products, running processes, and # per-user config folders that match — enough to say "this client is installed and # signed in" without guessing. _DETECT_PS = r''' $ErrorActionPreference='SilentlyContinue' $patterns = @(__PATTERNS__) function match($s){ if(-not $s){return $false}; foreach($p in $patterns){ if($s -match [regex]::Escape($p)){return $true} }; return $false } $installed=@() foreach($hive in @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')){ Get-ItemProperty $hive | Where-Object { match($_.DisplayName) } | ForEach-Object { $installed += [pscustomobject]@{ name=$_.DisplayName; version=$_.DisplayVersion; location=$_.InstallLocation } } } $procs = Get-Process | Where-Object { match($_.ProcessName) } | Select-Object -Expand ProcessName -Unique $cfg=@() foreach($d in @('Seafile','SeaDrive','ownCloud','ownCloudSync','Datto','Workplace','Workplace2')){ foreach($base in @($env:APPDATA,$env:LOCALAPPDATA,$env:USERPROFILE,"$env:USERPROFILE\Desktop")){ $pth = Join-Path $base $d if(Test-Path $pth){ if(match($d) -or match($pth)){ $cfg += $pth } } } } # signed-in heuristic: config/db files with recent writes $fresh=@() foreach($c in $cfg){ Get-ChildItem $c -Recurse -File -ErrorAction SilentlyContinue -Depth 2 | Where-Object { $_.LastWriteTime -gt (Get-Date).AddDays(-14) } | Select-Object -First 1 | ForEach-Object { $fresh += $c } } [pscustomobject]@{ host=$env:COMPUTERNAME installed=$installed processes=@($procs) config_dirs=@($cfg) active_config_dirs=@($fresh | Select-Object -Unique) detected=([bool]($installed.Count -or $procs.Count)) } | ConvertTo-Json -Depth 5 -Compress ''' class RmmClient: """Thin GuruRMM API client: login, list agents, run a detection PowerShell.""" def __init__(self, base: str = RMM_BASE): self.base = base.rstrip("/") self._token: Optional[str] = None self._agents: Optional[list[dict]] = None def login(self) -> None: email = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_EMAIL) pw = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_PASSWORD) status, body = http_json("POST", f"{self.base}/api/auth/login", body={"email": email, "password": pw}) if status != 200 or not isinstance(body, dict) or not body.get("token"): raise BackupError(f"GuruRMM login failed (HTTP {status}): {body}", status=status) self._token = body["token"] def _auth(self) -> dict: if not self._token: self.login() return {"Authorization": f"Bearer {self._token}"} def list_agents(self) -> list[dict]: if self._agents is None: status, body = http_json("GET", f"{self.base}/api/agents", headers=self._auth()) if status != 200 or not isinstance(body, list): raise BackupError(f"GET /api/agents failed (HTTP {status})", status=status) self._agents = body return self._agents def find_agents(self, client_substr: Optional[str] = None, hostname_substr: Optional[str] = None) -> list[dict]: out = [] for a in self.list_agents(): if client_substr and client_substr.lower() not in (a.get("client_name") or "").lower(): continue if hostname_substr and hostname_substr.lower() not in (a.get("hostname") or "").lower(): continue out.append(a) return out def run_ps(self, agent_id: str, script: str, timeout_seconds: int = 90, poll_seconds: int = 4, max_polls: int = 30) -> dict: """Dispatch a PowerShell command to an agent and poll to completion.""" status, body = http_json( "POST", f"{self.base}/api/agents/{agent_id}/command", headers=self._auth(), body={"command_type": "powershell", "command": script, "timeout_seconds": timeout_seconds}) if status not in (200, 201) or not isinstance(body, dict): raise BackupError(f"command dispatch failed (HTTP {status}): {body}", status=status) cid = body.get("command_id") or body.get("id") if not cid: raise BackupError(f"no command_id in dispatch response: {body}") terminal = {"completed", "failed", "cancelled", "interrupted", "timeout"} last: dict = {} for _ in range(max_polls): s, cb = http_json("GET", f"{self.base}/api/commands/{cid}", headers=self._auth()) if s == 200 and isinstance(cb, dict): last = cb if (cb.get("status") or "") in terminal: break time.sleep(poll_seconds) return last def detect_client(self, agent: dict, patterns: list[str], **kw) -> dict: """Run the detection script on one agent for the given product name-fragments.""" pat_lits = ",".join("'" + p.replace("'", "''") + "'" for p in patterns) script = _DETECT_PS.replace("__PATTERNS__", pat_lits) res = self.run_ps(agent["id"], script, **kw) out = {"hostname": agent.get("hostname"), "client_name": agent.get("client_name"), "agent_id": agent.get("id"), "status": res.get("status"), "exit_code": res.get("exit_code"), "detected": None, "detail": None} stdout = (res.get("stdout") or "").strip() if stdout: try: parsed = json.loads(stdout) out["detected"] = bool(parsed.get("detected")) out["detail"] = parsed except json.JSONDecodeError: out["detail"] = {"_raw": stdout[:600]} return out def eprint(*a: Any) -> None: print(*a, file=sys.stderr)