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:
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