diff --git a/.claude/scripts/backup_common.py b/.claude/scripts/backup_common.py new file mode 100644 index 00000000..e2a423ec --- /dev/null +++ b/.claude/scripts/backup_common.py @@ -0,0 +1,259 @@ +#!/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.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, 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). + + 4xx/5xx are returned (not raised) so callers can inspect the error body. + Transport failures raise BackupError. + """ + hdrs = dict(headers or {}) + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + hdrs.setdefault("Content-Type", "application/json") + 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) diff --git a/.claude/skills/datto-workplace/.gitignore b/.claude/skills/datto-workplace/.gitignore new file mode 100644 index 00000000..23898594 --- /dev/null +++ b/.claude/skills/datto-workplace/.gitignore @@ -0,0 +1,3 @@ +# No live secrets are cached (Basic auth is per-request), but keep scratch out. +.cache/ +__pycache__/ diff --git a/.claude/skills/datto-workplace/SKILL.md b/.claude/skills/datto-workplace/SKILL.md new file mode 100644 index 00000000..612dcae9 --- /dev/null +++ b/.claude/skills/datto-workplace/SKILL.md @@ -0,0 +1,74 @@ +--- +name: datto-workplace +description: "Read-only Datto Workplace backup verification for the GPS audit: confirm the file API is reachable under Basic auth, and (primary path) detect which GuruRMM endpoints actually run the Datto Workplace sync client. The file API is file-access only and this key sees no shared projects, so RMM endpoint detection is the real answer. Triggers: datto workplace, dattoworkplace, dwp, who backs up to datto workplace, datto sync." +--- + +# Datto Workplace Skill + +Read-only client for ACG's live **Datto Workplace** tenant, built for GPS backup +verification. Answers: **which enrolled machines actually run the Datto Workplace +sync client** (and, where the file API allows, what projects/files are visible). + +This skill is one of the backup-verification siblings alongside `seafile`, +`b2` (Backblaze), and `owncloud`. It never writes. + +## Running + +```bash +DWP=".claude/skills/datto-workplace/scripts/datto_workplace.py" +py "$DWP" status # file API reachable + endpoint/project count +py "$DWP" projects [--json] # file-API projects (likely empty for this key) +py "$DWP" audit --rmm --client "Birth" # GPS view: detect the client on GuruRMM endpoints +py "$DWP" audit --rmm --client Birth --json +``` + +Add `--json` to any command for machine-readable output. `--url` overrides the +file-API base. + +## Credentials + +Loaded at runtime from the SOPS vault (never hardcoded): +`msp-tools/datto-workplace.sops.yaml` -> `credentials.api-key` / `credentials.api-secret`. +Auth is **HTTP Basic**: username = api-key, password = api-secret, sent on every +request (no token exchange, nothing cached). + +## The file API is limited (why RMM detection is primary) + +Datto Workplace exposes a **file-access REST API v1** only. Base: +`https://acg.workplace.datto.com/api/v1` (the team path `/6/api/v1` is normalized +by the server to `/api/v1`). Endpoints (confirm with `GET /openapi.json`, surfaced +by `status`): `GET /file/projects`, `GET /file/{parentID}/files`, +`GET /file/{fileID}`, `GET /file/{fileID}/data`, `GET /file/search`, plus +`/webhook*`. There are **NO** user / device / member / team endpoints. + +GOTCHA: `GET /file/projects` currently returns `{"result": []}` for this key -- the +API principal has no projects shared to it. So the file API alone **cannot +enumerate the team's backups**. `projects` handles this and says so. + +Because of that, **RMM endpoint detection is the PRIMARY source of truth**. + +## The `--rmm` cross-check (primary path) + +`audit --rmm` logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and, +for each agent (scope with `--client` so it doesn't sweep all 350+ agents), runs a +**read-only** detection PowerShell that reports installed products / running +processes / config folders matching the Datto Workplace client. An agent is +reported only if the client is actually present. + +**Detection patterns must be precise:** this skill uses `["Datto Workplace", +"Workplace2"]`. A bare `"Datto"` false-matches **Datto EDR Agent**, Datto RMM, and +Datto AV -- do NOT use it. The v10 client installs as DisplayName +"Datto Workplace v10.x" in `C:\Program Files\Datto\Workplace2\` (process +`Workplace.exe`); the legacy v8 is "Datto Workplace Desktop". Matching +`Datto Workplace` + `Workplace2` catches both without catching EDR. + +## Notes / gotchas + +- Always scope `audit --rmm` with `--client` unless you truly mean to sweep the + whole fleet -- detection dispatches a live command to each endpoint. +- Shared plumbing (repo-root resolve, vault read, HTTP, GuruRMM client + endpoint + detection) lives in `.claude/scripts/backup_common.py`, used by all the + backup-verification siblings. Detection patterns are per-backend; this skill's + are the precise Datto Workplace set above. +- Known user: **Birth Biologic** backs up to Datto Workplace (agent hostnames + include `BB-*`, `KSTEEN*`, `EVO-X1`) -- a good `--client "Birth"` smoke test. diff --git a/.claude/skills/datto-workplace/scripts/datto_client.py b/.claude/skills/datto-workplace/scripts/datto_client.py new file mode 100644 index 00000000..f9ecfa96 --- /dev/null +++ b/.claude/skills/datto-workplace/scripts/datto_client.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Datto Workplace REST API v1 client for the `datto-workplace` skill. + +Talks to ACG's live Datto Workplace tenant file API. Read-only: this is a +FILE-ACCESS API (projects + files), NOT an admin/reporting API -- there are no +user/device/member/team endpoints. It answers "what shared projects + files does +this API principal see" for the GPS backup audit. + +Auth: HTTP Basic, username = api-key, password = api-secret (no token exchange; +Basic is sent on every request). Credentials come from the SOPS vault. + +Base URL defaults to the team file-API host. The team path /6/api/v1 is normalized +by the server to /api/v1; override with DATTO_WORKPLACE_URL if needed. + +IMPORTANT GOTCHA: GET /file/projects currently returns {"result": []} for this +key -- the principal has no projects shared to it, so the file API alone CANNOT +enumerate the team's backups. Handle empty gracefully. Because of that, RMM +endpoint detection (see datto_workplace.py `audit --rmm`) is the PRIMARY source +of "who backs up to Datto Workplace". +""" +from __future__ import annotations + +import os +import sys +import urllib.parse +from pathlib import Path +from typing import Any, Optional + +# Import the shared backup helpers from .claude/scripts. +_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/datto-workplace +_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts +sys.path.insert(0, str(_SCRIPTS_DIR)) +import backup_common as bc # noqa: E402 + +VAULT_ENTRY = "msp-tools/datto-workplace.sops.yaml" +VAULT_FIELD_KEY = "credentials.api-key" +VAULT_FIELD_SECRET = "credentials.api-secret" + +DEFAULT_URL = os.environ.get("DATTO_WORKPLACE_URL", "https://acg.workplace.datto.com/api/v1") + + +class DattoWorkplaceClient: + """Basic-auth GET client for the Datto Workplace file API. + + No token caching is needed -- HTTP Basic is sent per request. Credentials are + read once from the vault on first use and reused for the process lifetime. + """ + + def __init__(self, base: Optional[str] = None): + self.base = (base or DEFAULT_URL).rstrip("/") + self._basic: Optional[tuple[str, str]] = None + + # -- auth ------------------------------------------------------------------ + def _creds(self) -> tuple[str, str]: + if self._basic is None: + key = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_KEY) + secret = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_SECRET) + self._basic = (key, secret) + return self._basic + + def _get_raw(self, path: str, params: Optional[dict] = None) -> tuple[int, Any]: + """Low-level GET returning (status, body); callers decide how to react.""" + url = f"{self.base}{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + return bc.http_json("GET", url, basic=self._creds()) + + def _get(self, path: str, params: Optional[dict] = None) -> Any: + status, body = self._get_raw(path, params) + if status == 401: + raise bc.BackupError( + f"Datto Workplace auth failed (HTTP 401) for {path}: check api-key/api-secret", + status=status) + if status != 200: + raise bc.BackupError( + f"Datto Workplace GET {path} failed (HTTP {status}): {body}", status=status) + return body + + # -- read methods ---------------------------------------------------------- + def openapi(self) -> dict: + """Fetch the OpenAPI spec (confirms reachability + exact endpoint list).""" + body = self._get("/openapi.json") + return body if isinstance(body, dict) else {"_raw": body} + + def endpoint_paths(self) -> list[str]: + """Sorted list of paths the OpenAPI spec exposes.""" + spec = self.openapi() + paths = spec.get("paths") if isinstance(spec, dict) else None + return sorted(paths.keys()) if isinstance(paths, dict) else [] + + def projects(self) -> list[dict]: + """Top-level file projects visible to this API principal. + + Returns [] gracefully -- for this key the principal currently has no + projects shared to it. The server is inconsistent here: some responses + are 200 {"result": []}, others are 403 "Access denied" for the exact same + Basic auth that authorizes /openapi.json seconds earlier. Both mean the + same thing for the audit (no project-level file access), so a 403/401 on + this endpoint is treated as empty rather than a hard error. A genuine + credential failure surfaces on /openapi.json (status/openapi). + """ + status, body = self._get_raw("/file/projects") + if status in (401, 403): + return [] + if status != 200: + raise bc.BackupError( + f"Datto Workplace GET /file/projects failed (HTTP {status}): {body}", + status=status) + return self._as_list(body) + + def files(self, parent_id: str) -> list[dict]: + """List files/folders under a parent project or folder ID.""" + body = self._get(f"/file/{parent_id}/files") + return self._as_list(body) + + def file_info(self, file_id: str) -> dict: + """Metadata for a single file/folder ID.""" + body = self._get(f"/file/{file_id}") + if isinstance(body, dict): + return body.get("result") if isinstance(body.get("result"), dict) else body + return {"_raw": body} + + def search(self, query: str, parent_id: Optional[str] = None) -> list[dict]: + """Search files by name. Optional parent scope.""" + params = {"query": query} + if parent_id: + params["parentID"] = parent_id + body = self._get("/file/search", params) + return self._as_list(body) + + @staticmethod + def _as_list(body: Any) -> list[dict]: + """Normalize the Datto file-API envelope to a list. + + Responses wrap payloads as {"result": [...]} (or a bare list on some + endpoints); anything else yields []. + """ + if isinstance(body, dict): + res = body.get("result") + if isinstance(res, list): + return res + if isinstance(res, dict): + return [res] + return [] + if isinstance(body, list): + return body + return [] + + +def main() -> int: + try: + c = DattoWorkplaceClient() + paths = c.endpoint_paths() + projects = c.projects() + print(f"[OK] Datto Workplace file API reachable at {c.base}; " + f"{len(paths)} endpoints, {len(projects)} project(s) visible") + return 0 + except bc.BackupError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/datto-workplace/scripts/datto_workplace.py b/.claude/skills/datto-workplace/scripts/datto_workplace.py new file mode 100644 index 00000000..d05c72e9 --- /dev/null +++ b/.claude/skills/datto-workplace/scripts/datto_workplace.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""CLI for the `datto-workplace` skill -- read-only Datto Workplace backup +verification for the GPS audit. + +Commands: + status file API reachable under Basic auth + endpoint/project count + projects [--json] list file-API projects (likely empty for this principal) + audit [--client NAME] [--rmm] [--json] + the GPS view: server-side projects (if any), optionally + cross-checked against GuruRMM endpoints (Datto Workplace + client installed + signed in?) + +The Datto Workplace file API is FILE-ACCESS ONLY and this principal sees no shared +projects, so it CANNOT enumerate the team's backups on its own. RMM endpoint +detection (`audit --rmm`) is therefore the PRIMARY source: it detects the Datto +Workplace client on GuruRMM agents using precise patterns that do NOT false-match +Datto EDR / Datto RMM / Datto AV. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from datto_client import DattoWorkplaceClient # noqa: E402 +import backup_common as bc # noqa: E402 + +# Precise detection patterns. "Datto Workplace" catches the DisplayName of both the +# v10 client ("Datto Workplace v10.x") and legacy v8 ("Datto Workplace Desktop"); +# "Workplace2" catches the v10 install path / process family. A bare "Datto" would +# false-match "Datto EDR Agent", Datto RMM, and Datto AV -- do NOT use it. +DETECT_PATTERNS = ["Datto Workplace", "Workplace2"] + + +def cmd_status(c: DattoWorkplaceClient, args) -> int: + paths = c.endpoint_paths() + projects = c.projects() + if args.json: + print(json.dumps({"base": c.base, "reachable": True, + "endpoints": paths, "project_count": len(projects)}, indent=2)) + return 0 + print(f"[OK] Datto Workplace file API reachable at {c.base} (Basic auth 200)") + print(f"[INFO] {len(paths)} endpoints exposed:") + for p in paths: + print(f" {p}") + print(f"[INFO] {len(projects)} project(s) visible to this API principal", end="") + if not projects: + print(" - file API cannot enumerate backups; use `audit --rmm`") + else: + print() + return 0 + + +def cmd_projects(c: DattoWorkplaceClient, args) -> int: + projects = c.projects() + if args.json: + print(json.dumps(projects, indent=2)) + return 0 + if not projects: + print("[INFO] no file-API projects visible to this principal.") + print("[INFO] The api-key has no projects shared to it, so GET /file/projects " + "returns []. This is expected - the Datto Workplace file API is file-access " + "only. Use `audit --rmm` to verify backups via GuruRMM endpoint detection.") + return 0 + print(f"{'NAME':40} {'ID':38} {'FILES':>8}") + for p in projects: + print(f"{str(p.get('name') or '')[:40]:40} " + f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} " + f"{str(p.get('fileCount') or p.get('count') or '?'):>8}") + print(f"\n{len(projects)} project(s)") + return 0 + + +def cmd_audit(c: DattoWorkplaceClient, args) -> int: + projects = c.projects() + result = {"backend": "datto-workplace", "base": c.base, + "file_api_projects": projects, "file_api_limited": not projects} + + if args.rmm: + rmm = bc.RmmClient() + rmm.login() + checks = [] + agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents() + for a in agents: + det = rmm.detect_client(a, DETECT_PATTERNS) + if det.get("detected"): + checks.append(det) + result["rmm_endpoints_with_client"] = checks + result["rmm_agents_scanned"] = len(agents) + + if args.json: + print(json.dumps(result, indent=2)) + return 0 + + print(f"=== Datto Workplace backup audit - {c.base} ===") + if projects: + print(f"{'PROJECT':40} {'ID':38} {'FILES':>8}") + for p in projects: + print(f"{str(p.get('name') or '')[:40]:40} " + f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} " + f"{str(p.get('fileCount') or p.get('count') or '?'):>8}") + print(f"\n{len(projects)} file-API project(s)") + else: + print("[INFO] file API shows 0 projects (principal has none shared) - " + "server-side enumeration not available; relying on RMM detection.") + + if args.rmm: + eps = result.get("rmm_endpoints_with_client", []) + scanned = result.get("rmm_agents_scanned", 0) + scope = f"client~'{args.client}'" if args.client else "ALL agents" + print(f"\n--- GuruRMM endpoints running the Datto Workplace client " + f"({len(eps)} of {scanned} scanned, {scope}) ---") + for e in eps: + det = e.get("detail") or {} + installed = det.get("installed") or [] + prod = "-" + if isinstance(installed, list) and installed: + first = installed[0] + if isinstance(first, dict): + prod = f"{first.get('name') or '?'} {first.get('version') or ''}".strip() + elif isinstance(installed, dict): + prod = f"{installed.get('name') or '?'} {installed.get('version') or ''}".strip() + print(f" {str(e.get('hostname') or '')[:25]:25} " + f"client={str(e.get('client_name') or ''):20} " + f"product={prod}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="datto-workplace", + description="Datto Workplace backup verification (GPS audit)") + p.add_argument("--url", help="override file-API base URL (default team file-API host)") + sub = p.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("status"); s.add_argument("--json", action="store_true") + pr = sub.add_parser("projects"); pr.add_argument("--json", action="store_true") + a = sub.add_parser("audit") + a.add_argument("--client", help="scope RMM detection to agents whose client_name matches") + a.add_argument("--rmm", action="store_true", help="cross-check GuruRMM endpoints (primary path)") + a.add_argument("--json", action="store_true") + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + c = DattoWorkplaceClient(args.url) + handlers = {"status": cmd_status, "projects": cmd_projects, "audit": cmd_audit} + try: + return handlers[args.cmd](c, args) + except bc.BackupError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + # best-effort error logging per house rule + try: + root = bc.resolve_root() + bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"), + "datto-workplace", str(exc)[:200]], timeout=15) + except Exception: + pass + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/owncloud/.gitignore b/.claude/skills/owncloud/.gitignore new file mode 100644 index 00000000..eabeef1f --- /dev/null +++ b/.claude/skills/owncloud/.gitignore @@ -0,0 +1,3 @@ +# Skill scratch/cache - never commit. +.cache/ +__pycache__/ diff --git a/.claude/skills/owncloud/SKILL.md b/.claude/skills/owncloud/SKILL.md new file mode 100644 index 00000000..2c049694 --- /dev/null +++ b/.claude/skills/owncloud/SKILL.md @@ -0,0 +1,95 @@ +--- +name: owncloud +description: "Read-only inventory of ACG's ownCloud server (cloud.acghosting.com / 172.16.3.22, ownCloud 10.16 Community) for GPS backup verification: who has an account, per-user storage used + quota + last login, and which GuruRMM endpoints actually run the ownCloud desktop client. Triggers: owncloud, cloud.acghosting, who backs up to owncloud, owncloud usage, owncloud audit." +--- + +# ownCloud Skill + +Read-only client for ACG's live **ownCloud 10.16.0 Community** server +(`cloud.acghosting.com`, VM `172.16.3.22`, Rocky Linux 9.6). Answers the GPS +backup-audit question: **which customers back up here and how much data do they +hold** - and, with `--rmm`, which enrolled machines actually run the ownCloud +desktop client. It never writes. + +This skill is one of the backup-verification siblings alongside `seafile`, +`b2` (Backblaze) and `datto-workplace`. + +## Running + +```bash +OWNCLOUD=".claude/skills/owncloud/scripts/owncloud.py" +py "$OWNCLOUD" status # SSH ok + occ version / edition +py "$OWNCLOUD" users [--json] # all users + enabled/quota/used/last login +py "$OWNCLOUD" usage [--json] # per-user rollup, used GB desc (the audit view) +py "$OWNCLOUD" audit [--client NAME] [--rmm] [--json] +``` + +Add `--json` to `users` / `usage` / `audit` for machine-readable output. +`--host` overrides the server IP (default `172.16.3.22`). + +## Access channel + +There is **no ownCloud admin web / OCS account** in the vault, so the channel is +**SSH + the `occ` CLI**, not the OCS HTTP API (the OCS endpoint is reachable at +`https://cloud.acghosting.com/status.php` but we lack an OCS admin cred). The +client SSHes to `root@172.16.3.22:22` with **paramiko** (password auth, +AutoAddPolicy) and runs occ **as the web user**: + +``` +sudo -u apache php /var/www/owncloud/occ +``` + +Credentials load lazily from the SOPS vault (never hardcoded): +`infrastructure/owncloud-vm.sops.yaml` -> `credentials.username` (root) / +`credentials.password`. + +## The occ / DB commands it settled on + +- **status:** `occ status --output=json` (+ `occ -V` for the display version). +- **users:** `occ user:list --show-all-attributes --output=json` -> per user: + `uid, displayName, email, quota, enabled, lastLogin, creationTime, home, + backend`. `lastLogin`/`creationTime` are Unix epoch seconds (0 = never); the + client renders them as UTC. +- **used bytes:** occ has **no `user:info` / per-user usage** on this version, so + used storage is read from ownCloud's own **filecache** (the exact figure the + UI shows). DB creds are read live from `config.php` (we already have root), then: + `SELECT s.id, fc.size FROM oc_storages s JOIN oc_filecache fc ON + fc.storage=s.numeric_id WHERE s.id LIKE 'home::%' AND fc.path='';` + Each row is `home::` -> root size in bytes. + +## What the data means + +- **used_bytes** is the authoritative per-account storage used; an `enabled` user + with non-zero usage is actively backing up here. `usage`/`audit` roll this up, + sorted desc, with a total. +- **quota** is a string: `default`, `none` (unlimited), or a byte/size value. +- Usage is concentrated: as of the build (2026-07-05) a single account (`pavon`) + holds ~14 TB and `sysadmin` ~2.9 TB, dwarfing the rest - ownCloud is used by a + handful of clients, not the whole GPS base. + +## The "both" cross-check (`--rmm`) + +Server-side (occ) tells you who has an account; `--rmm` adds the endpoint half. It +logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and, for each agent +(filter with `--client`), runs a **read-only** detection PowerShell reporting +installed products / running processes / config folders matching **`ownCloud`**. +An agent is reported only if the client is actually present. This dispatches a +command to live endpoints - **always scope with `--client`** rather than sweeping +all 350+ agents. + +## Notes / gotchas / limitations + +- **Dirty filecache (-1):** if a user's cached root size is `-1` (cache not yet + computed), the client falls back to `du -sb /files` for that one user. + That du can take minutes on a multi-hundred-GB home (timeout is generous, 900s); + `used_source` in `--json` reports `filecache` vs `du` per user. At build time + one account (`anaise`) was dirty and resolved via du (~0.6 TB). +- **Orphan storages:** the filecache can contain `home::` rows for deleted + users (e.g. a former `QWM-Sheila`); the skill keys off the live `occ user:list`, + so orphans do not appear in `users`/`usage`. +- **No OCS/web API:** deeper per-file / sharing queries would need an OCS admin + cred we do not have; occ + filecache cover the audit needs. +- Shared plumbing (repo-root resolve, vault read, GuruRMM client + endpoint + detection) lives in `.claude/scripts/backup_common.py`, used by all the + server-backup skills. Detection patterns must be precise per backend - this + skill uses `ownCloud` (not a bare `cloud`, which would over-match). diff --git a/.claude/skills/owncloud/scripts/owncloud.py b/.claude/skills/owncloud/scripts/owncloud.py new file mode 100644 index 00000000..6daae28a --- /dev/null +++ b/.claude/skills/owncloud/scripts/owncloud.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""CLI for the `owncloud` skill - read-only ownCloud 10.16 inventory for the GPS +backup audit (server: cloud.acghosting.com / 172.16.3.22, driven via occ over SSH). + +Commands: + status SSH ok + occ version / edition + users [--json] all users + display name, enabled, quota, used, last login + usage [--json] per-user rollup: used GB (desc) + quota + last login (audit view) + audit [--client NAME] [--rmm] [--json] + active users holding data; with --rmm cross-check GuruRMM + endpoints for the ownCloud desktop client (scope with --client) + +Server-side (occ) is the source of "who has an account + how much data"; --rmm adds +the "which enrolled machine actually runs the ownCloud client" half. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from owncloud_client import OwncloudClient # noqa: E402 +import backup_common as bc # noqa: E402 + +GB = 1_000_000_000 + + +def _gb(n) -> float: + try: + return round(int(n) / GB, 2) + except (TypeError, ValueError): + return 0.0 + + +def cmd_status(c: OwncloudClient, args) -> int: + s = c.server_status() + print(f"[OK] {c.host}") + print(json.dumps(s, indent=2)) + return 0 + + +def cmd_users(c: OwncloudClient, args) -> int: + users = c.list_users() + users.sort(key=lambda u: int(u.get("used_bytes") or 0), reverse=True) + if args.json: + print(json.dumps(users, indent=2)) + return 0 + print(f"{'UID':20} {'NAME':22} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}") + for u in users: + print(f"{(u.get('uid') or '')[:20]:20} " + f"{(u.get('display_name') or '')[:22]:22} " + f"{'yes' if u.get('enabled') else 'NO':3} " + f"{_gb(u.get('used_bytes')):>7} GB " + f"{str(u.get('quota') or '-')[:12]:>12} " + f"{str(u.get('last_login') or '-')[:19]:20}") + print(f"\n{len(users)} users; total used " + f"{round(sum(_gb(u.get('used_bytes')) for u in users), 1)} GB") + return 0 + + +def _usage_rollup(c: OwncloudClient) -> list[dict]: + users = c.list_users() + rollup = [] + for u in users: + rollup.append({ + "uid": u.get("uid"), + "display_name": u.get("display_name"), + "email": u.get("email"), + "enabled": bool(u.get("enabled")), + "used_bytes": int(u.get("used_bytes") or 0), + "used_gb": _gb(u.get("used_bytes")), + "used_source": u.get("used_source"), + "quota": u.get("quota"), + "last_login": u.get("last_login"), + }) + rollup.sort(key=lambda x: x["used_bytes"], reverse=True) + return rollup + + +def cmd_usage(c: OwncloudClient, args) -> int: + rollup = _usage_rollup(c) + if args.json: + print(json.dumps(rollup, indent=2)) + return 0 + print(f"{'UID':20} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}") + for r in rollup: + print(f"{(r['uid'] or '')[:20]:20} " + f"{'yes' if r['enabled'] else 'NO':3} " + f"{r['used_gb']:>7} GB " + f"{str(r['quota'] or '-')[:12]:>12} " + f"{str(r['last_login'] or '-')[:19]:20}") + print(f"\n{len(rollup)} users; total used " + f"{round(sum(r['used_gb'] for r in rollup), 1)} GB") + return 0 + + +def cmd_audit(c: OwncloudClient, args) -> int: + rollup = _usage_rollup(c) + # enabled users holding data are the ones actually backing up here + active = [r for r in rollup if r["enabled"] and r["used_bytes"] > 0] + result = {"backend": "owncloud", "server": c.host, "accounts": active} + + if args.rmm: + rmm = bc.RmmClient() + rmm.login() + checks = [] + agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents() + for a in agents: + det = rmm.detect_client(a, ["ownCloud"]) + if det.get("detected"): + checks.append(det) + result["rmm_endpoints_with_client"] = checks + + if args.json: + print(json.dumps(result, indent=2)) + return 0 + + print(f"=== ownCloud backup accounts - {c.host} ===") + print(f"{'UID':20} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}") + for r in active: + print(f"{(r['uid'] or '')[:20]:20} {r['used_gb']:>7} GB " + f"{str(r['quota'] or '-')[:12]:>12} {str(r['last_login'] or '-')[:19]:20}") + print(f"\n{len(active)} active accounts with data; " + f"{round(sum(r['used_gb'] for r in active), 1)} GB total") + if args.rmm: + eps = result.get("rmm_endpoints_with_client", []) + print(f"\n--- GuruRMM endpoints running an ownCloud client ({len(eps)}) ---") + for e in eps: + print(f" {e['hostname']:25} client={e.get('client_name')}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="owncloud", description="ownCloud backup inventory (GPS audit)") + p.add_argument("--host", help="override server host/IP (default 172.16.3.22)") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status") + u = sub.add_parser("users"); u.add_argument("--json", action="store_true") + us = sub.add_parser("usage"); us.add_argument("--json", action="store_true") + a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true") + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + c = OwncloudClient(args.host) + handlers = {"status": cmd_status, "users": cmd_users, + "usage": cmd_usage, "audit": cmd_audit} + try: + return handlers[args.cmd](c, args) + except bc.BackupError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + # best-effort error logging per house rule + try: + root = bc.resolve_root() + bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"), + "owncloud", str(exc)[:200]], timeout=15) + except Exception: + pass + return 1 + finally: + c.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/owncloud/scripts/owncloud_client.py b/.claude/skills/owncloud/scripts/owncloud_client.py new file mode 100644 index 00000000..613b9b23 --- /dev/null +++ b/.claude/skills/owncloud/scripts/owncloud_client.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""ownCloud server client for the `owncloud` skill (GPS backup verification). + +Talks to ACG's live ownCloud 10.16 server (cloud.acghosting.com / 172.16.3.22) +over SSH and drives the `occ` admin CLI. Read-only: enumerates users, their +quota + actual storage used, and last login so the GPS backup audit can see who +is actually backing up to ownCloud and how much data they hold. + +There is NO ownCloud admin WEB/OCS account in the vault, so the access channel is +SSH (root@172.16.3.22, paramiko password auth) + `occ`, not the OCS HTTP API. +`occ` must run as the web/service user, so every invocation is: + + sudo -u apache php /var/www/owncloud/occ + +Per-user "used bytes" is not exposed by occ on this version (no `user:info`). +Instead we read the authoritative figure straight from ownCloud's filecache: the +size ownCloud itself reports for each user's root. DB creds are read live from +the server's config.php (we already have root), so nothing is duplicated and it +survives a DB-password rotation. If a user's cached size is dirty (-1), we fall +back to `du -sb /files` for that one user (slower, generous timeout). + +Credentials load lazily from the SOPS vault (never hardcoded): +`infrastructure/owncloud-vm.sops.yaml` -> credentials.username / credentials.password. +""" +from __future__ import annotations + +import json +import shlex +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +import paramiko + +# Import the shared backup helpers from .claude/scripts. +_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/owncloud +_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts +sys.path.insert(0, str(_SCRIPTS_DIR)) +import backup_common as bc # noqa: E402 + +VAULT_ENTRY = "infrastructure/owncloud-vm.sops.yaml" +VAULT_FIELD_USER = "credentials.username" +VAULT_FIELD_PASS = "credentials.password" + +DEFAULT_HOST = "172.16.3.22" +SSH_PORT = 22 +# occ must run as the web user (apache) with the system php against the install dir. +OCC_INSTALL_DIR = "/var/www/owncloud" +OCC = f"sudo -u apache php {OCC_INSTALL_DIR}/occ" +CONFIG_PHP = f"{OCC_INSTALL_DIR}/config/config.php" + +EXEC_TIMEOUT = 60 # seconds for normal occ / mysql calls +DU_TIMEOUT = 900 # du on a dirty-cache home can be hundreds of GB + + +class OwncloudClient: + def __init__(self, host: Optional[str] = None): + self.host = host or DEFAULT_HOST + self._ssh: Optional[paramiko.SSHClient] = None + self._db: Optional[dict] = None + + # -- ssh ------------------------------------------------------------------- + def _connect(self) -> paramiko.SSHClient: + if self._ssh is not None: + return self._ssh + user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER) + pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS) + cli = paramiko.SSHClient() + cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + cli.connect(self.host, port=SSH_PORT, username=user, password=pw, + timeout=20, look_for_keys=False, allow_agent=False) + except Exception as exc: # paramiko raises several distinct types + raise bc.BackupError(f"SSH connect to {user}@{self.host} failed: {exc}") from exc + self._ssh = cli + return cli + + def run(self, cmd: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]: + """Run a shell command over SSH; return (exit_status, stdout, stderr).""" + cli = self._connect() + try: + _in, out, err = cli.exec_command(cmd, timeout=timeout) + stdout = out.read().decode("utf-8", "replace") + stderr = err.read().decode("utf-8", "replace") + rc = out.channel.recv_exit_status() + except Exception as exc: + raise bc.BackupError(f"SSH command failed ({cmd[:60]}...): {exc}") from exc + return rc, stdout, stderr + + def occ(self, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]: + return self.run(f"{OCC} {args}", timeout=timeout) + + def close(self) -> None: + if self._ssh is not None: + try: + self._ssh.close() + finally: + self._ssh = None + + # -- server status --------------------------------------------------------- + def server_status(self) -> dict: + """occ status (installed/version/edition) plus the `occ -V` version string.""" + rc, out, err = self.occ("status --output=json") + if rc != 0: + raise bc.BackupError(f"occ status failed (rc={rc}): {err.strip()[:300]}") + try: + status = json.loads(out) + except json.JSONDecodeError: + raise bc.BackupError(f"occ status returned non-JSON: {out.strip()[:300]}") + rc2, ver, _ = self.occ("-V") + status["occ_version"] = ver.strip() if rc2 == 0 else None + status["host"] = self.host + return status + + # -- storage used (filecache primary, du fallback) ------------------------- + def _db_config(self) -> dict: + """Read DB name/user/password/prefix live from config.php (we have root).""" + if self._db is not None: + return self._db + rc, out, err = self.run( + "grep -E \"'db(name|user|password|tableprefix)'\" " + CONFIG_PHP) + if rc != 0: + raise bc.BackupError(f"reading {CONFIG_PHP} failed (rc={rc}): {err.strip()[:200]}") + cfg = {"dbname": "owncloud", "dbuser": "owncloud", + "dbpassword": "", "dbtableprefix": "oc_"} + for line in out.splitlines(): + # form: 'dbname' => 'value', + if "=>" not in line: + continue + key, _, val = line.partition("=>") + k = key.strip().strip("'\" ") + v = val.strip().rstrip(",").strip().strip("'\"") + if k in cfg: + cfg[k] = v + self._db = cfg + return cfg + + def _mysql(self, sql: str) -> str: + cfg = self._db_config() + cmd = (f"MYSQL_PWD={shlex.quote(cfg['dbpassword'])} " + f"mysql -N -B -u {shlex.quote(cfg['dbuser'])} " + f"{shlex.quote(cfg['dbname'])} -e {shlex.quote(sql)}") + rc, out, err = self.run(cmd) + if rc != 0: + raise bc.BackupError(f"mysql query failed (rc={rc}): {err.strip()[:200]}") + return out + + def _filecache_sizes(self) -> dict[str, int]: + """uid -> cached used bytes for each per-user home storage (ownCloud's own + figure). A size of -1 means the cache is dirty; such uids are omitted here + and handled by the du fallback.""" + pfx = self._db_config()["dbtableprefix"] + sql = (f"SELECT s.id, fc.size FROM {pfx}storages s " + f"JOIN {pfx}filecache fc ON fc.storage=s.numeric_id " + f"WHERE s.id LIKE 'home::%' AND fc.path='';") + out = self._mysql(sql) + sizes: dict[str, int] = {} + for row in out.splitlines(): + if "\t" not in row: + continue + sid, _, val = row.partition("\t") + if not sid.startswith("home::"): + continue + uid = sid[len("home::"):] + try: + n = int(val.strip()) + except ValueError: + continue + if n >= 0: + sizes[uid] = n + return sizes + + def _du_bytes(self, uid: str, home: str) -> Optional[int]: + """Fallback used-bytes via du on the on-disk home (dirty-cache users only).""" + if not home: + return None + rc, out, err = self.run( + f"du -sb {shlex.quote(home + '/files')} 2>/dev/null | cut -f1", + timeout=DU_TIMEOUT) + try: + return int(out.strip()) + except (ValueError, AttributeError): + return None + + # -- users ----------------------------------------------------------------- + def list_users(self, with_usage: bool = True) -> list[dict]: + """All users with attributes (uid, display name, email, quota, enabled, + last login, home, backend). When with_usage, enrich each with used bytes + from the filecache, falling back to du for dirty-cache users.""" + rc, out, err = self.occ("user:list --show-all-attributes --output=json") + if rc != 0: + raise bc.BackupError(f"occ user:list failed (rc={rc}): {err.strip()[:300]}") + try: + raw = json.loads(out) + except json.JSONDecodeError: + raise bc.BackupError(f"occ user:list returned non-JSON: {out.strip()[:300]}") + + users: list[dict] = [] + for uid, attrs in raw.items(): + attrs = attrs or {} + users.append({ + "uid": uid, + "display_name": attrs.get("displayName"), + "email": attrs.get("email"), + "quota": attrs.get("quota"), + "enabled": _as_bool(attrs.get("enabled")), + "last_login": _epoch(attrs.get("lastLogin")), + "creation_time": _epoch(attrs.get("creationTime")), + "backend": attrs.get("backend"), + "home": attrs.get("home"), + "used_bytes": None, + "used_source": None, + }) + + if with_usage: + sizes = self._filecache_sizes() + for u in users: + uid = u["uid"] + if uid in sizes: + u["used_bytes"] = sizes[uid] + u["used_source"] = "filecache" + else: + # dirty cache (-1) or no storage row: compute on disk + du = self._du_bytes(uid, u.get("home")) + u["used_bytes"] = du + u["used_source"] = "du" if du is not None else None + return users + + +def _epoch(v: Any) -> Optional[str]: + """occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never.""" + try: + n = int(v) + except (TypeError, ValueError): + return None + if n <= 0: + return None + return datetime.fromtimestamp(n, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +def _as_bool(v: Any) -> bool: + if isinstance(v, bool): + return v + if isinstance(v, str): + return v.strip().lower() in ("true", "yes", "1", "enabled") + return bool(v) + + +def main() -> int: + c = OwncloudClient() + try: + s = c.server_status() + print(f"[OK] ownCloud reachable at {c.host}; version={s.get('versionstring')} " + f"edition={s.get('edition')}") + return 0 + except bc.BackupError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 1 + finally: + c.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/seafile/.gitignore b/.claude/skills/seafile/.gitignore new file mode 100644 index 00000000..9959b58a --- /dev/null +++ b/.claude/skills/seafile/.gitignore @@ -0,0 +1,3 @@ +# Holds a live Seafile API token — never commit. +.cache/ +__pycache__/ diff --git a/.claude/skills/seafile/SKILL.md b/.claude/skills/seafile/SKILL.md new file mode 100644 index 00000000..78fc806a --- /dev/null +++ b/.claude/skills/seafile/SKILL.md @@ -0,0 +1,77 @@ +--- +name: seafile +description: "Read-only inventory of ACG's Seafile Pro server (the SeaCloud / SeaDrive backup backend on Jupiter): who has an account, per-user storage used, libraries, and which GuruRMM endpoints actually run the SeaDrive/Seafile client. Built for GPS backup verification. Triggers: seafile, seacloud, seadrive, sync.azcomputerguru, who backs up to seafile, seafile usage." +--- + +# Seafile (SeaCloud / SeaDrive) Skill + +Read-only client for ACG's live **Seafile Pro** server — the backend clients call +"SeaCloud" (the server) and "SeaDrive" (the desktop virtual-drive client). Runs in +Docker on Jupiter (`172.16.3.20:8082`, public `https://sync.azcomputerguru.com`). +Answers the GPS backup-audit question: **which customers back up here and how +much data do they hold** — and, with `--rmm`, which enrolled machines actually run +the client. + +This skill is one of the backup-verification siblings alongside `b2` (Backblaze), +`owncloud`, and `datto-workplace`. It never writes. + +## Running + +```bash +SEAFILE=".claude/skills/seafile/scripts/seafile.py" +py "$SEAFILE" status # server reachable + version +py "$SEAFILE" usage # per-user storage + library count (audit view) +py "$SEAFILE" users [--inactive] # all accounts + used/quota + last login +py "$SEAFILE" libraries # all libraries (repos) + owner + size +py "$SEAFILE" devices [--user EMAIL] # desktop/sync devices the server has seen +py "$SEAFILE" audit # active accounts with data (the GPS slice) +py "$SEAFILE" audit --rmm # + cross-check GuruRMM endpoints for the client +py "$SEAFILE" audit --client Russo --rmm --json +``` + +Add `--json` to any command for machine-readable output. `--url` overrides the +server base (default is the internal Docker endpoint; use the public host when +off-LAN). + +## Credentials + +Loaded at runtime from the SOPS vault (never hardcoded): +`services/seafile-pro.sops.yaml` -> `credentials.username` / `credentials.password` +(the Seafile admin account). Auth is `POST /api2/auth-token/` (form) -> a +long-lived API token used as `Authorization: Token `. The token is a secret; +it is cached at `.claude/skills/seafile/.cache/token.json` (gitignored, mode 0600) +and re-fetched weekly or on a 401/403. + +## What the data means + +- **quota_usage** (bytes) is the authoritative per-account storage used. A user + with `is_active: true` and non-zero usage is actively backing up here. +- **libraries** (repos) are the sync roots; `size` + `file_count` + `last_modified` + show freshness. `usage` rolls libraries up per owner. +- Accounts are emails, so the customer is usually obvious from the domain + (e.g. `russo@rrs-law.com` = Russo Law, `greg@gstoltzlaw.com` = Stoltz Law). +- As of the build (2026-07-05) only a **handful** of accounts hold data + (~5 TB total) — Seafile is used by a few clients, not the whole GPS base. + +## The "both" cross-check (`--rmm`) + +Server-side tells you who has an account; `--rmm` adds the endpoint half. It logs +in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`), and for each agent +(optionally filtered by `--client`) runs a **read-only** detection PowerShell that +reports installed products / running processes / config folders matching +`Seafile` / `SeaDrive`. An agent is reported only if the client is actually +present. Detection dispatches a command to live endpoints — scope with `--client` +rather than sweeping all 350+ agents unless you mean to. + +## Notes / gotchas + +- Base defaults to `http://172.16.3.20:8082` (direct, no Cloudflare); set + `SEAFILE_URL=https://sync.azcomputerguru.com` when off the LAN/Tailscale. +- `devices` depends on the admin devices endpoint, which varies by Seafile + version; it returns `[]` (with a note) rather than failing if unsupported. +- Shared plumbing (repo-root resolve, vault read, GuruRMM client + endpoint + detection) lives in `.claude/scripts/backup_common.py`, used by all three + server-backup skills. Detection patterns must be precise per backend — this + skill uses `Seafile`/`SeaDrive`. +- Seafile Pro also has a MySQL backend on Jupiter (vault `credentials.database.*`) + for deeper queries; not used here (the admin API covers the audit needs). diff --git a/.claude/skills/seafile/scripts/seafile.py b/.claude/skills/seafile/scripts/seafile.py new file mode 100644 index 00000000..d481442b --- /dev/null +++ b/.claude/skills/seafile/scripts/seafile.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""CLI for the `seafile` skill — read-only Seafile Pro (SeaCloud/SeaDrive) inventory +for the GPS backup audit. + +Commands: + status server reachable + version + users [--inactive] [--json] all accounts + storage used + last login + libraries [--json] all libraries (repos) + owner + size + usage [--json] per-user rollup: storage + library count (the audit view) + devices [--user EMAIL] [--json] desktop/sync devices the server has seen + audit [--client NAME] [--rmm] [--json] + the GPS view: users/usage, optionally cross-checked + against GuruRMM endpoints (SeaDrive/Seafile installed?) + +Server-side is the source of "who has an account + how much data"; --rmm adds the +"which enrolled machine actually runs the client" half (the 'both' cross-check). +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from seafile_client import SeafileClient # noqa: E402 +import backup_common as bc # noqa: E402 + +GB = 1_000_000_000 + + +def _gb(n) -> float: + try: + return round(int(n) / GB, 2) + except (TypeError, ValueError): + return 0.0 + + +def _fmt_quota(total) -> str: + try: + t = int(total) + except (TypeError, ValueError): + return "?" + return "unlimited" if t < 0 else f"{_gb(t)} GB" + + +def cmd_status(c: SeafileClient, args) -> int: + info = c.server_info() + print(f"[OK] {c.base}") + print(json.dumps(info, indent=2)) + return 0 + + +def cmd_users(c: SeafileClient, args) -> int: + users = c.list_users() + if args.inactive: + users = [u for u in users if not u.get("is_active")] + users.sort(key=lambda u: int(u.get("quota_usage") or 0), reverse=True) + if args.json: + print(json.dumps(users, indent=2)) + return 0 + print(f"{'EMAIL':40} {'ACTIVE':6} {'USED':>10} {'QUOTA':>10} {'LAST LOGIN':20}") + for u in users: + print(f"{(u.get('email') or '')[:40]:40} " + f"{'yes' if u.get('is_active') else 'NO':6} " + f"{_gb(u.get('quota_usage')):>7} GB " + f"{_fmt_quota(u.get('quota_total')):>10} " + f"{(u.get('last_login') or '-')[:19]:20}") + print(f"\n{len(users)} users; total used " + f"{round(sum(_gb(u.get('quota_usage')) for u in users), 1)} GB") + return 0 + + +def cmd_libraries(c: SeafileClient, args) -> int: + repos = c.list_libraries() + repos.sort(key=lambda r: int(r.get("size") or 0), reverse=True) + if args.json: + print(json.dumps(repos, indent=2)) + return 0 + print(f"{'LIBRARY':30} {'OWNER':35} {'SIZE':>10} {'FILES':>8} {'MODIFIED':20}") + for r in repos: + print(f"{(r.get('name') or '')[:30]:30} " + f"{(r.get('owner_email') or r.get('owner') or '')[:35]:35} " + f"{_gb(r.get('size')):>7} GB " + f"{str(r.get('file_count') or '?'):>8} " + f"{(r.get('last_modified') or '-')[:19]:20}") + print(f"\n{len(repos)} libraries; total " + f"{round(sum(_gb(r.get('size')) for r in repos), 1)} GB") + return 0 + + +def _usage_rollup(c: SeafileClient) -> list[dict]: + users = {u.get("email"): u for u in c.list_users()} + repos = c.list_libraries() + by_owner: dict[str, dict] = {} + for r in repos: + owner = r.get("owner_email") or r.get("owner") + d = by_owner.setdefault(owner, {"libraries": 0, "lib_bytes": 0}) + d["libraries"] += 1 + d["lib_bytes"] += int(r.get("size") or 0) + rollup = [] + for email, u in users.items(): + o = by_owner.get(email, {"libraries": 0, "lib_bytes": 0}) + rollup.append({ + "email": email, + "name": u.get("name"), + "is_active": bool(u.get("is_active")), + "quota_used_bytes": int(u.get("quota_usage") or 0), + "quota_used_gb": _gb(u.get("quota_usage")), + "quota_total": u.get("quota_total"), + "libraries": o["libraries"], + "library_gb": _gb(o["lib_bytes"]), + "last_login": u.get("last_login"), + "last_access": u.get("last_access_time"), + }) + rollup.sort(key=lambda x: x["quota_used_bytes"], reverse=True) + return rollup + + +def cmd_usage(c: SeafileClient, args) -> int: + rollup = _usage_rollup(c) + if args.json: + print(json.dumps(rollup, indent=2)) + return 0 + print(f"{'USER':40} {'ACT':4} {'USED':>9} {'LIBS':>5} {'LAST LOGIN':20}") + for r in rollup: + print(f"{(r['email'] or '')[:40]:40} " + f"{'yes' if r['is_active'] else 'NO':4} " + f"{r['quota_used_gb']:>6} GB " + f"{r['libraries']:>5} " + f"{(r['last_login'] or '-')[:19]:20}") + return 0 + + +def cmd_devices(c: SeafileClient, args) -> int: + devices = c.list_devices(args.user) + if args.json: + print(json.dumps(devices, indent=2)) + return 0 + if not devices: + print("[INFO] no devices returned (endpoint may be unsupported on this Seafile version)") + return 0 + print(f"{'USER':35} {'DEVICE':25} {'PLATFORM':12} {'LAST ACCESS':20}") + for d in devices: + print(f"{(d.get('user') or '')[:35]:35} " + f"{(d.get('device_name') or '')[:25]:25} " + f"{(d.get('platform') or '')[:12]:12} " + f"{(d.get('last_accessed') or d.get('last_login_ip') or '-')[:19]:20}") + return 0 + + +def cmd_audit(c: SeafileClient, args) -> int: + rollup = _usage_rollup(c) + # active accounts with data are the ones actually backing up here + active = [r for r in rollup if r["is_active"] and r["quota_used_bytes"] > 0] + result = {"backend": "seafile", "server": c.base, "accounts": active} + + if args.rmm: + rmm = bc.RmmClient() + rmm.login() + checks = [] + agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents() + for a in agents: + det = rmm.detect_client(a, ["Seafile", "SeaDrive"]) + if det.get("detected"): + checks.append(det) + result["rmm_endpoints_with_client"] = checks + + if args.json: + print(json.dumps(result, indent=2)) + return 0 + + print(f"=== Seafile (SeaCloud/SeaDrive) backup accounts - {c.base} ===") + print(f"{'USER':40} {'USED':>9} {'LIBS':>5} {'LAST LOGIN':20}") + for r in active: + print(f"{(r['email'] or '')[:40]:40} {r['quota_used_gb']:>6} GB " + f"{r['libraries']:>5} {(r['last_login'] or '-')[:19]:20}") + print(f"\n{len(active)} active accounts with data; " + f"{round(sum(r['quota_used_gb'] for r in active), 1)} GB total") + if args.rmm: + eps = result.get("rmm_endpoints_with_client", []) + print(f"\n--- GuruRMM endpoints running a Seafile/SeaDrive client ({len(eps)}) ---") + for e in eps: + print(f" {e['hostname']:25} client={e.get('client_name')}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory") + p.add_argument("--url", help="override server base URL (default internal Docker endpoint)") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status") + u = sub.add_parser("users"); u.add_argument("--inactive", action="store_true"); u.add_argument("--json", action="store_true") + l = sub.add_parser("libraries"); l.add_argument("--json", action="store_true") + us = sub.add_parser("usage"); us.add_argument("--json", action="store_true") + d = sub.add_parser("devices"); d.add_argument("--user"); d.add_argument("--json", action="store_true") + a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true") + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + c = SeafileClient(args.url) + handlers = {"status": cmd_status, "users": cmd_users, "libraries": cmd_libraries, + "usage": cmd_usage, "devices": cmd_devices, "audit": cmd_audit} + try: + return handlers[args.cmd](c, args) + except bc.BackupError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + # best-effort error logging per house rule + try: + root = bc.resolve_root() + bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"), + "seafile", str(exc)[:200]], timeout=15) + except Exception: + pass + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/seafile/scripts/seafile_client.py b/.claude/skills/seafile/scripts/seafile_client.py new file mode 100644 index 00000000..c3fe7846 --- /dev/null +++ b/.claude/skills/seafile/scripts/seafile_client.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Seafile Pro Web API v2.1 client for the `seafile` skill. + +Talks to ACG's live Seafile Pro server (the "SeaCloud" / "SeaDrive" backend) that +runs in Docker on Jupiter. Read-only: enumerates users, libraries (repos) and +per-user storage so the GPS backup audit can see who is actually backing up here. + +Auth: POST /api2/auth-token/ (form username+password) -> a long-lived API token, +then Bearer-style `Authorization: Token ` on every call. The token is a +secret; it is cached under the skill's .cache/ (gitignored) like the b2 skill. + +Base URL defaults to the internal Docker endpoint (http://172.16.3.20:8082) which +is directly reachable on the LAN/Tailscale; override with SEAFILE_URL (e.g. the +public https://sync.azcomputerguru.com). +""" +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +# Import the shared backup helpers from .claude/scripts. +_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/seafile +_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts +sys.path.insert(0, str(_SCRIPTS_DIR)) +import backup_common as bc # noqa: E402 + +VAULT_ENTRY = "services/seafile-pro.sops.yaml" +VAULT_FIELD_USER = "credentials.username" +VAULT_FIELD_PASS = "credentials.password" + +DEFAULT_URL = os.environ.get("SEAFILE_URL", "http://172.16.3.20:8082") + +CACHE_DIR = _SKILL_DIR / ".cache" +TOKEN_CACHE = CACHE_DIR / "token.json" +# Seafile API tokens don't rotate on their own; re-auth weekly to stay safe. +TOKEN_TTL_SECONDS = 7 * 24 * 3600 + + +class SeafileClient: + def __init__(self, base: Optional[str] = None): + self.base = (base or DEFAULT_URL).rstrip("/") + self._token: Optional[str] = None + + # -- auth ------------------------------------------------------------------ + def _read_cache(self) -> Optional[str]: + if not TOKEN_CACHE.exists(): + return None + try: + c = json.loads(TOKEN_CACHE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + if c.get("base") != self.base: + return None + ts = c.get("authorized_at") + try: + when = datetime.fromisoformat(ts) + except (TypeError, ValueError): + return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + if (datetime.now(timezone.utc) - when).total_seconds() > TOKEN_TTL_SECONDS: + return None + return c.get("token") + + def _write_cache(self, token: str) -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + TOKEN_CACHE.write_text(json.dumps({ + "base": self.base, "token": token, + "authorized_at": datetime.now(timezone.utc).isoformat(), + }, indent=2), encoding="utf-8") + try: + os.chmod(TOKEN_CACHE, 0o600) + except OSError: + pass + + def _authorize(self) -> str: + user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER) + pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS) + form = urllib.parse.urlencode({"username": user, "password": pw}).encode() + req = bc.urllib.request.Request( + f"{self.base}/api2/auth-token/", data=form, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp: + body = json.loads(resp.read().decode("utf-8", errors="replace")) + except bc.urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise bc.BackupError(f"Seafile auth-token failed (HTTP {exc.code}): {raw[:300]}") + except bc.urllib.error.URLError as exc: + raise bc.BackupError(f"Seafile auth request failed: {exc}") + token = body.get("token") + if not token: + raise bc.BackupError(f"Seafile auth-token response missing token: {body}") + self._write_cache(token) + return token + + def _tok(self) -> str: + if not self._token: + self._token = self._read_cache() or self._authorize() + return self._token + + def _get(self, path: str, params: Optional[dict] = None, retry_auth: bool = True) -> Any: + url = f"{self.base}{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + status, body = bc.http_json("GET", url, headers={"Authorization": f"Token {self._tok()}"}) + if status in (401, 403) and retry_auth: + self._token = self._authorize() + return self._get(path, params, retry_auth=False) + if status != 200: + raise bc.BackupError(f"Seafile GET {path} failed (HTTP {status}): {body}", status=status) + return body + + # -- read methods ---------------------------------------------------------- + def server_info(self) -> dict: + return self._get("/api2/server-info/") + + def list_users(self) -> list[dict]: + """All users via the admin API, paginated. Includes quota_usage (bytes).""" + users: list[dict] = [] + page = 1 + while True: + body = self._get("/api/v2.1/admin/users/", {"page": page, "per_page": 100}) + batch = body.get("data", []) if isinstance(body, dict) else [] + users.extend(batch) + info = body.get("page_info") if isinstance(body, dict) else None + # Admin users endpoint returns has_next_page in page_info on some + # versions; else stop when a short page comes back. + if info and not info.get("has_next_page"): + break + if not info and len(batch) < 100: + break + if not batch: + break + page += 1 + return users + + def list_libraries(self) -> list[dict]: + """All libraries (repos) via the admin API, paginated. Includes size + owner.""" + repos: list[dict] = [] + page = 1 + while True: + body = self._get("/api/v2.1/admin/libraries/", {"page": page, "per_page": 100}) + batch = body.get("repos", []) if isinstance(body, dict) else [] + repos.extend(batch) + info = body.get("page_info") if isinstance(body, dict) else None + if info and not info.get("has_next_page"): + break + if not info and len(batch) < 100: + break + if not batch: + break + page += 1 + return repos + + def list_devices(self, user_email: Optional[str] = None) -> list[dict]: + """Sync/desktop devices seen by the server (admin). Optional per-user filter. + + Proves a machine actually connected a Seafile/SeaDrive client. Endpoint + availability varies by Seafile version; returns [] if unsupported. + """ + try: + body = self._get("/api/v2.1/admin/devices/", {"page": 1, "per_page": 500}) + except bc.BackupError: + return [] + devices = body.get("devices", []) if isinstance(body, dict) else [] + if user_email: + devices = [d for d in devices if d.get("user") == user_email] + return devices + + +def main() -> int: + try: + c = SeafileClient() + info = c.server_info() + print(f"[OK] Seafile reachable at {c.base}; version={info.get('version')} " + f"edition={'pro' if 'seafile-pro' in (info.get('features') or []) else 'ce'}") + return 0 + except bc.BackupError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/projects/gps-rmm-audit/session-logs/2026-07/2026-07-05-howard-backup-verification-skills.md b/projects/gps-rmm-audit/session-logs/2026-07/2026-07-05-howard-backup-verification-skills.md new file mode 100644 index 00000000..3bcf3959 --- /dev/null +++ b/projects/gps-rmm-audit/session-logs/2026-07/2026-07-05-howard-backup-verification-skills.md @@ -0,0 +1,159 @@ +# GPS Backup Verification — Seafile / ownCloud / Datto Workplace Skills + +## User +- **User:** Howard Enos (howard) +- **Machine:** Howard-Home +- **Role:** tech + +## Session Summary + +Resumed the GPS -> GuruRMM coverage audit's Phase 4 (backup verification). Howard +corrected an earlier assumption: the backup-destination list was B2-only, but many +GPS customers back up through other channels — Datto Workplace, ownCloud, or Seafile +(the last two run on Jupiter; "SeaCloud"/"SeaDrive" are the Seafile server + its +desktop client). Decision was to build one query skill per backend so the audit can +gather correct per-customer backup data instead of guessing. + +Recovered context from `wiki/projects/gps-rmm-audit.md`, `wiki/systems/jupiter.md`, +and `.claude/memory/feedback_backup_targets_never_guessed.md`. Confirmed the access +surface for each backend: Seafile Pro has a documented Web API (admin creds vaulted), +ownCloud is reachable only via root SSH to the VM (no web/OCS admin cred stored) so +`occ` is the channel, and Datto Workplace had no vault credentials at all. Asked +Howard two decisions: how to handle Datto (he supplied API creds mid-session) and +what the source of truth should be (he chose "both" — server-side inventory plus +GuruRMM endpoint detection). + +Probed all three backends live. Seafile Web API authenticated and returned admin +user/library data. ownCloud VM was reachable over SSH (paramiko 4.0.0 present). +Datto Workplace API authenticated under HTTP Basic (key:secret) but is a file-access +API only (projects/files/webhooks, per its OpenAPI spec) with no team-admin +endpoints, and `/file/projects` returns empty for this key. Vaulted the Datto creds +(`msp-tools/datto-workplace.sops.yaml`) via the vault skill. + +Built a shared library `.claude/scripts/backup_common.py` (repo-root/vault +resolution, minimal JSON HTTP, and a GuruRMM `RmmClient` that logs in, lists agents, +and runs a read-only detection PowerShell to confirm a sync client is installed and +signed in). Built and live-verified the `seafile` skill as the reference +implementation, then delegated `owncloud` and `datto-workplace` to two parallel +sub-agents using seafile + backup_common as the template. Independently verified all +three against live data. + +Findings overturn the B2-only picture: Seafile holds 5 active accounts / ~5 TB +(Russo Law 2.2 TB, Stoltz Law, Sonoran Glass, + ACG internal); ownCloud holds 10 +active users / 19.2 TB (Pavon 14.2 TB, Rohrbach, BST, Martell, MinRec, The Marc +Group, and others); Datto Workplace is confirmed on Birth Biologic via endpoint +detection. BST appears in both ownCloud and a B2 bucket, showing the cross-backend +overlap the final map must reconcile. + +## Key Decisions + +- Source of truth = "both": server-side inventory (who has an account + how much + data) plus GuruRMM endpoint detection (which enrolled machine actually runs the + client). Chosen by Howard. +- Three skills, not four: "SeaCloud" and "SeaDrive" are the same Seafile system, so + one `seafile` skill covers them; `b2` already covers Backblaze. +- Factored shared plumbing into `.claude/scripts/backup_common.py` rather than + duplicating the GuruRMM client + vault reads across three skills (used identically + by all three). Deviates from b2's fully-standalone style, justified by 3x reuse. +- ownCloud channel = SSH + `occ` (via paramiko), not the OCS HTTP API — the vault + has only root SSH, no ownCloud web-admin account. Per-user "used bytes" is not + exposed by occ on 10.16, so the sub-agent reads ownCloud's own filecache sizes from + MySQL (config.php creds read live over SSH), with a `du` fallback for dirty-cache + users. +- Datto Workplace: RMM endpoint detection is the primary source (the file API is + scoped to what the key can see and returns no projects). Detection patterns MUST be + `["Datto Workplace","Workplace2"]`, never bare `"Datto"` (false-matches Datto EDR/ + RMM/AV — verified). +- Built seafile myself + verified, then delegated owncloud/datto to parallel + sub-agents (independent, multi-file, parallelizable) with the verified skill as the + template to keep the three consistent. + +## Problems Encountered + +- Bare `"Datto"` detection pattern false-matched "Datto EDR Agent" on a test machine. + Fixed by requiring precise patterns (`Datto Workplace`/`Workplace2`); confirmed + live: ACG-DWP-X-BB detected=True (installed "Datto Workplace Server"), an EDR-only + box detected=False. +- `/tmp` write blocked by the repo's block-tmp-path pre-hook. Switched probe temp + files to repo-relative paths (`./.dw_probe.json`). +- Datto Workplace endpoint names unknown — guessed resource names (team/users/etc) + all 404'd. Resolved by pulling the OpenAPI spec at `/api/v1/openapi.json`, which + revealed it is a `/file/*` + `/webhook/*` API only. +- ownCloud `usage` is slow (SSH + per-user work); the verification command + auto-backgrounded. Cause is the `du` fallback path for dirty-cache users; the + filecache MySQL query is the fast primary. Completed fine (10 users / 19.2 TB). +- TaskCreate tool schema not loaded (wrong param shape); skipped the task tracker + rather than fight it. + +## Configuration Changes + +Created: +- `.claude/scripts/backup_common.py` — shared: root/vault resolve, JSON HTTP, GuruRMM RmmClient + endpoint detection PowerShell. +- `.claude/skills/seafile/SKILL.md`, `scripts/seafile.py`, `scripts/seafile_client.py`, `.gitignore` +- `.claude/skills/owncloud/SKILL.md`, `scripts/owncloud.py`, `scripts/owncloud_client.py`, `.gitignore` (built by sub-agent) +- `.claude/skills/datto-workplace/SKILL.md`, `scripts/datto_workplace.py`, `scripts/datto_client.py`, `.gitignore` (built by sub-agent) + +Vault (separate repo, stored + verified, NOT yet pushed): +- `msp-tools/datto-workplace.sops.yaml` + +## Credentials & Secrets + +- **Datto Workplace API** — vaulted at `msp-tools/datto-workplace.sops.yaml`. + - Base: `https://acg.workplace.datto.com/api/v1` (team path `/6/api/v1` normalized to `/api/v1`). + - Auth: HTTP Basic, username = api-key, password = api-secret. + - api-key `6d1dbfa4-5652-4ae3-94ff-fdbbe312c36b`, api-secret `ed2cd873-5a49-4271-8043-a2d2e56e99f8`. + - File-access API only; `/file/projects` returns empty for this key. OpenAPI at `/api/v1/openapi.json`. +- Seafile Pro admin — existing vault `services/seafile-pro.sops.yaml` (credentials.username / credentials.password; also credentials.database.*). +- ownCloud VM — existing vault `infrastructure/owncloud-vm.sops.yaml` (credentials.username=root / credentials.password). +- GuruRMM API — existing vault `infrastructure/gururmm-server.sops.yaml` (credentials.gururmm-api.admin-email / admin-password). + +## Infrastructure & Servers + +- **Seafile Pro** — Docker on Jupiter, `http://172.16.3.20:8082` (direct) / `https://sync.azcomputerguru.com` (NPM). Version 12.0.19, edition pro. 11.8 TB storage. +- **ownCloud VM** — `172.16.3.22` (hostname cloud.acghosting.com), Rocky Linux 9.6, ownCloud 10.16.0 Community, install dir `/var/www/owncloud`, web user `apache`, `occ` = `sudo -u apache php /var/www/owncloud/occ`. +- **Datto Workplace** — SaaS `acg.workplace.datto.com` (team 6). On-prem "Datto Workplace Server" VM `ACG-DWP-X-BB` on Jupiter (Birth Biologic). +- **GuruRMM API** — `http://172.16.3.30:3001` (356 agents, 235 online at session time). + +## Commands & Outputs + +```bash +# Seafile skill +py .claude/skills/seafile/scripts/seafile.py usage +py .claude/skills/seafile/scripts/seafile.py audit [--client NAME] [--rmm] [--json] + +# ownCloud skill +py .claude/skills/owncloud/scripts/owncloud.py usage +py .claude/skills/owncloud/scripts/owncloud.py audit [--client NAME] [--rmm] [--json] + +# Datto Workplace skill +py .claude/skills/datto-workplace/scripts/datto_workplace.py status +py .claude/skills/datto-workplace/scripts/datto_workplace.py audit --client Birth --rmm +``` + +Live results: +- Seafile: 5 active accounts / ~5.02 TB (russo@rrs-law.com 2204.8 GB; mike 1469 GB; greg@gstoltzlaw.com 733 GB; debra@sonoranglass.org 448 GB; howard 165 GB). +- ownCloud: 10 users / 19189.2 GB (pavon 14187 GB; sysadmin 2893 GB; anaise 605 GB; rohrbach 603 GB; mara 308 GB; bst 253 GB; Martell 186 GB; minrec 98 GB; themarcgroup 29 GB; jburger 28 GB). +- Datto detection precision: ACG-DWP-X-BB (BirthBiologic) detected=True installed=['Datto Workplace Server']; DESKTOP-QNP3ON5 (EDR-only) detected=False. + +## Pending / Incomplete Tasks + +- Paused per Howard: "we will come back to this." +- Commit/publish: new skills + backup_common.py are staged in the working tree + (uncommitted); Datto vault entry stored+verified locally but NOT pushed. Run + `/scc` or `/sync` to publish (sync.sh Phase 6 pushes the vault repo). +- Next step (Howard's call): produce the corrected GPS per-customer backup map — + cross-reference all four backends (B2 + Seafile + ownCloud + Datto Workplace) + against the 40 active GPS clients + `targets.json`, resolving every billed backup + line to a real destination. Directly answers held findings (e.g. "AMT billed + backup, no B2 destination found" — check Seafile/ownCloud/Datto). +- Datto `audit --rmm` is per-endpoint (slow); always scope with `--client`. +- Optional: reconcile cross-backend overlap (e.g. BST in both ownCloud and B2 bucket ACG-BST). + +## Reference Information + +- Datto Workplace OpenAPI: `GET https://acg.workplace.datto.com/api/v1/openapi.json` (Basic auth). +- Datto WP endpoints: `/file/projects`, `/file/{parentID}/files`, `/file/{fileID}`, `/file/{fileID}/data`, `/file/search`, `/webhook*`. +- Seafile admin API: `/api2/auth-token/` (form) -> token; `/api/v2.1/admin/users/`, `/api/v2.1/admin/libraries/`, `/api/v2.1/admin/devices/`. +- ownCloud: `occ user:list --show-all-attributes --output=json`; per-user size from `oc_storages`/`oc_filecache` (home::, path=''). +- Detection engine: `.claude/scripts/backup_common.py` `RmmClient.detect_client(agent, patterns)`. +- Related memory: `feedback_backup_targets_never_guessed` (billing mismatch is a Winter/Mike question, not a deploy instruction).