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
This commit is contained in:
2026-07-05 20:00:20 -07:00
parent b0ebd44c9d
commit 976bfe7957
12 changed files with 1712 additions and 14 deletions

View File

@@ -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. 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 - Backup targets are a per-client decision — report mismatches, don't deploy jobs. See
[[feedback_backup_targets_never_guessed]]. Related: [[reference_backblaze_storage_rate]]. [[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.

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

@@ -0,0 +1,3 @@
# Skill scratch/cache - never commit. Auth token is per-run (not cached to disk).
.cache/
__pycache__/

View File

@@ -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 <UUID> --license-id <UUID> --confirm
py "$MSP" release-license --user-id <UUID> --confirm
py "$MSP" delete-user --id <UUID> [--keep-data] --confirm
py "$MSP" delete-company --id <UUID> --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 <token>` 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 <METHOD> <path>`): **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.

View File

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

View File

@@ -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 <root>/.claude/scripts/ ; this file is
# <root>/.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == <root>/.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)

View File

@@ -1,20 +1,27 @@
--- ---
name: owncloud 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 # ownCloud Skill
Read-only client for ACG's live **ownCloud 10.16.0 Community** server 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 (`cloud.acghosting.com`, VM `172.16.3.22`, Rocky Linux 9.6), driven entirely
backup-audit question: **which customers back up here and how much data do they through the `occ` admin CLI over SSH.
hold** - and, with `--rmm`, which enrolled machines actually run the ownCloud
desktop client. It never writes. 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`, This skill is one of the backup-verification siblings alongside `seafile`,
`b2` (Backblaze) and `datto-workplace`. `b2` (Backblaze) and `datto-workplace`.
## Running ## Running - read (GPS audit)
```bash ```bash
OWNCLOUD=".claude/skills/owncloud/scripts/owncloud.py" 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. Add `--json` to `users` / `usage` / `audit` for machine-readable output.
`--host` overrides the server IP (default `172.16.3.22`). `--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 <uid> 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 <app> --confirm
py "$OWNCLOUD" config-get [name] # no name -> dump all system config
py "$OWNCLOUD" config-set <name> --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 <src> <dst> [--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/<uid>.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 ## Access channel
There is **no ownCloud admin web / OCS account** in the vault, so the channel is 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`. so orphans do not appear in `users`/`usage`.
- **No OCS/web API:** deeper per-file / sharing queries would need an OCS admin - **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. 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 <cmd>`) before wiring the writes - flags differ by version.
Notable ones used: `user:modify <uid> displayname|email`, quota via
`user:setting <uid> 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 - Shared plumbing (repo-root resolve, vault read, GuruRMM client + endpoint
detection) lives in `.claude/scripts/backup_common.py`, used by all the detection) lives in `.claude/scripts/backup_common.py`, used by all the
server-backup skills. Detection patterns must be precise per backend - this server-backup skills. Detection patterns must be precise per backend - this

View File

@@ -17,6 +17,10 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re
import secrets
import string
import sys import sys
from pathlib import Path from pathlib import Path
@@ -26,6 +30,9 @@ import backup_common as bc # noqa: E402
GB = 1_000_000_000 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: def _gb(n) -> float:
try: try:
@@ -34,6 +41,43 @@ def _gb(n) -> float:
return 0.0 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 '<paste-here>'")
def cmd_status(c: OwncloudClient, args) -> int: def cmd_status(c: OwncloudClient, args) -> int:
s = c.server_status() s = c.server_status()
print(f"[OK] {c.host}") print(f"[OK] {c.host}")
@@ -132,6 +176,321 @@ def cmd_audit(c: OwncloudClient, args) -> int:
return 0 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: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="owncloud", description="ownCloud backup inventory (GPS audit)") 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)") 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") u = sub.add_parser("users"); u.add_argument("--json", action="store_true")
us = sub.add_parser("usage"); us.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") 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 return p
@@ -148,7 +563,26 @@ def main(argv=None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
c = OwncloudClient(args.host) c = OwncloudClient(args.host)
handlers = {"status": cmd_status, "users": cmd_users, 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: try:
return handlers[args.cmd](c, args) return handlers[args.cmd](c, args)
except bc.BackupError as exc: except bc.BackupError as exc:

View File

@@ -227,6 +227,210 @@ class OwncloudClient:
u["used_source"] = "du" if du is not None else None u["used_source"] = "du" if du is not None else None
return users 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 <uid> 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]: def _epoch(v: Any) -> Optional[str]:
"""occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never.""" """occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never."""

View File

@@ -1,11 +1,11 @@
--- ---
name: seafile 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 # 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 "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`). 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 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. the client.
This skill is one of the backup-verification siblings alongside `b2` (Backblaze), 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 ## 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 server base (default is the internal Docker endpoint; use the public host when
off-LAN). 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 <repo_id> --to newowner@acme.com --confirm
py "$SEAFILE" lib-delete <repo_id> --confirm # IRREVERSIBLE
# sharing
py "$SEAFILE" share <repo_id> --to a@acme.com b@acme.com --perm rw --confirm
py "$SEAFILE" unshare <repo_id> --to a@acme.com --confirm
py "$SEAFILE" share <repo_id> --to <group_id> --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 ## Credentials
Loaded at runtime from the SOPS vault (never hardcoded): Loaded at runtime from the SOPS vault (never hardcoded):

View File

@@ -12,6 +12,17 @@ Commands:
the GPS view: users/usage, optionally cross-checked the GPS view: users/usage, optionally cross-checked
against GuruRMM endpoints (SeaDrive/Seafile installed?) 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 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). "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 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": "<set>"}
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"] = "<set>"
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 "<admin account>"}
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: 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)") p.add_argument("--url", help="override server base URL (default internal Docker endpoint)")
sub = p.add_subparsers(dest="cmd", required=True) 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") 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") 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") 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 return p
@@ -203,7 +379,11 @@ def main(argv=None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
c = SeafileClient(args.url) c = SeafileClient(args.url)
handlers = {"status": cmd_status, "users": cmd_users, "libraries": cmd_libraries, 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: try:
return handlers[args.cmd](c, args) return handlers[args.cmd](c, args)
except bc.BackupError as exc: except bc.BackupError as exc:

View File

@@ -174,6 +174,111 @@ class SeafileClient:
devices = [d for d in devices if d.get("user") == user_email] devices = [d for d in devices if d.get("user") == user_email]
return devices 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: def main() -> int:
try: try:

View File

@@ -19,6 +19,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line --> <!-- Append entries below this line -->
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 | 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] 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]