Files
claudetools/.claude/scripts/backup_common.py
Howard Enos a8e76ba215 sync: auto-sync from HOWARD-HOME at 2026-07-05 20:23:39
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 20:23:39
2026-07-05 20:24:08 -07:00

269 lines
11 KiB
Python

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