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:
@@ -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
3
.claude/skills/msp360/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Skill scratch/cache - never commit. Auth token is per-run (not cached to disk).
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
103
.claude/skills/msp360/SKILL.md
Normal file
103
.claude/skills/msp360/SKILL.md
Normal 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.
|
||||||
430
.claude/skills/msp360/scripts/msp360.py
Normal file
430
.claude/skills/msp360/scripts/msp360.py
Normal 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())
|
||||||
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
119
.claude/skills/msp360/scripts/msp360_client.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user