From 976bfe795764aeebd9a58ab6a772ba96f9d6969e Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Sun, 5 Jul 2026 20:00:20 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-07-05 19:59:52 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-07-05 19:59:52 --- .../reference_msp360_backup_monitoring.md | 5 +- .claude/skills/msp360/.gitignore | 3 + .claude/skills/msp360/SKILL.md | 103 +++++ .claude/skills/msp360/scripts/msp360.py | 430 +++++++++++++++++ .../skills/msp360/scripts/msp360_client.py | 119 +++++ .claude/skills/owncloud/SKILL.md | 89 +++- .claude/skills/owncloud/scripts/owncloud.py | 436 +++++++++++++++++- .../owncloud/scripts/owncloud_client.py | 204 ++++++++ .claude/skills/seafile/SKILL.md | 44 +- .claude/skills/seafile/scripts/seafile.py | 184 +++++++- .../skills/seafile/scripts/seafile_client.py | 105 +++++ errorlog.md | 4 + 12 files changed, 1712 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/msp360/.gitignore create mode 100644 .claude/skills/msp360/SKILL.md create mode 100644 .claude/skills/msp360/scripts/msp360.py create mode 100644 .claude/skills/msp360/scripts/msp360_client.py diff --git a/.claude/memory/reference_msp360_backup_monitoring.md b/.claude/memory/reference_msp360_backup_monitoring.md index 461608ff..3e3075fd 100644 --- a/.claude/memory/reference_msp360_backup_monitoring.md +++ b/.claude/memory/reference_msp360_backup_monitoring.md @@ -24,4 +24,7 @@ where Cloud plans land; a bucket with files does not prove a recent backup ran). B2 buckets: `ACG-PST`=Peaceful Spirit, `ACG-TCA`=Tucson Coin and Autograph. - Backup targets are a per-client decision — report mismatches, don't deploy jobs. See [[feedback_backup_targets_never_guessed]]. Related: [[reference_backblaze_storage_rate]]. -- Worth building a read-only `msp360` skill (login + Monitoring) so this is repeatable. +- **Use the `msp360` skill** (`.claude/skills/msp360/`) — built 2026-07-05, full (not + read-only): reads (`status`/`monitoring --failed|--company|--stale`/`companies`/`users`/ + `licenses`/`billing`) plus `--confirm`-gated writes (create/delete user+company, + grant/release/revoke license) and a `raw` passthrough for the rest of the MBS API. diff --git a/.claude/skills/msp360/.gitignore b/.claude/skills/msp360/.gitignore new file mode 100644 index 00000000..5b0c8520 --- /dev/null +++ b/.claude/skills/msp360/.gitignore @@ -0,0 +1,3 @@ +# Skill scratch/cache - never commit. Auth token is per-run (not cached to disk). +.cache/ +__pycache__/ diff --git a/.claude/skills/msp360/SKILL.md b/.claude/skills/msp360/SKILL.md new file mode 100644 index 00000000..a9ea369d --- /dev/null +++ b/.claude/skills/msp360/SKILL.md @@ -0,0 +1,103 @@ +--- +name: msp360 +description: "Manage ACG's MSP360 Managed Backup Service (MBS) tenant via the provider REST API: the authoritative fleet backup-status source (who is backing up, last run, failures, stale plans) plus user/company/license provisioning. Reads run freely; writes (create/delete user or company, grant/release/revoke license) are gated --confirm. Triggers: msp360, mspbackups, managed backup, is X backing up, backup status, backup failures, create backup user, add backup company, grant backup license." +--- + +# MSP360 Managed Backup Service Skill + +Live client for ACG's **MSP360 Managed Backup Service (MBS)** provider tenant. This is the +**authoritative answer to "is client X actually backing up"** — the monitoring feed reports +every backup plan's last run, status, and data across the whole fleet. B2/Seafile/ownCloud +are just where the data lands; MSP360 is where you see whether a backup *ran*. + +Built as a sibling to the `b2` / `seafile` / `owncloud` / `datto-workplace` backup skills; +shares `.claude/scripts/backup_common.py` for repo-root/vault/HTTP plumbing. + +## Running + +```bash +MSP=".claude/skills/msp360/scripts/msp360.py" + +# --- reads (free) --- +py "$MSP" status # reachable + counts + plans needing attention +py "$MSP" monitoring # every plan: company/computer/status/last-run/data +py "$MSP" monitoring --failed # only plans NOT in a clean state (the triage view) +py "$MSP" monitoring --company Dataforth # scope to one client +py "$MSP" monitoring --stale 30 # plans whose last run is >=30 days old (or never) +py "$MSP" companies # 37 companies with ids + storage limits +py "$MSP" users --company "Business Services" # backup users (+ space used) in a company +py "$MSP" licenses --available # free vs taken licenses +py "$MSP" billing --json # billing feed +py "$MSP" raw GET /api/Users/{id} # generic passthrough for any GET endpoint + +# --- writes (require --confirm; without it you get a dry-run preview) --- +py "$MSP" create-company --name "New Client LLC" --confirm +py "$MSP" create-user --email backup@client.com --company "New Client LLC" \ + --first Jane --last Doe --notify admin@acg.com --confirm +py "$MSP" grant-license --user-id --license-id --confirm +py "$MSP" release-license --user-id --confirm +py "$MSP" delete-user --id [--keep-data] --confirm +py "$MSP" delete-company --id --confirm +py "$MSP" raw POST /api/Accounts/CreateDestination --data '{...}' --confirm +``` + +Add `--json` to any read for machine-readable output. `--base` overrides the API URL. + +## Credentials + +Loaded at runtime from the SOPS vault (never hardcoded): +**`msp-tools/msp360-api.sops.yaml`** -> `credentials.login` / `credentials.password`. +These are the **API-specific** creds +generated in the MSP360 console (Settings > General) by the main admin account — NOT the +console login. Auth flow: `POST /api/Provider/Login {UserName, Password}` -> Bearer +`access_token` (per-run, never cached to disk) -> `Authorization: Bearer ` on every +request. Base: `https://api.mspbackups.com`. + +## What "backing up?" looks like + +`monitoring` returns one row per backup plan with `Status` (0/1=Success, 2=Warning, +3=Failed, 4=Running, 6=Interrupted, 7=Completed-with-warnings), `LastStart`, `DataCopied`, +`CompanyName`, `ComputerName`, `PlanName`. Key reads: + +- **A company with 0 rows in `monitoring` is not backing up** even if it exists (this is how + the GPS audit found Arizona Medical Transit + T&C Sorensen billed-but-unprotected). +- `--failed` surfaces failing/warning plans; `--stale N` surfaces plans that stopped running. +- A B2 bucket with files does NOT prove a recent backup — always confirm with `monitoring`. + +## Writes & safety + +- **Every write is gated `--confirm`.** Without it the command prints a `[DRY-RUN]` line (and, + for create-*, the exact JSON payload) and changes nothing. +- `delete-user` defaults to removing metadata **and all backup data**; pass `--keep-data` to + drop only the metadata (`DELETE /api/Users/{id}/Account`) and retain the backups. +- `delete-company` / `delete-user` print a `[WARNING]` describing the blast radius first. +- create-user / grant-license payloads are built from the live API schema (User fields: + Email/FirstName/LastName/Company/Enabled/NotificationEmails; grant: UserID + LicenseID). + On first real provisioning, eyeball the API's response — it returns the created object or a + validation error, which the command prints verbatim. + +## Full endpoint surface (via `raw` when a typed command doesn't exist) + +The MBS API groups (all reachable through `raw `): **Provider** (login), +**Users** (GET/POST/PUT, DELETE `/{id}` or `/{id}/Account`), **Companies** (GET/POST/PUT/DELETE), +**Monitoring** (GET), **Licenses** (GET, POST Grant/Release/Revoke), **Destinations** +(GET/POST/PUT/DELETE), **Accounts** (GET/POST/PUT + Add/Create/Edit/RemoveDestination), +**Packages** (GET/POST/PUT/DELETE), **Billing** (GET/PUT), **Builds** (GET, POST +RequestCustomBuilds), **Administrators** (GET/POST/PUT/DELETE). Reference: +`https://help.mspbackups.com/mbs-api-specification/methods`. + +## Notes / gotchas + +- **`/api/Computers` returns HTTP 400 "Remote Management API methods are not enabled"** — + that feature is off on this account. `computers` prints a clean [INFO] and points you at + `users` + `monitoring`, which cover endpoint/backup data without it. +- **Git-Bash path mangling:** a leading-slash `raw` path like `/api/Users` gets rewritten to + `C:/Program Files/Git/api/Users` by MSYS. The `raw` command auto-recovers the `/api/...` + tail, so pass the path normally. +- MSP360 CompanyName is the join key to B2 buckets (e.g. `ACG-PST`=Peaceful Spirit, + `ACG-TCA`=Tucson Coin and Autograph) — useful when reconciling destinations. +- Backup *targets* are a per-client decision: report mismatches, don't provision jobs off a + billing count. See memory `feedback_backup_targets_never_guessed` + + `reference_msp360_backup_monitoring`. +- Failures are logged to the fleet `errorlog.md` via the canonical helper; expected + conditions (feature-gated endpoint, no matching rows) are not. diff --git a/.claude/skills/msp360/scripts/msp360.py b/.claude/skills/msp360/scripts/msp360.py new file mode 100644 index 00000000..8a8701f6 --- /dev/null +++ b/.claude/skills/msp360/scripts/msp360.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""MSP360 Managed Backup Service (MBS) CLI. + +Read the fleet backup state and manage users / companies / licenses via the MBS +provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is +printed without it). See SKILL.md for the trigger list and examples. + + Reads: status | monitoring | companies | users | computers | licenses | billing + Writes: create-company | delete-company | create-user | delete-user + grant-license | release-license | revoke-license | raw +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS # noqa: E402 +import backup_common as bc # noqa: E402 (msp360_client put .claude/scripts on sys.path) + + +# --- helpers ------------------------------------------------------------------ +def _root() -> Path: + # honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing. + return bc.resolve_root() + + +def _as_list(data, what: str) -> list: + """A read endpoint promised a JSON array; a non-list 2xx body is anomalous.""" + if isinstance(data, list): + return data + raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}") + + +def log_error(brief: str, context: str = "") -> None: + """Best-effort fleet error log; never breaks the caller (per CLAUDE.md).""" + try: + script = _root() / ".claude" / "scripts" / "log-skill-error.sh" + if not script.exists(): + return + cmd = ["bash", str(script), "msp360", brief] + if context: + cmd += ["--context", context] + subprocess.run(cmd, capture_output=True, text=True, timeout=20) + except Exception: + pass + + +def out(obj, as_json: bool) -> None: + if as_json: + print(json.dumps(obj, indent=2, default=str)) + + +def human(n) -> str: + try: + n = float(n) + except (TypeError, ValueError): + return str(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(n) < 1024: + return f"{n:.1f}{unit}" + n /= 1024 + return f"{n:.1f}PB" + + +def _days_since(ts: str): + """Whole days since an ISO timestamp. None if empty OR unparseable (callers must + distinguish 'never ran' from 'could not parse' via the raw value themselves).""" + if not ts: + return None + try: + dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_dt.timezone.utc) + return (_dt.datetime.now(_dt.timezone.utc) - dt).days + except Exception: + return None + + +def require_confirm(args, action: str) -> bool: + """Return True to proceed; if --confirm absent, print the dry-run and return False.""" + if getattr(args, "confirm", False): + return True + print(f"[DRY-RUN] Would: {action}") + print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.") + return False + + +# --- read commands ------------------------------------------------------------ +def cmd_status(c: Msp360Client, args) -> int: + comps = c.companies() + users = c.users() + mon = c.monitoring() + if args.json: + out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True) + return 0 + bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS) + print(f"[OK] MSP360 MBS reachable at {c.base}") + print(f"[INFO] companies={len(comps)} users={len(users)} backup plans={len(mon)} " + f"plans needing attention={bad}") + return 0 + + +def cmd_monitoring(c: Msp360Client, args) -> int: + mon = _as_list(c.monitoring(), "monitoring") + rows = [] + for m in mon: + if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower(): + continue + status = m.get("Status") + last_raw = m.get("LastStart") or "" + never = not last_raw + age = _days_since(last_raw) + # --failed = triage: anything that is NOT a clean completed run and is not + # in-progress. Includes never-run, Unknown(5) and null status (dead plans). + if args.failed: + clean_success = status in (0, 1) and not never + if clean_success or status == 4: # 4 = Running + continue + # --stale = last run older than N days, OR never ran. An unparseable-but-present + # timestamp is NOT claimed stale (avoid a false "not backing up"). + if args.stale is not None: + if never: + pass + elif age is not None and age >= args.stale: + pass + else: + continue + rows.append((m, age)) + if args.json: + out([r[0] for r in rows], True) + return 0 + if not rows: + print("[INFO] no plans match the filter.") + return 0 + print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA") + for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(), + r[0].get('ComputerName') or '')): + st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status"))) + last = m.get("LastStart") or "never" + agestr = f" ({age}d)" if age is not None and age >= 30 else "" + print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} " + f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} " + f"{human(m.get('DataCopied', 0))}") + print(f"\n[INFO] {len(rows)} plan(s). Filters: " + f"company={args.company or '-'} failed={args.failed} stale>={args.stale}") + return 0 + + +def cmd_companies(c: Msp360Client, args) -> int: + comps = _as_list(c.companies(), "companies") + if args.json: + out(comps, True) + return 0 + for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()): + lim = co.get("StorageLimit") + limstr = "unlimited" if lim in (-1, None) else human(lim) + print(f" {(co.get('Name') or ''):<34} id={co.get('Id')} storage_limit={limstr}") + print(f"\n[INFO] {len(comps)} companies") + return 0 + + +def cmd_users(c: Msp360Client, args) -> int: + users = _as_list(c.users(), "users") + if args.company: + users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()] + if args.json: + out(users, True) + return 0 + for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")): + en = "enabled" if u.get("Enabled") else "DISABLED" + print(f" {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} " + f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}") + print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else "")) + return 0 + + +def cmd_computers(c: Msp360Client, args) -> int: + try: + comps = _as_list(c.computers(), "computers") + except Msp360Error as exc: + msg = str(exc).lower() + if "remote management" in msg or "not enabled" in msg or "not available" in msg: + print("[INFO] /api/Computers is gated behind the Remote Management API feature, " + "which is not enabled on this MSP360 account. Use `users` + `monitoring` " + "for endpoint/backup data instead.") + return 0 + raise + if args.json: + out(comps, True) + return 0 + for m in comps: + print(f" {(m.get('Name') or m.get('ComputerName') or ''):<24} " + f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}") + print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers") + return 0 + + +def cmd_licenses(c: Msp360Client, args) -> int: + lic = _as_list(c.licenses(available=True if args.available else None), "licenses") + if args.json: + out(lic, True) + return 0 + taken = sum(1 for x in lic if x.get("IsTaken")) + for x in lic: + state = "TAKEN" if x.get("IsTaken") else "free" + num = x.get("Number") + num = "" if num is None else num + print(f" #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} " + f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}") + print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)") + return 0 + + +def cmd_billing(c: Msp360Client, args) -> int: + out(c.billing(), True) # billing has no table form; always JSON + return 0 + + +# --- write commands ----------------------------------------------------------- +def cmd_create_company(c: Msp360Client, args) -> int: + if not require_confirm(args, f"create company '{args.name}'"): + return 0 + res = c.create_company(args.name) + print(f"[OK] created company '{args.name}'") + out(res, True) + return 0 + + +def cmd_delete_company(c: Msp360Client, args) -> int: + print(f"[WARNING] Deleting company id={args.id} removes the company record.") + if not require_confirm(args, f"DELETE company id={args.id}"): + return 0 + res = c.delete_company(args.id) + print(f"[OK] deleted company id={args.id}") + out(res, True) + return 0 + + +def cmd_create_user(c: Msp360Client, args) -> int: + payload = { + "Email": args.email, + "FirstName": args.first or "", + "LastName": args.last or "", + "Company": args.company, + "Enabled": True, + "NotificationEmails": [args.notify] if args.notify else [], + } + if args.password: + payload["Password"] = args.password + if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"): + preview = dict(payload) + if "Password" in preview: + preview["Password"] = "***redacted***" # never echo a secret to the transcript + out(preview, True) + return 0 + res = c.create_user(payload) + print(f"[OK] created user '{args.email}'") + out(res, True) + return 0 + + +def cmd_delete_user(c: Msp360Client, args) -> int: + scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data" + print(f"[WARNING] Deleting user id={args.id}: {scope}.") + if not require_confirm(args, f"DELETE user id={args.id} ({scope})"): + return 0 + res = c.delete_user(args.id, keep_data=args.keep_data) + print(f"[OK] deleted user id={args.id}") + out(res, True) + return 0 + + +def cmd_grant_license(c: Msp360Client, args) -> int: + payload = {"UserID": args.user_id} + if args.license_id: + payload["LicenseID"] = args.license_id + if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"): + out(payload, True) + return 0 + res = c.grant_license(payload) + print("[OK] license granted") + out(res, True) + return 0 + + +def cmd_release_license(c: Msp360Client, args) -> int: + payload = {"UserID": args.user_id} + if args.license_id: + payload["LicenseID"] = args.license_id + if not require_confirm(args, f"release license from user {args.user_id}"): + return 0 + res = c.release_license(payload) + print("[OK] license released") + out(res, True) + return 0 + + +def cmd_revoke_license(c: Msp360Client, args) -> int: + payload = {"UserID": args.user_id} + if args.license_id: + payload["LicenseID"] = args.license_id + if not require_confirm(args, f"revoke license from user {args.user_id}"): + return 0 + res = c.revoke_license(payload) + print("[OK] license revoked") + out(res, True) + return 0 + + +def cmd_raw(c: Msp360Client, args) -> int: + method = args.method.upper() + body = json.loads(args.data) if args.data else None + # Git-Bash rewrites a leading-slash arg into a Windows path (e.g. + # '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail. + path = args.path + if "/api/" in path and not path.startswith("/api"): + path = path[path.index("/api/"):] + elif not path.startswith("/"): + path = "/" + path + if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"): + return 0 + res = c.request(method, path, body=body) + out(res, True) + return 0 + + +# --- argparse ----------------------------------------------------------------- +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI") + p.add_argument("--base", help="override API base URL") + sub = p.add_subparsers(dest="command", required=True) + + def add_json(sp): + sp.add_argument("--json", action="store_true", help="machine-readable JSON output") + return sp + + add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status) + + sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)") + sp.add_argument("--company", help="filter by company name substring") + sp.add_argument("--failed", action="store_true", help="only plans not in a clean state") + sp.add_argument("--stale", type=int, metavar="DAYS", + help="only plans whose last run is >= DAYS old (or never)") + add_json(sp) + sp.set_defaults(func=cmd_monitoring) + + add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies) + + sp = sub.add_parser("users", help="list backup users") + sp.add_argument("--company", help="filter by company name substring") + add_json(sp) + sp.set_defaults(func=cmd_users) + + add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers) + + sp = sub.add_parser("licenses", help="list licenses") + sp.add_argument("--available", action="store_true", help="only free/available licenses") + add_json(sp) + sp.set_defaults(func=cmd_licenses) + + sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing) + + # writes + sp = sub.add_parser("create-company", help="create a company [--confirm]") + sp.add_argument("--name", required=True) + sp.add_argument("--confirm", action="store_true") + sp.set_defaults(func=cmd_create_company) + + sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]") + sp.add_argument("--id", required=True) + sp.add_argument("--confirm", action="store_true") + sp.set_defaults(func=cmd_delete_company) + + sp = sub.add_parser("create-user", help="create a backup user [--confirm]") + sp.add_argument("--email", required=True) + sp.add_argument("--company", required=True) + sp.add_argument("--first") + sp.add_argument("--last") + sp.add_argument("--password") + sp.add_argument("--notify", help="notification email") + sp.add_argument("--confirm", action="store_true") + sp.set_defaults(func=cmd_create_user) + + sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]") + sp.add_argument("--id", required=True) + sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)") + sp.add_argument("--confirm", action="store_true") + sp.set_defaults(func=cmd_delete_user) + + for name, fn, verb in (("grant-license", cmd_grant_license, "grant"), + ("release-license", cmd_release_license, "release"), + ("revoke-license", cmd_revoke_license, "revoke")): + sp = sub.add_parser(name, help=f"{verb} a license [--confirm]") + sp.add_argument("--user-id", required=True) + sp.add_argument("--license-id") + sp.add_argument("--confirm", action="store_true") + sp.set_defaults(func=fn) + + sp = sub.add_parser("raw", help="raw API call (writes need --confirm)") + sp.add_argument("method") + sp.add_argument("path", help="e.g. /api/Users/{id}") + sp.add_argument("--data", help="JSON request body") + sp.add_argument("--confirm", action="store_true") + sp.set_defaults(func=cmd_raw) + + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + client = Msp360Client(base=args.base) + try: + return args.func(client, args) + except bc.BackupError as exc: # includes Msp360Error + vault/HTTP transport errors + print(f"[ERROR] {exc}", file=sys.stderr) + log_error(str(exc)[:200], context=f"command={args.command}") + return 1 + except Exception as exc: # noqa: BLE001 + print(f"[ERROR] unexpected: {exc}", file=sys.stderr) + log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/msp360/scripts/msp360_client.py b/.claude/skills/msp360/scripts/msp360_client.py new file mode 100644 index 00000000..a06d82ee --- /dev/null +++ b/.claude/skills/msp360/scripts/msp360_client.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""MSP360 Managed Backup Service (MBS) API client. + +Full CRUD over the provider REST API at https://api.mspbackups.com. Auth is a Bearer +token from POST /api/Provider/Login (login+password generated in MSP360 console +Settings > General, vaulted at msp-tools/msp360-api.sops.yaml). + +Reuses `.claude/scripts/backup_common.py` for repo-root resolution, the canonical SOPS +vault read, and stdlib JSON HTTP -- same plumbing as the seafile/owncloud/datto-workplace +sibling skills. Standalone: stdlib-only transport. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Optional + +# backup_common lives at /.claude/scripts/ ; this file is +# /.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == /.claude +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts")) +import backup_common as bc # noqa: E402 + +VAULT_ENTRY = "msp-tools/msp360-api.sops.yaml" +DEFAULT_BASE = "https://api.mspbackups.com" + +# Monitoring plan Status codes (observed + MBS convention). +STATUS_TEXT = { + 0: "Success", 1: "Success", 2: "Warning", 3: "Failed", + 4: "Running", 5: "Unknown", 6: "Interrupted", 7: "Completed/warnings", +} +# Statuses that mean "this run did NOT produce a clean backup". +BAD_STATUS = {2, 3, 6, 7} + + +class Msp360Error(bc.BackupError): + """MSP360 transport / auth / API error (carries optional HTTP status).""" + + +class Msp360Client: + """Thin MSP360 MBS API client: login once, then typed helpers over the endpoints.""" + + def __init__(self, base: Optional[str] = None): + self.base = (base or DEFAULT_BASE).rstrip("/") + self._token: Optional[str] = None + + # --- auth ----------------------------------------------------------------- + def login(self) -> None: + login = bc.vault_get_field(VAULT_ENTRY, "credentials.login") + pw = bc.vault_get_field(VAULT_ENTRY, "credentials.password") + st, body = bc.http_json( + "POST", f"{self.base}/api/Provider/Login", + body={"UserName": login, "Password": pw}) + if st != 200 or not isinstance(body, dict) or not body.get("access_token"): + raise Msp360Error(f"MSP360 login failed (HTTP {st}): {body}", status=st) + self._token = body["access_token"] + + def _auth(self) -> dict: + if not self._token: + self.login() + return {"Authorization": "Bearer " + str(self._token)} + + def request(self, method: str, path: str, body: Optional[dict] = None) -> Any: + """One API call. Raises Msp360Error on any non-2xx (with the API's error body).""" + st, data = bc.http_json(method, f"{self.base}{path}", headers=self._auth(), body=body) + if st < 200 or st >= 300: + raise Msp360Error(f"{method} {path} -> HTTP {st}: {data}", status=st) + return data + + # --- reads ---------------------------------------------------------------- + def monitoring(self, user_id: Optional[str] = None) -> list: + return self.request("GET", f"/api/Monitoring/{user_id}" if user_id else "/api/Monitoring") + + def companies(self) -> list: + return self.request("GET", "/api/Companies") + + def users(self) -> list: + return self.request("GET", "/api/Users") + + def computers(self, offset: int = 0, count: int = 1000) -> list: + return self.request("GET", f"/api/Computers/{offset}/{count}") + + def licenses(self, available: Optional[bool] = None) -> list: + path = "/api/Licenses" + if available is not None: + path += f"?isAvailable={'true' if available else 'false'}" + return self.request("GET", path) + + def billing(self) -> Any: + return self.request("GET", "/api/Billing") + + # --- writes (companies) --------------------------------------------------- + def create_company(self, name: str, **extra: Any) -> Any: + payload = {"Name": name, "StorageLimit": -1} + payload.update(extra) + return self.request("POST", "/api/Companies", body=payload) + + def delete_company(self, company_id: str) -> Any: + return self.request("DELETE", f"/api/Companies/{company_id}") + + # --- writes (users) ------------------------------------------------------- + # Property edits (PUT /api/Users, /api/Companies) are reachable via the CLI's + # `raw PUT ...` passthrough; not wrapped here until a typed edit command exists. + def create_user(self, payload: dict) -> Any: + return self.request("POST", "/api/Users", body=payload) + + def delete_user(self, user_id: str, keep_data: bool = False) -> Any: + # /{id}/Account = drop metadata but KEEP backup data; /{id} = drop everything. + suffix = f"/api/Users/{user_id}/Account" if keep_data else f"/api/Users/{user_id}" + return self.request("DELETE", suffix) + + # --- writes (licenses) ---------------------------------------------------- + def grant_license(self, payload: dict) -> Any: + return self.request("POST", "/api/Licenses/Grant", body=payload) + + def release_license(self, payload: dict) -> Any: + return self.request("POST", "/api/Licenses/Release", body=payload) + + def revoke_license(self, payload: dict) -> Any: + return self.request("POST", "/api/Licenses/Revoke", body=payload) diff --git a/.claude/skills/owncloud/SKILL.md b/.claude/skills/owncloud/SKILL.md index 2c049694..f0638ff2 100644 --- a/.claude/skills/owncloud/SKILL.md +++ b/.claude/skills/owncloud/SKILL.md @@ -1,20 +1,27 @@ --- 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." +description: "Manage ACG's ownCloud server (cloud.acghosting.com / 172.16.3.22, ownCloud 10.16 Community) via occ over SSH: read-only GPS backup inventory (who has an account, per-user storage + quota + last login, which GuruRMM endpoints run the desktop client) PLUS gated admin writes - user lifecycle, quota, groups, apps/config, maintenance mode, files scan/transfer, trashbin/versions cleanup. Reads run freely; writes require --confirm. Triggers: owncloud, cloud.acghosting, who backs up to owncloud, owncloud usage, owncloud audit, create/disable owncloud user, set owncloud quota, owncloud group, owncloud maintenance mode." --- # 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. +Client for ACG's live **ownCloud 10.16.0 Community** server +(`cloud.acghosting.com`, VM `172.16.3.22`, Rocky Linux 9.6), driven entirely +through the `occ` admin CLI over SSH. + +Two halves: +- **Read (GPS audit, ungated):** which customers back up here and how much data + they hold - and, with `--rmm`, which enrolled machines actually run the + ownCloud desktop client. +- **Manage (gated):** user/quota/group lifecycle, apps/config, maintenance mode, + filesystem scans, ownership transfer, trashbin/versions cleanup. Every + state-changing command **refuses without `--confirm`** (prints the WOULD line, + exits 3), mirroring the `b2` / `seafile` fleet convention. This skill is one of the backup-verification siblings alongside `seafile`, `b2` (Backblaze) and `datto-workplace`. -## Running +## Running - read (GPS audit) ```bash OWNCLOUD=".claude/skills/owncloud/scripts/owncloud.py" @@ -27,6 +34,64 @@ 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`). +## Running - manage (writes gated behind `--confirm`) + +```bash +# users +py "$OWNCLOUD" user-add jdoe --display-name "J Doe" --email j@x.com -g Clients --gen-password --confirm +py "$OWNCLOUD" user-disable jdoe --confirm # user-enable to re-enable +py "$OWNCLOUD" user-reset-password jdoe --password-env OC_NEW_PW --confirm +py "$OWNCLOUD" user-modify jdoe --email new@x.com --confirm +py "$OWNCLOUD" user-delete jdoe --confirm # DELETES the user AND all their data + +# quota (quota reads it; value like '5 GB', 'none' = unlimited) +py "$OWNCLOUD" quota jdoe +py "$OWNCLOUD" quota-set jdoe "50 GB" --confirm # quota-reset -> back to default + +# groups (groups / group-members / user-groups are read-only) +py "$OWNCLOUD" groups +py "$OWNCLOUD" group-add Clients --confirm +py "$OWNCLOUD" group-add-member Clients -m jdoe -m asmith --confirm +py "$OWNCLOUD" group-remove-member Clients -m jdoe --confirm +py "$OWNCLOUD" group-delete Clients --confirm + +# shares (read-only - occ cannot create shares; see Notes) +py "$OWNCLOUD" shares [--user jdoe] + +# server / apps / config +py "$OWNCLOUD" maintenance status|on|off [--confirm] # on/off need --confirm +py "$OWNCLOUD" apps +py "$OWNCLOUD" app-enable|app-disable --confirm +py "$OWNCLOUD" config-get [name] # no name -> dump all system config +py "$OWNCLOUD" config-set --value V [--type string|integer|double|boolean|json] --confirm + +# files admin +py "$OWNCLOUD" files-scan (--user jdoe | --all) [--path sub/dir] --confirm +py "$OWNCLOUD" transfer-ownership [--path folder] --confirm +py "$OWNCLOUD" trashbin-cleanup (--user jdoe ... | --all-users) --confirm +py "$OWNCLOUD" versions-cleanup (--user jdoe ... | --all-users) --confirm +``` + +### Passwords + the credential rule + +`user-add` / `user-reset-password` take the password one of three ways: +`--gen-password` (generates a strong one, prints it **once**), `--password-env VAR` +(reads it from that env var - preferred, keeps it out of shell history), or a +literal `--password` (discouraged: lands in history + `ps`). On the server the +value is passed via occ's own `OC_PASS` + `--password-from-env`. + +**Any password created or reset here MUST be stored in the SOPS vault** (house +rule) - the command prints a `[CRITICAL]` reminder with the exact `vault.sh` +line (suggested path `infrastructure/owncloud-users/.sops.yaml`). + +### Gating / exit codes + +- write command without `--confirm` -> prints `Would: ...`, **exit 3**, no change. +- `trashbin-cleanup` / `versions-cleanup` with **no** `--user` and **no** + `--all-users` -> **hard-fail exit 2** (occ treats "no user" as *every* user; a + fleet-wide purge must be made explicit with `--all-users`). +- invalid uid on `user-add` -> **exit 2**. + ## Access channel There is **no ownCloud admin web / OCS account** in the vault, so the channel is @@ -89,6 +154,16 @@ all 350+ agents. 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. +- **Shares are read-only here:** occ on this version exposes no share create/list + (only `sharing:cleanup-remote-storages`), so `shares` reads straight from the + `oc_share` DB table (joined to `oc_filecache` for the real path). Creating a + share would need the OCS API / an admin web cred we do not have. +- **occ command surface was verified live** against this exact 10.16 box (`occ + list` / `occ help `) before wiring the writes - flags differ by version. + Notable ones used: `user:modify displayname|email`, quota via + `user:setting files quota --value=`, passwords via `--password-from-env`, + `files:transfer-ownership -s`, `trashbin:cleanup`/`versions:cleanup` (no user + arg = ALL users, hence the `--all-users` guard). - 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 diff --git a/.claude/skills/owncloud/scripts/owncloud.py b/.claude/skills/owncloud/scripts/owncloud.py index 6daae28a..c8347df3 100644 --- a/.claude/skills/owncloud/scripts/owncloud.py +++ b/.claude/skills/owncloud/scripts/owncloud.py @@ -17,6 +17,10 @@ from __future__ import annotations import argparse import json +import os +import re +import secrets +import string import sys from pathlib import Path @@ -26,6 +30,9 @@ import backup_common as bc # noqa: E402 GB = 1_000_000_000 +# occ enforces this uid charset (a-z A-Z 0-9 and + _ . @ - '). +UID_RE = re.compile(r"^[A-Za-z0-9+_.@'\-]+$") + def _gb(n) -> float: try: @@ -34,6 +41,43 @@ def _gb(n) -> float: return 0.0 +# --- write-op gating (mirrors the fleet convention: refuse without --confirm) -- +def _gated(desc: str, confirm: bool) -> bool: + if not confirm: + print("[WARNING] Refusing state-changing action without --confirm.") + print(f"[INFO] Would: {desc}") + return False + return True + + +def _gen_password(n: int = 20) -> str: + alpha = string.ascii_letters + string.digits + "!@#%^*-_=+" + return "".join(secrets.choice(alpha) for _ in range(n)) + + +def _resolve_password(args, *, allow_gen: bool = True): + """Return (password, was_generated) from --password / --password-env / --gen-password. + Prefer --password-env or --gen-password: a literal --password lands in shell + history and `ps` on the operator's machine.""" + if getattr(args, "password", None): + return args.password, False + env_var = getattr(args, "password_env", None) + if env_var: + v = os.environ.get(env_var) + if not v: + raise SystemExit(f"[ERROR] env var {env_var} is unset/empty") + return v, False + if allow_gen and getattr(args, "gen_password", False): + return _gen_password(), True + return None, False + + +def _vault_reminder(uid: str) -> None: + print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:") + print(f" bash .claude/scripts/vault.sh set-field " + f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password ''") + + def cmd_status(c: OwncloudClient, args) -> int: s = c.server_status() print(f"[OK] {c.host}") @@ -132,6 +176,321 @@ def cmd_audit(c: OwncloudClient, args) -> int: return 0 +# --------------------------------------------------------------------------- +# Management commands (writes gated behind --confirm; reads are ungated) +# --------------------------------------------------------------------------- +def _ok(msg: str, payload: dict, as_json: bool) -> int: + if as_json: + print(json.dumps(payload, indent=2)) + else: + print(f"[OK] {msg}") + return 0 + + +# -- user lifecycle --------------------------------------------------------- +def cmd_user_add(c: OwncloudClient, args) -> int: + if not UID_RE.match(args.uid): + print(f"[ERROR] invalid uid {args.uid!r}: allowed chars are " + "a-z A-Z 0-9 + _ . @ - '", file=sys.stderr) + return 2 + pw, generated = _resolve_password(args) + if not pw: + print("[ERROR] supply --password, --password-env VAR, or --gen-password", + file=sys.stderr) + return 2 + desc = f"create ownCloud user '{args.uid}'" + if args.group: + desc += f" in group(s) {', '.join(args.group)}" + if not _gated(desc, args.confirm): + return 3 + res = c.create_user(args.uid, pw, args.display_name, args.email, args.group) + print(f"[OK] created user '{args.uid}'") + if generated: + print(f"[CRITICAL] generated password (shown once): {pw}") + _vault_reminder(args.uid) + if args.json: + print(json.dumps({"uid": args.uid, "created": True, + "generated_password": generated, "detail": res}, indent=2)) + return 0 + + +def cmd_user_delete(c: OwncloudClient, args) -> int: + if not _gated(f"DELETE user '{args.uid}' and ALL their data" + + (" (forced)" if args.force else ""), args.confirm): + return 3 + out = c.delete_user(args.uid, force=args.force) + return _ok(f"deleted user '{args.uid}'", {"uid": args.uid, "deleted": True, + "detail": out}, args.json) + + +def cmd_user_enable(c: OwncloudClient, args) -> int: + if not _gated(f"enable user '{args.uid}'", args.confirm): + return 3 + out = c.set_user_enabled(args.uid, True) + return _ok(f"enabled '{args.uid}'", {"uid": args.uid, "enabled": True, "detail": out}, args.json) + + +def cmd_user_disable(c: OwncloudClient, args) -> int: + if not _gated(f"disable user '{args.uid}'", args.confirm): + return 3 + out = c.set_user_enabled(args.uid, False) + return _ok(f"disabled '{args.uid}'", {"uid": args.uid, "enabled": False, "detail": out}, args.json) + + +def cmd_user_reset_password(c: OwncloudClient, args) -> int: + pw, generated = _resolve_password(args) + if not pw: + print("[ERROR] supply --password, --password-env VAR, or --gen-password", + file=sys.stderr) + return 2 + if not _gated(f"reset password for user '{args.uid}'", args.confirm): + return 3 + c.reset_password(args.uid, pw) + print(f"[OK] reset password for '{args.uid}'") + if generated: + print(f"[CRITICAL] generated password (shown once): {pw}") + _vault_reminder(args.uid) + if args.json: + print(json.dumps({"uid": args.uid, "password_reset": True, + "generated_password": generated}, indent=2)) + return 0 + + +def cmd_user_modify(c: OwncloudClient, args) -> int: + changes = [] + if args.display_name is not None: + changes.append(("displayname", args.display_name)) + if args.email is not None: + changes.append(("email", args.email)) + if not changes: + print("[ERROR] nothing to change: pass --display-name and/or --email", + file=sys.stderr) + return 2 + desc = "set " + ", ".join(f"{k}={v!r}" for k, v in changes) + f" on '{args.uid}'" + if not _gated(desc, args.confirm): + return 3 + for key, val in changes: + c.modify_user(args.uid, key, val) + return _ok(f"modified '{args.uid}'", {"uid": args.uid, + "changed": dict(changes)}, args.json) + + +# -- quota ------------------------------------------------------------------ +def cmd_quota(c: OwncloudClient, args) -> int: + """Read a user's quota (ungated).""" + q = c.get_quota(args.uid) + return _ok(f"{args.uid} quota: {q or 'default'}", + {"uid": args.uid, "quota": q}, args.json) + + +def cmd_quota_set(c: OwncloudClient, args) -> int: + if not _gated(f"set quota for '{args.uid}' to {args.value!r}", args.confirm): + return 3 + c.set_quota(args.uid, args.value) + return _ok(f"set '{args.uid}' quota = {args.value}", + {"uid": args.uid, "quota": args.value}, args.json) + + +def cmd_quota_reset(c: OwncloudClient, args) -> int: + if not _gated(f"reset '{args.uid}' quota to default", args.confirm): + return 3 + c.reset_quota(args.uid) + return _ok(f"reset '{args.uid}' quota to default", + {"uid": args.uid, "quota": "default"}, args.json) + + +# -- groups ----------------------------------------------------------------- +def cmd_groups(c: OwncloudClient, args) -> int: + g = c.list_groups() + if args.json: + print(json.dumps(g, indent=2)) + return 0 + names = g if isinstance(g, list) else list(g.keys()) + for n in names: + print(f" {n}") + print(f"\n{len(names)} groups") + return 0 + + +def cmd_group_members(c: OwncloudClient, args) -> int: + m = c.group_members(args.group) + if args.json: + print(json.dumps(m, indent=2)) + return 0 + members = m if isinstance(m, list) else list(m.keys()) + for u in members: + print(f" {u}") + print(f"\n{len(members)} members of '{args.group}'") + return 0 + + +def cmd_user_groups(c: OwncloudClient, args) -> int: + g = c.user_groups(args.uid) + if args.json: + print(json.dumps(g, indent=2)) + return 0 + groups = g if isinstance(g, list) else list(g.keys()) + for n in groups: + print(f" {n}") + print(f"\n'{args.uid}' is in {len(groups)} group(s)") + return 0 + + +def cmd_group_add(c: OwncloudClient, args) -> int: + if not _gated(f"create group '{args.group}'", args.confirm): + return 3 + c.create_group(args.group) + return _ok(f"created group '{args.group}'", {"group": args.group, "created": True}, args.json) + + +def cmd_group_delete(c: OwncloudClient, args) -> int: + if not _gated(f"delete group '{args.group}'", args.confirm): + return 3 + c.delete_group(args.group) + return _ok(f"deleted group '{args.group}'", {"group": args.group, "deleted": True}, args.json) + + +def cmd_group_add_member(c: OwncloudClient, args) -> int: + if not _gated(f"add {', '.join(args.member)} to group '{args.group}'", args.confirm): + return 3 + c.add_group_members(args.group, args.member) + return _ok(f"added {len(args.member)} member(s) to '{args.group}'", + {"group": args.group, "added": args.member}, args.json) + + +def cmd_group_remove_member(c: OwncloudClient, args) -> int: + if not _gated(f"remove {', '.join(args.member)} from group '{args.group}'", args.confirm): + return 3 + c.remove_group_members(args.group, args.member) + return _ok(f"removed {len(args.member)} member(s) from '{args.group}'", + {"group": args.group, "removed": args.member}, args.json) + + +# -- shares (read-only) ----------------------------------------------------- +def cmd_shares(c: OwncloudClient, args) -> int: + shares = c.list_shares(args.user) + if args.json: + print(json.dumps(shares, indent=2)) + return 0 + print(f"{'ID':>7} {'TYPE':12} {'OWNER':16} {'SHARE WITH':16} {'PATH/TARGET':30}") + for s in shares: + print(f"{str(s['id']):>7} {s['share_type'][:12]:12} " + f"{str(s['owner'] or '-')[:16]:16} {str(s['share_with'] or '-')[:16]:16} " + f"{str(s['path'] or s['target'] or '-')[:30]:30}") + print(f"\n{len(shares)} share(s)" + + (f" involving '{args.user}'" if args.user else "")) + return 0 + + +# -- server / apps / config ------------------------------------------------- +def cmd_maintenance(c: OwncloudClient, args) -> int: + if args.state == "status": + on = c.maintenance_status() + return _ok(f"maintenance mode is {'ON' if on else 'off'}", + {"maintenance": on}, args.json) + want_on = args.state == "on" + if not _gated(f"turn maintenance mode {'ON' if want_on else 'off'}", args.confirm): + return 3 + c.maintenance_mode(want_on) + return _ok(f"maintenance mode {'ON' if want_on else 'off'}", + {"maintenance": want_on}, args.json) + + +def cmd_apps(c: OwncloudClient, args) -> int: + apps = c.list_apps() + if args.json: + print(json.dumps(apps, indent=2)) + return 0 + enabled = apps.get("enabled", {}) if isinstance(apps, dict) else {} + disabled = apps.get("disabled", {}) if isinstance(apps, dict) else {} + print(f"--- enabled ({len(enabled)}) ---") + for a in sorted(enabled): + print(f" {a}") + print(f"--- disabled ({len(disabled)}) ---") + for a in sorted(disabled): + print(f" {a}") + return 0 + + +def cmd_app_enable(c: OwncloudClient, args) -> int: + if not _gated(f"enable app '{args.app}'", args.confirm): + return 3 + out = c.enable_app(args.app) + return _ok(f"enabled app '{args.app}'", {"app": args.app, "enabled": True, "detail": out}, args.json) + + +def cmd_app_disable(c: OwncloudClient, args) -> int: + if not _gated(f"disable app '{args.app}'", args.confirm): + return 3 + out = c.disable_app(args.app) + return _ok(f"disabled app '{args.app}'", {"app": args.app, "enabled": False, "detail": out}, args.json) + + +def cmd_config_get(c: OwncloudClient, args) -> int: + if args.name: + val = c.config_get(args.name) + return _ok(f"{args.name} = {val}", {"name": args.name, "value": val}, args.json) + cfg = c.config_list() + print(json.dumps(cfg, indent=2)) + return 0 + + +def cmd_config_set(c: OwncloudClient, args) -> int: + if not _gated(f"set system config {args.name} = {args.value!r} ({args.type})", args.confirm): + return 3 + c.config_set(args.name, args.value, args.type) + return _ok(f"set {args.name} = {args.value}", + {"name": args.name, "value": args.value, "type": args.type}, args.json) + + +# -- files admin ------------------------------------------------------------ +def cmd_files_scan(c: OwncloudClient, args) -> int: + if not args.all and not args.user: + print("[ERROR] specify a user or --all", file=sys.stderr) + return 2 + target = "ALL users" if args.all else f"'{args.user}'" + if not _gated(f"filesystem rescan for {target}" + + (f" path={args.path}" if args.path else ""), args.confirm): + return 3 + out = c.files_scan(uid=args.user, all_users=args.all, path=args.path) + return _ok(f"scanned {target}", {"scanned": target, "detail": out.splitlines()[-1:] if out else []}, args.json) + + +def cmd_transfer_ownership(c: OwncloudClient, args) -> int: + if not _gated(f"transfer files from '{args.source}' to '{args.dest}'" + + (f" path={args.path}" if args.path else ""), args.confirm): + return 3 + out = c.transfer_ownership(args.source, args.dest, args.path) + return _ok(f"transferred '{args.source}' -> '{args.dest}'", + {"source": args.source, "dest": args.dest, "detail": out}, args.json) + + +def _cleanup(c: OwncloudClient, args, kind: str) -> int: + """Shared guard for trashbin/versions cleanup: refuse a fleet-wide sweep + unless it is made explicit with --all-users (occ treats 'no user' as ALL).""" + users = args.user or [] + if not users and not args.all_users: + print(f"[ERROR] {kind} cleanup with no --user targets ALL users; " + "pass explicit --user, or --all-users to sweep everyone.", + file=sys.stderr) + return 2 + target = "ALL users" if args.all_users else ", ".join(users) + if not _gated(f"permanently delete {kind} for {target}", args.confirm): + return 3 + fn = c.trashbin_cleanup if kind == "trashbin" else c.versions_cleanup + out = fn([] if args.all_users else users) + return _ok(f"{kind} cleaned for {target}", + {"cleanup": kind, "target": target, "detail": out}, args.json) + + +def cmd_trashbin_cleanup(c: OwncloudClient, args) -> int: + return _cleanup(c, args, "trashbin") + + +def cmd_versions_cleanup(c: OwncloudClient, args) -> int: + return _cleanup(c, args, "versions") + + 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)") @@ -141,6 +500,62 @@ def build_parser() -> argparse.ArgumentParser: 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") + + def _pw_opts(sp): + sp.add_argument("--password", help="password literal (avoid: lands in shell history/ps)") + sp.add_argument("--password-env", metavar="VAR", help="read password from this env var") + sp.add_argument("--gen-password", action="store_true", help="generate a strong password") + + # --- user lifecycle (writes) --- + ua = sub.add_parser("user-add") + ua.add_argument("uid"); ua.add_argument("--display-name"); ua.add_argument("--email") + ua.add_argument("-g", "--group", action="append", help="group to add to (repeatable)") + _pw_opts(ua); ua.add_argument("--confirm", action="store_true"); ua.add_argument("--json", action="store_true") + + ud = sub.add_parser("user-delete"); ud.add_argument("uid") + ud.add_argument("-f", "--force", action="store_true"); ud.add_argument("--confirm", action="store_true"); ud.add_argument("--json", action="store_true") + + ue = sub.add_parser("user-enable"); ue.add_argument("uid"); ue.add_argument("--confirm", action="store_true"); ue.add_argument("--json", action="store_true") + udi = sub.add_parser("user-disable"); udi.add_argument("uid"); udi.add_argument("--confirm", action="store_true"); udi.add_argument("--json", action="store_true") + + urp = sub.add_parser("user-reset-password"); urp.add_argument("uid") + _pw_opts(urp); urp.add_argument("--confirm", action="store_true"); urp.add_argument("--json", action="store_true") + + um = sub.add_parser("user-modify"); um.add_argument("uid") + um.add_argument("--display-name"); um.add_argument("--email") + um.add_argument("--confirm", action="store_true"); um.add_argument("--json", action="store_true") + + # --- quota --- + q = sub.add_parser("quota"); q.add_argument("uid"); q.add_argument("--json", action="store_true") + qs = sub.add_parser("quota-set"); qs.add_argument("uid"); qs.add_argument("value", help="e.g. '5 GB', 'none' (unlimited)") + qs.add_argument("--confirm", action="store_true"); qs.add_argument("--json", action="store_true") + qr = sub.add_parser("quota-reset"); qr.add_argument("uid"); qr.add_argument("--confirm", action="store_true"); qr.add_argument("--json", action="store_true") + + # --- groups --- + gl = sub.add_parser("groups"); gl.add_argument("--json", action="store_true") + gm = sub.add_parser("group-members"); gm.add_argument("group"); gm.add_argument("--json", action="store_true") + ug = sub.add_parser("user-groups"); ug.add_argument("uid"); ug.add_argument("--json", action="store_true") + gadd = sub.add_parser("group-add"); gadd.add_argument("group"); gadd.add_argument("--confirm", action="store_true"); gadd.add_argument("--json", action="store_true") + gdel = sub.add_parser("group-delete"); gdel.add_argument("group"); gdel.add_argument("--confirm", action="store_true"); gdel.add_argument("--json", action="store_true") + gam = sub.add_parser("group-add-member"); gam.add_argument("group"); gam.add_argument("-m", "--member", action="append", required=True); gam.add_argument("--confirm", action="store_true"); gam.add_argument("--json", action="store_true") + grm = sub.add_parser("group-remove-member"); grm.add_argument("group"); grm.add_argument("-m", "--member", action="append", required=True); grm.add_argument("--confirm", action="store_true"); grm.add_argument("--json", action="store_true") + + # --- shares (read-only) --- + sh = sub.add_parser("shares"); sh.add_argument("--user"); sh.add_argument("--json", action="store_true") + + # --- server / apps / config --- + mnt = sub.add_parser("maintenance"); mnt.add_argument("state", choices=["status", "on", "off"]); mnt.add_argument("--confirm", action="store_true"); mnt.add_argument("--json", action="store_true") + ap = sub.add_parser("apps"); ap.add_argument("--json", action="store_true") + ape = sub.add_parser("app-enable"); ape.add_argument("app"); ape.add_argument("--confirm", action="store_true"); ape.add_argument("--json", action="store_true") + apd = sub.add_parser("app-disable"); apd.add_argument("app"); apd.add_argument("--confirm", action="store_true"); apd.add_argument("--json", action="store_true") + cg = sub.add_parser("config-get"); cg.add_argument("name", nargs="?", help="omit to dump all system config"); cg.add_argument("--json", action="store_true") + cs = sub.add_parser("config-set"); cs.add_argument("name"); cs.add_argument("--value", required=True); cs.add_argument("--type", default="string", choices=["string", "integer", "double", "boolean", "json"]); cs.add_argument("--confirm", action="store_true"); cs.add_argument("--json", action="store_true") + + # --- files admin --- + fs = sub.add_parser("files-scan"); fs.add_argument("--user"); fs.add_argument("--all", action="store_true"); fs.add_argument("--path"); fs.add_argument("--confirm", action="store_true"); fs.add_argument("--json", action="store_true") + to = sub.add_parser("transfer-ownership"); to.add_argument("source"); to.add_argument("dest"); to.add_argument("--path"); to.add_argument("--confirm", action="store_true"); to.add_argument("--json", action="store_true") + tc = sub.add_parser("trashbin-cleanup"); tc.add_argument("--user", action="append"); tc.add_argument("--all-users", action="store_true"); tc.add_argument("--confirm", action="store_true"); tc.add_argument("--json", action="store_true") + vc = sub.add_parser("versions-cleanup"); vc.add_argument("--user", action="append"); vc.add_argument("--all-users", action="store_true"); vc.add_argument("--confirm", action="store_true"); vc.add_argument("--json", action="store_true") return p @@ -148,7 +563,26 @@ 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} + "usage": cmd_usage, "audit": cmd_audit, + "user-add": cmd_user_add, "user-delete": cmd_user_delete, + "user-enable": cmd_user_enable, "user-disable": cmd_user_disable, + "user-reset-password": cmd_user_reset_password, + "user-modify": cmd_user_modify, + "quota": cmd_quota, "quota-set": cmd_quota_set, + "quota-reset": cmd_quota_reset, + "groups": cmd_groups, "group-members": cmd_group_members, + "user-groups": cmd_user_groups, + "group-add": cmd_group_add, "group-delete": cmd_group_delete, + "group-add-member": cmd_group_add_member, + "group-remove-member": cmd_group_remove_member, + "shares": cmd_shares, + "maintenance": cmd_maintenance, "apps": cmd_apps, + "app-enable": cmd_app_enable, "app-disable": cmd_app_disable, + "config-get": cmd_config_get, "config-set": cmd_config_set, + "files-scan": cmd_files_scan, + "transfer-ownership": cmd_transfer_ownership, + "trashbin-cleanup": cmd_trashbin_cleanup, + "versions-cleanup": cmd_versions_cleanup} try: return handlers[args.cmd](c, args) except bc.BackupError as exc: diff --git a/.claude/skills/owncloud/scripts/owncloud_client.py b/.claude/skills/owncloud/scripts/owncloud_client.py index 613b9b23..26b7d429 100644 --- a/.claude/skills/owncloud/scripts/owncloud_client.py +++ b/.claude/skills/owncloud/scripts/owncloud_client.py @@ -227,6 +227,210 @@ class OwncloudClient: u["used_source"] = "du" if du is not None else None return users + # -- occ helpers for management ops ---------------------------------------- + def occ_env(self, env: dict, args: str, + timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]: + """Run occ with extra environment (e.g. OC_PASS) exported for the apache + user. `sudo -u apache env KEY=val php occ ...` — the explicit `env` sets + the var inside apache's context regardless of sudo's env_reset policy. + + Caveat: the value is briefly visible in `ps` on the server to root; this + is occ's own documented mechanism for passwords and the box is root-only. + """ + prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items()) + cmd = f"sudo -u apache env {prefix} php {OCC_INSTALL_DIR}/occ {args}" + return self.run(cmd, timeout=timeout) + + def _occ_ok(self, args: str, timeout: int = EXEC_TIMEOUT) -> str: + """Run occ, raise BackupError on non-zero, return stdout.""" + rc, out, err = self.occ(args, timeout=timeout) + if rc != 0: + label = args.split()[0] if args else "occ" + raise bc.BackupError(f"occ {label} failed (rc={rc}): " + f"{(err or out).strip()[:300]}") + return out + + # -- user lifecycle -------------------------------------------------------- + def create_user(self, uid: str, password: str, + display_name: Optional[str] = None, + email: Optional[str] = None, + groups: Optional[list[str]] = None) -> dict: + parts = [f"user:add {shlex.quote(uid)} --password-from-env -n"] + if display_name: + parts.append(f"--display-name={shlex.quote(display_name)}") + if email: + parts.append(f"--email={shlex.quote(email)}") + for g in (groups or []): + parts.append(f"-g {shlex.quote(g)}") + rc, out, err = self.occ_env({"OC_PASS": password}, " ".join(parts)) + if rc != 0: + raise bc.BackupError(f"user:add {uid} failed (rc={rc}): " + f"{(err or out).strip()[:300]}") + return {"uid": uid, "output": out.strip()} + + def delete_user(self, uid: str, force: bool = False) -> str: + return self._occ_ok(f"user:delete {shlex.quote(uid)}" + + (" -f" if force else ""), timeout=DU_TIMEOUT).strip() + + def set_user_enabled(self, uid: str, enabled: bool) -> str: + verb = "user:enable" if enabled else "user:disable" + return self._occ_ok(f"{verb} {shlex.quote(uid)}").strip() + + def reset_password(self, uid: str, password: str) -> str: + rc, out, err = self.occ_env( + {"OC_PASS": password}, + f"user:resetpassword {shlex.quote(uid)} --password-from-env") + if rc != 0: + raise bc.BackupError(f"user:resetpassword {uid} failed (rc={rc}): " + f"{(err or out).strip()[:300]}") + return out.strip() + + def modify_user(self, uid: str, key: str, value: str) -> str: + """key is one of: displayname, email (per occ user:modify).""" + return self._occ_ok( + f"user:modify {shlex.quote(uid)} {shlex.quote(key)} {shlex.quote(value)}").strip() + + # -- quota (occ user:setting files quota) ---------------------------- + def get_quota(self, uid: str) -> Optional[str]: + rc, out, err = self.occ(f"user:setting {shlex.quote(uid)} files quota") + return out.strip() or None if rc == 0 else None + + def set_quota(self, uid: str, value: str) -> str: + return self._occ_ok( + f"user:setting {shlex.quote(uid)} files quota --value={shlex.quote(value)}").strip() + + def reset_quota(self, uid: str) -> str: + return self._occ_ok( + f"user:setting {shlex.quote(uid)} files quota --delete").strip() + + # -- groups ---------------------------------------------------------------- + def list_groups(self) -> Any: + return json.loads(self._occ_ok("group:list --output=json") or "[]") + + def group_members(self, group: str) -> Any: + return json.loads(self._occ_ok( + f"group:list-members {shlex.quote(group)} --output=json") or "[]") + + def user_groups(self, uid: str) -> Any: + return json.loads(self._occ_ok( + f"user:list-groups {shlex.quote(uid)} --output=json") or "[]") + + def create_group(self, name: str) -> str: + return self._occ_ok(f"group:add {shlex.quote(name)}").strip() + + def delete_group(self, name: str) -> str: + return self._occ_ok(f"group:delete {shlex.quote(name)}").strip() + + def add_group_members(self, group: str, members: list[str]) -> str: + ms = " ".join(f"-m {shlex.quote(m)}" for m in members) + return self._occ_ok(f"group:add-member {shlex.quote(group)} {ms}").strip() + + def remove_group_members(self, group: str, members: list[str]) -> str: + ms = " ".join(f"-m {shlex.quote(m)}" for m in members) + return self._occ_ok(f"group:remove-member {shlex.quote(group)} {ms}").strip() + + # -- shares (read-only, straight from the DB; occ has no share list) -------- + SHARE_TYPES = {0: "user", 1: "group", 3: "public_link", + 4: "email", 6: "federated"} + + def list_shares(self, uid: Optional[str] = None) -> list[dict]: + # NB: uid is NOT inlined into SQL. MySQL honours backslash escapes by + # default, so ''-doubling would not safely contain a hostile/odd uid + # (and occ allows "'" in uids). Fetch all rows, filter in Python. + pfx = self._db_config()["dbtableprefix"] + sql = (f"SELECT s.id, s.share_type, s.share_with, s.uid_owner, " + f"s.file_target, s.permissions, s.stime, fc.path " + f"FROM {pfx}share s " + f"LEFT JOIN {pfx}filecache fc ON fc.fileid=s.file_source " + f"ORDER BY s.stime DESC;") + out = self._mysql(sql) + shares: list[dict] = [] + for row in out.splitlines(): + f = [None if v in ("NULL", "") else v for v in row.split("\t")] + if len(f) < 8: + continue + if uid and uid not in (f[2], f[3]): # share_with / owner + continue + st = _int(f[1]) + shares.append({ + "id": _int(f[0]), + "share_type": self.SHARE_TYPES.get(st, str(st)), + "share_with": f[2], + "owner": f[3], + "target": f[4], + "permissions": _int(f[5]), + "created": _epoch(f[6]), + "path": f[7], + }) + return shares + + # -- server / apps / config ------------------------------------------------ + def maintenance_mode(self, on: bool) -> str: + return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip() + + def maintenance_status(self) -> bool: + rc, out, err = self.occ("config:system:get maintenance") + return rc == 0 and out.strip().lower() in ("true", "1", "yes") + + def list_apps(self) -> Any: + return json.loads(self._occ_ok("app:list --output=json") or "{}") + + def enable_app(self, app: str) -> str: + return self._occ_ok(f"app:enable {shlex.quote(app)}").strip() + + def disable_app(self, app: str) -> str: + return self._occ_ok(f"app:disable {shlex.quote(app)}").strip() + + def config_get(self, name: str) -> Optional[str]: + rc, out, err = self.occ(f"config:system:get {shlex.quote(name)}") + return out.strip() if rc == 0 else None + + def config_list(self) -> Any: + return json.loads(self._occ_ok("config:list system --output=json") or "{}") + + def config_set(self, name: str, value: str, vtype: str = "string") -> str: + return self._occ_ok( + f"config:system:set {shlex.quote(name)} " + f"--value={shlex.quote(value)} --type={shlex.quote(vtype)}").strip() + + # -- files admin ----------------------------------------------------------- + def files_scan(self, uid: Optional[str] = None, all_users: bool = False, + path: Optional[str] = None) -> str: + if all_users: + args = "files:scan --all" + elif uid: + args = f"files:scan {shlex.quote(uid)}" + else: + raise bc.BackupError("files_scan requires a uid or all_users=True") + if path: + args += f" --path={shlex.quote(path)}" + return self._occ_ok(args, timeout=DU_TIMEOUT).strip() + + def transfer_ownership(self, src: str, dst: str, + path: Optional[str] = None) -> str: + args = (f"files:transfer-ownership {shlex.quote(src)} " + f"{shlex.quote(dst)} -s") + if path: + args += f" --path={shlex.quote(path)}" + return self._occ_ok(args, timeout=DU_TIMEOUT).strip() + + def trashbin_cleanup(self, users: list[str]) -> str: + us = " ".join(shlex.quote(u) for u in users) + return self._occ_ok(f"trashbin:cleanup {us}".strip(), + timeout=DU_TIMEOUT).strip() + + def versions_cleanup(self, users: list[str]) -> str: + us = " ".join(shlex.quote(u) for u in users) + return self._occ_ok(f"versions:cleanup {us}".strip(), + timeout=DU_TIMEOUT).strip() + + +def _int(v: Any) -> Optional[int]: + try: + return int(str(v).strip()) + except (TypeError, ValueError): + return None + def _epoch(v: Any) -> Optional[str]: """occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never.""" diff --git a/.claude/skills/seafile/SKILL.md b/.claude/skills/seafile/SKILL.md index 78fc806a..69a30793 100644 --- a/.claude/skills/seafile/SKILL.md +++ b/.claude/skills/seafile/SKILL.md @@ -1,11 +1,11 @@ --- 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." +description: "Manage ACG's Seafile Pro server (the SeaCloud / SeaDrive backup backend on Jupiter): read-only inventory (who has an account, per-user storage, libraries, which GuruRMM endpoints run the SeaDrive/Seafile client) plus gated admin writes (create/deactivate/delete user, set quota, create/transfer/delete library, share/unshare). Reads run freely; writes preview by default and require --confirm. Built for GPS backup verification + client provisioning. Triggers: seafile, seacloud, seadrive, sync.azcomputerguru, who backs up to seafile, seafile usage, create seafile user, set seafile quota, seafile library." --- # Seafile (SeaCloud / SeaDrive) Skill -Read-only client for ACG's live **Seafile Pro** server — the backend clients call +Admin 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 @@ -13,7 +13,9 @@ much data do they hold** — and, with `--rmm`, which enrolled machines actually the client. This skill is one of the backup-verification siblings alongside `b2` (Backblaze), -`owncloud`, and `datto-workplace`. It never writes. +`owncloud`, and `datto-workplace`. Inventory is read-only; admin **writes** +(user/quota/library/share) exist but are gated behind `--confirm` (see below) and +preview by default. ## Running @@ -33,6 +35,42 @@ 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). +## Write ops (admin) — preview by default, `--confirm` to apply + +Every write is **gated**: run it once to see the `[PLAN]`, add `--confirm` to +execute. Preview is fully offline (no auth, no network), so it is safe to eyeball +first. All writes go out **form-encoded** (the share endpoint reads `share_to` via +`getlist`, which requires form encoding, not JSON) against the Seafile Pro 12.0 +admin API (`/api/v2.1/admin/...`). + +```bash +# users +py "$SEAFILE" user-create client@acme.com --password 'PW' --name 'Acme' --quota-gb 500 --confirm +py "$SEAFILE" user-set client@acme.com --inactive --confirm # deactivate +py "$SEAFILE" user-set client@acme.com --quota-gb 1024 --confirm # bump quota +py "$SEAFILE" quota client@acme.com --gb 1024 --confirm # quota shortcut +py "$SEAFILE" user-delete client@acme.com --confirm # IRREVERSIBLE + +# libraries +py "$SEAFILE" lib-create "Acme Backup" --owner client@acme.com --confirm +py "$SEAFILE" lib-transfer --to newowner@acme.com --confirm +py "$SEAFILE" lib-delete --confirm # IRREVERSIBLE + +# sharing +py "$SEAFILE" share --to a@acme.com b@acme.com --perm rw --confirm +py "$SEAFILE" unshare --to a@acme.com --confirm +py "$SEAFILE" share --to --group --perm r --confirm +``` + +**Quota unit:** `--quota-gb` / `--gb` are **GiB** — Seafile stores quota in MB and +its admin UI treats the field as GiB (1 GiB = 1024 MB). The preview always shows +the exact MB value it will send (e.g. `102400 MB (= 100 GiB)`) so you can verify +before `--confirm`. + +**Any password you set is a credential** — `user-create`/`user-set` print a +reminder; store it in the SOPS vault via the `vault` skill (house rule). Never +hand out or log a Seafile password without vaulting it. + ## Credentials Loaded at runtime from the SOPS vault (never hardcoded): diff --git a/.claude/skills/seafile/scripts/seafile.py b/.claude/skills/seafile/scripts/seafile.py index d481442b..bf0defdb 100644 --- a/.claude/skills/seafile/scripts/seafile.py +++ b/.claude/skills/seafile/scripts/seafile.py @@ -12,6 +12,17 @@ Commands: the GPS view: users/usage, optionally cross-checked against GuruRMM endpoints (SeaDrive/Seafile installed?) +Write ops (admin) -- ALL preview by default; add --confirm to apply: + user-create EMAIL --password P [--name N] [--quota-gb G] [--role R] [--inactive] + user-set EMAIL [--active|--inactive] [--quota-gb G] [--name N] [--role R] [--password P] + quota EMAIL --gb G set storage quota (GiB) + user-delete EMAIL remove account (irreversible) + lib-create NAME [--owner EMAIL] create a library + lib-transfer REPO_ID --to EMAIL transfer ownership + lib-delete REPO_ID delete a library (irreversible) + share REPO_ID --to EMAIL... [--perm r|rw] [--group] + unshare REPO_ID --to EMAIL [--group] + 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). """ @@ -185,8 +196,129 @@ def cmd_audit(c: SeafileClient, args) -> int: return 0 +# --- write ops (gated) -------------------------------------------------------- +# Seafile stores user quota in MB (binary unit); its own admin UI treats the quota +# field as GiB, i.e. 1 GiB = 1024 MB. We expose --gb and convert the same way. +GIB_MB = 1024 + + +def _gb_to_mb(gb) -> int: + return int(round(float(gb) * GIB_MB)) + + +def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None) -> int: + """Print the plan; execute only with --confirm. Preview is fully offline.""" + print(f"[PLAN] {label}") + for k, v in detail.items(): + print(f" {k}: {v}") + if not getattr(args, "confirm", False): + print("[PREVIEW] not executed. Re-run with --confirm to apply.") + return 0 + result = fn() + print(f"[OK] {label}") + if reminder: + print(f"[REMINDER] {reminder}") + if result: + print(json.dumps(result, indent=2)) + return 0 + + +def cmd_user_create(c: SeafileClient, args) -> int: + quota_mb = _gb_to_mb(args.quota_gb) if args.quota_gb is not None else None + detail = {"email": args.email, "active": not args.inactive, "password": ""} + if args.name: + detail["name"] = args.name + if quota_mb is not None: + detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)" + if args.role: + detail["role"] = args.role + return _do_write( + args, f"create user {args.email}", detail, + lambda: c.create_user(args.email, args.password, name=args.name, + quota_mb=quota_mb, role=args.role, + is_active=not args.inactive), + reminder="password is a credential -- store it in the SOPS vault (vault skill).") + + +def cmd_user_set(c: SeafileClient, args) -> int: + if args.active and args.inactive: + print("[ERROR] --active and --inactive are mutually exclusive", file=sys.stderr) + return 2 + is_active = True if args.active else (False if args.inactive else None) + quota_mb = _gb_to_mb(args.quota_gb) if args.quota_gb is not None else None + detail: dict = {"email": args.email} + if is_active is not None: + detail["active"] = is_active + if quota_mb is not None: + detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)" + if args.name is not None: + detail["name"] = args.name + if args.role is not None: + detail["role"] = args.role + if args.password is not None: + detail["password"] = "" + if len(detail) == 1: + print("[ERROR] nothing to change (give --active/--inactive/--quota-gb/--name/--role/--password)", + file=sys.stderr) + return 2 + reminder = ("password is a credential -- store it in the SOPS vault (vault skill)." + if args.password is not None else None) + return _do_write( + args, f"update user {args.email}", detail, + lambda: c.update_user(args.email, is_active=is_active, password=args.password, + name=args.name, quota_mb=quota_mb, role=args.role), + reminder=reminder) + + +def cmd_quota(c: SeafileClient, args) -> int: + quota_mb = _gb_to_mb(args.gb) + detail = {"email": args.email, "quota": f"{quota_mb} MB (= {args.gb} GiB)"} + return _do_write(args, f"set quota for {args.email}", detail, + lambda: c.update_user(args.email, quota_mb=quota_mb)) + + +def cmd_user_delete(c: SeafileClient, args) -> int: + detail = {"email": args.email, "note": "IRREVERSIBLE - removes the account"} + return _do_write(args, f"DELETE user {args.email}", detail, + lambda: c.delete_user(args.email)) + + +def cmd_lib_create(c: SeafileClient, args) -> int: + detail = {"name": args.name, "owner": args.owner or ""} + return _do_write(args, f"create library '{args.name}'", detail, + lambda: c.create_library(args.name, owner=args.owner)) + + +def cmd_lib_transfer(c: SeafileClient, args) -> int: + detail = {"repo_id": args.repo_id, "new_owner": args.to} + return _do_write(args, f"transfer library {args.repo_id}", detail, + lambda: c.transfer_library(args.repo_id, args.to)) + + +def cmd_lib_delete(c: SeafileClient, args) -> int: + detail = {"repo_id": args.repo_id, "note": "IRREVERSIBLE - deletes the library + data"} + return _do_write(args, f"DELETE library {args.repo_id}", detail, + lambda: c.delete_library(args.repo_id)) + + +def cmd_share(c: SeafileClient, args) -> int: + stype = "group" if args.group else "user" + detail = {"repo_id": args.repo_id, "share_to": args.to, + "permission": args.perm, "type": stype} + return _do_write(args, f"share library {args.repo_id}", detail, + lambda: c.share_library(args.repo_id, args.to, + permission=args.perm, share_type=stype)) + + +def cmd_unshare(c: SeafileClient, args) -> int: + stype = "group" if args.group else "user" + detail = {"repo_id": args.repo_id, "share_to": args.to, "type": stype} + return _do_write(args, f"revoke share on {args.repo_id}", detail, + lambda: c.unshare_library(args.repo_id, args.to, share_type=stype)) + + def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory") + p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory + admin") p.add_argument("--url", help="override server base URL (default internal Docker endpoint)") sub = p.add_subparsers(dest="cmd", required=True) @@ -196,6 +328,50 @@ def build_parser() -> argparse.ArgumentParser: 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") + + # --- write ops (all preview-by-default; require --confirm to apply) --- + uc = sub.add_parser("user-create", help="create an account") + uc.add_argument("email"); uc.add_argument("--password", required=True) + uc.add_argument("--name"); uc.add_argument("--quota-gb", type=float, dest="quota_gb") + uc.add_argument("--role"); uc.add_argument("--inactive", action="store_true") + uc.add_argument("--confirm", action="store_true") + + ust = sub.add_parser("user-set", help="update an account (activate/quota/name/role/password)") + ust.add_argument("email") + ust.add_argument("--active", action="store_true"); ust.add_argument("--inactive", action="store_true") + ust.add_argument("--quota-gb", type=float, dest="quota_gb"); ust.add_argument("--name") + ust.add_argument("--role"); ust.add_argument("--password") + ust.add_argument("--confirm", action="store_true") + + q = sub.add_parser("quota", help="set a user's storage quota (GiB)") + q.add_argument("email"); q.add_argument("--gb", type=float, required=True) + q.add_argument("--confirm", action="store_true") + + ud = sub.add_parser("user-delete", help="delete an account (irreversible)") + ud.add_argument("email"); ud.add_argument("--confirm", action="store_true") + + lc = sub.add_parser("lib-create", help="create a library") + lc.add_argument("name"); lc.add_argument("--owner", help="owner email (default: admin)") + lc.add_argument("--confirm", action="store_true") + + lt = sub.add_parser("lib-transfer", help="transfer library ownership") + lt.add_argument("repo_id"); lt.add_argument("--to", required=True, help="new owner email") + lt.add_argument("--confirm", action="store_true") + + ld = sub.add_parser("lib-delete", help="delete a library (irreversible)") + ld.add_argument("repo_id"); ld.add_argument("--confirm", action="store_true") + + sh = sub.add_parser("share", help="share a library to user(s)/group(s)") + sh.add_argument("repo_id"); sh.add_argument("--to", nargs="+", required=True, + help="recipient email(s), or group id(s) with --group") + sh.add_argument("--perm", choices=["r", "rw"], default="rw") + sh.add_argument("--group", action="store_true", help="share_to are group ids") + sh.add_argument("--confirm", action="store_true") + + un = sub.add_parser("unshare", help="revoke a library share") + un.add_argument("repo_id"); un.add_argument("--to", required=True, + help="recipient email, or group id with --group") + un.add_argument("--group", action="store_true"); un.add_argument("--confirm", action="store_true") return p @@ -203,7 +379,11 @@ 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} + "usage": cmd_usage, "devices": cmd_devices, "audit": cmd_audit, + "user-create": cmd_user_create, "user-set": cmd_user_set, + "quota": cmd_quota, "user-delete": cmd_user_delete, + "lib-create": cmd_lib_create, "lib-transfer": cmd_lib_transfer, + "lib-delete": cmd_lib_delete, "share": cmd_share, "unshare": cmd_unshare} try: return handlers[args.cmd](c, args) except bc.BackupError as exc: diff --git a/.claude/skills/seafile/scripts/seafile_client.py b/.claude/skills/seafile/scripts/seafile_client.py index c3fe7846..4a66bd48 100644 --- a/.claude/skills/seafile/scripts/seafile_client.py +++ b/.claude/skills/seafile/scripts/seafile_client.py @@ -174,6 +174,111 @@ class SeafileClient: devices = [d for d in devices if d.get("user") == user_email] return devices + # -- write plumbing -------------------------------------------------------- + def _write(self, method: str, path: str, form: Optional[dict] = None, + retry_auth: bool = True) -> Any: + """POST/PUT/DELETE against the admin API using form-encoding. + + The Seafile admin endpoints read from request.data; the share endpoints + use QueryDict.getlist('share_to'), which only works for form-encoded + bodies (not JSON) -- so all writes go out as + application/x-www-form-urlencoded. `form` values that are lists are + expanded (doseq) into repeated fields. + """ + url = f"{self.base}{path}" + data = urllib.parse.urlencode(form or {}, doseq=True).encode() + req = bc.urllib.request.Request( + url, data=data, method=method, + headers={"Authorization": f"Token {self._tok()}", + "Content-Type": "application/x-www-form-urlencoded"}) + try: + with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp: + status = resp.getcode() + raw = resp.read().decode("utf-8", errors="replace") + except bc.urllib.error.HTTPError as exc: + status = exc.code + raw = exc.read().decode("utf-8", errors="replace") + except bc.urllib.error.URLError as exc: + raise bc.BackupError(f"Seafile {method} {path} failed: {exc}") + if status in (401, 403) and retry_auth: + self._token = self._authorize() + return self._write(method, path, form, retry_auth=False) + try: + parsed = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + parsed = {"_raw": raw[:600]} + if status not in (200, 201): + msg = parsed.get("error_msg") or parsed.get("detail") or raw[:400] + raise bc.BackupError(f"Seafile {method} {path} failed (HTTP {status}): {msg}", + status=status) + return parsed + + @staticmethod + def _uenc(email: str) -> str: + return urllib.parse.quote(email, safe="") + + # -- user writes ----------------------------------------------------------- + def create_user(self, email: str, password: str, name: Optional[str] = None, + quota_mb: Optional[int] = None, role: Optional[str] = None, + is_active: bool = True) -> dict: + form: dict = {"email": email, "password": password, + "is_active": "true" if is_active else "false"} + if name: + form["name"] = name + if quota_mb is not None: + form["quota_total"] = str(int(quota_mb)) + if role: + form["role"] = role + return self._write("POST", "/api/v2.1/admin/users/", form) + + def update_user(self, email: str, is_active: Optional[bool] = None, + password: Optional[str] = None, name: Optional[str] = None, + quota_mb: Optional[int] = None, role: Optional[str] = None) -> dict: + form: dict = {} + if is_active is not None: + form["is_active"] = "true" if is_active else "false" + if password is not None: + form["password"] = password + if name is not None: + form["name"] = name + if quota_mb is not None: + form["quota_total"] = str(int(quota_mb)) + if role is not None: + form["role"] = role + if not form: + raise bc.BackupError("update_user: nothing to change") + return self._write("PUT", f"/api/v2.1/admin/users/{self._uenc(email)}/", form) + + def delete_user(self, email: str) -> dict: + return self._write("DELETE", f"/api/v2.1/admin/users/{self._uenc(email)}/") + + # -- library writes -------------------------------------------------------- + def create_library(self, name: str, owner: Optional[str] = None) -> dict: + form: dict = {"name": name} + if owner: + form["owner"] = owner + return self._write("POST", "/api/v2.1/admin/libraries/", form) + + def transfer_library(self, repo_id: str, new_owner: str) -> dict: + return self._write("PUT", f"/api/v2.1/admin/libraries/{repo_id}/", + {"owner": new_owner}) + + def delete_library(self, repo_id: str) -> dict: + return self._write("DELETE", f"/api/v2.1/admin/libraries/{repo_id}/") + + # -- sharing writes -------------------------------------------------------- + def share_library(self, repo_id: str, share_to, permission: str = "rw", + share_type: str = "user") -> dict: + recipients = share_to if isinstance(share_to, list) else [share_to] + form = {"share_type": share_type, "permission": permission, + "share_to": recipients} + return self._write("POST", f"/api/v2.1/admin/libraries/{repo_id}/shares/", form) + + def unshare_library(self, repo_id: str, share_to: str, + share_type: str = "user") -> dict: + return self._write("DELETE", f"/api/v2.1/admin/libraries/{repo_id}/shares/", + {"share_type": share_type, "share_to": share_to}) + def main() -> int: try: diff --git a/errorlog.md b/errorlog.md index dd69669e..8b4f05a7 100644 --- a/errorlog.md +++ b/errorlog.md @@ -19,6 +19,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +2026-07-06 | Howard-Home | msp360 | GET /api/Computers/0/1000 -> HTTP 400: {'Message': 'Remote Management API methods are not enabled for your account'} [ctx: command=computers] + +2026-07-06 | Howard-Home | msp360 | unexpected: URL can't contain control characters. '/Program Files/Git/api/Companies' (found at least ' ') [ctx: command=raw] + 2026-07-05 | Howard-Home | git/submodule-cwd | [friction] ran merge+push for guru-rmm from C:/claudetools (parent repo) after cd for verify.sh; harmless fail but wasted a roundtrip — cd back into the submodule before repo ops 2026-07-05 | Howard-Home | bash/tmp-path | [friction] redirected ssh output to /tmp/deployed-build-server.sh; blocked by block-tmp-path hook; used repo-relative ./.deployed-build-server.sh instead [ctx: ref=feedback_tmp_path_windows]