sync: auto-sync from HOWARD-HOME at 2026-07-05 15:29:59

Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 15:29:59
This commit is contained in:
2026-07-05 15:30:28 -07:00
parent a5b8f6e7b1
commit fa4867580f
14 changed files with 1849 additions and 0 deletions

View File

@@ -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 <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, 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)

View File

@@ -0,0 +1,3 @@
# No live secrets are cached (Basic auth is per-request), but keep scratch out.
.cache/
__pycache__/

View File

@@ -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.

View File

@@ -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())

View File

@@ -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())

3
.claude/skills/owncloud/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Skill scratch/cache - never commit.
.cache/
__pycache__/

View File

@@ -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 <cmd>
```
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::<uid>` -> 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 <home>/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::<uid>` 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).

View File

@@ -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())

View File

@@ -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 <cmd>
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 <home>/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())

3
.claude/skills/seafile/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Holds a live Seafile API token — never commit.
.cache/
__pycache__/

View File

@@ -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 <tok>`. 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).

View File

@@ -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())

View File

@@ -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 <tok>` 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())

View File

@@ -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::<uid>, 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).