From bd1e84d32c9a218b9c924a69559158a793bf172d Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Thu, 25 Jun 2026 12:39:42 -0700 Subject: [PATCH] skills: add datto-edr (Datto EDR / Infocyte HUNT) + syncro-rmm memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /datto-edr skill — standalone CLI for the Datto EDR REST API (azcomp4587, rebranded Infocyte HUNT, raw-token LoopBack). Live-verified reads across the whole MSP fleet: orgs/sites/agents/detections/sweep + deploy-cmd. Scan/isolate gated behind --confirm (shape-verified, not run against prod). Token vaulted at msp-tools/datto-edr.sops.yaml. Also: reference_syncro_rmm_api_gui_only memory (Syncro RMM policy mgmt is GUI-only) and the guru-rmm submodule pointer bump (Feature 6 EDR research). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/datto-edr/.gitignore | 3 + .claude/skills/datto-edr/SKILL.md | 153 ++++++++++++++++++ .../datto-edr/references/api-reference.md | 111 +++++++++++++ .claude/skills/datto-edr/scripts/edr.py | 54 ++++--- .../skills/datto-edr/scripts/edr_client.py | 33 ++-- .claude/skills/datto-edr/scripts/selftest.py | 51 ++++++ projects/msp-tools/guru-rmm | 2 +- 7 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 .claude/skills/datto-edr/.gitignore create mode 100644 .claude/skills/datto-edr/SKILL.md create mode 100644 .claude/skills/datto-edr/references/api-reference.md create mode 100644 .claude/skills/datto-edr/scripts/selftest.py diff --git a/.claude/skills/datto-edr/.gitignore b/.claude/skills/datto-edr/.gitignore new file mode 100644 index 00000000..027b062d --- /dev/null +++ b/.claude/skills/datto-edr/.gitignore @@ -0,0 +1,3 @@ +.cache/ +__pycache__/ +*.pyc diff --git a/.claude/skills/datto-edr/SKILL.md b/.claude/skills/datto-edr/SKILL.md new file mode 100644 index 00000000..3291135a --- /dev/null +++ b/.claude/skills/datto-edr/SKILL.md @@ -0,0 +1,153 @@ +--- +name: datto-edr +description: >- + Control the ACG Datto EDR / Datto AV tenant (azcomp4587 — Datto EDR is rebranded + Infocyte HUNT; per-tenant LoopBack REST API). Read the whole MSP fleet from ONE + token: per-client organizations, sites, agents (online/AV/isolation/version), + detections (MITRE-tagged alerts), and a per-client security-posture sweep. Gated + actions: trigger scans, invoke response extensions (host isolation), and emit the + agent install one-liner for RMM-pushed deployment. Read-only by default; mutating + ops require --confirm. Triggers: datto edr, datto av, infocyte, EDR detections, + isolate endpoint, run a scan, deploy edr agent, edr security sweep, endpoint + detection response, azcomp4587. +--- + +# Datto EDR (Infocyte HUNT) Skill + +Standalone CLI for the Datto EDR REST API against the live ACG tenant +(`azcomp4587.infocyte.com`). Datto EDR is the rebranded **Infocyte HUNT** platform, +so the API is a per-tenant **LoopBack** service: `https://.infocyte.com/api/`. +Read-only by default; mutating operations are gated behind `--confirm`. + +One API token sees the **entire MSP fleet** (13 client orgs, 40 scan groups, 215 +agents, 1500+ alerts as of build) — segmented per-client by Organization. + +## Running the CLI + +```bash +PY="bash $CLAUDETOOLS_ROOT/.claude/scripts/py.sh" +EDR="$PY C:/claudetools/.claude/skills/datto-edr/scripts/edr.py" + +$EDR status # tenant rollup +$EDR orgs # clients (Organizations) +$EDR sites --org # client sites (Locations) +$EDR agents --org # endpoints for a client +$EDR detections --org --days 7 +$EDR sweep # per-client posture, sorted by recent detections +``` + +Transport auto-selects httpx if installed, else stdlib urllib (no hard dependency). + +## Credentials + +The token is NEVER hardcoded. Loaded at runtime from the SOPS vault: + +``` +bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \ + get-field msp-tools/datto-edr.sops.yaml credentials.api_token +``` + +**Auth model (critical):** the token is passed in the **raw `Authorization` header +— NOT `Bearer `, no prefix.** (Verified live 2026-06-25.) For testing you can +override with `DATTO_EDR_TOKEN` and `DATTO_EDR_BASE_URL`. The token **expires ~1 year +after creation (~2027-06-25)** — refresh it in the console (username menu → Admin → +Users & Tokens → API Tokens) and update the vault entry before it lapses. + +## Data model (how the tenant is structured) + +- **Organization** = a client/company (`orgs`). Carries agentCount/alertCount. +- **Location** = a client site (`sites`). Carries `organizationId`. **Agents belong + to a Location** via `Agent.locationId`. This is the inventory hierarchy: + Organization → Location → Agent. +- **Target** = a *scan* target group (`scan-targets`), the scannable unit for the + `scan` command (`POST targets/{id}/scan`). Distinct from Location (though they may + share an id when a site maps 1:1 to a scan group). +- **deviceGroup** = a global category ("Servers", "Workstations") spanning all orgs — + NOT a per-client grouping. Not used for client scoping. +- **Alert** = a detection. Carries `severity` (0 info … 4 critical), `mitreTactic`/ + `mitreId`, `hostname`, `organizationName`, `targetGroupName`, `eventTime`. + +## Command surface + +### Reads (run freely — always live, never cached) + +```bash +$EDR status # counts: orgs/sites/agents/active/isolated/alerts +$EDR orgs # clients + agent/alert/site counts +$EDR sites [--org ] # Locations (client sites) +$EDR scan-targets [--org ] # scan target groups (for `scan`) +$EDR agents [--org ] [--site ] [--limit N] +$EDR agent # full agent detail +$EDR detections [--org ] [--site ] [--severity 0-4] [--days N] [--limit N] +$EDR detection # full alert detail +$EDR sweep [--org ] # per-client posture rollup (headline view) +$EDR extensions # list response/collection extensions +$EDR deploy-cmd [--regkey ] # emit the agent install one-liner (see Deployment) +``` + +`agent`/`detection`/`status`/`sweep` support `--json` (place it before the subcommand +or after — argparse global flag): `$EDR --json sweep`. + +### Mutating (gated — refuse without `--confirm`) + +```bash +# Scan: POST targets/{targetGroupId}/scan (whole group) or POST targets/scan (single) +$EDR scan --target-group --confirm +$EDR scan --target-group --target --confirm + +# Response extension (host isolation, kill, quarantine) — runs as a scan w/ extension +$EDR isolate --target-group --target --extension-name "Host Isolation" --confirm + +# Power tool — any endpoint. Non-GET requires --confirm. +$EDR raw GET Agents --filter '{"limit":1}' +$EDR raw POST targets//scan --data '{"options":{...}}' --confirm +``` + +## Deployment + +Agent install is **not a REST call** — it's the agent binary run on the endpoint. +`deploy-cmd` emits the official one-liner (pulls a live registration key from +`/agentKeys`): + +``` +Install-EDR -InstanceName azcomp4587 -RegKey +``` + +Push it to a machine via **GuruRMM script execution** (`/rmm`) or any remote-exec +channel. `RegKey` auto-approves the agent and adds it to the default target group; +without it the agent registers but waits for manual approval in the console. + +## Safety gating + +- Reads never mutate and run without confirmation. +- `scan`, `isolate`, and any non-GET `raw` print a `[DRY-RUN]` line and exit non-zero + unless `--confirm` is passed. +- **Never** run a scan or isolation casually — these hit live client production + endpoints. Confirm the target group / host first with the read commands. + +## Verified vs. unverified + +**Live-verified 2026-06-25 (read fully exercised on the tenant):** `status`, `orgs`, +`sites`, `scan-targets`, `agents` (incl. per-org via Location mapping), `agent`, +`detections`, `detection`, `sweep`, `deploy-cmd` (real key returned), `raw` GET, and +the `--confirm` gate. + +**SHAPE-verified, RUN-unverified (NOT executed against prod during build — would +scan/isolate real client machines):** `scan`, `isolate`/`run_response_extension`. The +payload shapes come from the KaseyaDEDR `InfocyteHUNTAPI` module (`scan.ps1`). Before +first real use, confirm the response-extension id/name via `$EDR extensions` and do a +single deliberate test on an ACG-internal machine. + +## Relationship to GuruRMM (Feature 6) + +This skill is the **prototype for GuruRMM security-connector #2** (Datto EDR), exactly +as the `bitdefender` skill prototyped connector #1. Its read logic (per-client agents + +detections + AV/isolation state) ports into the GuruRMM `SecurityVendor` trait; the +live "EDR add-on" dashboard is best fed by EDR **webhooks** (Admin → Webhooks) into a +GuruRMM receiver, complemented by REST polling here. See +`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md` Feature 6. + +## Reference + +Full endpoint map, auth detail, LoopBack filter syntax, and the scan/response payload +shapes: `references/api-reference.md`. diff --git a/.claude/skills/datto-edr/references/api-reference.md b/.claude/skills/datto-edr/references/api-reference.md new file mode 100644 index 00000000..b182e5b6 --- /dev/null +++ b/.claude/skills/datto-edr/references/api-reference.md @@ -0,0 +1,111 @@ +# Datto EDR (Infocyte HUNT) API reference + +Datto EDR == rebranded Infocyte HUNT. The API is a per-tenant **LoopBack** REST +service. Everything below was verified live against `azcomp4587.infocyte.com` on +2026-06-25 unless marked otherwise. + +## Base URL & auth + +- Base: `https://.infocyte.com/api` (this tenant: `azcomp4587`). +- Self-documenting LoopBack explorer: `https://.infocyte.com/explorer`. +- **Auth: raw 64-char token in the `Authorization` header.** NO `Bearer` prefix, no + Basic, no OAuth. `Authorization: `. (Source: KaseyaDEDR PowershellTools + `requestHelpers.ps1`; confirmed live — `Bearer` prefix would fail.) +- Token created in console: username menu → **Admin → Users & Tokens → API Tokens → + Create new token**. Shown once. **Expires 1 year after creation.** +- Vault: `msp-tools/datto-edr.sops.yaml` field `credentials.api_token`. + +## LoopBack conventions + +- Models are PascalCase collections: `Organizations`, `Locations`, `Targets`, + `Agents`, `Alerts`, `Boxes`, `Reports`, `deviceGroups` (lowercase), `agentKeys`. +- List with a filter: `GET /?filter=` where the JSON supports + `{"where":{...},"limit":N,"order":"field DIR","fields":{"x":true}}`. +- `where` operators: `{"field":{"gt":"..."}}`, `{"field":{"inq":[...]}}`, equality + `{"field":val}`. +- Count: `GET //count?where=` → `{"count":N}`. +- Detail: `GET //{id}`. +- A model whose route doesn't exist returns `{"error":{"statusCode":404,"message": + "There is no method to handle GET /..."}}`. + +## Data hierarchy + +``` +Organization (client) GET /Organizations [id,name,agentCount,alertCount,locationCount,tenantId] + └─ Location (site) GET /Locations [id,name,organizationId,agentCount,activeAgentCount,alertCount,lastScannedOn] + └─ Agent (endpoint) GET /Agents (Agent.locationId -> Location.id) +Target (SCAN group) GET /Targets [id,name,organizationId,agentCount,activeAgentCount,lastScannedOn] (scannable unit) +deviceGroup (global category) GET /deviceGroups [id,name,deviceType] ("Servers"/"Workstations" — spans all orgs) +Alert (detection) GET /Alerts (carries organizationId/Name, targetGroupId/Name, severity, mitre*) +``` + +To list **agents for a client**: resolve org → its Locations (`where organizationId`) +→ `GET /Agents?filter={"where":{"locationId":{"inq":[]}}}`. (Agents do NOT +carry organizationId directly; `deviceGroupId` is a global category, not the client.) + +## Agent object (key fields) + +`id, hostname, name, os, osWindows/osLinux/osOsx/osOther, version, ip/ipPub, +active (online), heartbeat, isolated (containment state), dattoAvEnabled, +markedForUninstall, markedForUpdate, locationId, deviceGroupId, deviceId, authorized, +eppData, rwdInfo`. + +## Alert object (key fields) + +`id, name, description, severity (0 info,1 low,2 medium,3 high,4 critical), +mitreTactic, mitreId, hostname, organizationId, organizationName, targetGroupId, +targetGroupName, deviceId, agentId, hostId, eventTime, createdOn, sourceType, +sourceName, responseData, signed, managed, archived`. + +## Reads (verified) + +| Op | Method/path | +|---|---| +| Tenant counts | `GET /Organizations/count`, `/Targets/count`, `/Agents/count`, `/Alerts/count` | +| Organizations | `GET /Organizations` | +| Locations (sites) | `GET /Locations` (filter `where organizationId`) | +| Scan target groups | `GET /Targets` | +| Agents | `GET /Agents` (filter `where locationId` / `inq`) | +| Agent detail | `GET /Agents/{id}` | +| Alerts | `GET /Alerts` (filter `where` org/severity/createdOn) | +| Alert detail | `GET /Alerts/{id}` | +| Extensions | `GET /Extensions` | +| Agent reg keys | `GET /agentKeys` (for deploy one-liner) | + +## Mutating (shape-verified from the InfocyteHUNTAPI module; gate behind --confirm) + +| Op | Method/path | Body | +|---|---|---| +| Scan a target group | `POST /targets/{targetGroupId}/scan` | `{"options":{...ScanOptions}, "where":{...}?}` | +| Scan a single target | `POST /targets/scan` | `{"target":"ip/host","targetGroup":{"id":"..."},"options":{...}}` | +| Response extension (isolate/kill/quarantine) | `POST /targets/scan` | `{"target":"...","targetGroup":{"id":"..."},"options":{"extensions":[{"id"|"name":...}]}}` | + +**ScanOptions** booleans: `process, module, driver, memory, account, artifact, +autostart, application, installed, hook, network, events`, plus +`extensions:[{id,args,order}]`. + +These were **not executed** against the prod tenant during build (they scan/isolate +real client endpoints). Confirm the response-extension id/name via `GET /Extensions` +and run one deliberate test on an ACG-internal machine before relying on them. + +## Deployment (not a REST call) + +The agent installs by running the binary on the endpoint: +`agent.exe --key --url https://.infocyte.com`, or via the official +PowerShell wrapper one-liner (`Install-EDR -InstanceName -RegKey `). Pull +a live key from `GET /agentKeys`. Push via GuruRMM `/rmm` script execution or any +remote-exec channel. Nothing ties install to Datto RMM. + +## Liftable client + +`github.com/KaseyaDEDR/PowershellTools` — the `InfocyteHUNTAPI` module (Apache-2.0) is +a complete REST wrapper and the de-facto API spec. `extension-docs` (Lua 5.3) +documents the agent response actions (host isolation `isolator:isolate()`, process +kill, quarantine). + +## Webhooks (for the GuruRMM add-on) + +Admin → Webhooks → Add Webhook: EDR POSTs full alert JSON over HTTPS on each detection, +with custom auth headers supported. This is the recommended near-real-time feed for a +GuruRMM "EDR add-on" dashboard, complemented by REST polling here. (Not wired in this +skill yet — Feature 6 work.) diff --git a/.claude/skills/datto-edr/scripts/edr.py b/.claude/skills/datto-edr/scripts/edr.py index 957f1a1b..2accfcb7 100644 --- a/.claude/skills/datto-edr/scripts/edr.py +++ b/.claude/skills/datto-edr/scripts/edr.py @@ -102,11 +102,19 @@ def _t_orgs(rows): def _t_sites(rows): - print(f"{'SITE (TARGET GROUP)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7} ID") + print(f"{'SITE (LOCATION)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7} ID") for t in rows: print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} " f"{t.get('activeAgentCount',0):>7} {t.get('alertCount',0):>7} {t.get('id')}") - print(f"\n{len(rows)} target groups") + print(f"\n{len(rows)} locations") + + +def _t_scan_targets(rows): + print(f"{'SCAN TARGET GROUP':<34} {'AGENTS':>7} {'ACTIVE':>7} {'LAST SCAN':<20} ID") + for t in rows: + print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} " + f"{t.get('activeAgentCount',0):>7} {_trunc(t.get('lastScannedOn'),20):<20} {t.get('id')}") + print(f"\n{len(rows)} scan target groups") def _agent_os(a): @@ -119,13 +127,13 @@ def _agent_os(a): def _t_agents(rows): print(f"{'HOSTNAME':<26} {'OS':<8} {'ONLINE':<7} {'ISO':<4} {'AV':<4} " - f"{'VERSION':<10} ID") + f"{'VERSION':<13} ID") for a in rows: print(f"{_trunc(a.get('hostname') or a.get('name'),26):<26} " f"{_agent_os(a):<8} {('yes' if a.get('active') else 'no'):<7} " f"{('ISO' if a.get('isolated') else '-'):<4} " f"{('on' if a.get('dattoAvEnabled') else '-'):<4} " - f"{_trunc(a.get('version'),10):<10} {a.get('id')}") + f"{_trunc(a.get('version'),13):<13} {a.get('id')}") print(f"\n{len(rows)} agents") @@ -154,11 +162,11 @@ def _t_sweep(s): # --- org -> target group resolution ------------------------------------------- def _agents_for_org(client, org_id, limit): - """Agents across all target groups in an org (Agents carry deviceGroupId).""" - out = [] - for tg in client.list_targets(org_id=org_id): - out.extend(client.list_agents(target_group_id=tg.get("id"), limit=limit)) - return out + """Agents across all sites (Locations) in an org. Agent.locationId -> Location.""" + loc_ids = [l.get("id") for l in client.list_locations(org_id=org_id) if l.get("id")] + if not loc_ids: + return [] + return client.list_agents(location_ids=loc_ids, limit=limit) def build_parser() -> argparse.ArgumentParser: @@ -170,12 +178,15 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("orgs", help="list organizations (clients)") sub.add_parser("clients", help="alias for orgs") - sp = sub.add_parser("sites", help="list target groups") + sp = sub.add_parser("sites", help="list sites (Locations)") + sp.add_argument("--org", help="filter by organizationId") + + sp = sub.add_parser("scan-targets", help="list scan target groups") sp.add_argument("--org", help="filter by organizationId") sp = sub.add_parser("agents", help="list agents") sp.add_argument("--org") - sp.add_argument("--site", help="target group id") + sp.add_argument("--site", help="location id (a single site)") sp.add_argument("--limit", type=int, default=500) sp = sub.add_parser("agent", help="agent detail") @@ -200,12 +211,13 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--regkey", help="registration key (else pulled from /agentKeys)") sp = sub.add_parser("scan", help="trigger a scan (gated)") - sp.add_argument("--site", required=True, help="target group id") + sp.add_argument("--target-group", required=True, + help="scan target group id (from scan-targets)") sp.add_argument("--target", help="single ip/host (else whole group)") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("isolate", help="invoke a response extension e.g. host isolation (gated)") - sp.add_argument("--site", required=True, help="target group id") + sp.add_argument("--target-group", required=True, help="scan target group id") sp.add_argument("--target", required=True, help="ip/host") sp.add_argument("--extension-id") sp.add_argument("--extension-name", default="Host Isolation") @@ -242,12 +254,14 @@ def main(argv=None) -> int: elif cmd in ("orgs", "clients"): _emit(client.list_organizations(), j, _t_orgs) elif cmd == "sites": - _emit(client.list_targets(org_id=args.org), j, _t_sites) + _emit(client.list_locations(org_id=args.org), j, _t_sites) + elif cmd == "scan-targets": + _emit(client.list_targets(org_id=args.org), j, _t_scan_targets) elif cmd == "agents": if args.org and not args.site: rows = _agents_for_org(client, args.org, args.limit) else: - rows = client.list_agents(target_group_id=args.site, limit=args.limit) + rows = client.list_agents(location_id=args.site, limit=args.limit) _emit(rows, j, _t_agents) elif cmd == "agent": _emit(client.get_agent(args.id), j) @@ -270,20 +284,20 @@ def main(argv=None) -> int: _emit(_deploy_payload(client, regkey), j, _t_deploy) elif cmd == "scan": tgt = f"target {args.target}" if args.target else "the whole target group" - if not _gate(args, f"scan {tgt} in site {args.site}"): + if not _gate(args, f"scan {tgt} in target group {args.target_group}"): return 2 if args.target: - res = client.scan_single_target(args.site, args.target) + res = client.scan_single_target(args.target_group, args.target) else: - res = client.scan_target_group(args.site) + res = client.scan_target_group(args.target_group) _emit(res, j) elif cmd == "isolate": if not _gate(args, f"run response extension " f"'{args.extension_name or args.extension_id}' " - f"on {args.target} (site {args.site})"): + f"on {args.target} (target group {args.target_group})"): return 2 res = client.run_response_extension( - args.site, args.target, + args.target_group, args.target, extension_id=args.extension_id, extension_name=None if args.extension_id else args.extension_name) _emit(res, j) diff --git a/.claude/skills/datto-edr/scripts/edr_client.py b/.claude/skills/datto-edr/scripts/edr_client.py index 98a9aa1a..f127278f 100644 --- a/.claude/skills/datto-edr/scripts/edr_client.py +++ b/.claude/skills/datto-edr/scripts/edr_client.py @@ -228,9 +228,23 @@ class DattoEDRClient: "alertCount": True, "locationCount": True}, }) or [] + def list_locations(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]: + """List Locations (= client sites). VERIFIED LIVE. This is the per-client + grouping that agents belong to (Agent.locationId -> Location.id), and a + Location carries organizationId. Org -> Locations -> Agents is the + inventory hierarchy.""" + filt: dict = {"limit": limit, "order": "name ASC", + "fields": {"id": True, "name": True, "organizationId": True, + "agentCount": True, "activeAgentCount": True, + "alertCount": True, "lastScannedOn": True}} + if org_id: + filt["where"] = {"organizationId": org_id} + return self._get("Locations", filt) or [] + def list_targets(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]: - """List Targets (= target groups / sites). VERIFIED LIVE. - Each Target belongs to an Organization via organizationId.""" + """List Targets (= SCAN target groups). VERIFIED LIVE. Distinct from + Locations: a Target is the scannable unit (POST targets/{id}/scan). Each + Target belongs to an Organization via organizationId.""" filt: dict = {"limit": limit, "order": "name ASC", "fields": {"id": True, "name": True, "organizationId": True, "agentCount": True, "activeAgentCount": True, @@ -239,16 +253,17 @@ class DattoEDRClient: filt["where"] = {"organizationId": org_id} return self._get("Targets", filt) or [] - def list_agents(self, org_id: Optional[str] = None, - target_group_id: Optional[str] = None, + def list_agents(self, location_id: Optional[str] = None, + location_ids: Optional[list[str]] = None, limit: int = 500) -> list[dict]: - """List Agents (endpoints). VERIFIED LIVE. - Filter by deviceGroupId (target group). Org filtering is done by the CLI - via the target-group->org map since Agents carry deviceGroupId, not orgId.""" + """List Agents (endpoints). VERIFIED LIVE. Filter by locationId (a single + site) or location_ids (an org's sites, via LoopBack `inq`).""" filt: dict = {"limit": limit, "order": "hostname ASC"} where: dict = {} - if target_group_id: - where["deviceGroupId"] = target_group_id + if location_id: + where["locationId"] = location_id + elif location_ids: + where["locationId"] = {"inq": location_ids} if where: filt["where"] = where return self._get("Agents", filt) or [] diff --git a/.claude/skills/datto-edr/scripts/selftest.py b/.claude/skills/datto-edr/scripts/selftest.py new file mode 100644 index 00000000..e8c1e93c --- /dev/null +++ b/.claude/skills/datto-edr/scripts/selftest.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Self-test for the datto-edr skill. Read-only; never mutates the tenant. + +Confirms: token loads from vault, transport works, and the core read endpoints +return live data. Exits non-zero on any failure. Set EDR_SUPPRESS_ERRORLOG=1 so +expected probe errors don't spam errorlog.md. +""" +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("EDR_SUPPRESS_ERRORLOG", "1") + +from edr_client import DattoEDRClient, DattoEDRError + + +def main() -> int: + checks: list[tuple[str, bool, str]] = [] + try: + client = DattoEDRClient() + _ = client.api_token + checks.append(("vault token load", True, "")) + except DattoEDRError as exc: + print(f"[FAIL] token load: {exc}", file=sys.stderr) + return 1 + + def check(name, fn): + try: + val = fn() + checks.append((name, True, str(val))) + except Exception as exc: # noqa: BLE001 - selftest summarizes failures + checks.append((name, False, str(exc))) + + check("status rollup", lambda: client.get_status()["agents"]) + check("list organizations", lambda: f"{len(client.list_organizations())} orgs") + check("list locations", lambda: f"{len(client.list_locations())} sites") + check("list targets", lambda: f"{len(client.list_targets())} scan groups") + check("list agents (limit 5)", lambda: f"{len(client.list_agents(limit=5))} agents") + check("list alerts (limit 5)", lambda: f"{len(client.list_alerts(limit=5))} alerts") + + ok = True + for name, passed, detail in checks: + marker = "[OK]" if passed else "[FAIL]" + print(f"{marker} {name}{(' -> ' + detail) if detail else ''}") + ok = ok and passed + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/projects/msp-tools/guru-rmm b/projects/msp-tools/guru-rmm index de30ebcb..3b3f0697 160000 --- a/projects/msp-tools/guru-rmm +++ b/projects/msp-tools/guru-rmm @@ -1 +1 @@ -Subproject commit de30ebcb9bbd4aeceb9f9302c56c9bd4d323b94f +Subproject commit 3b3f06976c6469e3eb3c006eb6f3d6c1384be435