add GPS backup audit aggregator + enforce skill-first routing
GPS audit: new gps-audit.py queries MSP360/Seafile/ownCloud in one pass, produces unified per-client backup report with OK/REVIEW/FAIL verdicts. Supports --json, --client, --failed, --skip-owncloud flags. Skill routing: added /mailbox and 11 missing skills to SKILL_ROUTING.md, added concrete "NEVER hand-roll these" API endpoint block list to CLAUDE.md to prevent bypassing skills with raw curl calls. Updated skill-first memory with specific bypass patterns (Graph API, UniFi, Discord). Tracker: closed #19 (datto-edr), #20 (screenconnect) as validated, filed #88 (tailscale, closed/skipped), #89 (skill-bypass enforcement). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,18 @@ production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
|
|||||||
the memory rules (e.g. [[feedback_syncro_billing]]) describe what the SKILL does, not a license
|
the memory rules (e.g. [[feedback_syncro_billing]]) describe what the SKILL does, not a license
|
||||||
to free-hand it. Reach for raw API ONLY when no skill fits or the skill genuinely cannot do it
|
to free-hand it. Reach for raw API ONLY when no skill fits or the skill genuinely cannot do it
|
||||||
— and say so explicitly when you do. Mistakes here go to `errorlog.md` (`--correction`).
|
— and say so explicitly when you do. Mistakes here go to `errorlog.md` (`--correction`).
|
||||||
|
**NEVER hand-roll these (hard blocks — skill exists, use it):**
|
||||||
|
`graph.microsoft.com` / EXO REST → `/mailbox` (read/send) or `remediation-tool` (audit) |
|
||||||
|
UniFi controller API / SSH to UDM/AP → `unifi-wifi` |
|
||||||
|
`api.mspbackups.com` → `msp360` | `workplace.datto.com` → `datto-workplace` |
|
||||||
|
`infocyte.com` → `datto-edr` | `gravityzone` JSON-RPC → `bitdefender` |
|
||||||
|
Syncro API → `/syncro` | `screenconnect.com` → `screenconnect` |
|
||||||
|
Seafile API → `seafile` | ownCloud occ → `owncloud` | Tailscale API → `tailscale` |
|
||||||
|
`api.backblazeb2.com` → `b2` | NetSapiens API → `packetdial` | YMCS API → `yealink-ymcs` |
|
||||||
|
Mailprotector API → `mailprotector` | SOPS/age decrypt → `vault` |
|
||||||
|
Discord API / webhook → `discord-dm` or `ask-forum` | Gitea API → `bug-tracker` or `gitea`.
|
||||||
|
Before writing `curl` to ANY of these endpoints, **STOP and use the skill**. The full
|
||||||
|
routing table is `.claude/SKILL_ROUTING.md` — read it when unsure.
|
||||||
- **Definition of Done — route the REQUEST to a doing-skill, then GATE the result with the
|
- **Definition of Done — route the REQUEST to a doing-skill, then GATE the result with the
|
||||||
matching check-skill(s) before calling work done — automatically, without being asked.**
|
matching check-skill(s) before calling work done — automatically, without being asked.**
|
||||||
You map the request to skills from the conversation + expected end result; the user should not
|
You map the request to skills from the conversation + expected end result; the user should not
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
| Any credential — read/store/rotate a secret, API key, password, token, SSH key | `vault` |
|
| Any credential — read/store/rotate a secret, API key, password, token, SSH key | `vault` |
|
||||||
| Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) |
|
| Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) |
|
||||||
| M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` |
|
| M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` |
|
||||||
|
| Read/send email, check inbox, unread messages, reply to an email | `/mailbox` (NOT hand-rolled Graph API) |
|
||||||
| Onboard a new M365 tenant to the remediation app suite | `onboard365` |
|
| Onboard a new M365 tenant to the remediation app suite | `onboard365` |
|
||||||
| Anything about a customer's **BACKUP** — is X backing up, who backs up where, backup status/failures, or set up / provision a backup | **Backups → wiki-first** (see the "Backups" block right below this table) |
|
| Anything about a customer's **BACKUP** — is X backing up, who backs up where, backup status/failures, or set up / provision a backup | **Backups → wiki-first** (see the "Backups" block right below this table) |
|
||||||
| Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` |
|
| Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` |
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
| ScreenConnect / CW Control sessions, access installer, backstage command | `screenconnect` |
|
| ScreenConnect / CW Control sessions, access installer, backstage command | `screenconnect` |
|
||||||
| Synology NAS | `synology` |
|
| Synology NAS | `synology` |
|
||||||
| Yealink phone device management | `yealink-ymcs` |
|
| Yealink phone device management | `yealink-ymcs` |
|
||||||
| UniFi WiFi tuning / RF / channel analysis | `unifi-wifi` |
|
| UniFi WiFi tuning / RF / channel analysis / AP config / 2.4GHz congestion / sticky clients / interference | `unifi-wifi` (NOT hand-rolled UniFi API calls or SSH) |
|
||||||
| Map/repoint a Windows network drive on a remote endpoint | `drive-map` |
|
| Map/repoint a Windows network drive on a remote endpoint | `drive-map` |
|
||||||
| Send someone a Discord DM / copy-paste-friendly link or command | `discord-dm` |
|
| Send someone a Discord DM / copy-paste-friendly link or command | `discord-dm` |
|
||||||
| Ask a teammate a question/decision/sign-off and get their human answer back in-session | `ask-forum` (run the `--wait` as a proper background task — NO shell `&`) |
|
| Ask a teammate a question/decision/sign-off and get their human answer back in-session | `ask-forum` (run the `--wait` as a proper background task — NO shell `&`) |
|
||||||
@@ -45,6 +46,15 @@
|
|||||||
| Independent 2nd opinion / adversarial verify | `grok` (xAI) or `agy` (Gemini) |
|
| Independent 2nd opinion / adversarial verify | `grok` (xAI) or `agy` (Gemini) |
|
||||||
| Image/video gen, live web/X search past cutoff | `grok` |
|
| Image/video gen, live web/X search past cutoff | `grok` |
|
||||||
| Deep multi-source researched report | `deep-research` |
|
| Deep multi-source researched report | `deep-research` |
|
||||||
|
| ALIS staff import / Medtelligent roster for Cascades | `alis` |
|
||||||
|
| Tailscale tailnet — devices, auth keys, delete/authorize | `tailscale` |
|
||||||
|
| 1Password — store/read secrets, secret references, op:// | `1password` |
|
||||||
|
| Brainstorm / shape an idea / design something before building | `brainstorming` (explicit request only) |
|
||||||
|
| Lint/analyze the error log, find patterns | `errorlog-dream` |
|
||||||
|
| Post a technical article to the community forum | `forum-post` |
|
||||||
|
| Frontend UI design / web components / pages / dashboards | `frontend-design` |
|
||||||
|
| GuruConnect codebase audit | `gc-audit` |
|
||||||
|
| Trace an error to its root cause | `root-cause-tracing` |
|
||||||
|
|
||||||
> Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`.
|
> Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`.
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ When a request maps to an installed skill or slash-command, **invoke that skill*
|
|||||||
|
|
||||||
**How to apply:**
|
**How to apply:**
|
||||||
- Billing/invoicing/ticketing/scheduling in Syncro -> `/syncro` (after-hours/emergency -> `/syncro-emergency-billing`). No exceptions, even for a "quick" one-line charge.
|
- Billing/invoicing/ticketing/scheduling in Syncro -> `/syncro` (after-hours/emergency -> `/syncro-emergency-billing`). No exceptions, even for a "quick" one-line charge.
|
||||||
- More generally: before reaching for raw API, ask "is there a skill for this?" Credentials -> `vault`; RMM actions -> `/rmm` (find the host with `rmm-search`); M365 investigation/remediation -> `remediation-tool`; the per-vendor skills (bitdefender, datto-edr, packetdial, b2, mailprotector, screenconnect...) own their APIs.
|
- More generally: before reaching for raw API, ask "is there a skill for this?" The CLAUDE.md "NEVER hand-roll these" block lists every covered API endpoint. Key ones that get bypassed in practice:
|
||||||
|
- `graph.microsoft.com` / EXO REST -> `/mailbox` (read/send) or `remediation-tool` (audit)
|
||||||
|
- UniFi controller API / SSH to UDM/AP -> `unifi-wifi` (NOT raw curl or SSH)
|
||||||
|
- `api.mspbackups.com` -> `msp360`
|
||||||
|
- Discord API/webhook -> `discord-dm` or `ask-forum` (never hand-roll)
|
||||||
|
- Credentials -> `vault`; RMM actions -> `/rmm` (find host with `rmm-search`); per-vendor skills (bitdefender, datto-edr, packetdial, b2, mailprotector, screenconnect, seafile, owncloud, tailscale, yealink-ymcs) own their APIs.
|
||||||
- Use raw API ONLY when no skill fits, or the skill genuinely cannot do the thing — and SAY SO explicitly when you do, so the user can sanity-check.
|
- Use raw API ONLY when no skill fits, or the skill genuinely cannot do the thing — and SAY SO explicitly when you do, so the user can sanity-check.
|
||||||
|
- **Practical check:** before writing `curl <url>`, does the URL domain appear in the CLAUDE.md block list? If yes, STOP and use the skill.
|
||||||
- When the user corrects a bypass, log it: `bash .claude/scripts/log-skill-error.sh "<skill>" "hand-rolled API instead of using the skill" --correction`.
|
- When the user corrects a bypass, log it: `bash .claude/scripts/log-skill-error.sh "<skill>" "hand-rolled API instead of using the skill" --correction`.
|
||||||
|
|
||||||
Related: [[feedback_psa_default_syncro]] (Syncro is the default PSA), [[feedback_syncro_preview_mandatory]] (preview gate the skill enforces), [[feedback_syncro_priority_type_format]] (a malformed-ticket case Winter flagged), [[feedback_syncro_billing]], [[feedback_syncro_workflow]].
|
Related: [[feedback_psa_default_syncro]] (Syncro is the default PSA), [[feedback_syncro_preview_mandatory]] (preview gate the skill enforces), [[feedback_syncro_priority_type_format]] (a malformed-ticket case Winter flagged), [[feedback_syncro_billing]], [[feedback_syncro_workflow]].
|
||||||
|
|||||||
@@ -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-20 | Howard-Home | harness/skill-routing | [correction] Skills being bypassed -- hand-rolling API calls when skills exist (email/mailbox, unifi, others). CLAUDE.md skill-first rule not sufficient as a guardrail. [ctx: ref=CLAUDE.md-skill-first-rule]
|
||||||
|
|
||||||
|
2026-07-20 | Howard-Home | gps-audit | skill call failed: C:claudetools.claudeskillsowncloudscriptsowncloud.py ['usage', '--json'] -- Command '['bash', 'C:claudetools.claudescriptspy.sh', 'C:claudetools.claudeskillsowncloudscriptsowncloud.py', 'usage', '--json']' timed out after 60 seconds
|
||||||
|
|
||||||
2026-07-20 | Howard-Home | tailscale | Skill blocked: no vault entry at tailscale/api-access.sops.yaml -- need OAuth client_id+client_secret or API key provisioned [ctx: ref=skill-testing-2026-07-20]
|
2026-07-20 | Howard-Home | tailscale | Skill blocked: no vault entry at tailscale/api-access.sops.yaml -- need OAuth client_id+client_secret or API key provisioned [ctx: ref=skill-testing-2026-07-20]
|
||||||
|
|
||||||
2026-07-20 | Howard-Home | bash/parallel-tools | [friction] Ran Skill call parallel with a Bash call that errored -- system cancelled the Skill. Must not pair fragile Bash calls with Skill calls in the same parallel block. [ctx: ref=parallel-tool-cancel]
|
2026-07-20 | Howard-Home | bash/parallel-tools | [friction] Ran Skill call parallel with a Bash call that errored -- system cancelled the Skill. Must not pair fragile Bash calls with Skill calls in the same parallel block. [ctx: ref=parallel-tool-cancel]
|
||||||
|
|||||||
370
projects/gps-rmm-audit/tools/gps-audit.py
Normal file
370
projects/gps-rmm-audit/tools/gps-audit.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GPS backup audit aggregator -- queries all backup backends and produces a
|
||||||
|
unified per-client backup status report.
|
||||||
|
|
||||||
|
Backends:
|
||||||
|
- MSP360 MBS (monitoring API) -- the primary "is it backing up" source
|
||||||
|
- Seafile Pro (usage API)
|
||||||
|
- ownCloud (usage via SSH/occ)
|
||||||
|
- Datto Workplace (RMM endpoint detection -- API is blind)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
py gps-audit.py # full report, all backends
|
||||||
|
py gps-audit.py --json # JSON output
|
||||||
|
py gps-audit.py --client "Dataforth" # single client
|
||||||
|
py gps-audit.py --failed # only FAIL/REVIEW clients
|
||||||
|
py gps-audit.py --skip-owncloud # skip slow SSH-based ownCloud check
|
||||||
|
py gps-audit.py --skip-datto # skip Datto Workplace (needs RMM probe)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent.parent # tools -> gps-rmm-audit -> projects -> repo... no
|
||||||
|
# Correct: tools/ is inside projects/gps-rmm-audit/tools/
|
||||||
|
# repo root is 3 levels up from tools/
|
||||||
|
_THIS = Path(__file__).resolve()
|
||||||
|
REPO = _THIS.parent.parent.parent.parent # tools -> gps-rmm-audit -> projects -> repo
|
||||||
|
|
||||||
|
PY_SH = str(REPO / ".claude" / "scripts" / "py.sh")
|
||||||
|
MSP360_CLI = str(REPO / ".claude" / "skills" / "msp360" / "scripts" / "msp360.py")
|
||||||
|
SEAFILE_CLI = str(REPO / ".claude" / "skills" / "seafile" / "scripts" / "seafile.py")
|
||||||
|
OWNCLOUD_CLI = str(REPO / ".claude" / "skills" / "owncloud" / "scripts" / "owncloud.py")
|
||||||
|
LOGERR = str(REPO / ".claude" / "scripts" / "log-skill-error.sh")
|
||||||
|
|
||||||
|
# Client -> backend mapping (from backup-map.md, manually maintained)
|
||||||
|
# Only clients that are BILLED for backup or have known backup activity
|
||||||
|
CLIENT_BACKENDS: dict[str, list[str]] = {
|
||||||
|
"Dataforth": ["msp360"],
|
||||||
|
"Valley Wide Plastering": ["msp360"],
|
||||||
|
"Curtis Plumbing": ["msp360"],
|
||||||
|
"Ridgetop Group": ["msp360"],
|
||||||
|
"Brett Interiors": ["msp360"],
|
||||||
|
"Gary A Hartman LLC": ["msp360"],
|
||||||
|
"Jimmy Company": ["msp360"],
|
||||||
|
"Glaz-Tech Industries": ["msp360"],
|
||||||
|
"Grabb & Durando Law Office": ["msp360"],
|
||||||
|
"Bill Tedards": ["msp360"],
|
||||||
|
"Arizona Medical Transit": ["msp360"],
|
||||||
|
"Reliant Well Drilling": ["msp360"],
|
||||||
|
"The Prairie Schooner": ["msp360"],
|
||||||
|
"Janet Altschuler": ["msp360"],
|
||||||
|
"Design and Brand Envoys": ["msp360"],
|
||||||
|
"Mineralogical Record": ["owncloud"],
|
||||||
|
# Revenue leak (backing up but not billed)
|
||||||
|
"Cascades of Tucson": ["msp360"],
|
||||||
|
"Len's Auto Brokerage": ["msp360"],
|
||||||
|
"Russo Law Firm": ["msp360", "seafile"],
|
||||||
|
"Horseshoe Management": ["msp360"],
|
||||||
|
"Business Services of Tucson": ["msp360", "owncloud"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# MSP360 company names don't always match the canonical client names.
|
||||||
|
# This maps MSP360 CompanyName -> our CLIENT_BACKENDS key.
|
||||||
|
MSP360_ALIASES: dict[str, str] = {
|
||||||
|
"Valleywide Plastering": "Valley Wide Plastering",
|
||||||
|
"Glaztech Industries": "Glaz-Tech Industries",
|
||||||
|
"Grabb and Durando": "Grabb & Durando Law Office",
|
||||||
|
"Tedards": "Bill Tedards",
|
||||||
|
"Hartman": "Gary A Hartman LLC",
|
||||||
|
"Len's Auto": "Len's Auto Brokerage",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(msg: str) -> None:
|
||||||
|
try:
|
||||||
|
subprocess.run(["bash", LOGERR, "gps-audit", msg],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run_skill(cli: str, args: list[str], timeout: int = 120) -> Any:
|
||||||
|
"""Run a skill CLI and parse its JSON output."""
|
||||||
|
cmd = ["bash", PY_SH, cli] + args
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
return json.loads(r.stdout)
|
||||||
|
except (json.JSONDecodeError, subprocess.TimeoutExpired) as e:
|
||||||
|
log_error(f"skill call failed: {cli} {args} -- {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_msp360() -> list[dict]:
|
||||||
|
"""Fetch all MSP360 monitoring plans."""
|
||||||
|
data = run_skill(MSP360_CLI, ["monitoring", "--json"])
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_seafile() -> list[dict]:
|
||||||
|
"""Fetch Seafile user usage."""
|
||||||
|
data = run_skill(SEAFILE_CLI, ["usage", "--json"])
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_owncloud() -> list[dict]:
|
||||||
|
"""Fetch ownCloud user usage (slow -- SSH)."""
|
||||||
|
data = run_skill(OWNCLOUD_CLI, ["usage", "--json"], timeout=60)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def days_ago(ts_str: str | None) -> int | None:
|
||||||
|
"""Parse an ISO timestamp and return days since now."""
|
||||||
|
if not ts_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Handle both aware and naive timestamps
|
||||||
|
ts_str = ts_str.rstrip("Z")
|
||||||
|
if "+" in ts_str or (ts_str.count("-") > 2):
|
||||||
|
# Has timezone offset
|
||||||
|
dt = datetime.fromisoformat(ts_str)
|
||||||
|
if dt.tzinfo:
|
||||||
|
return (datetime.now(timezone.utc) - dt).days
|
||||||
|
dt = datetime.fromisoformat(ts_str)
|
||||||
|
return (datetime.now(timezone.utc).replace(tzinfo=None) - dt).days
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def grade_msp360_plan(plan: dict) -> str:
|
||||||
|
"""Grade a single MSP360 plan: OK / STALE / FAILING."""
|
||||||
|
status = plan.get("Status", -1)
|
||||||
|
last_start = plan.get("LastStart")
|
||||||
|
age = days_ago(last_start)
|
||||||
|
|
||||||
|
if status != 0:
|
||||||
|
error = plan.get("ErrorMessage", "")
|
||||||
|
if error:
|
||||||
|
return f"FAILING ({error[:40]})"
|
||||||
|
return "FAILING"
|
||||||
|
|
||||||
|
if age is not None and age > 7:
|
||||||
|
return f"STALE ({age}d)"
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_client(name: str) -> str:
|
||||||
|
"""Normalize client name for fuzzy matching."""
|
||||||
|
return name.lower().strip().replace(" ", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_msp360_company(company_name: str) -> str | None:
|
||||||
|
"""Resolve an MSP360 company name to our canonical client name."""
|
||||||
|
# Direct alias lookup first
|
||||||
|
if company_name in MSP360_ALIASES:
|
||||||
|
return MSP360_ALIASES[company_name]
|
||||||
|
# Exact match against CLIENT_BACKENDS keys
|
||||||
|
for client in CLIENT_BACKENDS:
|
||||||
|
if normalize_client(client) == normalize_client(company_name):
|
||||||
|
return client
|
||||||
|
# Substring match (either direction)
|
||||||
|
for client in CLIENT_BACKENDS:
|
||||||
|
cn = normalize_client(company_name)
|
||||||
|
ck = normalize_client(client)
|
||||||
|
if cn in ck or ck in cn:
|
||||||
|
return client
|
||||||
|
# First-word match (catches "Dataforth" matching "Dataforth Corp")
|
||||||
|
company_first = normalize_client(company_name).split()[0] if company_name else ""
|
||||||
|
for client in CLIENT_BACKENDS:
|
||||||
|
if normalize_client(client).split()[0] == company_first and len(company_first) > 3:
|
||||||
|
return client
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
msp360_plans: list[dict],
|
||||||
|
seafile_users: list[dict],
|
||||||
|
owncloud_users: list[dict],
|
||||||
|
client_filter: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build unified per-client backup status rows."""
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for client, backends in CLIENT_BACKENDS.items():
|
||||||
|
if client_filter and normalize_client(client_filter) not in normalize_client(client):
|
||||||
|
continue
|
||||||
|
|
||||||
|
row: dict[str, Any] = {
|
||||||
|
"client": client,
|
||||||
|
"backends": backends,
|
||||||
|
"msp360": [],
|
||||||
|
"seafile": [],
|
||||||
|
"owncloud": [],
|
||||||
|
"verdict": "UNKNOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
# MSP360
|
||||||
|
if "msp360" in backends:
|
||||||
|
for plan in msp360_plans:
|
||||||
|
co = plan.get("CompanyName", "")
|
||||||
|
resolved = resolve_msp360_company(co)
|
||||||
|
if resolved == client:
|
||||||
|
grade = grade_msp360_plan(plan)
|
||||||
|
row["msp360"].append({
|
||||||
|
"computer": plan.get("ComputerName", "?"),
|
||||||
|
"plan": plan.get("PlanName", "?"),
|
||||||
|
"last_start": plan.get("LastStart"),
|
||||||
|
"age_days": days_ago(plan.get("LastStart")),
|
||||||
|
"status_code": plan.get("Status"),
|
||||||
|
"data_gb": round((plan.get("DataCopied") or 0) / 1e9, 1),
|
||||||
|
"grade": grade,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Seafile
|
||||||
|
if "seafile" in backends:
|
||||||
|
for user in seafile_users:
|
||||||
|
email = user.get("email", "")
|
||||||
|
uname = user.get("name", "")
|
||||||
|
# Match by client name in email domain or display name
|
||||||
|
client_lower = normalize_client(client)
|
||||||
|
if any(client_lower.split()[0] in s.lower() for s in [email, uname]):
|
||||||
|
row["seafile"].append({
|
||||||
|
"email": email,
|
||||||
|
"name": uname,
|
||||||
|
"used_gb": round(user.get("quota_used_gb", user.get("quota_used_bytes", 0) / 1e9), 1),
|
||||||
|
"last_access": user.get("last_access"),
|
||||||
|
"age_days": days_ago(user.get("last_access")),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ownCloud
|
||||||
|
if "owncloud" in backends:
|
||||||
|
for user in owncloud_users:
|
||||||
|
uid = user.get("uid", "")
|
||||||
|
client_lower = normalize_client(client)
|
||||||
|
# Known mappings: minrec -> Mineralogical Record, bst -> Business Services
|
||||||
|
if (client_lower.startswith("mineral") and uid == "minrec") or \
|
||||||
|
(client_lower.startswith("business") and uid == "bst") or \
|
||||||
|
(normalize_client(uid) in client_lower):
|
||||||
|
row["owncloud"].append({
|
||||||
|
"uid": uid,
|
||||||
|
"used_gb": user.get("used_gb", 0),
|
||||||
|
"enabled": user.get("enabled", False),
|
||||||
|
"last_login": user.get("last_login"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compute verdict
|
||||||
|
all_grades = [p["grade"] for p in row["msp360"]]
|
||||||
|
ok_plans = sum(1 for g in all_grades if g == "OK")
|
||||||
|
failing_plans = sum(1 for g in all_grades if "FAILING" in g)
|
||||||
|
stale_plans = sum(1 for g in all_grades if "STALE" in g)
|
||||||
|
has_seafile = len(row["seafile"]) > 0 and any(s["used_gb"] > 0 for s in row["seafile"])
|
||||||
|
has_owncloud = len(row["owncloud"]) > 0 and any(o["used_gb"] > 0 and o["enabled"] for o in row["owncloud"])
|
||||||
|
|
||||||
|
if not row["msp360"] and not has_seafile and not has_owncloud:
|
||||||
|
row["verdict"] = "FAIL (no backup found)"
|
||||||
|
elif ok_plans == 0 and not has_seafile and not has_owncloud:
|
||||||
|
if failing_plans > 0:
|
||||||
|
row["verdict"] = "FAIL (all plans failing)"
|
||||||
|
elif stale_plans > 0:
|
||||||
|
stale_detail = [g for g in all_grades if "STALE" in g][0]
|
||||||
|
row["verdict"] = f"REVIEW ({stale_detail})"
|
||||||
|
else:
|
||||||
|
row["verdict"] = "FAIL"
|
||||||
|
elif ok_plans > 0 or has_seafile or has_owncloud:
|
||||||
|
bad = failing_plans + stale_plans
|
||||||
|
if bad == 0:
|
||||||
|
row["verdict"] = "OK"
|
||||||
|
else:
|
||||||
|
row["verdict"] = f"OK ({ok_plans} plans OK, {bad} need attention)"
|
||||||
|
else:
|
||||||
|
row["verdict"] = "REVIEW"
|
||||||
|
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
# Sort: FAIL first, then REVIEW, then OK
|
||||||
|
order = {"FAIL": 0, "REVIEW": 1, "OK": 2, "UNKNOWN": 3}
|
||||||
|
rows.sort(key=lambda r: (order.get(r["verdict"].split()[0].rstrip("("), 3), r["client"]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def print_table(rows: list[dict]) -> None:
|
||||||
|
"""Print a human-readable summary table."""
|
||||||
|
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
print(f"GPS Backup Audit Report -- {now_str}")
|
||||||
|
print(f"{'=' * 90}")
|
||||||
|
print(f"{'CLIENT':<35} {'BACKEND':<12} {'MACHINES':<10} {'LAST OK':<12} {'VERDICT'}")
|
||||||
|
print(f"{'-' * 90}")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
backend_str = "+".join(row["backends"])
|
||||||
|
|
||||||
|
# Count machines
|
||||||
|
machine_count = len(row["msp360"]) + len(row["seafile"]) + len(row["owncloud"])
|
||||||
|
|
||||||
|
# Most recent OK backup
|
||||||
|
ages = []
|
||||||
|
for p in row["msp360"]:
|
||||||
|
if p["grade"] == "OK" and p["age_days"] is not None:
|
||||||
|
ages.append(p["age_days"])
|
||||||
|
for s in row["seafile"]:
|
||||||
|
if s["age_days"] is not None:
|
||||||
|
ages.append(s["age_days"])
|
||||||
|
last_ok = f"{min(ages)}d ago" if ages else "-"
|
||||||
|
|
||||||
|
print(f"{row['client'][:35]:<35} {backend_str:<12} {machine_count:<10} {last_ok:<12} {row['verdict']}")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
total = len(rows)
|
||||||
|
ok = sum(1 for r in rows if r["verdict"].startswith("OK"))
|
||||||
|
review = sum(1 for r in rows if r["verdict"].startswith("REVIEW"))
|
||||||
|
fail = sum(1 for r in rows if r["verdict"].startswith("FAIL"))
|
||||||
|
unknown = sum(1 for r in rows if r["verdict"].startswith("UNKNOWN"))
|
||||||
|
|
||||||
|
print(f"\n{'=' * 90}")
|
||||||
|
print(f"TOTAL: {total} clients | OK: {ok} | REVIEW: {review} | FAIL: {fail} | UNKNOWN: {unknown}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="GPS backup audit aggregator")
|
||||||
|
parser.add_argument("--json", action="store_true", help="JSON output")
|
||||||
|
parser.add_argument("--client", help="Filter to a single client (partial match)")
|
||||||
|
parser.add_argument("--failed", action="store_true", help="Show only FAIL/REVIEW clients")
|
||||||
|
parser.add_argument("--skip-owncloud", action="store_true", help="Skip ownCloud (slow SSH)")
|
||||||
|
parser.add_argument("--skip-datto", action="store_true", help="Skip Datto Workplace")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Fetch from all backends
|
||||||
|
print("[INFO] Fetching MSP360 monitoring data...", file=sys.stderr)
|
||||||
|
msp360_plans = fetch_msp360()
|
||||||
|
print(f"[OK] MSP360: {len(msp360_plans)} plans", file=sys.stderr)
|
||||||
|
|
||||||
|
print("[INFO] Fetching Seafile usage...", file=sys.stderr)
|
||||||
|
seafile_users = fetch_seafile()
|
||||||
|
print(f"[OK] Seafile: {len(seafile_users)} users", file=sys.stderr)
|
||||||
|
|
||||||
|
owncloud_users: list[dict] = []
|
||||||
|
if not args.skip_owncloud:
|
||||||
|
print("[INFO] Fetching ownCloud usage (SSH, may take a moment)...", file=sys.stderr)
|
||||||
|
owncloud_users = fetch_owncloud()
|
||||||
|
print(f"[OK] ownCloud: {len(owncloud_users)} users", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("[SKIP] ownCloud", file=sys.stderr)
|
||||||
|
|
||||||
|
# Build the report
|
||||||
|
rows = build_report(msp360_plans, seafile_users, owncloud_users, args.client)
|
||||||
|
|
||||||
|
if args.failed:
|
||||||
|
rows = [r for r in rows if not r["verdict"].startswith("OK")]
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(rows, indent=2, default=str))
|
||||||
|
else:
|
||||||
|
print_table(rows)
|
||||||
|
|
||||||
|
# Return non-zero if any FAIL
|
||||||
|
if any(r["verdict"].startswith("FAIL") for r in rows):
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user