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:
3
.claude/skills/datto-workplace/.gitignore
vendored
Normal file
3
.claude/skills/datto-workplace/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# No live secrets are cached (Basic auth is per-request), but keep scratch out.
|
||||
.cache/
|
||||
__pycache__/
|
||||
74
.claude/skills/datto-workplace/SKILL.md
Normal file
74
.claude/skills/datto-workplace/SKILL.md
Normal 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.
|
||||
164
.claude/skills/datto-workplace/scripts/datto_client.py
Normal file
164
.claude/skills/datto-workplace/scripts/datto_client.py
Normal 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())
|
||||
165
.claude/skills/datto-workplace/scripts/datto_workplace.py
Normal file
165
.claude/skills/datto-workplace/scripts/datto_workplace.py
Normal 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
3
.claude/skills/owncloud/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Skill scratch/cache - never commit.
|
||||
.cache/
|
||||
__pycache__/
|
||||
95
.claude/skills/owncloud/SKILL.md
Normal file
95
.claude/skills/owncloud/SKILL.md
Normal 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).
|
||||
169
.claude/skills/owncloud/scripts/owncloud.py
Normal file
169
.claude/skills/owncloud/scripts/owncloud.py
Normal 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())
|
||||
265
.claude/skills/owncloud/scripts/owncloud_client.py
Normal file
265
.claude/skills/owncloud/scripts/owncloud_client.py
Normal 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
3
.claude/skills/seafile/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Holds a live Seafile API token — never commit.
|
||||
.cache/
|
||||
__pycache__/
|
||||
77
.claude/skills/seafile/SKILL.md
Normal file
77
.claude/skills/seafile/SKILL.md
Normal 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).
|
||||
222
.claude/skills/seafile/scripts/seafile.py
Normal file
222
.claude/skills/seafile/scripts/seafile.py
Normal 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())
|
||||
191
.claude/skills/seafile/scripts/seafile_client.py
Normal file
191
.claude/skills/seafile/scripts/seafile_client.py
Normal 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())
|
||||
Reference in New Issue
Block a user