Compare commits

...

192 Commits

Author SHA1 Message Date
46d2ae7a5b sync: auto-sync from HOWARD-HOME at 2026-07-10 15:11:39
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-10 15:11:39
2026-07-10 15:12:08 -07:00
830a86ea95 wiki: goldstein - full network + remote-access topology (UniFi gateways, Site Magic, OpenVPN, roaming playbook) (#32384) 2026-07-10 15:03:13 -07:00
a0690793bc guru-scan: bump gitlink to 3ca5ba7 (RKill whitelist + scan-log protection)
Point the superproject at the tested guru-scan commit: RKill -w process
whitelist (spares the PowerShell host + running GuruRMM/Syncro/Datto EDR/
ScreenConnect/Bitdefender/MSP360/Splashtop agents), Datto EDR folders added to
the HitmanPro/Emsisoft exclusion list, and cleanup guard so scan logs/archives
are never deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:47:29 -07:00
0fdeabde3d sync: auto-sync from HOWARD-HOME at 2026-07-10 14:44:39
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-10 14:44:39
2026-07-10 14:45:46 -07:00
Winter Williams
df46f56e59 sync: auto-sync from GURU-BEAST-ROG at 2026-07-10 14:25:09
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-10 14:25:09
2026-07-10 14:25:55 -07:00
Winter Williams
299c053795 sync: auto-sync from GURU-BEAST-ROG at 2026-07-10 14:03:06
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-10 14:03:06
2026-07-10 14:03:53 -07:00
Winter Williams
8350a4aeaf sync: auto-sync from GURU-BEAST-ROG at 2026-07-10 13:15:01
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-10 13:15:01
2026-07-10 13:16:44 -07:00
1041db8587 sync: auto-sync from GURU-5070 at 2026-07-10 12:36:41
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-10 12:36:41
2026-07-10 12:37:18 -07:00
ebeb0ec1f6 wiki: compile instrumental-music-center (update) — refresh prepaid hours (10.0), fold 2026-07-08 log source, bump last_compiled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:20:28 -07:00
ecc0c8cd82 sync: auto-sync from HOWARD-HOME at 2026-07-10 11:12:18
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-10 11:12:18
2026-07-10 11:12:54 -07:00
Winter Williams
07404759f3 sync: auto-sync from GURU-BEAST-ROG at 2026-07-10 10:06:22
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-10 10:06:22
2026-07-10 10:07:09 -07:00
Winter Williams
3286a47090 sync: auto-sync from GURU-BEAST-ROG at 2026-07-10 09:58:26
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-10 09:58:26
2026-07-10 09:59:18 -07:00
f95809cd9d sync: auto-sync from Mikes-MacBook-Air.local at 2026-07-10 09:04:22
Author: Mike Swanson
Machine: Mikes-MacBook-Air.local
Timestamp: 2026-07-10 09:04:22
2026-07-10 09:04:29 -07:00
e78152a706 sync: auto-sync from Mikes-MacBook-Air.local at 2026-07-10 08:30:31
Author: Mike Swanson
Machine: Mikes-MacBook-Air.local
Timestamp: 2026-07-10 08:30:31
2026-07-10 08:30:39 -07:00
017dcdf04c voip: VWP park extension 107 (Payroll) - shared mailbox
Extension 107 (Payroll) marked as PARKED pending decision on shared mailbox setup.
Removed from CSV bulk import (11 -> 10 users).
Updated all documentation to reflect new counts: 13 matched, 1 shared, 4 parked.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:59:00 -07:00
2f35163866 harness: read Syncro keys from vault, stop hardcoding the repo root
Two defects found while running /wiki-compile, both from tooling assuming
a path instead of resolving it.

1. Plaintext Syncro API keys. Mike's and Howard's live PSA keys were
   copy-pasted into three command files, a script, and two catalog docs --
   despite both already being vaulted at msp-tools/syncro and
   msp-tools/syncro-howard. Replaced with reads from the SOPS vault via a
   new sourced helper, .claude/scripts/syncro-env.sh. Write paths fail
   closed; read-only paths degrade to skipped enrichment rather than a
   wrong key. Per-user mapping is unchanged, so Syncro attribution is too.

2. Hardcoded repo root. wiki-compile/wiki-lint/inject-standards and
   gen_b64.py hardcoded D:/claudetools; this machine is C:/claudetools.
   syncro.md also read ~/.claude/identity.json before the repo copy -- the
   same bug that made remediation-tool's consent-audit report a fully
   consented tenant as RED. Root now resolves from the script's own
   location, with identity.json claudetools_root as the override.

get-identity.sh had a chicken-and-egg bug: it read ${CLAUDETOOLS_ROOT:-.}
but never set it, so every caller had to already know the root. It now
self-resolves and exports CLAUDETOOLS_ROOT + VAULT_ROOT.

Verified: all three command setup blocks authenticate against live Syncro;
get-identity.sh works from any cwd and honors a pre-set root; gps-rmm
autoenroll resolves its key from the vault. security-review: no findings.

NOTE: both keys remain valid in git history. Rotation in the Syncro portal
is the required follow-up -- this commit does not resolve that exposure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:56:33 -07:00
c34cdb3a39 voip: VWP note phone MAC assignments for Kayla and Jesse III
Extension 109 (Kayla Guerrero) - Phone MAC last 4: 73b7
Extension 117 (Jesse III) - Phone MAC last 4: 6cf5

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:55:32 -07:00
ec1cb40b62 voip: VWP note phone MAC assignment for Chris
Extension 113 (Chris Guerrero) - Phone MAC last 4: 755b

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:49:42 -07:00
f9b44cfb68 voip: VWP note phone MAC assignment for Tammy
Extension 106 (Tammy) - Phone MAC last 4: 6a36

Documented for device assignment workflow after second CSV batch.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:49:14 -07:00
1ca456b03f voip: VWP note phone MAC assignment for Rose
Extension 103 (Rose Guerrero) - Phone MAC last 4: 5217

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:43:26 -07:00
250eb8ecfe voip: VWP note phone MAC assignment for Ron
Extension 110 (Ron Winger) - Phone MAC last 4: 6509

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:42:42 -07:00
5641532429 voip: VWP note phone MAC assignment for J.R.
Extension 105 (J.R. Guerrero) - Phone MAC last 4: 7441

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:41:52 -07:00
1b0eeb5595 voip: VWP note phone MAC assignment for Ty
Extension 114 (Ty Fetters) - Phone MAC last 4: 6d01

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:41:21 -07:00
b4972497a7 voip: VWP note phone MAC assignment for Kitchen shared phone
Extension 111 (Kitchen) - Phone MAC last 4: 958e
Shared phone extension (no M365 account required)
Updated counts: 3 extensions parked (down from 4)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:40:43 -07:00
a70ec28bdc voip: VWP note phone MAC assignment for Shelly
Extension 104 (Shelly) - Phone MAC last 4: 96b8

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:39:02 -07:00
7f8fb5d9c5 voip: VWP note phone MAC assignment for Toni
Extension 115 (Toni) - Phone MAC last 4: 6cf9

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:38:25 -07:00
d477970ab8 voip: VWP note phone MAC assignments for Jesse Sr and Bart
Extension 102 (Jesse Sr) - Phone MAC last 4: 7559
Extension 116 (Bart) - Phone MAC last 4: a890

Documented for device assignment workflow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:37:01 -07:00
522594dedb wiki: compile cascades-tucson (update)
Fold 4 session logs since last_compiled=2026-07-01:
- 2026-07-02 caregiver phone login / PSO-Caregivers FGPP
- 2026-07-02 Shelby Trozzi Company Web Docs share + mapped drive
- 2026-07-09 Charity Menle caregiver rename
- 2026-07-09 Chris Knight accounting mailbox delegation

Surgical update mode (not --full): 735 of 739 lines left byte-for-byte
intact. Adds Company Web Docs share record, Shelby vault path (path only),
4 History rows; refreshes Syncro hours/tickets (37.5 hrs, 0 open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:36:49 -07:00
06ed9ba562 voip: VWP clarify ext 106 (Tammy) and park ext 108 (Shannon)
Extension 106 (Tammy) matched to acctpay@valleywideplastering.com
Extension 108 (Shannon) parked pending clarification
Updated counts: 14 matched, 4 unmatched/parked

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:13:58 -07:00
25af453f61 feat(voip): Extension 117 clarification - Jesse III external email
CLARIFIED (2026-07-10):
- Extension 117: Jesse III
- Email: jesse@nescoap.com (external domain)
- Similar to ext 114 (Ty@CASARICA.NET - external domain)
- Status: Ready to add to second CSV batch

UPDATED:
- Matched users: 12 -> 13 (Jesse III now matched)
- Unmatched users: 6 -> 5 (removed Jesse III from unmatched list)
- Will be included in second CSV import with other clarified/shared extensions

FILES MODIFIED:
- extension-mapping.md: Updated ext 117 to show jesse@nescoap.com
- PROVISIONING-STATUS.md: Moved ext 117 to clarified section
- README.md: Updated user counts and user summary

CURRENT STATUS:
- 11 users in CSV ready for bulk import (102-116 minus gaps)
- 2 users for separate provisioning (101 Natalya, 117 Jesse III)
- 5 users still need clarification (106, 108, 111, 112, 118)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:09:35 -07:00
736d43f482 feat(voip): Extension 101 clarification - Natalya is receptionist
CLARIFIED (2026-07-10):
- Extension 101: Natalya (Receptionist)
- M365 Account: Customer Service (customerservice@valleywideplastering.com)
- Phone: Test phone from yesterday (MAC 805e0cdd71b1 / Last 4: 71b1)
- Status: Phone already registered, ready for user provisioning

UPDATED:
- Matched users: 11 -> 12 (Natalya now matched)
- Unmatched users: 7 -> 6 (removed Natalya from unmatched list)
- Created provision-natalya-101.md with step-by-step commands

FILES MODIFIED:
- extension-mapping.md: Updated ext 101 to show customerservice@ match
- PROVISIONING-STATUS.md: Added clarified section for ext 101
- README.md: Updated user counts and added provision-natalya-101.md link
- provision-natalya-101.md: NEW - Complete provisioning guide for receptionist

NEXT ACTIONS:
1. Upload 11-user CSV to PacketDial (unchanged)
2. Provision extension 101 separately (commands documented)
3. Remaining 6 unmatched extensions need clarification

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-10 07:09:35 -07:00
033019df18 sync: auto-sync from GURU-5070 at 2026-07-10 06:59:57
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-10 06:59:57
2026-07-10 07:00:29 -07:00
bf95a10685 sync: auto-sync from HOWARD-HOME at 2026-07-09 20:26:12
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-09 20:26:12
2026-07-09 20:26:40 -07:00
2fba62b7df sync: auto-sync from HOWARD-HOME at 2026-07-09 20:23:39
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-09 20:23:39
2026-07-09 20:24:08 -07:00
db10206aea feat(voip): VWP complete provisioning prep + setup documentation
Critical RPS provisioning URL fix + CSV bulk import ready + complete VoIP setup guide

CRITICAL FIX:
- RPS server URL corrected: http://ndp.ucaasnetwork.com/cfg (was ftp://p.packetdials.net)
- Discovered ACG has TWO whitelabel accounts (WLC vs OIT/PacketDial)
- Test phone (805e0cdd71b1) now registered immediately after fix (Status 1)

USER PROVISIONING:
- M365 account matching: 11 matched users, 7 unmatched (need clarification)
- CSV bulk import prepared (vwp-users-import.csv)
- Strategy: users WITHOUT devices initially (add phone extension: no)
- Device assignment workflow: incremental as phones distributed

COMPLETE VOIP SETUP DOCUMENTED:
- DID (phone number) management + routing options
- Call queues (ring groups) - recommended queues for VWP
- Auto-attendants (IVR menus) - main menu design
- Time frames (business hours routing)
- Additional features (hunt groups, voicemail-only, fax-to-email)
- All components with API commands + examples

FILES CREATED (7):
- README.md: Quick navigation hub + domain info
- PROVISIONING-STATUS.md: Current status + next steps
- SESSION-SUMMARY-2026-07-09.md: Complete session documentation (18 pages)
- COMPLETE-CLIENT-SETUP.md: Full VoIP setup guide (15 pages)
- DEVICE-ASSIGNMENT-COMMANDS.md: Copy-paste command reference
- extension-mapping.md: Extension to M365 account mapping
- vwp-users-import.csv: Bulk import file (READY for upload)

IMPLEMENTATION PHASES:
Phase 1 (In Progress): Basic connectivity - CSV ready for upload
Phase 2 (Planned): Main line setup - queue + auto-attendant + DID
Phase 3 (Planned): Business hours routing - time-based call handling
Phase 4 (Optional): Additional features per client needs

NEXT ACTIONS:
- Upload CSV to PacketDial web UI (manual step)
- Get client clarification on 7 unmatched users
- Assign devices as phones distributed (per-phone workflow documented)

Domain: vwp.91912.service
Main Number: 480-705-9500
E911: a-6a395c03d4cfe (301 N 56TH ST, CHANDLER AZ)
YMCS Site: 1e7578a6fe0e41cfb5a3e8b40933ffee

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-07-09 20:21:57 -07:00
eb04287396 sync: auto-sync from HOWARD-HOME at 2026-07-09 20:14:58
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-09 20:14:58
2026-07-09 20:16:45 -07:00
ab987bf9e1 wiki(sif-oidak): close the onmicrosoft.com UPN rename as not actionable
Removing onmicrosoft.com from M365 usernames needs sifoidak.com verified as a custom
Entra domain, which needs a DNS TXT record at GoDaddy. Mike confirmed ACG has no
registrar access to that account, so UPNs stay on sifoidak.onmicrosoft.com and new
users follow the same convention. Recorded as decided rather than blocked so it is
not re-proposed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:16:05 -07:00
d9f877661e wiki(sif-oidak): correct identity model, add printer + onboarding runbook
Sif-oidak was documented as a hybrid AD/M365 environment. It is not: there is no
Entra Connect on either DC, no AD object carries msDS-ConsistencyGuid, and every
M365 user has onPremisesSyncEnabled=null. AD and M365 are disjoint, and email is a
third system entirely (sifoidak.com at GoDaddy; the O365_BUSINESS SKU carries the
EXCHANGE_S_FOUNDATION no-mailbox stub).

That mismatch caused a real failure: Dwayne Ortega had a cloud account only, so he
could not log in to a domain-joined workstation. Created his AD account and recorded
the two-account onboarding rule.

Also documented: printer error 740 (Point-and-Print driver install needs admin,
UAC prompt never surfaces), a GPO pushing the MX-6240N queue from the wrong server,
SIF-SERVER2 confirmed as a backup DC, and refreshed stale laptop agent UUIDs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:16:04 -07:00
5a463ae133 sync: auto-sync from GURU-5070 at 2026-07-09 15:56:52
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-09 15:56:52
2026-07-09 15:57:31 -07:00
6942827391 sync: auto-sync from Mikes-MacBook-Air.local at 2026-07-09 14:52:07
Author: Mike Swanson
Machine: Mikes-MacBook-Air.local
Timestamp: 2026-07-09 14:52:07
2026-07-09 14:52:17 -07:00
Winter Williams
07268cd741 sync: auto-sync from GURU-BEAST-ROG at 2026-07-09 13:39:12
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-09 13:39:12
2026-07-09 13:40:02 -07:00
57c23fc8d7 sync: auto-sync from Mikes-MacBook-Air.local at 2026-07-09 12:31:21
Author: Mike Swanson
Machine: Mikes-MacBook-Air.local
Timestamp: 2026-07-09 12:31:21
2026-07-09 12:31:30 -07:00
2e92a2d38d sync: auto-sync from GURU-5070 at 2026-07-09 11:06:29
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-09 11:06:29
2026-07-09 11:12:33 -07:00
9926e5f2cc sync: auto-sync from HOWARD-HOME at 2026-07-09 11:00:18
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-09 11:00:18
2026-07-09 11:01:24 -07:00
Winter Williams
252d22048b sync: auto-sync from GURU-BEAST-ROG at 2026-07-09 10:09:06
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-09 10:09:06
2026-07-09 10:12:27 -07:00
b3ea44a8ff sync: auto-sync from HOWARD-HOME at 2026-07-08 18:20:05
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 18:20:05
2026-07-08 18:20:34 -07:00
e765c9115d sync: auto-sync from HOWARD-HOME at 2026-07-08 18:04:09
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 18:04:09
2026-07-08 18:04:40 -07:00
cac5eadaee sync: auto-sync from HOWARD-HOME at 2026-07-08 17:06:40
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 17:06:40
2026-07-08 17:07:11 -07:00
9c998a95d0 sync: auto-sync from HOWARD-HOME at 2026-07-08 17:03:04
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 17:03:04
2026-07-08 17:03:59 -07:00
447b18d62b sync: auto-sync from HOWARD-HOME at 2026-07-08 17:00:33
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 17:00:33
2026-07-08 17:01:23 -07:00
a7b2fae468 sync: auto-sync from HOWARD-HOME at 2026-07-08 17:00:10
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 17:00:10
2026-07-08 17:00:39 -07:00
cc336243e3 sync: auto-sync from HOWARD-HOME at 2026-07-08 13:09:22
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 13:09:22
2026-07-08 13:09:52 -07:00
fa97bcc49b sync: auto-sync from HOWARD-HOME at 2026-07-08 13:01:22
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 13:01:22
2026-07-08 13:01:51 -07:00
d037d82f5e sync: auto-sync from HOWARD-HOME at 2026-07-08 12:39:04
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 12:39:04
2026-07-08 12:39:32 -07:00
c0d304485a wiki: compile desert-rv-center (seed) 2026-07-08 12:38:25 -07:00
25ca9306e1 sync: auto-sync from HOWARD-HOME at 2026-07-08 12:15:56
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 12:15:56
2026-07-08 12:16:23 -07:00
fcf43044d4 sync: auto-sync from HOWARD-HOME at 2026-07-08 09:00:22
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 09:00:22
2026-07-08 09:00:51 -07:00
2a1ea6c9f7 sync: auto-sync from Mikes-MacBook-Air.local at 2026-07-08 06:49:28
Author: Mike Swanson
Machine: Mikes-MacBook-Air.local
Timestamp: 2026-07-08 06:49:28
2026-07-08 06:50:46 -07:00
fc12c596f1 sync: auto-sync from HOWARD-HOME at 2026-07-08 06:09:58
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-08 06:09:58
2026-07-08 06:10:25 -07:00
eee469ce86 sync: auto-sync from HOWARD-HOME at 2026-07-07 21:17:44
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 21:17:44
2026-07-07 21:18:15 -07:00
4e148b6dc5 sync: auto-sync from HOWARD-HOME at 2026-07-07 21:05:08
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 21:05:08
2026-07-07 21:05:37 -07:00
19edfc45c0 wiki(IMC): SQL max-memory caps APPLIED 2026-07-07 (12GB/256/512) + probable-cause watch flag
- Confirmed caps were never applied (all uncapped); applied + verified via GuruRMM
- SQLEXPRESS/AIMSQL required IMC\guru impersonation (SYSTEM not sysadmin there)
- WATCH flag through ~2026-07-11: if AIM/perf issues reported, suspect this first; rollback = uncap
- Active Work item marked done

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:03:49 -07:00
e548f63605 fix(ps-encoded.sh): Python fallback when iconv.exe absent (Git-for-Windows ships libiconv DLL only, no pacman)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:51:17 -07:00
840b0ce9d6 wiki(IMC): 2026-07-07 fleet reconciliation — Datto EDR rollout, AV vendor, backup/billing gap, retire list
- AV/EDR is Datto (not Bitdefender); REPAIRADMIN BD->EDR swap in progress
- EDR deployed to BigMom + DESKTOP-NFU17AJ; queued LUIS/EdServices1/REPAIRADMIN
- DESKTOP-NFU17AJ enrolled in RMM (17->18); 4 more queued via SC
- MSP360 backup live-but-unbilled on IMC1; AV billed 20 seats
- 12 Syncro assets to archive (>40d offline; archive=UI-only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:45:53 -07:00
09ab3de106 wiki: Prairie Schooner server migration state (paused, resume after UniFi swap) 2026-07-07 20:45:00 -07:00
361478dd66 sync: auto-sync from HOWARD-HOME at 2026-07-07 20:39:12
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 20:39:12
2026-07-07 20:39:41 -07:00
0a438929d0 ask-forum: harden per high-effort review + document capability limits
Fixes from the code-review workflow (7 findings): page the poll cursor past the
100-msg window (no false timeout behind bot/overflow messages); honor 429
retry_after and bail fast on permanent Discord errors (10003/50001/...) instead
of burning the timeout; status-check overflow follow-up chunks (warn, don't
silently truncate); clamp --read limit safely (guard >64-bit overflow); add
--wait --after for precise resumption; DRY the newline-trim; set LC_ALL=C.UTF-8
for codepoint-safe slicing. SKILL.md gains the tested bot capability/limit map
(can: post/read/edit-own/react; cannot without a grant: delete/archive/unlock/pin)
and a robustness section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:37:46 -07:00
Winter Williams
f379a828e9 bot: deploy #ct-forum guard on BEAST (submodule -> 56938bb) + log restart
Requested by Mike via #ct-forum thread; restart fires via one-shot
scheduled task so the bot survives posting its own confirmation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 20:32:39 -07:00
a2a277ab67 sync: auto-sync from HOWARD-HOME at 2026-07-07 20:31:16
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 20:31:16
2026-07-07 20:31:47 -07:00
9340cdec17 ask-forum: promote to a full skill + wire into skill-first routing
Add .claude/skills/ask-forum/SKILL.md (usage contract, correlation model,
forum-only scope, access/permissions) and route to it from CLAUDE.md
(skill-first covered domains) + SKILL_ROUTING.md, so sessions invoke the
skill instead of hand-rolling the Discord API — the footgun that produced a
broken background wait. Capture that footgun as memory
feedback_background_task_no_ampersand (run_in_background, never a shell &).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:12:18 -07:00
8def189a17 ask-forum: human-in-the-loop question flow via #ct-forum
Add ask-forum.sh + /ask-forum command: a live Claude session posts a
question to the private #ct-forum forum and blocks (server-side, one tool
call) until a human replies, then acts on the answer. Same session throughout
- a three-way between the user, the session, and the teammate. No DMs, no
second bot. Bumps the discord-bot submodule to the matching #ct-forum guard.

Approved by Mike via #ct-forum (a-go, forum-only, /ask-forum yes), 2026-07-08.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:27:45 -07:00
29725a3851 wiki: IMC MOO location address (6300 E El Dorado Cir) 2026-07-07 17:58:43 -07:00
81398663ac wiki: IMC two-location split (Main + MOO sites, agent placement, SC marking) 2026-07-07 17:53:42 -07:00
2fd646218d sync: auto-sync from HOWARD-HOME at 2026-07-07 17:33:19
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 17:33:19
2026-07-07 17:33:56 -07:00
Winter Williams
30c20df2b9 sync: auto-sync from GURU-BEAST-ROG at 2026-07-07 14:18:48
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-07 14:18:48
2026-07-07 14:19:54 -07:00
5edab36df6 sync: auto-sync from GURU-BEAST-ROG at 2026-07-07 14:11:51
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-07 14:11:51
2026-07-07 14:13:04 -07:00
1952c36675 wiki: compile gururmm (update) — Feature 6 security-detection-alerts spec'd
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:02:10 -07:00
8b016edb9d sync: auto-sync from HOWARD-HOME at 2026-07-07 13:55:03
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 13:55:03
2026-07-07 13:55:32 -07:00
5dfaaaa33b sync: auto-sync from HOWARD-HOME at 2026-07-07 12:47:44
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 12:47:44
2026-07-07 12:48:10 -07:00
2259f4c172 sync: auto-sync from HOWARD-HOME at 2026-07-07 11:48:31
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 11:48:31
2026-07-07 11:49:07 -07:00
a507678254 sync: auto-sync from HOWARD-HOME at 2026-07-07 11:45:40
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 11:45:40
2026-07-07 11:46:46 -07:00
Winter Williams
1e1bae772d sync: auto-sync from GURU-BEAST-ROG at 2026-07-07 11:44:27
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-07 11:44:27
2026-07-07 11:45:34 -07:00
3d28f9ea5d sync: auto-sync from HOWARD-HOME at 2026-07-07 11:15:40
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 11:15:40
2026-07-07 11:16:14 -07:00
Winter Williams
a0a86742bc sync: auto-sync from GURU-BEAST-ROG at 2026-07-07 11:01:13
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-07 11:01:13
2026-07-07 11:02:25 -07:00
27595d6475 sync: auto-sync from HOWARD-HOME at 2026-07-07 11:01:18
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 11:01:18
2026-07-07 11:01:49 -07:00
766c7fa54d sync: auto-sync from GURU-5070 at 2026-07-07 10:26:45
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-07 10:26:45
2026-07-07 10:27:22 -07:00
ff6546bee8 wiki: compile gps-rmm-audit (full) — Phase 4 backup verification, master-status list, 20 client wikis, onboardings, onsite installer kit 2026-07-07 10:15:38 -07:00
33b6140236 sync: auto-sync from HOWARD-HOME at 2026-07-07 10:07:55
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 10:07:55
2026-07-07 10:08:25 -07:00
efca405669 sync: auto-sync from HOWARD-HOME at 2026-07-07 09:08:42
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 09:08:42
2026-07-07 09:09:11 -07:00
f30413cffc sync: auto-sync from HOWARD-HOME at 2026-07-07 09:02:48
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 09:02:48
2026-07-07 09:03:16 -07:00
5d53452c6d sync: auto-sync from HOWARD-HOME at 2026-07-07 08:58:14
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 08:58:14
2026-07-07 08:58:46 -07:00
03d02128d7 sync: auto-sync from HOWARD-HOME at 2026-07-07 08:51:03
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 08:51:03
2026-07-07 08:51:30 -07:00
2e262c619e sync: auto-sync from HOWARD-HOME at 2026-07-07 08:48:12
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 08:48:12
2026-07-07 08:48:40 -07:00
8efc351e25 sync: auto-sync from HOWARD-HOME at 2026-07-07 08:22:41
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 08:22:41
2026-07-07 08:23:08 -07:00
ead30520e5 sync: auto-sync from HOWARD-HOME at 2026-07-07 07:23:12
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 07:23:12
2026-07-07 07:23:39 -07:00
0daf06263f sync: auto-sync from HOWARD-HOME at 2026-07-07 07:13:10
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 07:13:10
2026-07-07 07:13:39 -07:00
8854a584f3 sync: auto-sync from HOWARD-HOME at 2026-07-07 06:53:34
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 06:53:34
2026-07-07 06:54:02 -07:00
5968ee9231 sync: auto-sync from HOWARD-HOME at 2026-07-07 06:47:06
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-07 06:47:06
2026-07-07 06:48:26 -07:00
da2aa60990 sync: auto-sync from GURU-BEAST-ROG at 2026-07-07 06:38:06
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-07 06:38:06
2026-07-07 06:41:00 -07:00
b9635cafd9 wiki: compile gururmm (update) — ContextTree collapse-during-search fix
Fold today's dashboard bug fix (commit 5bf7896) into the GuruRMM article as a
dated Recent Work entry + two sources. Incremental update, not a full rebuild:
the article was already fully compiled today (last_compiled 2026-07-06) and the
delta is a single minor UI fix, so a Sonnet re-synthesis was unwarranted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:05:44 -07:00
45e1072dcb sync: auto-sync from HOWARD-HOME at 2026-07-06 17:39:50
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 17:39:50
2026-07-06 17:40:18 -07:00
7264840da6 sync: auto-sync from HOWARD-HOME at 2026-07-06 16:51:24
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 16:51:24
2026-07-06 16:51:53 -07:00
43101f4597 sync: auto-sync from HOWARD-HOME at 2026-07-06 16:48:18
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 16:48:18
2026-07-06 16:48:45 -07:00
e790d7c7e8 wiki: compile goldstein (seed) 2026-07-06 16:43:49 -07:00
12c3d83f75 sync: auto-sync from HOWARD-HOME at 2026-07-06 16:41:20
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 16:41:20
2026-07-06 16:41:51 -07:00
Winter Williams
419de64af5 sync: auto-sync from GURU-BEAST-ROG at 2026-07-06 16:23:09
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-06 16:23:09
2026-07-06 16:24:18 -07:00
ba2403b798 sync: auto-sync from GURU-BEAST-ROG at 2026-07-06 16:14:04
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-06 16:14:04
2026-07-06 16:15:18 -07:00
7dc519827c sync: auto-sync from HOWARD-HOME at 2026-07-06 16:10:24
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 16:10:24
2026-07-06 16:10:52 -07:00
8f4984c5a6 sync: auto-sync from HOWARD-HOME at 2026-07-06 15:56:41
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 15:56:41
2026-07-06 15:57:08 -07:00
0201e7422c sync: auto-sync from HOWARD-HOME at 2026-07-06 15:49:07
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 15:49:07
2026-07-06 15:49:33 -07:00
0b11a400ed sync: auto-sync from HOWARD-HOME at 2026-07-06 15:48:08
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 15:48:08
2026-07-06 15:48:36 -07:00
4b535a1a81 sync: auto-sync from HOWARD-HOME at 2026-07-06 15:10:00
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 15:10:00
2026-07-06 15:10:27 -07:00
c9ea7e8f01 wiki: client Wi-Fi inventory structure (vault-backed) + memory; ignore .ocu scratch
- wiki/reference/client-wifi.md: readable index (no passwords), /wiki-compile-exempt
- reference_client_wifi_inventory.md: convention (vault clients/<slug>/wifi.sops.yaml)
- wiki/index.md: new Reference section
- .gitignore: .ocu.* scratch artifacts
2026-07-06 13:47:22 -07:00
27dc83f927 sync: auto-sync from HOWARD-HOME at 2026-07-06 13:46:28
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 13:46:28
2026-07-06 13:46:58 -07:00
b10f527cda sync: auto-sync from HOWARD-HOME at 2026-07-06 13:44:28
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 13:44:28
2026-07-06 13:46:04 -07:00
bd247191e6 sync: auto-sync from HOWARD-HOME at 2026-07-06 13:43:09
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 13:43:09
2026-07-06 13:46:03 -07:00
b20985d241 sync: auto-sync from GURU-5070 at 2026-07-06 13:43:08
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-06 13:43:08
2026-07-06 13:43:38 -07:00
3cc9b22594 sync: auto-sync from GURU-5070 at 2026-07-06 13:11:54
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-06 13:11:54
2026-07-06 13:12:28 -07:00
49d5a535a4 wiki(index): refresh lonestar summary (0 hrs, Unraid VM-loss root cause + live IP)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:48:39 -07:00
37a6598ef7 wiki: compile lonestar-electrical (full) — Unraid VM-loss root cause (/boot/config/go hijack), IP fix 172.16.1.188->192.168.120.177, hours 0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:47:20 -07:00
a2a9356a02 sync: auto-sync from GURU-5070 at 2026-07-06 12:39:35
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-06 12:39:35
2026-07-06 12:39:59 -07:00
c3ad79d9fe promotion-candidates: zip GURU-5070 .claude/tmp scratch (21) for Beast review
21 graduation candidates from GURU-5070's local .claude/tmp/ (gitignored/per-machine)
zipped for GURU-BEAST-ROG to review + graduate keepers per TEMP_GRADUATION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:05:41 -07:00
dfc237e6d4 sync: auto-sync from GURU-5070 at 2026-07-06 11:01:42
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-06 11:01:42
2026-07-06 11:02:17 -07:00
14ac542a00 sync: auto-sync from GURU-BEAST-ROG at 2026-07-06 10:55:23
Author: Mike Swanson
Machine: GURU-BEAST-ROG
Timestamp: 2026-07-06 10:55:23
2026-07-06 10:58:07 -07:00
c1706393b9 sync: auto-sync from HOWARD-HOME at 2026-07-06 09:41:46
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 09:41:46
2026-07-06 09:42:14 -07:00
e08f6c534e sync: auto-sync from HOWARD-HOME at 2026-07-06 08:31:47
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 08:31:47
2026-07-06 08:32:15 -07:00
f88c7d1739 tailscale skill (admin add/delete) + verified Windows edition-upgrade procedure
- New `tailscale` skill + `/tailscale` command: Tailscale API v2 admin ops
  (devices/keys list, delete-device, authorize, create-key, delete-key).
  Vault-first auth (OAuth preferred, token fallback), every write gated
  --confirm, name->id resolution stops on ambiguous match, [TAILSCALE] bot
  alerts. Live add/delete verification pending an API credential.
- reference_windows_edition_upgrade_rmm memory: verified Home->Pro/PfW path
  (changepk not DISM; Restart-Computer not shutdown /t; PfW-MAK auto-upgrade)
  from the ACG-Tech03L upgrade.
- errorlog: quote-stripping + shutdown-drop friction, PfW-MAK label correction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:30:22 -07:00
d5f7eadf82 sync: auto-sync from HOWARD-HOME at 2026-07-06 08:29:31
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 08:29:31
2026-07-06 08:30:01 -07:00
c289ba3370 sync: auto-sync from ACG-TECH03L at 2026-07-05 23:45:36
Author: Howard Enos
Machine: ACG-TECH03L
Timestamp: 2026-07-05 23:45:36
2026-07-05 23:46:12 -07:00
60b307316c sync: auto-sync from ACG-TECH03L at 2026-07-05 23:22:43
Author: Howard Enos
Machine: ACG-TECH03L
Timestamp: 2026-07-05 23:22:43
2026-07-05 23:23:21 -07:00
355c5f1e3f wiki: compile gururmm (fold policy-ui Fleet-Policy Console + WS-looper comms-durability; agent 0.6.79 beta / server 0.3.103, mig 068) 2026-07-05 23:13:26 -07:00
d70cdd88e4 submodule: advance guru-rmm -> 7058195 (session log: WS-looper comms-durability) 2026-07-05 21:26:14 -07:00
faf796386a sync: auto-sync from HOWARD-HOME at 2026-07-05 21:24:32
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 21:24:32
2026-07-05 21:25:01 -07:00
8b08ec64c2 seafile: route provisioning/admin writes to the skill in SKILL_ROUTING
Add a doing-skill line for Seafile (SeaCloud) admin writes -- create/
deactivate/delete account, set quota, create/transfer/delete library,
share/unshare -- pointing at the `seafile` skill (preview by default,
--confirm to apply). Complements the existing read/inventory line.

The write-ops implementation itself (seafile.py subcommands, client
_write via shared http_json form=, quota decimal-GB round-trip, gated
--confirm, SKILL.md docs) landed earlier via auto-sync in a8e76ba.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:54:57 -07:00
da6567ffe0 owncloud: extend skill from read-only audit to full occ management
Layer gated admin writes onto the read-only GPS-audit skill, driven through
occ over SSH (same paramiko/vault/filecache plumbing):

- user lifecycle (add/delete/enable/disable/reset-password/modify), quota
  get/set/reset, groups (create/delete/members), read-only share listing
  (from oc_share; occ has no share create/list), apps, system config,
  maintenance mode, files scan, ownership transfer, trashbin/versions cleanup.
- All writes gated behind --confirm (refuse -> rc 3); hard-fail guards (rc 2)
  for invalid uid, missing password, and all-users cleanup with no target.
- --all-users trashbin/versions purge now ALSO requires --force-all-users in
  addition to --confirm, so a single stray --confirm cannot trigger an
  irreversible fleet-wide purge.

Built against the verified live occ surface (occ list / occ help), not guessed
flags. Fixes from code-review + security-review: Python-side share filtering
(no MySQL SQL-injection via '' -doubling), occ reads no longer mask a failed
read as healthy (maintenance/quota/config), guarded json.loads, clean --json
output, and '--' end-of-options guards so dash-leading uids can't be parsed as
occ flags. dry_test.sh added as a non-destructive regression harness (43 checks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:52:25 -07:00
3d8aa2be59 submodule: advance guru-rmm -> c81eeb5 (WS-looper comms-durability: write-timeout + heartbeat-count diagnostics + AIMD keepalive; agent 0.6.79 beta, server 0.3.102 deployed; BUG-024 linux 0.6.79 re-quarantined)
Merge 07307e3 (fix/ws-looper-writetimeout-hbdiag-aimd) + CI version bump c81eeb5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:29:44 -07:00
a8e76ba215 sync: auto-sync from HOWARD-HOME at 2026-07-05 20:23:39
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 20:23:39
2026-07-05 20:24:08 -07:00
976bfe7957 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
2026-07-05 20:00:20 -07:00
b0ebd44c9d sync: auto-sync from HOWARD-HOME at 2026-07-05 19:34:16
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 19:34:16
2026-07-05 19:34:44 -07:00
f274eed8fa dataforth-dos: advance pointer to #32489 toolchain + telemetry commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:39:02 -07:00
5e682c9293 sync: auto-sync from GURU-5070 at 2026-07-05 16:35:57
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-05 16:35:57
2026-07-05 16:36:27 -07:00
bee140c020 sync: auto-sync from GURU-5070 at 2026-07-05 16:34:34
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-05 16:34:34
2026-07-05 16:35:02 -07:00
a737c4ab24 wiki: compile gururmm (full) — fold 07-04/05 batches (v0.3.97-99, agent 0.6.78), policy-core deployed (mig 067), BUG-003 closed, BUG-023 fixed, BUG-024 linux glibc OPEN/quarantined
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:42:55 -07:00
fa4867580f sync: auto-sync from HOWARD-HOME at 2026-07-05 15:29:59
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 15:29:59
2026-07-05 15:30:28 -07:00
a5b8f6e7b1 sync: auto-sync from HOWARD-HOME at 2026-07-05 13:42:15
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 13:42:15
2026-07-05 13:43:35 -07:00
6ad608c3bf rmm: bump guru-rmm -> policy-core deployed to prod (migration 067)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:08:47 -07:00
ea84b7bcd2 submodule: advance guru-rmm -> 120854a (v0.3.99 batch-4: command TTL + status reconciler + update supersede; BUG-024 linux glibc incident quarantined + ix/Jupiter revived; orphan dupes purged)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:58:54 -07:00
b835f350d6 submodule: advance guru-rmm (batch-3 session log)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:57:25 -07:00
5e1ebf4789 sync: auto-sync from HOWARD-HOME at 2026-07-05 09:56:12
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 09:56:12
2026-07-05 09:56:42 -07:00
656101a7b4 submodule: advance guru-rmm -> d85d6be (v0.3.98: visible_agents view+trigger, --migrate-only deploy gate, BUG-003 pipeline hardening live, BUG-023 channel-race fixed, 0.6.78 promoted stable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:48:43 -07:00
7c0f467cdf rmm: bump guru-rmm -> 4b0a28b (policy-core shape spec)
docs/specs/policy-core/ — folders+inheritance gate spec for the policy redesign (#7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:52:50 -07:00
26b1cf400f sync: auto-sync from HOWARD-HOME at 2026-07-05 01:00:55
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 01:00:55
2026-07-05 01:01:23 -07:00
e359702837 gps-rmm-audit: CP-QB WMI-repaired+enrolled (112/189); IMC-PRINTSERVER agent installed, blocked by box-wide outbound TLS drop
CP-QB: corrupt WMI class store (Win32_Processor invalid class) killed the universal
installer's arch check; mofcomp cimwin32.mof fixed it; enrolled + reassigned to
Curtis Plumbing. IMC-PRINTSERVER: agent+watchdog installed manually over LAN HTTP
from IMC1 (PowerShell dead on box, SMB hangs, SC extension eats chains/multiline);
box drops ALL outbound TLS (incl google) so agent cannot connect — fix is at IMC
UniFi gateway or local filter; no reinstall needed. Session log + tracker updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 01:00:48 -07:00
67822d5ca8 sync: auto-sync from HOWARD-HOME at 2026-07-05 00:45:20
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 00:45:20
2026-07-05 00:45:47 -07:00
346832b469 sync: auto-sync from HOWARD-HOME at 2026-07-05 00:23:45
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 00:23:45
2026-07-05 00:24:15 -07:00
a7174a894e sync: auto-sync from HOWARD-HOME at 2026-07-04 23:42:44
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 23:42:44
2026-07-04 23:43:13 -07:00
3600df76cf wiki: compile gururmm (full rebuild — v0.3.96, easy-bugs batch, dedup, legacy disable, VSS/tray/integrations)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:42:31 -07:00
9efa6eaf4f sync: auto-sync from HOWARD-HOME at 2026-07-04 23:29:28
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 23:29:28
2026-07-04 23:29:57 -07:00
9542de2b0a sync: auto-sync from HOWARD-HOME at 2026-07-04 23:15:20
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 23:15:20
2026-07-04 23:15:49 -07:00
a0998fa255 sync: auto-sync from HOWARD-HOME at 2026-07-04 22:38:31
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 22:38:31
2026-07-04 22:39:06 -07:00
c8036d7e67 sync: auto-sync from HOWARD-HOME at 2026-07-04 20:47:45
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 20:47:45
2026-07-04 20:48:11 -07:00
567fd8dd8c sync: auto-sync from HOWARD-HOME at 2026-07-04 20:44:56
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 20:44:56
2026-07-04 20:45:25 -07:00
66211a5652 sync: auto-sync from HOWARD-HOME at 2026-07-04 19:35:27
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 19:35:27
2026-07-04 19:35:53 -07:00
46705d48ff sync: auto-sync from HOWARD-HOME at 2026-07-04 19:34:24
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 19:34:24
2026-07-04 19:34:54 -07:00
e6dae3dba2 sync: auto-sync from HOWARD-HOME at 2026-07-04 19:33:17
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 19:33:17
2026-07-04 19:33:49 -07:00
46d4900229 sync: auto-sync from HOWARD-HOME at 2026-07-04 19:27:24
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 19:27:24
2026-07-04 19:27:52 -07:00
65b6043cde gps-rmm-audit: JANC (2248945) != Janet Altschuler (457710) — confirmed separate clients
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:18:11 -07:00
7a6fbcfc29 wiki: compile gps-rmm-audit (seed) — GPS->GuruRMM coverage audit project article
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:53:54 -07:00
371fce1104 sync: auto-sync from HOWARD-HOME at 2026-07-04 18:44:32
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 18:44:32
2026-07-04 18:45:07 -07:00
42d43a5bcc sync: auto-sync from HOWARD-HOME at 2026-07-04 18:39:34
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 18:39:34
2026-07-04 18:40:02 -07:00
a270f405a6 guru-rmm: align submodule pointer to deployed main (c4f24de, dedup fix live)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:38:06 -07:00
a6a6a477d5 sync: auto-sync from HOWARD-HOME at 2026-07-04 18:34:08
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 18:34:08
2026-07-04 18:34:36 -07:00
b8bba3cd8f sync: auto-sync from HOWARD-HOME at 2026-07-04 18:08:15
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 18:08:15
2026-07-04 18:08:41 -07:00
011ee7350d sync: auto-sync from HOWARD-HOME at 2026-07-04 18:00:11
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 18:00:11
2026-07-04 18:00:38 -07:00
21b198f1ee sync: auto-sync from HOWARD-HOME at 2026-07-04 17:49:31
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 17:49:31
2026-07-04 17:49:58 -07:00
6d36f7151c sync: auto-sync from HOWARD-HOME at 2026-07-04 17:09:47
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 17:09:47
2026-07-04 17:10:14 -07:00
edd4bd39e7 sync: auto-sync from HOWARD-HOME at 2026-07-04 17:05:53
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 17:05:53
2026-07-04 17:06:23 -07:00
ddd6bb06c2 sync: auto-sync from GURU-5070 at 2026-07-04 16:52:21
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-04 16:52:21
2026-07-04 16:52:59 -07:00
b97223f69e sync: auto-sync from HOWARD-HOME at 2026-07-04 16:36:49
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 16:36:49
2026-07-04 16:37:15 -07:00
f64418d5e1 sync: auto-sync from HOWARD-HOME at 2026-07-04 16:34:34
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 16:34:34
2026-07-04 16:34:59 -07:00
2aed9d93c9 sync: auto-sync from HOWARD-HOME at 2026-07-04 16:20:30
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 16:20:30
2026-07-04 16:20:55 -07:00
cb99daa6cb sync: auto-sync from HOWARD-HOME at 2026-07-04 16:09:13
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 16:09:13
2026-07-04 16:09:41 -07:00
d7bc2b34ac guru-rmm: bump submodule — dedup reuses record without re-homing (moves persist)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:38:12 -07:00
734a7b201d guru-rmm: bump submodule — cross-site agent dedup fix
Points to cdc87d2 (server: fix cross-site agent duplication on re-enrollment).
Needs build + deploy by Mike/Howard before the dedup deletes are run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:31:59 -07:00
d4a1bbbc5b sync: auto-sync from HOWARD-HOME at 2026-07-04 15:30:44
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 15:30:44
2026-07-04 15:31:14 -07:00
a733db1029 sync: auto-sync from HOWARD-HOME at 2026-07-04 14:18:41
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 14:18:41
2026-07-04 14:19:11 -07:00
d5020a6415 sync: auto-sync from GURU-5070 at 2026-07-04 13:52:09
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-04 13:52:09
2026-07-04 13:52:35 -07:00
0da31cbb65 memory: GuruRMM legacy 1.77 build disabled + advance guru-rmm gitlink (tray feature deployed, legacy frozen at 0.6.76)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:30:06 -07:00
ff7025d912 sync: auto-sync from GURU-5070 at 2026-07-04 13:07:09
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-04 13:07:09
2026-07-04 13:10:12 -07:00
ad6a529ba4 sync: auto-sync from HOWARD-HOME at 2026-07-04 12:42:50
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-04 12:42:50
2026-07-04 12:43:27 -07:00
60a0a5728f sync: auto-sync from GURU-5070 at 2026-07-04 12:23:18
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-04 12:23:18
2026-07-04 12:27:48 -07:00
10b1ea7528 sync: auto-sync from GURU-5070 at 2026-07-04 07:30:32
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-04 07:30:32
2026-07-04 12:26:42 -07:00
306 changed files with 99730 additions and 1635 deletions

36
.analyze-glaztech.py Normal file
View File

@@ -0,0 +1,36 @@
import json
from collections import defaultdict
with open('/Users/azcomputerguru/ClaudeTools/.glaztech-sessions.json') as f:
data = json.load(f)
print(f'Total machines: {len(data)}')
# Extract IPs and current sites
subnet_info = defaultdict(lambda: {'count': 0, 'machines': [], 'current_sites': set()})
for session in data:
ip = session.get('GuestInfo', {}).get('PrivateNetworkAddress', '')
name = session.get('Name', 'Unknown')
current_site = session.get('CustomProperties', {}).get('CustomProperty2', '')
if ip and ip != '':
# Extract /24 subnet
subnet = '.'.join(ip.split('.')[:3]) + '.0/24'
subnet_info[subnet]['count'] += 1
subnet_info[subnet]['machines'].append({'name': name, 'ip': ip})
if current_site:
subnet_info[subnet]['current_sites'].add(current_site)
# Sort by count descending
sorted_subnets = sorted(subnet_info.items(), key=lambda x: x[1]['count'], reverse=True)
print('\n=== Subnet Distribution ===')
for subnet, info in sorted_subnets:
sites_str = ', '.join(sorted(info['current_sites'])) if info['current_sites'] else 'No site tag'
print(f'{subnet:20s} {info["count"]:3d} machines Current tags: {sites_str}')
# Show first 3 machines as examples
for m in info['machines'][:3]:
print(f' - {m["name"]:30s} {m["ip"]}')
if len(info['machines']) > 3:
print(f' ... and {len(info["machines"]) - 3} more')

10
.bk.ps1 Normal file
View File

@@ -0,0 +1,10 @@
$b='C:\ProgramData\acg-backup'
'--- COMPLETE.txt ---'
if (Test-Path (Join-Path $b 'COMPLETE.txt')) { Get-Content (Join-Path $b 'COMPLETE.txt') } else { 'absent' }
'--- dir listing ---'
Get-ChildItem $b | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize | Out-String
'--- rclone.log tail ---'
Get-Content (Join-Path $b 'rclone.log') -Tail 25
'--- rclone process ---'
$p = Get-Process rclone -ErrorAction SilentlyContinue
if ($p) { 'RUNNING pid=' + $p.Id } else { 'not running' }

7
.chk.ps1 Normal file
View File

@@ -0,0 +1,7 @@
Import-Module BitsTransfer
$j = Get-BitsTransfer -Name 'Win11ARM64ISO' -AllUsers -ErrorAction SilentlyContinue
if ($j) { 'state=' + $j.JobState + ' bytes=' + $j.BytesTransferred + '/' + $j.BytesTotal }
else { 'NO JOB' }
$f = Get-Item 'C:\Users\howar\Desktop\Win11_25H2_English_Arm64_v2.iso' -ErrorAction SilentlyContinue
if ($f) { 'file present size=' + $f.Length }
'uptime-min: ' + [math]::Round(((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalMinutes,0)

8
.chk2.ps1 Normal file
View File

@@ -0,0 +1,8 @@
Import-Module BitsTransfer
'--- all BITS jobs (AllUsers) ---'
Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue | Select-Object DisplayName,JobState,BytesTransferred,BytesTotal,OwnerAccount | Format-Table -AutoSize | Out-String
'--- desktop files ---'
Get-ChildItem 'C:\Users\howar\Desktop' -Filter '*.iso*' -Force -ErrorAction SilentlyContinue | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize | Out-String
'--- tmp BITS file? ---'
Get-ChildItem 'C:\Users\howar\Desktop' -Filter 'BIT*' -Force -Hidden -ErrorAction SilentlyContinue | Select-Object Name,Length | Format-Table -AutoSize | Out-String
'uptime-min: ' + [math]::Round(((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalMinutes,0)

View File

@@ -30,6 +30,12 @@ failures, production risk). Bump one tier for: security, auth, credential, migra
production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
## Key rules (always)
- **Simplest solution FIRST.** Start with the least complex thing that could answer the
problem, confirm it works, and only THEN graduate to more advanced/complicated approaches
as the simple one proves insufficient. Don't reach for SSH/plink/multi-hop tooling when a
one-shot query (a single DNS lookup, one API call, one command) would answer it. E.g. "what
IP does the UDM think X is?" = `nslookup X <udm-ip>`, not shelling into the device. Escalate
deliberately, not by default.
- **NO EMOJIS.** Use ASCII markers: `[OK]` `[ERROR]` `[WARNING]` `[INFO]` `[CRITICAL]`.
- **Skill-first — if a skill/command covers the task, USE IT; never hand-roll the API.**
When a request maps to an installed skill or slash-command, INVOKE THAT SKILL instead of
@@ -38,7 +44,9 @@ production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
records (e.g. Syncro tickets/invoices) reach a human for cleanup. **Syncro billing/invoicing
ALWAYS runs through `/syncro` (or `/syncro-emergency-billing`) — no exceptions.** Same for
other covered domains: credentials → `vault`, RMM actions → `/rmm` (+ `rmm-search` to find a
host), M365 → `remediation-tool`, etc. Knowing the API is NOT a reason to bypass the skill —
host), M365 → `remediation-tool`, asking a human a question/decision in Discord (get their
reply back in-session) → `ask-forum` (never hand-roll the Discord API — that footgun cost us
a broken background wait), etc. Knowing the API is NOT a reason to bypass the skill —
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
— and say so explicitly when you do. Mistakes here go to `errorlog.md` (`--correction`).

View File

@@ -23,7 +23,7 @@
| 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` |
| Onboard a new M365 tenant to the remediation app suite | `onboard365` |
| Backblaze / B2 storage, buckets, backup storage cost | `b2` |
| 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` |
| Datto EDR / Datto AV — detections, isolate, scan, agent deploy | `datto-edr` |
| VoIP — PacketDial / OITVOIP / NetSapiens domains, users, DIDs, queues, CDRs | `packetdial` |
@@ -34,6 +34,7 @@
| UniFi WiFi tuning / RF / channel analysis | `unifi-wifi` |
| 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` |
| 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 `&`) |
| Inter-session messaging, fleet todos, resource locks, coord status | `coord` |
| Git / Gitea / submodules / sync health | `gitea` (or `/sync`, `/scc`, `/save` for session ops) |
| Build/verify GuruRMM agent/server/dashboard, pre-merge check | `gururmm-build` |
@@ -46,6 +47,31 @@
> Not listed? If no skill fits, do it directly — and say so. New recurring need → `skill-creator`.
### Backups (wiki-first routing)
A customer can back up through **any** of several backends, so a backup request is a
two-step route: **first find out which backend THIS customer uses, then use that skill.**
Never assume B2/MSP360 — several clients are on Seafile, ownCloud, or Datto Workplace.
1. **Identify the backend for this customer (don't guess).** Read the client wiki
(`wiki/clients/<slug>.md`) and the backup map (`projects/gps-rmm-audit/backup-map.md`) —
both record each customer's real destination. If it isn't recorded, confirm with
`msp360 monitoring --company <name>` plus the per-backend inventory skills, then write the
answer back to the client wiki.
2. **Route to that backend's skill:**
| Need | Skill |
|---|---|
| Is X backing up? status / last run / failures / stale plans (MSP360 + B2 Cloud plans) | `msp360`**authoritative**, check here not bucket contents |
| Who backs up to Seafile / ownCloud / Datto Workplace (inventory + endpoint detection) | `seafile` / `owncloud` / `datto-workplace` |
| Storage / bucket / cost on the destination side | `b2` (Backblaze) |
| **Set up / provision a backup** — create account/user, quota, library, license, destination | the **target backend's** skill: `msp360` (MBS user/company/license), `seafile`, `owncloud` (all writes gated `--confirm`); `b2` for a new bucket/key |
- **Which machine gets backed up is a per-client decision** — report mismatches (billed vs
running), don't provision jobs off a billing count. See [[feedback_backup_targets_never_guessed]].
- Full per-customer destination + health map: `projects/gps-rmm-audit/backup-map.md`;
how backup-status works: memory `[[reference_msp360_backup_monitoring]]`.
---
## Check-skills (work-type → the gate run BEFORE "done")

View File

@@ -0,0 +1,46 @@
---
name: ask-forum
description: Ask a teammate (e.g. Mike) a question in the private #ct-forum Discord forum and BLOCK until a human answers, then act on the reply — a live three-way between the user, this same Claude session, and the teammate. No second bot, no DMs, one tool call (the wait runs server-side in bash).
---
# /ask-forum — human-in-the-loop question via #ct-forum
Entry point to the `ask-forum` skill — full contract in `.claude/skills/ask-forum/SKILL.md`.
Engine: `.claude/scripts/ask-forum.sh`. Lets THIS session put a question
to a human in the private #ct-forum forum and get their answer back in-session, so it
can keep going. The forum post's thread id is the correlation key — the session reads
only the thread it created, so an answer is never confused with another question's.
Forum-only by design (Mike's call, 2026-07-08): DM replies are intentionally not used,
so answers stay visible to the team. The BEAST bot ignores #ct-forum, so the asking
session is the only agent that consumes replies there.
## Usage
```bash
# Ask and block until a human replies (default timeout 600s, poll 8s):
bash .claude/scripts/ask-forum.sh "your question" --tag @264814939619721216
# Re-read a thread you already asked in (one-shot, no waiting):
bash .claude/scripts/ask-forum.sh --read <thread_id>
# Resume blocking on a thread already posted (e.g. after a timeout):
bash .claude/scripts/ask-forum.sh --wait <thread_id> --timeout 1800 --poll 10
```
Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the
question), `--timeout SEC`, `--poll SEC`, `--tag @<discord_id>` (repeatable — pings
that person; ids in `.claude/users.json`: mike 264814939619721216,
howard 624667664501178379, winter 624666486362996755, rob 261978810713505792).
## How to run it well
- **Long waits → background it** with `run_in_background: true` (NO shell `&` — that
forks the wait off and exits early). You get the answer when the human replies.
- Only **non-bot** replies count as answers, so bot chatter can never be mistaken for
the human's answer.
- Exit codes: 0 answered · 2 no token · 3 Discord API error · 4 timeout (thread stays
open — resume with `--wait <thread_id>`).
Channel: #ct-forum (`1522960388432465950`), private — @everyone denied; Howard, Mike,
and the bot allowed. Add someone via a channel permission overwrite if they need access.

View File

@@ -23,7 +23,7 @@ Follow these steps exactly when /inject-standards is invoked:
### Step 2 — Auto-select relevant standards
1. Read `D:/claudetools/.claude/standards/index.yml`.
1. Read `.claude/standards/index.yml`.
2. Review the descriptions for all entries.
3. Select the 25 standards most relevant to either:
- The task description in $ARGUMENTS, or
@@ -35,7 +35,7 @@ Follow these steps exactly when /inject-standards is invoked:
For each selected standard (in order of relevance):
1. Read the file at `D:/claudetools/.claude/standards/<slug>.md`.
1. Read the file at `.claude/standards/<slug>.md`.
2. Display a header:
```
=== STANDARD: <slug> ===
@@ -87,8 +87,8 @@ Reads the recent conversation, infers the task type, selects 25 most relevant
## Standards index location
`D:/claudetools/.claude/standards/index.yml`
`.claude/standards/index.yml`
## Standards files location
`D:/claudetools/.claude/standards/<folder>/<name>.md`
`.claude/standards/<folder>/<name>.md`

View File

@@ -96,53 +96,39 @@ Every Syncro API call is attributed to the **owner of the API key**. Comments, l
- **Correcting mis-billed labor** (a debug action) must keep the ORIGINAL tech's `user_id` (their commission). `update_line_item` preserves the existing `user_id`; a remove+add defaults the new line to the API-key owner — so set `user_id` to the original tech on `add_line_item`, or PUT it afterward. Determine the original tech from `.ticket.user_id` and the line's `.user_id`. Don't take a tech's commission just because the math was fixed by someone else.
- **Ticket ownership:** adding notes/labor or changing status does NOT change the ticket owner. Multiple techs routinely work one ticket. Only PUT a ticket's `user_id` (reassign owner) when explicitly asked; status PUTs send only `status`.
| identity.json user | Syncro user | user_id |
|---|---|---|
| `mike` | Michael Swanson | 1735 |
| `howard` | Howard Enos | 1750 |
| identity.json user | Syncro user | user_id | vault path |
|---|---|---|---|
| `mike` | Michael Swanson | 1735 | `msp-tools/syncro` |
| `howard` | Howard Enos | 1750 | `msp-tools/syncro-howard` |
| `winter` | Winter Williams | 1737 | `msp-tools/syncro-winter` |
Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at `msp-tools/syncro-<user>.sops.yaml`.
Keys live in the SOPS vault and are resolved by `.claude/scripts/syncro-env.sh` (source it — never hardcode a key). When `CLAUDETOOLS_REQUESTER_USER` is set (Discord bot threads), syncro-env.sh prefers the REQUESTER's key if one is vaulted, so bot-driven Syncro actions attribute to the person who asked; it falls back to the identity.json user otherwise. To add a new user: generate a token in Syncro → Admin → API Tokens, vault it at `msp-tools/syncro-<user>.sops.yaml`, and add a case to `_syncro_key_path()` in syncro-env.sh.
### Get API key
```bash
BASE="https://computerguru.syncromsp.com/api/v1"
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
#
# The key is per-user on purpose: every action in Syncro is attributed to the key
# owner, so using someone else's key misattributes tickets, time, and invoices.
#
# NOTE: identity.json lives in the REPO (.claude/identity.json), not ~/.claude/.
# Reading ~/.claude/identity.json first is a known bug — it silently picks up a
# stale or absent file. syncro-env.sh resolves the repo copy from its own location.
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || {
echo "[ERROR] Cannot resolve Syncro credentials — run onboarding / check the vault" >&2
exit 1
}
# Get repo root from identity.json (set during machine onboarding)
# Fallback to dynamic detection for legacy machines that haven't updated identity.json yet
IDENTITY_PATH="${HOME}/.claude/identity.json"
if [ ! -f "$IDENTITY_PATH" ]; then
# Try in-repo identity.json (gitignored, machine-specific)
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -n "$REPO_ROOT" ]; then
IDENTITY_PATH="$REPO_ROOT/.claude/identity.json"
fi
fi
BASE="$SYNCRO_BASE"
API_KEY="$SYNCRO_API_KEY"
REPO_ROOT="$CLAUDETOOLS_ROOT"
if [ ! -f "$IDENTITY_PATH" ]; then
echo "[ERROR] Cannot locate identity.json - run onboarding first" >&2
if [ -z "$API_KEY" ]; then
echo "[ERROR] No Syncro API key for user '${SYNCRO_USER:-unknown}'" >&2
exit 1
fi
REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH")
if [ -z "$REPO_ROOT" ]; then
# Legacy fallback for machines without claudetools_root in identity.json
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "[ERROR] claudetools_root not set in identity.json and not in a git directory" >&2
echo "[ERROR] Add 'claudetools_root' field to $IDENTITY_PATH" >&2
exit 1
fi
echo "[WARNING] Using git-detected repo root. Add 'claudetools_root' to identity.json to avoid this." >&2
fi
# Per-user keys — actions in Syncro are attributed to the key owner
USER_ID=$(jq -r '.user // empty' "$IDENTITY_PATH")
case "$USER_ID" in
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
*) echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
esac
```
### Ollama drafting
@@ -613,6 +599,27 @@ COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
- Do NOT wrap the payload in `{"comment": {...}}` — returns 422.
- **If `COMMENT_ID` is null:** GET `/tickets/{id}` and check `.ticket.comments[]` by subject before doing anything else. Comments cannot be deleted via API — duplicates require manual GUI removal.
#### Customer Assets (retire / archive — API GAP)
**Retiring a device in Syncro = "Archive" (UI ONLY — no REST API).** Verified against
Syncro's own docs 2026-07-08 (`docs.syncrosecure.com/assets-rmm/archive-assets`): archiving
is done in the web UI only — asset Details page → **Actions → Archive**, or bulk via the
Assets table checkboxes → **Bulk Actions → Archive**. There is **no** archive endpoint and
**no** `archived` field on `PUT /customer_assets/{id}`. Do NOT try to archive via API, and
do NOT substitute `DELETE /customer_assets/{id}` — delete is destructive/irreversible and
loses history + breaks ticket linkages (Archive keeps the asset visible on its tickets/alerts).
- **To retire assets: hand the user the asset list + the Bulk-Actions-→-Archive steps.** We
cannot do it for them via API.
- Asset READS are fine: `GET /customer_assets?customer_id=N[&query=<name>]` (list, 100/page,
`.assets[]`) and `GET /customer_assets/{id}` (detail; `.asset` — RMM data lives under
`.asset.properties.kabuto_information`). `updated_at` is NOT a reliable "last online" (bulk
account touches move it) — use the ScreenConnect `GuestInfoUpdateTime` or kabuto data for
real last-seen.
- **[RESEARCH]** Re-check if Syncro ships an archive API (watch release notes). If/when it
does, wire it in here and add `--confirm` gating. Until then this is a "cannot be performed
via skill" function. (First hit: IMC retirement pass 2026-07-08.)
#### Customers
```bash

View File

@@ -0,0 +1,55 @@
---
name: tailscale
description: Manage an ACG-administered Tailscale tailnet via the REST API v2 - list/inspect devices, delete a node, authorize a device, create/list/revoke tagged pre-auth keys. Reads free; writes gated --confirm.
---
# /tailscale — Tailscale tailnet management (REST API v2)
Thin entry point to the `tailscale` skill. Engine:
`.claude/skills/tailscale/scripts/tailscale-api.sh`. Full reference:
`.claude/skills/tailscale/SKILL.md`. Doctrine (per-client tailnets, tagged pre-auth
keys, offboarding): `wiki/patterns/tailscale-client-management.md`.
Read-only by default; every device delete/authorize and auth-key create/delete is
**gated behind `--confirm`** and previews first. ADMIN rights: add + delete.
## Usage
```bash
TS="bash .claude/skills/tailscale/scripts/tailscale-api.sh"
# Reads (no confirm)
$TS status # auth check + tailnet + device count
$TS devices [--json] # list devices
$TS device <name|id|100.x> [--json] # device detail
$TS keys [--json] # list auth keys
# Writes (GATED — preview without --confirm, act with it)
$TS create-key --tag tag:<client> --reusable --preauth [--ephemeral] \
[--expiry-days N] [--desc "..."] --confirm
$TS delete-key <keyId> --confirm # revoke an auth key
$TS authorize <name|id|100.x> --confirm # approve a device
$TS delete-device <name|id|100.x> --confirm # remove a node
# Point at a specific per-client tailnet's vault entry
$TS devices --vault tailscale/roberts.sops.yaml
```
## Rules
1. **Vault-first auth.** Credentials come from `tailscale/api-access.sops.yaml` (OAuth
client preferred, `credentials.api_key` token fallback); tailnet defaults to `-`. Never
hardcode a token. Provisioning commands are in the skill doc.
2. **Read before write.** Device ops resolve a name/100.x to the stable id first; an
**ambiguous match STOPS** (lists candidates, does nothing) — pass a specific id.
3. **Gated writes.** No `--confirm` = preview + exit, no change. Deleting a node drops it
off the tailnet; revoking a key blocks future enrollments with it.
4. **Per-client isolation.** One tailnet per client, one vault entry per tailnet. Confirm
the `--vault`/tailnet before any write.
5. **Key secrets → vault, never chat/commit.** `create-key` prints the secret once; store
it via `/vault` immediately.
6. **Bot alert after every write.** Successful writes auto-post a `[TAILSCALE] ...` line to
Discord (soft-fail); reads do not.
Use this to *drive* the tailnet; use `/rmm` to push the tagged pre-auth key onto client
machines (`tailscale-client-enroll.ps1`) once a key is minted.

View File

@@ -36,17 +36,18 @@ want a periodic clean rebuild. For "I just did some work, capture it," use plain
## Phase 0 — Setup
```bash
CLAUDETOOLS_ROOT="D:/claudetools"
VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh"
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
# Exports: CLAUDETOOLS_ROOT, VAULT_ROOT, SYNCRO_USER, SYNCRO_BASE, SYNCRO_API_KEY.
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || true
# Syncro auth (read-only operations only — GET requests ONLY in this skill)
BASE="https://computerguru.syncromsp.com/api/v1"
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
case "$USER_ID" in
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
*) echo "[WARNING] Unknown user — Syncro enrichment skipped" ; API_KEY="" ;;
esac
VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh"
BASE="$SYNCRO_BASE" # read-only: GET requests ONLY in this skill
API_KEY="$SYNCRO_API_KEY" # empty if no key vaulted for this user -> skip enrichment
if [ -z "$API_KEY" ]; then
echo "[WARNING] No Syncro key for user '${SYNCRO_USER:-unknown}' — Syncro enrichment skipped"
fi
```
**Synthesis engine:** seed/full article drafting is done by a **Sonnet subagent** (Agent tool, `model: "sonnet"`), not Ollama. The main agent gathers sources + Syncro data, delegates the draft, then reviews it before writing. No Ollama dependency.

View File

@@ -10,7 +10,8 @@ Scan for clients and projects that have session logs but no wiki article.
```bash
# List all client slugs that have session-logs but no wiki article
cd D:/claudetools
# Repo root is machine-specific (C:/ on some, D:/ on others) — never hardcode it.
cd "$(git rev-parse --show-toplevel)"
for dir in clients/*/session-logs; do
slug=$(echo "$dir" | sed 's|clients/||;s|/session-logs||')
wiki="wiki/clients/$slug.md"
@@ -96,13 +97,17 @@ For every client wiki article that contains a `Syncro customer ID` line, pull li
### Setup
```bash
BASE="https://computerguru.syncromsp.com/api/v1"
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
case "$USER_ID" in
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
*) echo "[SYNCRO] No API key for user '$USER_ID' — skipping Step 6" ; exit 0 ;;
esac
# Resolves CLAUDETOOLS_ROOT + VAULT_ROOT from identity.json and reads the caller's
# per-user Syncro key from the SOPS vault. Never hardcode a repo path or an API key.
source "$(git rev-parse --show-toplevel)/.claude/scripts/syncro-env.sh" || true
BASE="$SYNCRO_BASE"
API_KEY="$SYNCRO_API_KEY"
if [ -z "$API_KEY" ]; then
echo "[SYNCRO] No API key for user '${SYNCRO_USER:-unknown}' — skipping Step 6"
exit 0
fi
```
### For Each Client Article

View File

@@ -1,213 +1,230 @@
# Memory Index
## Reference
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
- [ALIS (Medtelligent)](reference_alis_medtelligent.md) — Cascades assisted-living EHR. API host api.alisonline.com, community 622; username must be tenant-qualified (howard.enos@cascadestucson). Staff are READ-ONLY via API — create/change staff via web-UI Staff Import .xls. Use the `alis` skill.
- [GuruRMM User Manager](reference_gururmm_user_manager.md) — GuruRMM has a built-in per-agent User Manager tab (reset_password/enable/disable/groups for local+domain+AAD endpoint users; domain users only on a DC via `is_dc`). Use it, NOT raw Set-ADAccountPassword via /rmm. Endpoints: /api/agents/{id}/users + /users/action.
- [RMM map network drive (err67 double-hop)](reference_rmm_map_network_drive.md) — Pushing a persistent mapped drive to a remote share via /rmm user_session fails with err67/1702 (impersonated token = no network cred/double-hop). Plant HKCU:\Network\<drv> keys + cmdkey; mounts at next interactive logon. Immediate visibility needs the live session (ScreenConnect).
- [exchange-op = all-access Exchange tier](feedback_exchange_op_all_access.md) — STOP claiming "no tier can write mail." Exchange Operator app = Exchange Admin role + full_access_as_app + Exchange.ManageAsApp = full all-access (move mail, rules, config, EWS). Default to `exchange-op` for any Exchange write.
- [Tedards tenant facts](reference_tedards_tenant_facts.md) — Bill Tedards law office; tenant `4fcbb1f4…`; bt@/y226@ mailboxes; matter-number filing; UAL ingestion OFF; 9 synced devices; botched-import DUPLICATE folder.
- [Investigator EXO ManageAsApp gap](reference_investigator_exo_manageasapp_gap.md) — Security Investigator app lacks `Exchange.ManageAsApp` (only `full_access_as_app`) so `investigator-exo` 401s on EXO adminapi; use `exchange-op` tier for InvokeCommand.
- [Tailscale subnet-route key expiry](reference_tailscale_subnet_key_expiry.md) — "internet OK but all of 172.16.3.x (Gitea .20, RMM/coord .30) dead" = Tailscale infra-node KEY EXPIRY (pfSense subnet router advertises 172.16.0.0/22), NOT a LAN outage; expiry now disabled on infra nodes (2026-06-25). Fallback: gururmm-server direct at tailnet 100.86.12.15:3001.
- [GravityZone support center](reference_gravityzone_support.md) — Authoritative Bitdefender GravityZone product + Public API docs; use to confirm UNVERIFIED `bitdefender` skill methods/param shapes (push setPushEventSettings, assignPolicy, report/account writes, maintenancewindows/integrations names).
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — GURU-5070 now has cargo + MSVC + protoc; build/clippy/test guru-connect LOCALLY (set PROTOC to the winget path) instead of the build host. CI only clippy-checks the Linux server, not the Windows agent.
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
- [Syncro RMM policies = API-impossible](reference_syncro_rmm_api_gui_only.md) — policy create/assign/folder-move is GUI-ONLY; `policy_folder_id` is read-only on PUT (live-proven), policy endpoints 404, /policy_folders 401 scope-gated. Don't build /syncro move-asset; use `bitdefender` for API policy work.
- [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE.
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
- [Cloudflare access](reference_cloudflare_access.md) — Cloudflare API creds in SOPS `services/cloudflare.sops.yaml` (full DNS + account tokens; azcomputerguru zone_id 1beb9917...). azcomputerguru.com DNS is on Cloudflare (not IX) — edit via Cloudflare API, not whmapi1.
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
- [GuruRMM command timeout_seconds](reference_gururmm_command_timeout_seconds.md) — agent command dispatch honors `timeout_seconds`, NOT `timeout`; long jobs die ~300s / go zombie (`running`, empty stdout) otherwise. Cost Birth Biologic a full day.
- [SharePoint Graph large-file upload](reference_sharepoint_graph_large_file_upload.md) — <4MB simple PUT, >=4MB MUST use chunked upload session (Content-Range); `\\?\` long paths; idempotent size-check; verify counts via /root/delta; single stream ~40Mbps (SPO throttle).
- [RMM-spawn headless Claude](reference_rmm_spawn_headless_claude.md) — run `claude -p` on any RMM-managed Windows box with Claude Code (reaches coord-isolated sites like AD2); use `context:user_session`, UNSET the stale machine `ANTHROPIC_API_KEY` (shadows OAuth → "Invalid API key"), detach + poll a DONE marker. Validated on AD2 2026-07-01.
- [RMM agent update model](rmm-agent-update-model.md) — Agent updates are server-PUSH on heartbeat (no self-poll); available versions = filesystem scan needing a `.sha256`; promote flips `.channel` sidecars beta→stable globally. Two stranders: beta-first freezes stable until an explicit promote; agents older than ~0.6.50 re-enroll with a NEW device_id/agent row when updated.
- [GuruRMM physical server storage](gururmm-physical-server-storage.md) — New box 172.16.1.231 (temp IP→will be .30), Ubuntu 26.04, ssh key `gururmm-physical`/alias `gururmm-new`. SSD (915G root) = HOT (PG default tablespace + WAL + builds); HDD ext4 at `/data` = COLD (`gururmm_cold` PG tablespace for aged `agent_logs` partitions + downloads + backups + archive). The #3 retention answer.
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
- [reference_backblaze_storage_rate](reference_backblaze_storage_rate.md) -- ACG's Backblaze B2 storage cost rate ($0.00695/GB) for the GuruRMM mspbackups storage-cost calculation
- [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets.
- [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector.
- [INKY outbound breaks DMARC](reference_inky_outbound_breaks_dmarc.md) — Reverse-resolve DMARC rua failing IPs before blaming a sender: ipw-outbound.inkyphishfence.com / us.cloud-sec-av.com = INKY re-injection breaking DKIM+SPF. INKY is in-M365 (connectors+transport rules) per enrolled tenant, but hosting-level (IX/cPanel website) outbound also routes through it independent of M365 enrollment. Fix is INKY-side (outbound DKIM/SPF/ARC), not cPanel DNS.
- [Syncro prepay: full-GET only](feedback_syncro_prepay_full_get_only.md) — read prepay_hours ONLY from GET /customers/{id}; the customer search/list endpoint returns null/stale prepay. Never assert "no block" in a billing preview from search data.
- [Syncro priority/type format](feedback_syncro_priority_type_format.md) — every ticket create needs a number-prefixed priority ("2 Normal", not bare "Normal" which renders blank) AND a valid problem_type. Winter flagged #32193/#32194. Use the syncro skill's create flow.
- [Syncro line-item category](feedback_syncro_line_item_category.md) — product_category is a controlled vocab: never invent one, never invoice a line whose product has product_category:null (blank CATEGORY breaks QB mapping). Pre-flight GET /products/<id>. Winter fixed Cascades 67887/67890 "Windows Pro Upgrade".
- [RMM drive-map Explorer refresh](reference_rmm_drive_map_explorer_refresh.md) — drive mapped via RMM user_session works but the user's running Explorer won't show it until SHChangeNotify(DRIVEADD); also UNC \\ gets eaten in heredoc+jq, build it from [char]92.
- [Verify live before acting](feedback_verify_live_before_acting.md) — pull LIVE data (OMSA/iDRAC/live API) before acting on a hardware/infra flag; wiki/logs go stale. Cascades CS-SERVER "degraded RAID" was 9-day-stale (mirror self-recovered, SSDs bought needlessly). Windows can't see RAID member health.
- [Windows Pro upgrade billing](feedback_windows_pro_upgrade_billing.md) — ACG MAK key (vault infrastructure/windows-pro-mak, Mike's ACG key) for Home->Pro upgrades; RULE: invoice $99 PER machine activated, name the machine on the line item, bill after success. Each use consumes a MAK count.
- [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp.
- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients/<slug>/udm-ssh`.
- [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration.
- [AD2 SSH MTU blackhole](ad2-ssh-mtu-blackhole.md) — AD2 SSH "lockouts"/mid-session read-errors over the Dataforth OpenVPN were a PMTU blackhole (tunnel PMTU ~1424 vs adapter MTU 1500), NOT a ban/account-lockout/flaky tunnel. Fix: pin the OpenVPN adapter MTU to 1400 (done on GURU-5070 via its SYSTEM RMM agent); permanent = `mssfix 1360` on the OpenVPN server. Diagnose over RMM, not SSH.
- [DSCA33/45 resolved via Hoffman](project_dsca33_45_resolved_via_hoffman.md) — The "lost" DSCA33/45 spec files are recoverable from the Hoffman API (original certs survived the wipe); do NOT ask John. 56/58 models mined into projects/dataforth-dos/dsca33-45-templates.json; only DSCA33-1948 + DSCA45-1746 (24 units) lack an original. AD2 handoff: DSCA33-45-HOFFMAN-RECOVERY-2026-06-18.md.
- [AD2 comms via sync only](ad2-comms-via-sync-only.md) — The AD2 Dataforth-box Claude session is coord-API-isolated (Gitea only); coord msg/lock/todo never reach it. Coordinate with AD2 ONLY via git /sync (committed docs + ## Note blocks).
- [reference_syncro_agent_handle_leak](reference_syncro_agent_handle_leak.md) -- RDS "no available computers in the pool" (0x3/0x408) can really be a SyncroLive.Agent.Runner handle leak starving the box. How to spot + fix.
## Users
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
## Feedback
- [Report times in Arizona time](feedback_timezone_arizona_reporting.md) — All user-facing times in America/Phoenix (MST, no DST), labeled AZ; convert from UTC. Set by Winter 2026-07-02.
- [RMM dashboard: beta before main](rmm-dashboard-beta-before-main.md) — GuruRMM website/dashboard changes land on beta (rmm-beta.azcomputerguru.com) and get tested BEFORE pushing/merging to main, unless told otherwise. Pipeline auto-builds beta FROM main, so don't merge to get on beta — build the feature branch's dashboard and rsync to /var/www/gururmm/dashboard-beta via a git worktree. Promote beta→prod with promote-dashboard.sh --confirm.
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — When a target has SSH (key auth) and the task is easier over it, default to `scp script + ssh run` (system OpenSSH); RMM runs as SYSTEM + hits the server-side timeout reaper. Reserve RMM as fallback when SSH/VPN is down.
- [Re-clone submodule creds](reclone-submodule-creds.md) — Re-cloning the restructured claudetools (projects now submodules): set `credential.helper=store` GLOBALLY before `git submodule update --init --recursive` or every Gitea submodule fails "could not read Username". Steps in RECLONE.md.
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
- [RMM Set-Acl timeout loses stdout](feedback_rmm_setacl_timeout_password_loss.md) — NTFS ACL propagation (Set-Acl/icacls) on a large folder tree exceeds the RMM command timeout and stdout is dropped, so a password printed in that script is lost. Generate secrets LOCALLY (placeholder-inject) so they survive; isolate the ACL grant into its own long-timeout command.
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
- [DMARC rua INKY only when onboarded](feedback_dmarc_rua_inky_onboarded_only.md) — Don't point a client's DMARC rua at reports-sg.inkydmarc.com unless that client is onboarded to INKY (most aren't). Use plain `p=none` with no rua otherwise.
- [Use rmm-search to find machines](feedback_rmm_search_skill.md) — Find GuruRMM agents via the `rmm-search` skill (`rmm-search.sh <words> [-c client]`), never hand-grep /api/agents (it bleeds across clients). Then hand hostname/id to `/rmm`.
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. MECHANICAL FIX: deliver PS scripts via `.claude/scripts/ps-encoded.sh` (-EncodedCommand, byte-exact; author the file with the Write tool). Inline one-offs: single-quoted heredoc `<<'JSON'` + `--data-binary @-`; build `"` from `[char]34`.
- [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover.
- [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2).
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
- [agy.exe NOW works headless (2026-07-03)](reference_antigravity_agy_not_headless.md) — Antigravity `agy.exe` v1.0.6+ has a working `-p/--print` headless mode (clean stdout, `--add-dir` to read files); use it as the Gemini/Antigravity second-model path, esp. when gemini-cli OAuth is ineligible. Call by full path until PATH refresh. (Supersedes the old "agy is DOA headless" note.)
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
- [Drive-letter mapping convention](feedback_drive_letter_mapping.md) — Pick the MAIN/primary drive letter first (consistent across users for the principal share), then assign smaller/secondary maps. Don't retroactively renumber existing maps unless asked.
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
- [Impeccable on outbound](feedback_impeccable_on_outbound.md) — Run the `impeccable` skill on anything sent to a client or vendor before delivery; internal drafts exempt.
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
- [Don't present inferred topology as fact](feedback_no_inferred_topology_as_fact.md) — Private-IP overlap (172.16.x on both sides) is NOT proof of a site-to-site link; I fabricated a VWP<->office VPN. State observations vs inferences; a failed reachability test disproves a link, don't explain it away; test "can reach RMM" against the EXTERNAL endpoint, not internal 172.16.3.30.
### Syncro
- [Skill-first routing + Definition of Done](feedback_skill_first_routing.md) — If a skill covers the task, INVOKE IT (Syncro billing ALWAYS via `/syncro`, never ad-hoc curl), AND gate the result with the matching check-skill before "done" (code→/code-review+/simplify+/security-review; outbound→impeccable/stop-slop; wiki/memory→wiki-lint/memory-dream) — inferred from the request, not user-named. CORE rule; full map in `.claude/SKILL_ROUTING.md`; calibrate to stakes, Ollama Tier-0 for cheap passes.
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
### GuruRMM
- [GuruRMM build verification (read before touching the pipeline)](feedback_gururmm_build_verification.md) — Merge-to-main IS the build+deploy; verify locally FIRST. Canonical refs: guru-rmm `docs/BUILD.md` + the `gururmm-build` skill (`verify.sh server|agent|dashboard|migrations`) + `deploy/build-pipeline/README.md`. Compile-gate trap: Windows cargo can't verify Linux-gated agent code (openssl-sys); Linux build on .30 is the real gate. Server needs SQLX_OFFLINE + fresh server/.sqlx; check migration-number collisions.
- [Submodule auto-sync discipline](feedback_submodule_autosync_discipline.md) — In auto-synced submodules (guru-rmm/guru-connect) local branch refs/HEAD don't survive across calls (background sync resets to the lagging gitlink; sessions share the tree). Use a git worktree or commit+push-by-explicit-SHA + `ls-remote` verify; assert HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files. Recurring fleet friction.
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
### Cascades
- [Cascades operational rules](feedback_cascades.md) — Active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU. (3) Do NOT lock down the legacy Main\Company Web Docs\Accounting (Everyone:Full) folder — still in active use. (4) NEVER change Cascades production infra (pfSense/UniFi/switches/DHCP) without discussing it + explicit per-change go — read-only/dry-run until then.
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
- [feedback_bitdefender_unattended_install](feedback_bitdefender_unattended_install.md) -- Bitdefender unattended RMM install must use the FULL KIT as SYSTEM (silent, no UAC) — the downloader stub fails headless and triggers UAC
- [feedback_rmm_longops_fire_and_forget](feedback_rmm_longops_fire_and_forget.md) -- Long-running RMM endpoint ops (software installs, big downloads) must be fire-and-forget, not live-monitored
## Machine
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
## Project
- [Cascades network + CS-SERVER SMB instability](project_cascades_network_segments.md) — NAS->CS-SERVER migration. NOT a CSC-ENT-vs-CSCNet issue (corrected): fresh SMB to `\\cs-server\*` fails err 67 from BOTH Wi-Fis; only persistent admin-mapped drives work, intermittently → CS-SERVER-side SMB instability (multichannel advertises .248/.254/IPv6 ULAs; Get-SmbServerConfiguration errors). Blockers: CSCNet=WPA3 (old adapters can't join); workstations store domain-admin cred. Repoint tool [[drive-map]].
- [CyndyOffice physical HP lockups](cyndyoffice-physical-hp-lockups.md) — RMM "Howard-VM" site agent CyndyOffice is a PHYSICAL HP Pavilion TP01 (not a VM); ~20 hard freezes/6wk = Kernel-Power 41 bugcheck-0, no dump/WHEA = hardware (RAM/PSU/BIOS), SSD healthy. UUID re-enrolls.
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
- [GuruRMM security scope — integrate AV, don't replace it](project_gururmm_security_scope.md) — No native virus/malware removal in the RMM; AV products do that. RMM monitors AV reports + sends commands to AV products, and its built-in value is helping techs FIND issues. Program removal is a separate feature.
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
- [Howard-Home LAN shadow (RESOLVED)](howard-home-lan-shadow.md) — Howard-Home renumbered 2026-06-16 to **10.137.42.0/24** (gw 10.137.42.1, UniFi — NOT pfSense), off the old 192.168.0.0/24 that shadowed Cascades pfSense .0.x over the VPN. Cascades .0.x should now route via the tunnel; this machine is 10.137.42.x now (not 192.168.0.x).
- [Cascades CARF tech plan](project_cascades_carf_tech_plan.md) — Ashley's "technology plan" is a CARF accreditation deliverable (Aging Services Technology and System Plan standard); must use CARF action-plan structure (owner/cost/target+completion dates per area), persons-served assistive-tech lens, annual-review sign-off; it's Cascades' leadership-adopted plan, ACG supplies content.
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
- [Cascades isolated-VLAN pattern](project_cascades_isolated_vlan_pattern.md) — pfSense: the GUEST VLAN (VLAN50/igc1.50) is the isolation template (4 any-proto quick rules: block 192.168.0.0/22 + 10.0.0.0/8 + 172.16.0.0/12, then pass any; public DNS via DHCP). VLAN20 is NOT isolated. Verify with `pfctl -sr`, not config.xml. Protocol MUST be Any (TCP-only leaks UDP). VOICE VLAN30 built to this 2026-06-17.
- [Cascades VLAN20 migration + routing](project_cascades_vlan20_migration_routing.md) — Staff machines/printers moving to VLAN20 (10.0.20.0/24). CS-SERVER couldn't reach VLAN20 printers because the LAN "allow LAN to any" rule policy-routes via WAN_Group → add a top LAN pass rule (src CS-SERVER 192.168.2.248, dst 10.0.20.0/24, gw=default) to bypass. pfSense SSH from VPN is blocked (do firewall in GUI). Printer client-map via GPO or SYSTEM `printui /ga` to dodge the 0x800702e4 PrintNightmare prompt; build UNC with [char]92.
- [Cascades KPI dashboard (parked)](project_cascades_kpi_dashboard.md) — Ashley Jensen wants one dashboard across their reporting SaaS (ALIS/QuickBooks/Bill.com/Relias/You've Got Leads/TELS/Focus HR/Helpany/POS). Power BI Gateway is the WRONG frame (on-prem only). Recommended Tier1→Tier2: scheduled exports → SharePoint → Power BI Pro, automate API-capable systems (Bill.com/QBO) via Power Automate later. Full notes: `clients/cascades-tucson/docs/proposals/kpi-dashboard.md`. Next: draft client one-pager.
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
- [AMPIPIT toolkit](project_ampipit.md) — Howard's ACTIVE Rust+Slint Windows deploy/recovery toolkit (not just a reference program), migrated into the fleet as a private Gitea submodule at projects/msp-tools/ampipit (azcomputerguru/ampipit). Active feature: recovery partition with GuruRMM agent + built-in repair scripts for offline-Windows repair (spike GO-WITH-WORK). Preserve all work; PRIVATE Gitea only, no public GitHub.
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
- [Defender: exempt all Claude activity](feedback_defender_claude_exclusions.md) — Mike wants NOTHING Claude/ClaudeTools issues flagged by Defender; ClickFix threat IDs 2147939088/2147945138 (RMM curl dispatch AMSI FPs) set to Allow + broad process/path exclusions. ThreatID-Allow is the lever; exclusions alone don't stop AMSI CmdLine detections.
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
- [VoIP vendor stack: PacketDial / OIT / NetSapiens / YMCS](reference_packetdial_oit_netsapiens.md) — PacketDial = ACG's VoIP-dept brand (pbx.packetdial.com, the `packetdial` skill); NetSapiens = the PBX platform (API v2); OIT/OITVOIP = white-label wholesaler running NetSapiens (api.ucaasnetwork.com); reseller `91912.service`. YMCS (Yealink) = phone device-mgmt, pairs with the PBX.
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; now wrapped by the /screenconnect skill. Verified surface: GetSessionsByName/GetSessionDetails + writes SendCommand/SendMessage/UpdateCustomProperties + parameterized self-tagging installer. Still NO full-fleet inventory method (GetSessions missing).
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
- [Check for client-slug fragmentation](feedback_client_slug_fragmentation.md) — Before concluding a client has no records, grep broadly (company/owner/initials/hostname/"Last, First") across clients/, wiki/, session-logs/, vault — one client gets split across slug variants (Wolkin was 4: wolkin/wolkin-law/rswolkin/robert-wolkin). Consolidate to one canonical slug; action prior logs' Pending items.
- [RMM user_session = false SMB failures](feedback_rmm_user_session_smb_false_negative.md) — GuruRMM net use/net view/Add-Printer to a remote \HOST fail with error 67 / RPC 1702 (even with valid creds) because user_session is a WTS-impersonated non-interactive token that can't do authenticated SMB. The share/printer may work fine interactively. Treat RMM SMB results as "can't tell"; verify via ScreenConnect.
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — when a target has SSH (key auth) and it's easier, drive it via system OpenSSH (scp+ssh) instead of the GuruRMM agent; RMM runs as SYSTEM + is bound by the server-side timeout reaper + forces base64/quoting gymnastics. Reserve RMM as the fallback when SSH/VPN is down.
- [Broken [[backlinks]] = write-me-later markers](feedback_broken_backlinks_are_writeme_markers.md) — A [[name]] with no matching file is an intentional "worth writing" marker, not breakage. Flesh the missing memory out from session history/logs and index it; never strip the link to silence the warning. memory-dream reports these as INFO candidates, not errors.
- [gururmm session-logs are in a submodule](gururmm-session-logs-submodule-save.md) — commit in the submodule + `git push origin HEAD:main` (GURU-5070 CAN push over HTTP now); then advance the parent gitlink
- [Use `python` not `python3` on GURU-5070](python3-shim-use-python.md) — `python3` in Git bash hits the flaky MS Store shim; real interpreters are `python` (3.12) / `py` (3.14). coord.py + wiki-compile work via `python`; the coord lock IS claimable here
- [Beast = primary GuruRMM Windows build host](gururmm-beast-windows-build-host.md) — GURU-BEAST-ROG (i9), reached from .30 via Tailscale-on-.30 at 100.101.122.4 as guru; Pluto is the fallback (`attempt_build beast || attempt_build pluto`). WiX must be 4.x (v6+ = OSMF); Beast NuGet needed nuget.org added
- [GuruRMM command_type gotcha](reference_gururmm_command_type.md) — only shell/powershell/python/script/claude_task (+cmd alias); unknown type silently dropped, looks like a black-hole
- [GuruRMM log analysis -> Claude Haiku](gururmm-log-analysis-claude-cutover.md) — cut over from Ollama-on-Beast (timed out on fleet-sized prompts; "unreachable" was a mislabeled 120s timeout) to Anthropic API Haiku 4.5 w/ structured outputs; key at vault `projects/gururmm/anthropic-api`; ZDR pending; deploy needs root on .30 (.env + restart)
- [IX WHM API access = 'ClaudeTools' token, not password](ix-whm-dns-api-access.md) — IX cPanel/WHM (ix.azcomputerguru.com:2087) DNS + all API work uses the FULL-ACCESS-root WHM API token at vault `infrastructure/ix-server` `credentials.whm-api-token` via header `Authorization: whm root:<token>` (force curl -4). Password basic-auth on legacy json-api now 403s. Public NS ns1/ns2.acghosting.com = 52.52.94.202.
- [Vault EVERY credential surfaced in-session](feedback-vault-every-credential.md) — any cred (pasted/created/discovered) -> store via the vault skill + document purpose & exact usage immediately; it's a standing job rule (reinforced in CORE CLAUDE.md). Lost IX creds wasted ~1h on 2026-06-12.
- [GuruRMM install-report v1: reuse endpoint + failed-install agent](gururmm-install-report-failed-agent-v1.md) — legacy NSIS installer reuses /api/install-report (machine info + logs, success+fail); server upserts a visible "failed-install" device on failure reports (Mike: in v1); verify-connect-before-success; trend/near-fail analytics. Server side is a separate sequential SPEC after the legacy-agent branch lands.
- [DM wrapping commands to Mike in Discord](feedback_dm_wrapping_commands_to_mike.md) — long/wrapping output (commands, consent links, URLs) goes via Discord DM (copies clean), not just chat; use the `discord-dm` skill (`discord-dm.sh mike "<x>"`). Gotchas: bot token vault projects/discord-bot/bot-token, Mike uid 264814939619721216, MUST set User-Agent or Cloudflare 403 errcode 1010, build JSON via jq + printf|--data-binary (direct -d → 50109).
- [Physical access codes -> vault + wiki pointer](feedback_physical_access_codes.md) — alarm/lockbox/door codes go in vault clients/<slug>/physical-access-<location>.sops.yaml (kind: physical-access) + a `## Physical Access` pointer section in the client wiki; never plaintext. First entry: Peaceful Spirit NW.
- [CT Thoughts backlog](feedback_ct_thoughts_backlog.md) — ClaudeTools harness ideas go in docs/CT_THOUGHTS.md (trigger "ct thought:"); CT analogue of RMM_THOUGHTS. Don't build until explicit go. First entry = ClaudeTools 3.0 web co-work vision.
- [AI-auth product boundary](project_ai_auth_product_boundary.md) — ClaudeTools/ClaudeTools 3.0 = internal-only, per-person subscription OAuth ok; GuruRMM = sellable, customer brings own API key (never ACG's subscription); backend dev = internal. Anthropic ToS bans subscription auth in third-party products.
- [RMM SYSTEM context can't see user mapped drives](feedback_rmm_system_context_mapped_drives.md) — RMM runs as SYSTEM; `Test-Path F:\` etc. is False even when the user's mapped/redirected drive exists. Diagnose mapped-drive/redirect issues in `context:user_session`. Elevated apps (e.g. QB DB Server Manager "unable to retrieve root folder") need `EnableLinkedConnections=1` + reboot.
- [AD2 = Dataforth-ops fork](project_ad2_dataforth_fork.md) — branch ad2 = main + thin Dataforth layer; keep fork edits ADDITIVE (Dataforth context in clients/dataforth/CLAUDE.dataforth.md, NOT .claude/CLAUDE.md); rebase onto main directly when sync.sh self-lock hits; no vault/jq/sops/age on this box.
- [GuruScan verification IN TEST / paused](project_guruscan_in_test_paused.md) — multi-engine scanner verify on DESKTOP-MS42HNC paused 2026-06-22 (VM rebooted mid-Emsisoft run); HitmanPro done (36 removed), Emsisoft full-scan unverified; resume `guruscan-agent-test.sh DESKTOP-MS42HNC scan-one Emsisoft`; Defender RTP/Tamper still off on VM
- [GuruRMM fleet dispatch-hang fix](project_gururmm_dispatch_hang_fix.md) — blocking send_to on a full bounded channel to one black-holed agent wedged ALL command dispatch; fixed with try_send (9dae20c, deployed); proper black-hole eviction still missing (was reverted in 80df458) — finish it if it recurs
- [Windows won't-boot / offline DISM repair playbook](windows-offline-dism-repair-gotchas.md) — Automatic Repair loop = boot-critical fault (disk/registry/wedged update), NOT shell/appx store corruption (that's a symptom); `FaultyPackageInProgress` + 100s of Install/Uninstall-Pending packages = wedged CU -> RevertPendingActions or clean install. Offline DISM rejects `wim:` source (0x800f082e) -> MOUNT the wim, source `\Windows`. Ventoy breaks WIM mount (0xc1420134) -> use Rufus. 25H2(26200)=24H2(26100)+enablement, so match 26100 media. First hit: Four Paws AvImark #32447.
- [365 app suite — authoritative map + consent-drift fix](reference_365_app_suite.md) — full map in `.claude/skills/remediation-tool/references/app-suite.md`; per-tenant consent is NOT uniform (VWP had the app but no SharePoint role). Run `consent-audit.sh <tenant|--all>` to detect gaps; fix via adminconsent URL or direct appRoleAssignment grant.
- [Remediation-tool has full M365 access (incl. SharePoint)](reference_remediation_tool_365_access.md) — the app suite covers Graph/EXO/Defender/SharePoint; don't declare "no access" on an accessDenied. SharePoint app-only needs a CERT (secret = "Unsupported app only token"); use get-token.sh `sharepoint`/`sharepoint-admin` tiers + CSOM admin API (Graph /admin/sharepoint/settings scope not held). Full map: skill references/app-permissions-and-sharepoint.md.
- [AV migration: Bitdefender -> Datto EDR](project_av_migration_bitdefender_to_edr.md) — retire Bitdefender fleet-wide except Dataforth; end-state per machine = GuruRMM + Datto EDR
- [RMM deploy via ScreenConnect](reference_rmm_deploy_via_screenconnect.md) — push GuruRMM agent to client workstations via SC send-command (SYSTEM), not DC remote-exec (DCOM/schtasks blocked on Win11 clients)
- [ScreenConnect custom-property slots](reference_screenconnect_custom_property_slots.md) — CP1=Company CP2=Site CP3=Department CP4=Device Type CP8=Tag (API hides labels; UpdateSessionCustomProperties replaces the whole array)
- [ScreenConnect cleanup uses wiki as source](feedback_screenconnect_cleanup_wiki_source.md) — per-client SC/RMM metadata cleanup pulls machine->dept/location from the client wiki; enrich the wiki when missing
- [TECH03L systemprofile shortcut corruption](project_tech03l_systemprofile_shortcut_corruption.md) — Auto-Claude "opens then closes" = .lnk pointing at nonexistent systemprofile path; repoint, do not debug the app
# Memory Index
## Reference
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
- [MSP360 backup monitoring](reference_msp360_backup_monitoring.md) — Authoritative "is client X actually backing up / last run" source for the whole fleet (NOT B2 bucket contents). MSP360 MBS API `/api/Monitoring`, vault `msp-tools/msp360-api.sops.yaml`. CompanyName also IDs B2 buckets (ACG-PST=Peaceful Spirit, ACG-TCA=Tucson Coin).
- [Backup checks only for billed clients](feedback_backup_check_only_billed.md) — Don't scan backup status for clients with no billed Data Backup line (Syncro schedule qty=0); verify billed clients only. Non-billed-but-backing-up = revenue-leak question for Mike/Winter, not a scan target.
- [ALIS (Medtelligent)](reference_alis_medtelligent.md) — Cascades assisted-living EHR. API host api.alisonline.com, community 622; username must be tenant-qualified (howard.enos@cascadestucson). Staff are READ-ONLY via API — create/change staff via web-UI Staff Import .xls. Use the `alis` skill.
- [GuruRMM User Manager](reference_gururmm_user_manager.md) — GuruRMM has a built-in per-agent User Manager tab (reset_password/enable/disable/groups for local+domain+AAD endpoint users; domain users only on a DC via `is_dc`). Use it, NOT raw Set-ADAccountPassword via /rmm. Endpoints: /api/agents/{id}/users + /users/action.
- [RMM map network drive (err67 double-hop)](reference_rmm_map_network_drive.md) — Pushing a persistent mapped drive to a remote share via /rmm user_session fails with err67/1702 (impersonated token = no network cred/double-hop). Plant HKCU:\Network\<drv> keys + cmdkey; mounts at next interactive logon. Immediate visibility needs the live session (ScreenConnect).
- [exchange-op = all-access Exchange tier](feedback_exchange_op_all_access.md) — STOP claiming "no tier can write mail." Exchange Operator app = Exchange Admin role + full_access_as_app + Exchange.ManageAsApp = full all-access (move mail, rules, config, EWS). Default to `exchange-op` for any Exchange write.
- [Tedards tenant facts](reference_tedards_tenant_facts.md) — Bill Tedards law office; tenant `4fcbb1f4…`; bt@/y226@ mailboxes; matter-number filing; UAL ingestion OFF; 9 synced devices; botched-import DUPLICATE folder.
- [Investigator EXO ManageAsApp gap](reference_investigator_exo_manageasapp_gap.md) — Security Investigator app lacks `Exchange.ManageAsApp` (only `full_access_as_app`) so `investigator-exo` 401s on EXO adminapi; use `exchange-op` tier for InvokeCommand.
- [Windows edition upgrade via RMM](reference_windows_edition_upgrade_rmm.md) — Verified Home->Pro/Pro-for-Workstations fleet procedure: `changepk.exe /ProductKey <generic Pro>` (NOT DISM Set-Edition — Error 50) -> reboot with `Restart-Computer -Force` (`shutdown /r /t N` is silently dropped in SYSTEM session) -> `slmgr /ipk <MAK>` + /ato. The vault MAK is a Pro-for-WORKSTATIONS key (auto-upgrades edition past plain Pro).
- [Tailscale subnet-route key expiry](reference_tailscale_subnet_key_expiry.md) — "internet OK but all of 172.16.3.x (Gitea .20, RMM/coord .30) dead" = Tailscale infra-node KEY EXPIRY (pfSense subnet router advertises 172.16.0.0/22), NOT a LAN outage; expiry now disabled on infra nodes (2026-06-25). Fallback: gururmm-server direct at tailnet 100.86.12.15:3001.
- [GravityZone support center](reference_gravityzone_support.md) — Authoritative Bitdefender GravityZone product + Public API docs; use to confirm UNVERIFIED `bitdefender` skill methods/param shapes (push setPushEventSettings, assignPolicy, report/account writes, maintenancewindows/integrations names).
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — toolchain installed BUT a WDAC/Smart App Control policy (since 2026-07-04) BLOCKS running rustc/cargo/sops/openssl locally (os error 4551) — build the Windows agent on Beast, not here. Toolchain paths + CI gates still valid for reference.
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
- [Syncro RMM policies = API-impossible](reference_syncro_rmm_api_gui_only.md) — policy create/assign/folder-move is GUI-ONLY; `policy_folder_id` is read-only on PUT (live-proven), policy endpoints 404, /policy_folders 401 scope-gated. Don't build /syncro move-asset; use `bitdefender` for API policy work.
- [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE.
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
- [Cloudflare access](reference_cloudflare_access.md) — Cloudflare API creds in SOPS `services/cloudflare.sops.yaml` (full DNS + account tokens; azcomputerguru zone_id 1beb9917...). azcomputerguru.com DNS is on Cloudflare (not IX) — edit via Cloudflare API, not whmapi1.
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
- [Client Wi-Fi Inventory](reference_client_wifi_inventory.md) — Building a fleet Wi-Fi list to connect onsite without asking. Passwords → vault `clients/<slug>/wifi.sops.yaml` (`credentials.<key>_ssid/_password`); readable index → `wiki/reference/client-wifi.md`. `/wifi` importer deferred (structure-only 2026-07-06).
- [Git Bash TZ ignored](feedback_gitbash_tz_ignored.md) — `TZ=America/Phoenix date` in Git Bash silently prints UTC (no tzdata) — confidently-wrong "current time". Use PowerShell `Get-Date` (machine already AZ) or plain `date` with no TZ override for now-time on Windows.
- [Discord read replies](feedback_discord_read_replies.md) — Discord DM replies don't come through coord/repo sync. Use `discord-dm.sh read <user>` to see if mike/winter/rob actually answered before assuming silence.
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
- [GuruRMM command timeout_seconds](reference_gururmm_command_timeout_seconds.md) — agent command dispatch honors `timeout_seconds`, NOT `timeout`; long jobs die ~300s / go zombie (`running`, empty stdout) otherwise. Cost Birth Biologic a full day.
- [SharePoint Graph large-file upload](reference_sharepoint_graph_large_file_upload.md) — <4MB simple PUT, >=4MB MUST use chunked upload session (Content-Range); `\\?\` long paths; idempotent size-check; verify counts via /root/delta; single stream ~40Mbps (SPO throttle).
- [RMM-spawn headless Claude](reference_rmm_spawn_headless_claude.md) — run `claude -p` on any RMM-managed Windows box with Claude Code (reaches coord-isolated sites like AD2); use `context:user_session`, UNSET the stale machine `ANTHROPIC_API_KEY` (shadows OAuth → "Invalid API key"), detach + poll a DONE marker. Validated on AD2 2026-07-01.
- [RMM agent update model](rmm-agent-update-model.md) — Agent updates are server-PUSH on heartbeat (no self-poll); available versions = filesystem scan needing a `.sha256`; promote flips `.channel` sidecars beta→stable globally. Two stranders: beta-first freezes stable until an explicit promote; agents older than ~0.6.50 re-enroll with a NEW device_id/agent row when updated.
- [GuruRMM physical server storage](gururmm-physical-server-storage.md) — New box 172.16.1.231 (temp IP→will be .30), Ubuntu 26.04, ssh key `gururmm-physical`/alias `gururmm-new`. SSD (915G root) = HOT (PG default tablespace + WAL + builds); HDD ext4 at `/data` = COLD (`gururmm_cold` PG tablespace for aged `agent_logs` partitions + downloads + backups + archive). The #3 retention answer.
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
- [reference_backblaze_storage_rate](reference_backblaze_storage_rate.md) -- ACG's Backblaze B2 storage cost rate ($0.00695/GB) for the GuruRMM mspbackups storage-cost calculation
- [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets.
- [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector.
- [INKY outbound breaks DMARC](reference_inky_outbound_breaks_dmarc.md) — Reverse-resolve DMARC rua failing IPs before blaming a sender: ipw-outbound.inkyphishfence.com / us.cloud-sec-av.com = INKY re-injection breaking DKIM+SPF. INKY is in-M365 (connectors+transport rules) per enrolled tenant, but hosting-level (IX/cPanel website) outbound also routes through it independent of M365 enrollment. Fix is INKY-side (outbound DKIM/SPF/ARC), not cPanel DNS.
- [Syncro prepay: full-GET only](feedback_syncro_prepay_full_get_only.md) — read prepay_hours ONLY from GET /customers/{id}; the customer search/list endpoint returns null/stale prepay. Never assert "no block" in a billing preview from search data.
- [Syncro priority/type format](feedback_syncro_priority_type_format.md) — every ticket create needs a number-prefixed priority ("2 Normal", not bare "Normal" which renders blank) AND a valid problem_type. Winter flagged #32193/#32194. Use the syncro skill's create flow.
- [Syncro line-item category](feedback_syncro_line_item_category.md) — product_category is a controlled vocab: never invent one, never invoice a line whose product has product_category:null (blank CATEGORY breaks QB mapping). Pre-flight GET /products/<id>. Winter fixed Cascades 67887/67890 "Windows Pro Upgrade".
- [RMM drive-map Explorer refresh](reference_rmm_drive_map_explorer_refresh.md) — drive mapped via RMM user_session works but the user's running Explorer won't show it until SHChangeNotify(DRIVEADD); also UNC \\ gets eaten in heredoc+jq, build it from [char]92.
- [Verify live before acting](feedback_verify_live_before_acting.md) — pull LIVE data (OMSA/iDRAC/live API) before acting on a hardware/infra flag; wiki/logs go stale. Cascades CS-SERVER "degraded RAID" was 9-day-stale (mirror self-recovered, SSDs bought needlessly). Windows can't see RAID member health.
- [Windows Pro upgrade billing](feedback_windows_pro_upgrade_billing.md) — ACG MAK key (vault infrastructure/windows-pro-mak, Mike's ACG key) for Home->Pro upgrades; RULE: invoice $99 PER machine activated, name the machine on the line item, bill after success. Each use consumes a MAK count.
- [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp.
- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients/<slug>/udm-ssh`.
- [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration.
- [AD2 SSH MTU blackhole](ad2-ssh-mtu-blackhole.md) — AD2 SSH "lockouts"/mid-session read-errors over the Dataforth OpenVPN were a PMTU blackhole (tunnel PMTU ~1424 vs adapter MTU 1500), NOT a ban/account-lockout/flaky tunnel. Fix: pin the OpenVPN adapter MTU to 1400 (done on GURU-5070 via its SYSTEM RMM agent); permanent = `mssfix 1360` on the OpenVPN server. Diagnose over RMM, not SSH.
- [DSCA33/45 resolved via Hoffman](project_dsca33_45_resolved_via_hoffman.md) — The "lost" DSCA33/45 spec files are recoverable from the Hoffman API (original certs survived the wipe); do NOT ask John. 56/58 models mined into projects/dataforth-dos/dsca33-45-templates.json; only DSCA33-1948 + DSCA45-1746 (24 units) lack an original. AD2 handoff: DSCA33-45-HOFFMAN-RECOVERY-2026-06-18.md.
- [AD2 comms via sync only](ad2-comms-via-sync-only.md) — The AD2 Dataforth-box Claude session is coord-API-isolated (Gitea only); coord msg/lock/todo never reach it. Coordinate with AD2 ONLY via git /sync (committed docs + ## Note blocks).
- [reference_syncro_agent_handle_leak](reference_syncro_agent_handle_leak.md) -- RDS "no available computers in the pool" (0x3/0x408) can really be a SyncroLive.Agent.Runner handle leak starving the box. How to spot + fix.
## Users
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
## Feedback
- [Simplest solution first](feedback_simplest_solution_first.md) — Start with the least-complex thing that could answer the problem (one DNS lookup / one API call / one command), confirm it works, THEN graduate to advanced/complicated as needed. Don't reach for SSH/plink/multi-hop when a one-shot query answers it. Now a CLAUDE.md core rule.
- [Report times in Arizona time](feedback_timezone_arizona_reporting.md) — All user-facing times in America/Phoenix (MST, no DST), labeled AZ; convert from UTC. Set by Winter 2026-07-02.
- [RMM dashboard: beta before main](rmm-dashboard-beta-before-main.md) — GuruRMM website/dashboard changes land on beta (rmm-beta.azcomputerguru.com) and get tested BEFORE pushing/merging to main, unless told otherwise. Pipeline auto-builds beta FROM main, so don't merge to get on beta — build the feature branch's dashboard and rsync to /var/www/gururmm/dashboard-beta via a git worktree. Promote beta→prod with promote-dashboard.sh --confirm.
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — When a target has SSH (key auth) and the task is easier over it, default to `scp script + ssh run` (system OpenSSH); RMM runs as SYSTEM + hits the server-side timeout reaper. Reserve RMM as fallback when SSH/VPN is down.
- [Re-clone submodule creds](reclone-submodule-creds.md) — Re-cloning the restructured claudetools (projects now submodules): set `credential.helper=store` GLOBALLY before `git submodule update --init --recursive` or every Gitea submodule fails "could not read Username". Steps in RECLONE.md.
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
- [RMM Set-Acl timeout loses stdout](feedback_rmm_setacl_timeout_password_loss.md) — NTFS ACL propagation (Set-Acl/icacls) on a large folder tree exceeds the RMM command timeout and stdout is dropped, so a password printed in that script is lost. Generate secrets LOCALLY (placeholder-inject) so they survive; isolate the ACL grant into its own long-timeout command.
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
- [DMARC rua INKY only when onboarded](feedback_dmarc_rua_inky_onboarded_only.md) — Don't point a client's DMARC rua at reports-sg.inkydmarc.com unless that client is onboarded to INKY (most aren't). Use plain `p=none` with no rua otherwise.
- [Use rmm-search to find machines](feedback_rmm_search_skill.md) — Find GuruRMM agents via the `rmm-search` skill (`rmm-search.sh <words> [-c client]`), never hand-grep /api/agents (it bleeds across clients). Then hand hostname/id to `/rmm`.
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. MECHANICAL FIX: deliver PS scripts via `.claude/scripts/ps-encoded.sh` (-EncodedCommand, byte-exact; author the file with the Write tool). Inline one-offs: single-quoted heredoc `<<'JSON'` + `--data-binary @-`; build `"` from `[char]34`.
- [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover.
- [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2).
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
- [agy.exe NOW works headless (2026-07-03)](reference_antigravity_agy_not_headless.md) — Antigravity `agy.exe` v1.0.6+ has a working `-p/--print` headless mode (clean stdout, `--add-dir` to read files); use it as the Gemini/Antigravity second-model path, esp. when gemini-cli OAuth is ineligible. Call by full path until PATH refresh. (Supersedes the old "agy is DOA headless" note.)
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
- [Drive-letter mapping convention](feedback_drive_letter_mapping.md) — Pick the MAIN/primary drive letter first (consistent across users for the principal share), then assign smaller/secondary maps. Don't retroactively renumber existing maps unless asked.
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
- [Impeccable on outbound](feedback_impeccable_on_outbound.md) — Run the `impeccable` skill on anything sent to a client or vendor before delivery; internal drafts exempt.
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
- [Scheduled tasks must not flash a console](feedback_scheduled_task_no_console_flash.md) — A task action running bash.exe/py.exe/python.exe with Hidden=False draws a console window every fire (the recurring "command prompt opening/closing"). Wrap bash via a wscript VBS (style 0), use pythonw.exe for python, always add `-Hidden`. Fixed installers: register-edr-watcher.ps1, register-orphan-detector.ps1.
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
- [Don't present inferred topology as fact](feedback_no_inferred_topology_as_fact.md) — Private-IP overlap (172.16.x on both sides) is NOT proof of a site-to-site link; I fabricated a VWP<->office VPN. State observations vs inferences; a failed reachability test disproves a link, don't explain it away; test "can reach RMM" against the EXTERNAL endpoint, not internal 172.16.3.30.
### Syncro
- [Skill-first routing + Definition of Done](feedback_skill_first_routing.md) — If a skill covers the task, INVOKE IT (Syncro billing ALWAYS via `/syncro`, never ad-hoc curl), AND gate the result with the matching check-skill before "done" (code→/code-review+/simplify+/security-review; outbound→impeccable/stop-slop; wiki/memory→wiki-lint/memory-dream) — inferred from the request, not user-named. CORE rule; full map in `.claude/SKILL_ROUTING.md`; calibrate to stakes, Ollama Tier-0 for cheap passes.
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
### GuruRMM
- [GuruRMM build verification (read before touching the pipeline)](feedback_gururmm_build_verification.md) — Merge-to-main IS the build+deploy; verify locally FIRST. Canonical refs: guru-rmm `docs/BUILD.md` + the `gururmm-build` skill (`verify.sh server|agent|dashboard|migrations`) + `deploy/build-pipeline/README.md`. Compile-gate trap: Windows cargo can't verify Linux-gated agent code (openssl-sys); Linux build on .30 is the real gate. Server needs SQLX_OFFLINE + fresh server/.sqlx; check migration-number collisions.
- [Submodule auto-sync discipline](feedback_submodule_autosync_discipline.md) — In auto-synced submodules (guru-rmm/guru-connect) local branch refs/HEAD don't survive across calls (background sync resets to the lagging gitlink; sessions share the tree). Use a git worktree or commit+push-by-explicit-SHA + `ls-remote` verify; assert HEAD==origin/main (or read `origin/main:<file>`) before audits; never `checkout --` shared files. Recurring fleet friction.
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
### Cascades
- [Cascades operational rules](feedback_cascades.md) — Active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU. (3) Do NOT lock down the legacy Main\Company Web Docs\Accounting (Everyone:Full) folder — still in active use. (4) NEVER change Cascades production infra (pfSense/UniFi/switches/DHCP) without discussing it + explicit per-change go — read-only/dry-run until then.
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
- [feedback_bitdefender_unattended_install](feedback_bitdefender_unattended_install.md) -- Bitdefender unattended RMM install must use the FULL KIT as SYSTEM (silent, no UAC) — the downloader stub fails headless and triggers UAC
- [feedback_rmm_longops_fire_and_forget](feedback_rmm_longops_fire_and_forget.md) -- Long-running RMM endpoint ops (software installs, big downloads) must be fire-and-forget, not live-monitored
## Machine
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
## Project
- [GuruRMM software-removal PR #58](project_gururmm_software_removal_pr58.md) — SPEC-030 hardening (driver exclusion in removal UI, lingering-uninstaller sweep, job list + stale reaper) on PR #58, UNMERGED; merge = beta deploy; live testing planned 2026-07-06 on DESKTOP-MS42HNC.
- [Cascades network + CS-SERVER SMB instability](project_cascades_network_segments.md) — NAS->CS-SERVER migration. NOT a CSC-ENT-vs-CSCNet issue (corrected): fresh SMB to `\\cs-server\*` fails err 67 from BOTH Wi-Fis; only persistent admin-mapped drives work, intermittently → CS-SERVER-side SMB instability (multichannel advertises .248/.254/IPv6 ULAs; Get-SmbServerConfiguration errors). Blockers: CSCNet=WPA3 (old adapters can't join); workstations store domain-admin cred. Repoint tool [[drive-map]].
- [CyndyOffice physical HP lockups](cyndyoffice-physical-hp-lockups.md) — RMM "Howard-VM" site agent CyndyOffice is a PHYSICAL HP Pavilion TP01 (not a VM); ~20 hard freezes/6wk = Kernel-Power 41 bugcheck-0, no dump/WHEA = hardware (RAM/PSU/BIOS), SSD healthy. UUID re-enrolls.
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
- [GuruRMM security scope — integrate AV, don't replace it](project_gururmm_security_scope.md) — No native virus/malware removal in the RMM; AV products do that. RMM monitors AV reports + sends commands to AV products, and its built-in value is helping techs FIND issues. Program removal is a separate feature.
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
- [GuruRMM legacy 1.77 build disabled](project_gururmm_legacy_disabled.md) — legacy (2008R2/Win7) Rust 1.77 variant DISABLED 2026-07-04 (`LEGACY_ENABLED=false`) after an edition2024 dep (chacha20/rand_core 0.10.1) broke the unpinned 1.77 re-resolve and fail-closed the whole Windows build; existing legacy artifacts preserved at 0.6.76. Do NOT re-enable blindly — 2008R2 support returns via a C++/.NET or clean-rewrite legacy agent (Mike, required).
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
- [Howard-Home LAN shadow (RESOLVED)](howard-home-lan-shadow.md) — Howard-Home renumbered 2026-06-16 to **10.137.42.0/24** (gw 10.137.42.1, UniFi — NOT pfSense), off the old 192.168.0.0/24 that shadowed Cascades pfSense .0.x over the VPN. Cascades .0.x should now route via the tunnel; this machine is 10.137.42.x now (not 192.168.0.x).
- [Cascades CARF tech plan](project_cascades_carf_tech_plan.md) — Ashley's "technology plan" is a CARF accreditation deliverable (Aging Services Technology and System Plan standard); must use CARF action-plan structure (owner/cost/target+completion dates per area), persons-served assistive-tech lens, annual-review sign-off; it's Cascades' leadership-adopted plan, ACG supplies content.
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
- [Cascades isolated-VLAN pattern](project_cascades_isolated_vlan_pattern.md) — pfSense: the GUEST VLAN (VLAN50/igc1.50) is the isolation template (4 any-proto quick rules: block 192.168.0.0/22 + 10.0.0.0/8 + 172.16.0.0/12, then pass any; public DNS via DHCP). VLAN20 is NOT isolated. Verify with `pfctl -sr`, not config.xml. Protocol MUST be Any (TCP-only leaks UDP). VOICE VLAN30 built to this 2026-06-17.
- [Cascades VLAN20 migration + routing](project_cascades_vlan20_migration_routing.md) — Staff machines/printers moving to VLAN20 (10.0.20.0/24). CS-SERVER couldn't reach VLAN20 printers because the LAN "allow LAN to any" rule policy-routes via WAN_Group → add a top LAN pass rule (src CS-SERVER 192.168.2.248, dst 10.0.20.0/24, gw=default) to bypass. pfSense SSH from VPN is blocked (do firewall in GUI). Printer client-map via GPO or SYSTEM `printui /ga` to dodge the 0x800702e4 PrintNightmare prompt; build UNC with [char]92.
- [Cascades KPI dashboard (parked)](project_cascades_kpi_dashboard.md) — Ashley Jensen wants one dashboard across their reporting SaaS (ALIS/QuickBooks/Bill.com/Relias/You've Got Leads/TELS/Focus HR/Helpany/POS). Power BI Gateway is the WRONG frame (on-prem only). Recommended Tier1→Tier2: scheduled exports → SharePoint → Power BI Pro, automate API-capable systems (Bill.com/QBO) via Power Automate later. Full notes: `clients/cascades-tucson/docs/proposals/kpi-dashboard.md`. Next: draft client one-pager.
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
- [AMPIPIT toolkit](project_ampipit.md) — Howard's ACTIVE Rust+Slint Windows deploy/recovery toolkit (not just a reference program), migrated into the fleet as a private Gitea submodule at projects/msp-tools/ampipit (azcomputerguru/ampipit). Active feature: recovery partition with GuruRMM agent + built-in repair scripts for offline-Windows repair (spike GO-WITH-WORK). Preserve all work; PRIVATE Gitea only, no public GitHub.
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
- [Defender: exempt all Claude activity](feedback_defender_claude_exclusions.md) — Mike wants NOTHING Claude/ClaudeTools issues flagged by Defender; ClickFix threat IDs 2147939088/2147945138 (RMM curl dispatch AMSI FPs) set to Allow + broad process/path exclusions. ThreatID-Allow is the lever; exclusions alone don't stop AMSI CmdLine detections.
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
- [VoIP vendor stack: PacketDial / OIT / NetSapiens / YMCS](reference_packetdial_oit_netsapiens.md) — PacketDial = ACG's VoIP-dept brand (pbx.packetdial.com, the `packetdial` skill); NetSapiens = the PBX platform (API v2); OIT/OITVOIP = white-label wholesaler running NetSapiens (api.ucaasnetwork.com); reseller `91912.service`. YMCS (Yealink) = phone device-mgmt, pairs with the PBX.
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; now wrapped by the /screenconnect skill. Verified surface: GetSessionsByName/GetSessionDetails + writes SendCommand/SendMessage/UpdateCustomProperties + parameterized self-tagging installer. Still NO full-fleet inventory method (GetSessions missing).
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
- [Check for client-slug fragmentation](feedback_client_slug_fragmentation.md) — Before concluding a client has no records, grep broadly (company/owner/initials/hostname/"Last, First") across clients/, wiki/, session-logs/, vault — one client gets split across slug variants (Wolkin was 4: wolkin/wolkin-law/rswolkin/robert-wolkin). Consolidate to one canonical slug; action prior logs' Pending items.
- [RMM user_session = false SMB failures](feedback_rmm_user_session_smb_false_negative.md) — GuruRMM net use/net view/Add-Printer to a remote \HOST fail with error 67 / RPC 1702 (even with valid creds) because user_session is a WTS-impersonated non-interactive token that can't do authenticated SMB. The share/printer may work fine interactively. Treat RMM SMB results as "can't tell"; verify via ScreenConnect.
- [Prefer SSH over RMM](feedback_prefer_ssh_over_rmm.md) — when a target has SSH (key auth) and it's easier, drive it via system OpenSSH (scp+ssh) instead of the GuruRMM agent; RMM runs as SYSTEM + is bound by the server-side timeout reaper + forces base64/quoting gymnastics. Reserve RMM as the fallback when SSH/VPN is down.
- [Broken [[backlinks]] = write-me-later markers](feedback_broken_backlinks_are_writeme_markers.md) — A [[name]] with no matching file is an intentional "worth writing" marker, not breakage. Flesh the missing memory out from session history/logs and index it; never strip the link to silence the warning. memory-dream reports these as INFO candidates, not errors.
- [gururmm session-logs are in a submodule](gururmm-session-logs-submodule-save.md) — commit in the submodule + `git push origin HEAD:main` (GURU-5070 CAN push over HTTP now); then advance the parent gitlink
- [Use `python` not `python3` on GURU-5070](python3-shim-use-python.md) — `python3` in Git bash hits the flaky MS Store shim; real interpreters are `python` (3.12) / `py` (3.14). coord.py + wiki-compile work via `python`; the coord lock IS claimable here
- [Beast = primary GuruRMM Windows build host](gururmm-beast-windows-build-host.md) — GURU-BEAST-ROG (i9), reached from .30 via Tailscale-on-.30 at 100.101.122.4 as guru; Pluto is the fallback (`attempt_build beast || attempt_build pluto`). WiX must be 4.x (v6+ = OSMF); Beast NuGet needed nuget.org added
- [GuruRMM command_type gotcha](reference_gururmm_command_type.md) — only shell/powershell/python/script/claude_task (+cmd alias); unknown type silently dropped, looks like a black-hole
- [GuruRMM log analysis -> Claude Haiku](gururmm-log-analysis-claude-cutover.md) — cut over from Ollama-on-Beast (timed out on fleet-sized prompts; "unreachable" was a mislabeled 120s timeout) to Anthropic API Haiku 4.5 w/ structured outputs; key at vault `projects/gururmm/anthropic-api`; ZDR pending; deploy needs root on .30 (.env + restart)
- [IX WHM API access = 'ClaudeTools' token, not password](ix-whm-dns-api-access.md) — IX cPanel/WHM (ix.azcomputerguru.com:2087) DNS + all API work uses the FULL-ACCESS-root WHM API token at vault `infrastructure/ix-server` `credentials.whm-api-token` via header `Authorization: whm root:<token>` (force curl -4). Password basic-auth on legacy json-api now 403s. Public NS ns1/ns2.acghosting.com = 52.52.94.202.
- [Vault EVERY credential surfaced in-session](feedback-vault-every-credential.md) — any cred (pasted/created/discovered) -> store via the vault skill + document purpose & exact usage immediately; it's a standing job rule (reinforced in CORE CLAUDE.md). Lost IX creds wasted ~1h on 2026-06-12.
- [GuruRMM install-report v1: reuse endpoint + failed-install agent](gururmm-install-report-failed-agent-v1.md) — legacy NSIS installer reuses /api/install-report (machine info + logs, success+fail); server upserts a visible "failed-install" device on failure reports (Mike: in v1); verify-connect-before-success; trend/near-fail analytics. Server side is a separate sequential SPEC after the legacy-agent branch lands.
- [DM wrapping commands to Mike in Discord](feedback_dm_wrapping_commands_to_mike.md) — long/wrapping output (commands, consent links, URLs) goes via Discord DM (copies clean), not just chat; use the `discord-dm` skill (`discord-dm.sh mike "<x>"`). Gotchas: bot token vault projects/discord-bot/bot-token, Mike uid 264814939619721216, MUST set User-Agent or Cloudflare 403 errcode 1010, build JSON via jq + printf|--data-binary (direct -d → 50109).
- [Physical access codes -> vault + wiki pointer](feedback_physical_access_codes.md) — alarm/lockbox/door codes go in vault clients/<slug>/physical-access-<location>.sops.yaml (kind: physical-access) + a `## Physical Access` pointer section in the client wiki; never plaintext. First entry: Peaceful Spirit NW.
- [CT Thoughts backlog](feedback_ct_thoughts_backlog.md) — ClaudeTools harness ideas go in docs/CT_THOUGHTS.md (trigger "ct thought:"); CT analogue of RMM_THOUGHTS. Don't build until explicit go. First entry = ClaudeTools 3.0 web co-work vision.
- [AI-auth product boundary](project_ai_auth_product_boundary.md) — ClaudeTools/ClaudeTools 3.0 = internal-only, per-person subscription OAuth ok; GuruRMM = sellable, customer brings own API key (never ACG's subscription); backend dev = internal. Anthropic ToS bans subscription auth in third-party products.
- [RMM SYSTEM context can't see user mapped drives](feedback_rmm_system_context_mapped_drives.md) — RMM runs as SYSTEM; `Test-Path F:\` etc. is False even when the user's mapped/redirected drive exists. Diagnose mapped-drive/redirect issues in `context:user_session`. Elevated apps (e.g. QB DB Server Manager "unable to retrieve root folder") need `EnableLinkedConnections=1` + reboot.
- [AD2 = Dataforth-ops fork](project_ad2_dataforth_fork.md) — branch ad2 = main + thin Dataforth layer; keep fork edits ADDITIVE (Dataforth context in clients/dataforth/CLAUDE.dataforth.md, NOT .claude/CLAUDE.md); rebase onto main directly when sync.sh self-lock hits; no vault/jq/sops/age on this box.
- [GuruScan verification IN TEST / paused](project_guruscan_in_test_paused.md) — multi-engine scanner verify on DESKTOP-MS42HNC paused 2026-06-22 (VM rebooted mid-Emsisoft run); HitmanPro done (36 removed), Emsisoft full-scan unverified; resume `guruscan-agent-test.sh DESKTOP-MS42HNC scan-one Emsisoft`; Defender RTP/Tamper still off on VM
- [GuruRMM fleet dispatch-hang fix](project_gururmm_dispatch_hang_fix.md) — blocking send_to on a full bounded channel to one black-holed agent wedged ALL command dispatch; fixed with try_send (9dae20c, deployed); proper black-hole eviction still missing (was reverted in 80df458) — finish it if it recurs
- [Windows won't-boot / offline DISM repair playbook](windows-offline-dism-repair-gotchas.md) — Automatic Repair loop = boot-critical fault (disk/registry/wedged update), NOT shell/appx store corruption (that's a symptom); `FaultyPackageInProgress` + 100s of Install/Uninstall-Pending packages = wedged CU -> RevertPendingActions or clean install. Offline DISM rejects `wim:` source (0x800f082e) -> MOUNT the wim, source `\Windows`. Ventoy breaks WIM mount (0xc1420134) -> use Rufus. 25H2(26200)=24H2(26100)+enablement, so match 26100 media. First hit: Four Paws AvImark #32447.
- [365 app suite — authoritative map + consent-drift fix](reference_365_app_suite.md) — full map in `.claude/skills/remediation-tool/references/app-suite.md`; per-tenant consent is NOT uniform (VWP had the app but no SharePoint role). Run `consent-audit.sh <tenant|--all>` to detect gaps; fix via adminconsent URL or direct appRoleAssignment grant.
- [Remediation-tool has full M365 access (incl. SharePoint)](reference_remediation_tool_365_access.md) — the app suite covers Graph/EXO/Defender/SharePoint; don't declare "no access" on an accessDenied. SharePoint app-only needs a CERT (secret = "Unsupported app only token"); use get-token.sh `sharepoint`/`sharepoint-admin` tiers + CSOM admin API (Graph /admin/sharepoint/settings scope not held). Full map: skill references/app-permissions-and-sharepoint.md.
- [AV migration: Bitdefender -> Datto EDR](project_av_migration_bitdefender_to_edr.md) — retire Bitdefender fleet-wide, ONLY exception Glaztech (Dataforth migrates); end-state per machine = GuruRMM + Datto EDR
- [RMM deploy via ScreenConnect](reference_rmm_deploy_via_screenconnect.md) — push GuruRMM agent to client workstations via SC send-command (SYSTEM), not DC remote-exec (DCOM/schtasks blocked on Win11 clients)
- [ScreenConnect custom-property slots](reference_screenconnect_custom_property_slots.md) — CP1=Company CP2=Site CP3=Department CP4=Device Type CP8=Tag (API hides labels; UpdateSessionCustomProperties replaces the whole array)
- [ScreenConnect cleanup uses wiki as source](feedback_screenconnect_cleanup_wiki_source.md) — per-client SC/RMM metadata cleanup pulls machine->dept/location from the client wiki; enrich the wiki when missing
- [TECH03L systemprofile shortcut corruption](project_tech03l_systemprofile_shortcut_corruption.md) — Auto-Claude "opens then closes" = .lnk pointing at nonexistent systemprofile path; repoint, do not debug the app
- [Claude tui fullscreen crash](feedback_claude_tui_fullscreen_crash.md) — "flicker-free" prompt writes tui=fullscreen; instant-exit crash loop on some Windows terminals; set tui=default (reinstall does NOT fix)
- [Backup targets never guessed](feedback_backup_targets_never_guessed.md) — billed-vs-running mismatches are billing questions for Mike/Winter; never infer/deploy backup targets
- [Fire #dev-alerts for client/RMM work](feedback_fire_dev_alerts_for_client_work.md) — Howard+Mike watch #dev-alerts Discord for live visibility into what techs/sessions touch. Fire post-bot-alert.sh ([DEV]/[RMM]/[DEPLOY]/[GURURMM] prefix -> #dev-alerts) at the START of any RMM/Dev or active-client-machine action. No CLAUDE.md rule yet — this memory is the rule.
- [M365 app: SharePoint needs CERT](reference_m365_app_sharepoint_rest_vs_graph.md) — SP app-only works via the CERT (get-token.sh <tenant> sharepoint); the secret gives "Unsupported app only token". Graph app-only uses the secret.
- [EDR auto-isolates GuruRMM PowerShell](project_edr_rmm_autoisolation_fp.md) — Datto "Exfiltration Over HTTP" rule auto-isolates benign GuruRMM scripts fleet-wide (vwp-qbs 4h outage); watcher + narrow suppression deployed, policy fix pending Mike
- [Background tasks — no shell `&`](feedback_background_task_no_ampersand.md) — with run_in_background:true, run the command in the foreground of the shell; adding `&`/`disown` forks + exits 0 instantly, orphaning a blocking wait so it never delivers (hit twice building ask-forum)
- [ask-forum — human-in-the-loop via #ct-forum](../skills/ask-forum/SKILL.md) — ask a teammate a question in the private ct-forum Discord forum, get their human answer back in-session; forum-only, any human can answer, run the --wait as a proper background task

View File

@@ -0,0 +1,26 @@
---
name: Background tasks — run_in_background only, never add a shell `&`
description: When launching a long-running command as a background task (run_in_background:true), do NOT also append a shell `&`/`disown`/`nohup`. The shell forks the command and exits 0 immediately, orphaning it — a blocking wait then never delivers its result. Run it in the FOREGROUND of that shell; the harness does the backgrounding.
type: feedback
---
When you want a long-running command (e.g. `ask-forum.sh --wait`, a poll loop, a blocking
watcher) to run in the background, use the Bash tool's **`run_in_background: true`** and run
the command in the **foreground of that shell** — a bare `bash script.sh ...` with **no
trailing `&`, no `disown`, no `nohup`**.
**Why:** appending `&` forks the command off inside the shell; the shell then reaches the
end and exits **0 immediately**. The harness sees exit 0 and reports the task "completed"
right away, while the forked process is orphaned (and often killed). A blocking `--wait`
that was supposed to sit for minutes and deliver an answer instead returns nothing — its
result is never captured or notified.
**How to apply:**
- Background wait, CORRECT: `Bash(command="bash .claude/scripts/ask-forum.sh --wait <id> --timeout 1800", run_in_background=true)` — the wait truly blocks server-side, costs zero tokens while pending, and the harness pings you the instant it completes.
- WRONG: `Bash(command="bash ... --wait ... &", run_in_background=true)` and `... & disown` — orphans the wait, "completes" instantly, no answer.
- The two mechanisms are redundant: `run_in_background` IS the backgrounding. Never stack a shell `&` on top of it.
**Incident:** hit TWICE in one session (2026-07-08) building the [[ask-forum]] flow — each
time the "background wait" for a Discord reply exited 0 instantly and never delivered the
answer, which looked like the reply was lost and forced the user to manually prompt "did
they answer?". Logged as `--friction` in `errorlog.md`. Related: [[ask-forum]].

View File

@@ -0,0 +1,20 @@
---
name: feedback_backup_check_only_billed
description: Only verify backup status for clients that actually bill a backup line - don't scan non-billed clients
metadata:
type: feedback
---
Backup verification is scoped to clients **marked for backups** — i.e. those with a billed
Data Backup line (Syncro recurring-schedule line qty > 0). If a client is NOT billed for
backup, do NOT scan their machines / MSP360 / backends for backup status. (Howard,
2026-07-07, GPS audit — Marty Ryan has Svc = A E only, no backup line, so stop checking.)
**Why:** "is X backing up" only matters where the client pays for it; scanning non-billed
clients burns time/endpoint calls and produces findings nobody asked for.
**How to apply:** determine billed-backup status first (Syncro backup line qty — see the
`/syncro schedules`/`schedule` pull, authoritative), then only verify the ones with a seat.
Non-billed clients that happen to be backing up are a separate "revenue leak" question for
Mike/Winter, not a per-machine scan target. Related: [[feedback_backup_targets_never_guessed]],
[[reference_msp360_backup_monitoring]].

View File

@@ -0,0 +1,21 @@
---
name: feedback_backup_targets_never_guessed
description: Backup targets are a deliberate per-client decision - never infer which machine needs backup or deploy backup jobs from billing counts
metadata:
type: feedback
---
When reconciling billed backup lines (e.g. Syncro "Service - Data Backup" qty) against
machines actually backing up (MSP360/B2), a mismatch is a BILLING question for Mike/Winter,
never a deployment instruction. (Howard, 2026-07-05, GPS->RMM audit backup verification.)
**Why:** which machine gets backed up is a deliberate choice made per client (usually the
server or a specific data machine) — billed qty does not mean "N machines each need a
backup job." Guessing targets and adding backup jobs would burn storage and misrepresent
what the client agreed to.
**How to apply:** report mismatches (billed > running, running > billed, dead buckets) as
findings with evidence; route the decision to Mike/Winter/client. Only configure a backup
job when the target machine is explicitly named by the user/client. Related:
[[project_av_migration_bitdefender_to_edr]] (the AV migration IS deploy-everywhere, unlike
backup — don't conflate the two postures).

View File

@@ -0,0 +1,22 @@
---
name: claude-tui-fullscreen-crash
description: Claude Code "flicker-free rendering" prompt writes tui=fullscreen to settings.json and can crash-loop the CLI on Windows; fix = delete the tui key
metadata:
type: feedback
---
On ACG-TECH03L (2026-07-04, Claude Code 2.1.201), accepting the startup prompt asking to
enable **flicker-free rendering** wrote `"tui": "fullscreen"` into
`~/.claude/settings.json`. On that machine's terminal the fullscreen/alternate-screen
mode crashed the CLI instantly on every subsequent launch ("opens and then closes") —
from any launch path, since all read the same settings file.
**Why:** the setting persists, so one bad answer to the prompt turns into a permanent
crash loop; reinstalling Claude Code does NOT fix it because settings survive reinstall.
**How to apply:** if a fleet machine's Claude Code exits immediately at launch, check
`~/.claude/settings.json` for `"tui": "fullscreen"` and set it to `"tui": "default"`
(surgically — keep hooks/plugins; explicit "default" also suppresses re-prompting,
which deleting the key does not). Emergency override without touching config:
`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`. Decline the flicker-free prompt on machines
with older terminals/conhost. Related: [[tech03l-systemprofile-shortcut-corruption]].

View File

@@ -0,0 +1,26 @@
---
name: feedback_discord_read_replies
description: Discord DM replies are invisible to coord/repo sync — use `discord-dm.sh read <user>` to see if mike/winter/rob actually answered.
metadata:
type: feedback
---
When we DM someone via the `discord-dm` skill and wait on an answer, their reply does NOT
come through coord messages or repo sync — those channels never carry Discord replies. Before
telling the user "no reply yet / they didn't answer," READ the DM channel:
```bash
bash .claude/scripts/discord-dm.sh read mike 10 # last 10 in the mike DM
bash .claude/scripts/discord-dm.sh read #dev-alerts # a channel
```
Output is oldest-first; lines marked `>>>` are from THEM (replies), indented lines are us.
**Why:** the bot participates in every DM channel it opened, so `GET /channels/<id>/messages`
returns full content — the Message Content privileged intent only gates gateway events, not this
REST fetch. Before adding `read` (2026-07-07, Howard flagged it) the wrapper was send-only, so
replies from mike/winter were silently missed.
**How to apply:** after DMing a question to mike/winter/rob, `read` that user to check for their
answer instead of assuming silence. Related: [[reference_community_forum]] is a different channel;
this is Discord DMs/channels via [[discord-dm]] (`.claude/scripts/discord-dm.sh`).

View File

@@ -0,0 +1,29 @@
---
name: feedback_fire_dev_alerts_for_client_work
description: Fire a #dev-alerts Discord post (post-bot-alert.sh with an [RMM]/[DEV]/[DEPLOY]/[BUILD]/[GURURMM] prefix) at the START of any RMM/Dev or active-client-machine action — Howard+Mike watch that channel for live visibility into what techs/sessions are touching
metadata:
type: feedback
---
Howard and Mike watch the **#dev-alerts** Discord channel to see, in real time, what the other techs
(and Claude sessions) are working on. Any substantive action on **RMM/Dev** or an **ACTIVE CLIENT
machine** MUST post a #dev-alerts heads-up — ideally at the START of the work, not just after.
**What counts:** SSH into a client server, restarting/reconfiguring services, editing boot/config on a
live box, RMM remote commands, agent/server/dashboard deploys, channel promotions, anything with real
blast radius on production/client infra.
**Why:** without it a session can be hand-editing a live client box (e.g. Lonestar Electrical's Unraid
`/boot/config/go`, cycling libvirt) with ZERO visibility to the team — exactly what happened
2026-07-06, which Mike flagged: "all of the actions you've taken today should have fired the discord
alerts."
**How to apply:**
`bash .claude/scripts/post-bot-alert.sh "[DEV] <client/box> — <what + why>. — Claude @ <machine> (<user>)"`
Prefixes `[RMM] [DEPLOY] [DEV] [BUILD] [GURURMM] [SMARTBADGE-WATCH]` auto-route to the PRIVATE
#dev-alerts (Howard+Mike only, channel 1509998508198068484); everything else → #bot-alerts (whole
team). It soft-fails (best-effort), so it never blocks the work. Channel IDs + routing live in
`.claude/scripts/post-bot-alert.sh`.
As of 2026-07-06 there is NO CLAUDE.md rule enforcing this — treat this memory AS the rule until a core
rule/hook lands (worth proposing one). Related: [[discord-dm]] skill for direct DMs.

View File

@@ -0,0 +1,22 @@
# Git Bash `TZ=` is silently ignored on Windows — never use it for current-time reporting
**Date:** 2026-07-09
**Source:** Winter correction, Len's Auto browser-hijack thread (Discord bot session)
## What happened
Reported "current time" to Winter as `4:40 PM AZ` using
`TZ="America/Phoenix" date '+... AZ'` in Git Bash on BEAST. MSYS/Git-Bash has no
tzdata for that zone name, so `TZ` was silently ignored and the command printed
**UTC** (16:40) — which the format string then labeled "AZ". Real local time was
9:40 AM AZ. Winter caught it; caused a confusing side-quest about whether BEAST's
clock was a month off (it wasn't — clock + NTP + timezone all correct).
## Rule
- For current date/time on a Windows machine: use **PowerShell `Get-Date`**
(system local time — BEAST is already set to Arizona), or `date` in bash with
NO TZ override (MSYS date honors the Windows local zone), or an external HTTP
`Date:` header for authoritative UTC.
- Never trust `TZ=<IANA zone>` in Git Bash — unknown zones fall back to UTC with
no error, and a hardcoded zone label in the format string turns that into a
confidently-wrong answer.
- Calendar math (`date -d 2026-06-05 +%A`) is unaffected — that stays fine.

View File

@@ -0,0 +1,29 @@
---
name: feedback_scheduled_task_no_console_flash
description: Windows scheduled tasks must launch console apps windowless (wscript VBS for bash, pythonw + -Hidden for python) or they flash a console window on the desktop every run
metadata:
type: feedback
---
A Windows scheduled task whose action runs a **console-subsystem** program — `bash.exe`,
`py.exe`, `python.exe`, `cmd.exe` — with `LogonType=Interactive` and `Settings.Hidden=False`
draws a visible console window on the desktop **every time it fires**. On short repetition
intervals (the EDR watcher fires every 10 min) this reads to the user as a command prompt
"opening and closing" constantly. It is the single most reported harness annoyance and it
keeps recurring because the *installer scripts* recreate the flashing task.
**Why:** wscript.exe is a GUI-subsystem host and pythonw.exe is the windowless Python host;
neither allocates a console. bash.exe / py.exe / python.exe do.
**How to apply:** whenever you register (or find) a ClaudeTools scheduled task:
- **bash target** -> point the action at `C:\Windows\System32\wscript.exe` with a one-line VBS
wrapper that does `CreateObject("WScript.Shell").Run "...bash.exe -lc ...", 0, False`
(window style `0` = hidden). Pattern files: `gps-rmm-progress-hidden.vbs`,
`edr-isolation-watch-hidden.vbs`.
- **python target** -> use `pythonw.exe` (next to the active interpreter), never `py.exe`/`python.exe`.
- **Always** add `-Hidden` to `New-ScheduledTaskSettingsSet` as belt-and-suspenders.
- Verify: `Start-ScheduledTask`, then confirm no visible bash/wscript/conhost MainWindow.
Fixed installers: `register-edr-watcher.ps1`, `register-orphan-detector.ps1`. The scheduled
tasks themselves are per-machine (not in the repo) — re-run the fixed installer, or repoint
the existing task's action, on every box that runs it. Related: [[feedback_session_recovery]].

View File

@@ -0,0 +1,20 @@
---
name: feedback_simplest_solution_first
description: Always try the simplest solution to a problem first; escalate to complex only as needed
metadata:
type: feedback
---
Always START with the simplest solution that could solve the problem. Confirm it works, then
graduate to more advanced/complicated approaches only as the simple one proves insufficient.
**Why:** Mike watched a "ask the UDM what IP it thinks VWP-QBS is" turn balloon into vault
reads, plink host-key pinning, and password sanity checks — when the clean answer was a single
`nslookup vwp-qbs.vwp.us 172.16.9.1` from a machine already on the VPN. The heavy path burned
tokens and his patience ("incapable of doing things cleanly like a week ago"). Now a standing
CLAUDE.md core rule.
**How to apply:** Before picking tooling, ask "what is the least-complex thing that answers
this?" — one DNS lookup, one API call, one command. Do that first and observe the result.
Only reach for SSH/plink/double-hops/multi-step orchestration when the simple probe genuinely
can't answer. Escalate deliberately, not by default. See [[feedback_verify_live_before_acting]].

View File

@@ -1,24 +1,32 @@
---
name: project_av_migration_bitdefender_to_edr
description: AV strategy — migrate all clients from Bitdefender to Datto EDR, except Glaztech and Dataforth
description: AV strategy — migrate all clients from Bitdefender to Datto EDR; ONLY exception is Glaztech
metadata:
type: project
---
Standing AV direction (set by Howard 2026-07-03): ACG is moving endpoint AV/security
from **Bitdefender GravityZone -> Datto EDR** for **all clients EXCEPT Glaztech Industries
and Dataforth Corp** (those two stay on Bitdefender / handled separately).
Standing AV direction (Howard 2026-07-03, scope narrowed 2026-07-04): ACG is moving
endpoint AV/security from **Bitdefender GravityZone -> Datto EDR** for **all clients
EXCEPT Glaz-Tech Industries (glaztech)** — the ONLY client staying on Bitdefender.
**Dataforth migrates fully** (originally excepted; Howard removed the exception
2026-07-04 — Dataforth already runs 51 EDR agents with only 5 BD endpoints left:
D1-ENGI-006, DESKTOP-L2LE31M, DATAFORTH-PC, SURFACEOPS, MING-HP).
**Why:** consolidate on Datto EDR as the security plane; Bitdefender is being retired
fleet-wide (Glaztech + Dataforth are the two exceptions — both have large established
Bitdefender footprints: Glaztech ~242 endpoints, Dataforth managed separately).
fleet-wide. Glaztech keeps its large established Bitdefender footprint (~242 BD
endpoint records, vs 159 GPS-billed — count includes stale ghosts).
**How to apply:** whenever setting up or reconciling a client's endpoints (e.g. the
GPS->GuruRMM coverage audit), the target end-state per machine is: GuruRMM agent +
Datto EDR agent, and Bitdefender **removed**. Do NOT deploy new Bitdefender coverage.
Use existing Bitdefender inventory only as a discovery source for which machines exist
(its company names carry the Syncro CID `_NNNNN`, handy for mapping). Deploy Datto EDR
via `[[datto-edr]]` (create-group -> mint-key -> deploy-cmd, pushed through `/rmm`).
Datto EDR agent (AV on), and Bitdefender **removed**. Do NOT deploy new Bitdefender
coverage. Use existing Bitdefender inventory only as a discovery source for which
machines exist (its company names carry the Syncro CID `_NNNNN` suffix — exact join
key to Syncro customers). Deploy Datto EDR via `[[datto-edr]]` (create-group ->
mint-key -> deploy-cmd, pushed through `/rmm`).
Related: GPS->RMM audit tracker `projects/gps-rmm-audit/tracker.md`. Exceptions = Glaztech +
Dataforth (leave their existing AV alone; do not migrate them to EDR in this effort).
Migration scope quantified 2026-07-04 (tracker Phase 4): 27 clients / 141 BD
endpoints + Dataforth's 5. Datto EDR "Default RMM Org" holds ~35 unassigned agents
that belong to real clients (IMC x7, Russo x2, Len's, Rednour, Reliant, etc.) —
attribute them to proper orgs as part of the migration.
Related: GPS->RMM audit tracker `projects/gps-rmm-audit/tracker.md`.

View File

@@ -0,0 +1,28 @@
---
name: project_edr_rmm_autoisolation_fp
description: Datto EDR "Exfiltration Over HTTP" rule auto-isolates benign GuruRMM PowerShell fleet-wide; watcher + narrow suppression deployed, policy fix pending Mike
metadata:
type: project
---
Incident 2026-07-07: **vwp-qbs** (Valley Wide Plastering QuickBooks server) was auto-isolated by Datto EDR — Mike lost ~4h before realizing EDR did it. No notification fired (EDR alert delivery was never wired to any channel).
**Root cause:** Datto EDR rule **"Exfiltration Over HTTP Protocol" (MITRE T1041, HIGH)** carries an automated response of **`kill-process` + `isolate-host`**. It fires on *signed* PowerShell doing an outbound HTTPS POST — which our **GuruRMM automation** does routinely. Process tree on the alerts: `gururmm-agent.exe -> cmd.exe -> powershell.exe`. Confirmed benign both times (vwp-qbs = a vSphere VM-power-on script; tps-tina = a network/connectivity diagnostic).
**This is SYSTEMIC, not a one-off.** The same rule fired + attempted auto-isolate on **tps-tina** (The Prairie Schooner) the same day — different GuruRMM script, different client. Per-command-line suppression is whack-a-mole. Note: the isolate-host response only actually cuts a host off where its endpoint policy has isolation enabled (vwp-qbs got isolated; tps-tina fired the same alert but `extensionSuccess=None`, stayed online).
**FLEET-WIDE FIX LIVE (2026-07-07):** Datto EDR suppression rule **`3365e79a`** "GuruRMM origin - Exfil-over-HTTP (fleet-wide FP)" — matches Rule Name = Exfiltration Over HTTP Protocol + Grand Parent = `gururmm-agent.exe`, org/loc empty = ALL clients. Suppresses this one rule for any GuruRMM-launched process fleet-wide (present + future scripts); every other rule still fires on RMM-origin. Created in the CONSOLE (by Howard) after the API create path proved unsafe. The two earlier narrow per-command-line rules (`e4dd55bf`, `7ea2a577`) are now redundant subsets — harmless, left in place.
**API FOOTGUN (do NOT create suppressions via `raw POST` on this tenant):** `POST /SuppressionRules` forces `active:true` and auto-creates an EMPTY match-everything version → a live "suppress all" window (I hit this with `7c36b89f`, deleted within ~2 min). `GET`/`PATCH /SuppressionRules/{id}` 400 ("undefined is not valid JSON"); `DELETE /{id}` works. Create suppressions in the CONSOLE until an atomic API create is verified. Details in `.claude/skills/datto-edr/references/api-reference.md`.
**What's deployed (done):**
- Narrow suppression rule (Datto EDR, id `e4dd55bf`) for VWP's exact script: matches Process Command Line + Grand Parent = `gururmm-agent.exe`. Does NOT blind other PowerShell. API schema for suppression is now in `.claude/skills/datto-edr/references/api-reference.md`.
- **EDR isolation watcher**: `.claude/scripts/edr-isolation-watch.sh` + `register-edr-watcher.ps1`, registered as Windows Scheduled Task **"ClaudeTools - EDR Isolation Watcher"** on Howard-Home (runs every 10 min, posts new auto-isolations to #dev-alerts — see [[feedback_fire_dev_alerts_for_client_work]]). Plain script, zero tokens. State file `.edr-watch-state.json` (gitignored) dedupes; seeded with the vwp-qbs + tps-tina event ids so it stays silent for the already-triaged ones. Retire when GuruRMM EDR webhook (Feature 6) lands.
**Pending Mike decisions (do NOT act without him):**
1. Auto-isolate vs **alert-only** on behavioral rules — the real fix (the isolation, not the alert, caused the outage).
2. Whether to allow a small curated "GuruRMM-origin + this-rule OK" exception set — but NOT a blanket `gururmm-agent.exe` whitelist (RMM = top MSP attack path; blinding EDR there is dangerous).
3. **Fix the RMM scripts** to stop looking like exfil (drop `-EncodedCommand`, least-priv accounts).
4. Hygiene: VWP's script had **ESXi root creds hardcoded** (base64, cert-validation disabled) — already in vault `clients/vwp/esxi.sops.yaml`; move to a least-priv service account + vault injection.
See [[reference_datto_edr_detection_behavior]] (behavioral rules DO fire on signed PowerShell, unlike reputation rules).

View File

@@ -0,0 +1,27 @@
---
name: project_gururmm_legacy_disabled
description: GuruRMM's legacy Rust 1.77 (Server 2008R2 / Win7) build variant is DISABLED as of 2026-07-04 — do not just re-enable it; 2008R2 support returns via a separate legacy agent (C++/.NET or clean rewrite)
metadata:
type: project
---
The GuruRMM Windows build's **legacy Rust 1.77 variant is disabled** (2026-07-04) via
`LEGACY_ENABLED=false` in `deploy/build-pipeline/build-windows.sh`.
**Why:** the legacy build moves `Cargo.lock` aside and re-resolves deps unpinned; crates.io now
serves edition2024 versions of transitive deps (`chacha20`/`rand_core` 0.10.1) that Rust 1.77
cannot parse ("feature `edition2024` is required"). This fail-closed the ENTIRE Windows build and
blocked modern (amd64/x86/tray) deploys. It is a **BUG-021 recurrence** and is NOT caused by any
agent code change — it reproduces at any base commit right now. This is at least the 3rd edition2024
break of the 1.77 build (the `agent/Cargo.toml` transitive-pin list is a monument to it).
**What the disable does:** skips the legacy wave/copy/deploy/symlink and EXCLUDES legacy from the
artifact cleanup, so the last-built legacy binaries (**0.6.76**) are PRESERVED and existing Server
2008R2 / Win7 endpoints stay supported *frozen at 0.6.76*. Modern agents advance normally.
**Mike's directive (2026-07-04):** "Kill the legacy version for now, but we will come back to it" —
2008R2 support is REQUIRED (a number are still in the wild) and will return via a **different
architecture (C++/.NET) or a clean rewrite for legacy**, NOT by flipping `LEGACY_ENABLED` back on
without first solving the 1.77 edition2024 pin problem. Do NOT re-enable it blindly. Tracked in
`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`. Related: the policy-controlled tray feature that
was shipping when this surfaced ([[project_guruconnect]] is a different project).

View File

@@ -0,0 +1,31 @@
---
name: project_gururmm_software_removal_pr58
description: SPEC-030 software-removal hardening (driver exclusion, lingering-process sweep, job list/reaper) is on PR #58 awaiting merge; live testing planned 2026-07-06 on DESKTOP-MS42HNC.
metadata:
type: project
---
GuruRMM SPEC-030 software removal (Assets -> Inventory tab): the 2026-06-24 session's open
requests were implemented 2026-07-05 and sit on branch `feat/software-removal-hardening`,
**PR #58** (https://git.azcomputerguru.com/azcomputerguru/gururmm/pulls/58), NOT merged —
merge deploys to beta. Live testing planned 2026-07-06 on the test box DESKTOP-MS42HNC
(agent 0de89b88-b21d-4647-ab64-96157ba87cc5).
What PR #58 contains:
- Engine `-List` emits `is_driver` (heuristic: driver/chipset/firmware name words, or hw-vendor
publisher + device words; junkware like "Driver Booster" deliberately excluded). Dashboard
hides drivers by default, keeps them out of select-all, deselects on re-hide.
- End-of-run lingering-uninstaller sweep in uninstall-engine.ps1: 45s budget-aware grace, then
taskkill of started trees (StartTime PID-reuse guard, same-session only, null CreationDate
excluded) + late re-verify that flips async-finisher false-failures to success. This is the
chosen resolution of the Launchy/AIMP "failed / no usable result" open decision.
- `GET /agents/:id/software/uninstall/jobs` (list, results omitted), stale-running-job reaper
(900s no-progress -> failed), `finish_job` guarded WHERE status='running', UI 100-target cap.
Verified pre-merge: engine live tests on Howard-Home (driver flags, sweep kill/no-kill,
orphaned/refused paths), heuristic unit cases, `verify.sh server --check` + `dashboard` PASS,
high-effort workflow code review with all confirmed findings fixed.
Remaining after this: recipe catalog (P3, [[project_gururmm_av_removal_recipes]] if created),
dashboard UI for the new job-list endpoint (client binding `softwareApi.uninstallJobs` exists,
unused), and TeamViewer retry-verify still not re-validated live.

View File

@@ -0,0 +1,32 @@
---
name: reference_client_wifi_inventory
description: Where client Wi-Fi networks are recorded so techs connect onsite without asking — vault holds passwords, wiki holds the readable index.
metadata:
type: reference
---
We are building a fleet-wide client Wi-Fi list so anyone onsite can connect without asking
for the SSID/password again. Established 2026-07-06 (Howard).
**Split (security-driven):**
- **Passwords/PSKs → SOPS vault**, `clients/<slug>/wifi.sops.yaml`, one file per client.
Per network use `credentials.<key>_ssid`, `credentials.<key>_password`, optional
`credentials.<key>_auth` (`<key>` = `staff`/`guest`/`voice`/`warehouse`...). Everything under
`credentials:` is encrypted at rest, so the entry is self-contained for an importer. Write it
with the `vault` skill's `vault-helper.sh new`/`set` (NEVER paste a Wi-Fi password into chat,
a ticket, a session log, or the wiki).
- **Readable index → wiki**, `wiki/reference/client-wifi.md` (registered in `wiki/index.md`
under a new **Reference** section). Client / SSID / band / location / vault-field — NO passwords.
It lives under `wiki/reference/` so `/wiki-compile` never clobbers it (compile only touches
clients/projects/systems slugs).
**Capture flow onsite:** ask "Wi-Fi name + password? staff vs guest? which should our machines
use?" → store in vault via vault-helper → add a row to the wiki inventory → `/sync`.
**Importer is DEFERRED** (Howard chose "structure only for now" 2026-07-06). Planned end state:
a `/wifi import <client>` that reads the vault file, builds a Windows WLAN profile per network
(`netsh wlan add profile`), and auto-connects. Interim: read the password with
`vault.sh get-field clients/<slug>/wifi credentials.<key>_password` and connect manually.
Build the importer/`/wifi` skill once a few real networks exist.
Keep `<slug>` identical to the client's `wiki/clients/<slug>.md` slug so vault + wiki + article line up.

View File

@@ -14,6 +14,8 @@ Verified live 2026-06-25/26 on RMM-TEST-MACHINE (EDR agent `b98b3ba0-...`, group
**Datto EDR is reputation-based, NOT structural.** A synthetic "looks suspicious" artifact (Run-key/scheduled task launching hidden `-EncodedCommand` powershell) is collected by the forensic scan but scored BENIGN → no alert (powershell.exe is signed/clean). To get an EDR detection you need a real reputation hit: wire a known-bad file as the **executable of an autostart** (Run-key/scheduled task) so the survey collects + hashes it. EICAR-as-autostart works → high-sev `rule` alert. A loose file on disk is NOT scanned by the EDR forensic survey (it only walks execution/persistence artifacts).
**BUT behavioral rules DO fire on signed PowerShell** (unlike the reputation scoring above). Rules like **"Exfiltration Over HTTP Protocol" (T1041)** key on *runtime behavior* (outbound HTTP from powershell), not file reputation, so they alert HIGH on clean signed `powershell.exe` — and can carry an automated `isolate-host`/`kill-process` response. This routinely false-positives on GuruRMM automation (`gururmm-agent.exe -> cmd.exe -> powershell`). See [[project_edr_rmm_autoisolation_fp]].
**AV-suppression gotchas (to isolate EDR on an endpoint):**
- Datto AV is **tamper-protected**: `Stop-Service EndpointProtectionService2 -Force` is refused ("cannot be stopped"); can't disable from the endpoint. Supported path = console policy (AV disabled / path-exclusion) — console-only, like policy assignment.
- Disabling Datto AV in the policy **uninstalls** the AV component on the box (services `EndpointProtectionService`/`...2` go absent; `HUNTAgent` EDR stays). Platform `dattoAvEnabled` flips to False at the console first; the on-box apply lags a few minutes.

View File

@@ -1,12 +1,24 @@
---
name: reference_guru5070_rust_toolchain
description: GURU-5070 has the full local Rust toolchain (cargo + MSVC + protoc) — build/clippy/test the guru-connect workspace LOCALLY instead of the build host; set PROTOC first
description: GURU-5070 has a full Rust toolchain installed BUT a WDAC/Smart App Control policy now blocks running rustc/cargo locally (os error 4551) — build the Windows agent on Beast, not here; toolchain paths + CI gates still valid for reference
metadata:
type: reference
---
As of 2026-05-30, GURU-5070 has the full Rust dev toolchain installed, so GuruConnect can be
built/linted/tested locally — **no more build-host (172.16.3.30) round-trips just for `cargo fmt`/clippy.**
**BLOCKED AS OF 2026-07-04 — do NOT rely on local Rust builds on GURU-5070.** A Windows
Application Control (WDAC / Smart App Control) policy now blocks execution of `rustc.exe`/`cargo.exe`
(and `sops.exe`, `openssl`, `gawk`, Python `cryptography`'s native DLL) — attempts fail with
`An Application Control policy has blocked this file. (os error 4551)`. This is the same policy behind
the "Windows can't confirm who published …" blocks on unsigned binaries. Until the policy is adjusted
to trust the toolchain, **compile Windows agent/GuruConnect changes on Beast** (`guru@100.101.122.4`,
Tailscale; sccache at `C:\sccache`) via a throwaway `git worktree` + `cargo check` — see
[[gururmm-beast-windows-build-host]]. The paths/gates below remain accurate for reference and for when
the policy is relaxed. WDAC impact also breaks the `vault`/`sops` path here (use `age`+Node fallback per
[[feedback_vault_gcm_shadow_auth]]) and silently swallowed error logging (`CLAUDETOOLS_ROOT=D:/claudetools`
wrong-case → `log-skill-error.sh` can't write; real repo is `D:/ClaudeTools`).
As of 2026-05-30, GURU-5070 has the full Rust dev toolchain installed (able to build locally *until the
2026-07-04 WDAC block above*):
- **cargo/rustc/clippy/rustfmt:** `C:\Users\guru\.cargo\bin\` (rustup; cargo 1.96, rustfmt 1.9, clippy 0.1.96).
- **MSVC C++ Build Tools:** VS2022 BuildTools (VCTools workload) — provides the `x86_64-pc-windows-msvc` linker.

View File

@@ -0,0 +1,38 @@
---
name: reference_m365_app_sharepoint_rest_vs_graph
description: ComputerGuru M365 app suite — SharePoint app-only WORKS but requires the CERT (client_assertion), not the secret. Secret => "Unsupported app only token". Use get-token.sh <tenant> sharepoint.
metadata:
type: reference
---
The ACG **Tenant Admin app** (`709e6eed-0711-4875-9c44-2d3518c47063`) has **full SharePoint app-only
access** — `Sites.FullControl.All` on the SharePoint resource — but it is gated by an auth-method rule
that keeps biting:
- **SharePoint app-only REQUIRES a CERTIFICATE (client_assertion). A `client_secret` token is rejected**
with **`Unsupported app only token`** on EVERY SharePoint endpoint (REST `/_api/...` and CSOM
`/_vti_bin/client.svc/ProcessQuery`). This is a SharePoint platform rule, not a missing grant.
- **Graph** app-only accepts the secret fine — so Graph SharePoint calls (driveItem `createdBy`/
`lastModifiedBy`/`retentionLabel`, item `/permissions` + inheritance, `/groups` + members) work with
the secret and are the right tool for *investigation*.
**Do NOT hand-roll a SharePoint token from the secret. Use the remediation-tool cert tiers:**
```bash
cd .claude/skills/remediation-tool
bash scripts/get-token.sh <tenant> sharepoint # content resource <name>.sharepoint.com (cert)
bash scripts/get-token.sh <tenant> sharepoint-admin # admin resource <name>-admin.sharepoint.com (cert)
```
`get-token.sh` forces cert for the `sharepoint*` tiers automatically; the minted token's `roles` =
`["Sites.FullControl.All"]`. The cert is vaulted in `msp-tools/computerguru-tenant-admin.sops.yaml`
(`cert_thumbprint_b64url` + `cert_private_key_pem_b64`). Tenant arg = the domain (`birthbiologic.com`).
**Use the CERT (SP REST/CSOM) for:** list settings (`ForceCheckout`, `EnableModeration`/content approval,
versioning), per-item `CheckoutUserId`/checkout state, re-stamping `Author`/`Editor` (Created By /
Modified By), site lock, tenant settings. Graph can't do these.
Reference (authoritative): `.claude/skills/remediation-tool/references/app-permissions-and-sharepoint.md`
and `app-suite.md`. **Before ever telling the user "the tool can't do X" on SharePoint, use the cert
tier and verify** — the "Unsupported app only token" wall means *wrong auth method (secret)*, NOT no access.
See [[birth-biologic]] (migrated content owned by "SharePoint App" => greyed-out Move; fix = re-stamp
Author/Editor via cert CSOM `SystemUpdate`). Open question (Mike, 2026-07-06): standardize ALL M365
app-only auth on cert (cert is resource-agnostic + more secure) to kill this secret-vs-cert friction.

View File

@@ -0,0 +1,30 @@
---
name: reference_msp360_backup_monitoring
description: MSP360 MBS /api/Monitoring is the authoritative "is this client backing up" source for the whole ACG fleet
metadata:
type: reference
---
For any "is client X actually backing up / when did it last run" question, the authoritative
source is the **MSP360 Managed Backup Service API**, not B2 bucket contents (buckets are just
where Cloud plans land; a bucket with files does not prove a recent backup ran).
- Creds: vault `msp-tools/msp360-api.sops.yaml` (API login+password, generated in MSP360
console Settings>General — not the console login).
- Auth: `POST https://api.mspbackups.com/api/Provider/Login` `{UserName, Password}` -> Bearer
`access_token`.
- `GET /api/Monitoring` -> every backup plan across all companies: `CompanyName`,
`ComputerName`, `PlanName`, `StorageType`, `LastStart`, `Status` (0/1=Success, 2=Warning,
3=Failed, 4=Running, 6=Interrupted, 7=completed-with-warnings), `DataCopied`, `TotalData`,
`ErrorMessage`. Also `GET /api/Companies` (37, with Destinations) and `GET /api/Users` (59).
`/api/Computers` is 404.
- Used 2026-07-05 for the GPS backup map (`projects/gps-rmm-audit/backup-map.md`): resolved 11
of 12 "no destination found" clients, surfaced 4 not-backing-up findings (AMT + T&C Sorensen
= 0 plans, Grabb GND-SERVER failing, Bill Tedards ~12mo stale). MSP360 CompanyName also IDs
B2 buckets: `ACG-PST`=Peaceful Spirit, `ACG-TCA`=Tucson Coin and Autograph.
- Backup targets are a per-client decision — report mismatches, don't deploy jobs. See
[[feedback_backup_targets_never_guessed]]. Related: [[reference_backblaze_storage_rate]].
- **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.

View File

@@ -0,0 +1,54 @@
---
name: reference_windows_edition_upgrade_rmm
description: Verified fleet procedure to upgrade a Windows machine Home->Pro/Pro-for-Workstations via RMM, with the three gotchas (DISM error 50, shutdown /t drop, PfW MAK auto-upgrade).
metadata:
type: reference
---
Verified on ACG-Tech03L 2026-07-06 (Home 26200 -> Win 11 Pro for Workstations, permanently activated).
**Procedure (all steps as SYSTEM via `/rmm`, byte-exact via -EncodedCommand):**
1. Probe: EditionID/LicenseStatus (`.rmm-key-probe.ps1` pattern), confirm no logged-in user
(`query user`) so a reboot is safe.
2. Flip edition: `changepk.exe /ProductKey VK7JG-NPHTM-C97JM-9MPGT-3V66T` (generic Pro key).
Flips EditionID Core->Professional **live** (registry) + sets CBS RebootPending. exit 0.
3. Reboot to finalize (see gotcha #2).
4. Install MAK + activate: `slmgr /ipk <MAK>` then `slmgr /ato`; verify `slmgr /xpr` =
"permanently activated" and WMI SoftwareLicensingProduct LicenseStatus=1.
**Gotcha 1 — DISM is a dead end for online edition change.** `DISM /online /Set-Edition:Professional`
returns **Error 50 "Setting an edition is not supported with online images"** even though
`/Get-TargetEditions` lists Professional. Use `changepk.exe`, not DISM, for online Home->Pro.
**Gotcha 2 — `shutdown /r /t N` is silently DROPPED in the SYSTEM service session.** The command
returns exit 0 but the box never reboots (verified twice on tech03l). Also `shutdown /c "comment"`
fails outright because the RMM->cmd.exe layer strips the quotes ([[feedback_windows_quote_stripping]]).
Reboot reliably with `Restart-Computer -Force` (encoded PS) or `shutdown /r /t 0 /f` (no comment).
NOTE: on a fast SSD the edition-upgrade reboot can be <30s of downtime — a 30s-interval last_seen
monitor can miss the gap entirely; confirm the reboot by `LastBootUpTime` advancing, not by catching
the offline window. `is_connected` is null fleet-wide — track `last_seen` age, never that flag.
**Gotcha 3 — the ACG MAK `infrastructure/windows-pro-mak` is a Pro-for-WORKSTATIONS MAK** (mislabeled
"Pro"). `slmgr /ipk` of it on a Professional machine AUTO-UPGRADES the edition to ProfessionalWorkstation
(a strict superset of Pro), live, **no extra reboot** (verified DALLAS 2026-07-07). If a client genuinely
needs *plain* Pro, this key will over-shoot to PfW — there is no plain-Pro MAK in the vault. Billing:
each activation consumes one MAK count = $99 to the customer (ACG-internal machines: no invoice, still
burns a count).
**Gotcha 4 — `changepk.exe` TIMES OUT under SYSTEM but STILL flips the edition. Do NOT treat the timeout
as failure — VERIFY EditionID.** Run as SYSTEM via `/rmm`, `changepk.exe /ProductKey <key>` does not return
cleanly (the RMM command comes back `failed` / exit -1 `Command timeout`, or your Start-Process/`&` wrapper
hangs to the agent timeout), yet the registry `EditionID` flip to `Professional` DID happen. Always confirm
with a one-liner (`Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' EditionID`) before
concluding anything — if it reads `Professional`, proceed to the finalize reboot; the running OS still
reports Home in `Win32_OperatingSystem.Caption` until that reboot. (Verified DALLAS 2026-07-07 — the
`changepk` command timed out but EditionID was already `Professional`.)
**Gotcha 5 — Modern Standby laptops (S0 Low Power Idle) drop the RMM/SC agent when idle/lid-closed, which
mimics an endless reboot.** On a Core Ultra / S0-only machine, idle or a closed lid drops Wi-Fi -> agent
offline for long stretches (looked like a 25-min "slow reboot"; the box had actually been up 25 days and
never rebooted). Before a reboot-driven upgrade on a laptop, pin it awake first:
`powercfg /change standby-timeout-ac 0; powercfg /setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0;
powercfg /setactive SCHEME_CURRENT` (AC only — safe; DON'T lid-nothing on DC/battery). A command dispatched
to an already-offline agent goes `pending` and fires (uncontrolled) on next wake — cancel stale ones with
`/rmm cancel`. NOTE: an RDP *target* laptop must STAY awake to be reachable, so keep the AC no-sleep in place.

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env bash
# ask-forum.sh — ask a human a question in the #ct-forum Discord forum and BLOCK
# until a person answers, then print their answer and exit. Built for a live
# three-way: the user in the terminal, THIS Claude session, and a teammate (e.g.
# Mike) in the forum — all the same session. The wait loop runs here in bash, so
# the calling model pays for ONE tool call, not a polling loop.
#
# Usage:
# ask-forum.sh "question text" [--title "short title"] [--timeout SEC] [--poll SEC] [--tag @id ...]
# echo "question" | ask-forum.sh [flags]
# ask-forum.sh --read <thread_id> [limit] # one-shot read, no wait
# ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC] [--after <msg_id>]
#
# Flow (ask): POST a forum post (thread) with the question, then poll that thread
# for the first NON-BOT reply (human answer), print it, exit 0.
#
# Correlation is automatic: we create the thread and read only that thread.
# Exit codes: 0 answered; 1 usage; 2 token missing; 3 Discord API error; 4 timeout.
set -u
export LC_ALL="${LC_ALL:-C.UTF-8}" # count code points, not bytes, when slicing $CONTENT
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
API="https://discord.com/api/v10"
UA="ClaudeToolsBot (claudetools, 1.0)"
FORUM_ID="1522960388432465950" # #ct-forum (private forum; Howard + bot + Mike)
LIMIT_CHARS=1900 # Discord caps message content at 2000
TIMEOUT=600
POLL=8
TITLE=""
TAGS=()
MSG=""
# --- token resolver (shared by read + wait + ask) ---
resolve_token() {
local t
t="$(bash "$ROOT/.claude/scripts/vault.sh" get-field \
projects/discord-bot/bot-token.sops.yaml credentials.bot_token 2>/dev/null)"
if [ -z "$t" ] || [ "$t" = "null" ]; then
local env_file="$ROOT/projects/discord-bot/.env"
[ -f "$env_file" ] && t="$(grep -iE '^[[:space:]]*DISCORD_TOKEN[[:space:]]*=' "$env_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'"'"']//; s/["'"'"'][[:space:]]*$//')"
fi
printf '%s' "$t"
}
# trim a string to the last newline within it (keeps split chunks on line boundaries)
trim_at_newline() {
local s="$1" nl
nl="${s%$'\n'*}"
if [ "${#nl}" -lt "${#s}" ] && [ "${#nl}" -gt 0 ]; then printf '%s' "$nl"; else printf '%s' "$s"; fi
}
# clamp a read limit to Discord's valid 1..100 (guard huge values that overflow bash math)
clamp_limit() {
local n="$1"
printf '%s' "$n" | grep -qE '^[0-9]+$' || { printf '25'; return; }
[ "${#n}" -ge 4 ] && { printf '100'; return; } # >=1000 always exceeds 100
[ "$n" -lt 1 ] && n=1; [ "$n" -gt 100 ] && n=100
printf '%s' "$n"
}
# Given a non-array poll response body, decide what to do. Echoes: "retry" (transient/
# rate-limited — sleeps any retry_after first) or "bail:<reason>" (permanent Discord error).
poll_disposition() {
local body="$1" ra code
ra="$(printf '%s' "$body" | jq -r '.retry_after // empty' 2>/dev/null)"
if [ -n "$ra" ]; then
# rate limited: honor Discord's back-off (ceil), then retry
sleep "$(printf '%s\n' "$ra" | awk '{printf "%d", ($1==int($1)?$1:int($1)+1)}')" 2>/dev/null || sleep 2
printf 'retry'; return
fi
code="$(printf '%s' "$body" | jq -r '.code // empty' 2>/dev/null)"
if [ -n "$code" ]; then printf 'bail:%s %s' "$code" "$(printf '%s' "$body" | jq -r '.message // ""')"; return; fi
printf 'retry' # unrecognized/transient (network blip, empty body)
}
# emit the human (non-bot) replies from a messages array as ">>> user: text" lines
emit_human() {
printf '%s' "$1" | jq -r 'sort_by(.id)[] | select(.author.bot|not) | ">>> " + .author.username + ": " + ((.content//"")|gsub("\n";" "))'
}
# newest message id in an array (string/lexical max — snowflakes are monotonic; avoids jq number precision loss)
newest_id() { printf '%s' "$1" | jq -r 'sort_by(.id)|.[-1].id // empty'; }
# ---------------------------------------------------------------------------
# --read: one-shot fetch of a thread's messages, no posting
# ---------------------------------------------------------------------------
if [ "${1:-}" = "--read" ]; then
THREAD="${2:-}"
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read <thread_id> [limit]" >&2; exit 1; }
LIMIT="$(clamp_limit "${3:-25}")"
TOKEN="$(resolve_token)"
{ [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; } && { echo "[ERROR] no Discord bot token" >&2; exit 2; }
auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}")
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=${LIMIT}")"
printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || { echo "[ERROR] could not read thread ${THREAD}: $(printf '%s' "$MSGS" | head -c 160)" >&2; exit 3; }
echo "[OK] thread ${THREAD} (oldest first; '>>>' = human, ' ' = bot):"
printf '%s' "$MSGS" | jq -r 'sort_by(.id)[] | ((if (.author.bot // false) then " " else ">>> " end) + .author.username + ": " + ((.content // "")|gsub("\n";" ")))'
exit 0
fi
# ---------------------------------------------------------------------------
# --wait: block on an EXISTING thread until a human replies, no posting
# Baselines on the starter (thread id == starter message id for a forum post),
# so a reply that already landed before --wait began is caught (not missed).
# Override the baseline with --after <msg_id> to wait for replies strictly after
# a specific message (use when resuming a thread whose earlier answer you already
# consumed and you want only the NEXT reply).
# ---------------------------------------------------------------------------
if [ "${1:-}" = "--wait" ]; then
THREAD="${2:-}"; shift 2
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC] [--after <msg_id>]" >&2; exit 1; }
AFTER="$THREAD"
while [ "$#" -gt 0 ]; do
case "$1" in
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
--poll) POLL="${2:-8}"; shift 2 ;;
--after) AFTER="${2:-$THREAD}"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
TOKEN="$(resolve_token)"
{ [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; } && { echo "[ERROR] no Discord bot token" >&2; exit 2; }
auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}")
echo "[INFO] waiting on thread ${THREAD} for a human reply (up to ${TIMEOUT}s)..." >&2
DEADLINE=$(( $(date +%s) + TIMEOUT ))
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}&limit=100")"
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
D="$(poll_disposition "$MSGS")"
case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac
sleep "$POLL"; continue
fi
HUMAN="$(emit_human "$MSGS")"
if [ -n "$HUMAN" ]; then
echo "[OK] answer received in thread ${THREAD}:"; printf '%s\n' "$HUMAN"; echo "THREAD_ID=${THREAD}"; exit 0
fi
# all-bot batch: page forward so a reply beyond the 100-msg window isn't missed
NID="$(newest_id "$MSGS")"; [ -n "$NID" ] && AFTER="$NID"
sleep "$POLL"
done
echo "[TIMEOUT] no human reply on thread ${THREAD} within ${TIMEOUT}s" >&2
echo "THREAD_ID=${THREAD}" >&2
exit 4
fi
# ---------------------------------------------------------------------------
# ask mode (default): post a question and block for the first human reply
# ---------------------------------------------------------------------------
while [ "$#" -gt 0 ]; do
case "$1" in
--title) TITLE="${2:-}"; shift 2 ;;
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
--poll) POLL="${2:-8}"; shift 2 ;;
--tag) TAGS+=("${2:-}"); shift 2 ;;
-h|--help) sed -n '2,20p' "$0"; exit 1 ;;
*) MSG="${MSG:+$MSG }$1"; shift ;;
esac
done
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi
if [ -z "$MSG" ]; then echo "[ERROR] no question given" >&2; exit 1; fi
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
if [ -z "$TITLE" ]; then
TITLE="$(printf '%s' "$MSG" | tr '\n' ' ' | cut -c1-60)"
[ -z "$TITLE" ] && TITLE="Question"
fi
# prepend any @mentions so the tagged person gets pinged
MENTION=""
for t in "${TAGS[@]:-}"; do
[ -z "$t" ] && continue
id="${t#@}"
printf '%s' "$id" | grep -qE '^[0-9]{17,19}$' && MENTION="${MENTION}<@${id}> "
done
CONTENT="${MENTION}${MSG}"
TOKEN="$(resolve_token)"
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "[ERROR] no Discord bot token (vault + .env both empty)" >&2
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "no Discord bot token" >/dev/null 2>&1
exit 2
fi
auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}")
# keep the forum starter <=1900 chars; any overflow goes out as follow-up messages
STARTER_CONTENT="$CONTENT"; OVERFLOW=""
if [ "${#CONTENT}" -gt "$LIMIT_CHARS" ]; then
STARTER_CONTENT="$(trim_at_newline "${CONTENT:0:$LIMIT_CHARS}")"
OVERFLOW="${CONTENT:${#STARTER_CONTENT}}"; OVERFLOW="${OVERFLOW#$'\n'}"
fi
# --- create the forum post (thread) with the question ---
BODY="$(jq -nc --arg name "$TITLE" --arg c "$STARTER_CONTENT" '{name:$name, message:{content:$c}}')"
RESP="$(printf '%s' "$BODY" | curl -s -m 20 -w $'\n%{http_code}' "${auth[@]}" \
-X POST "$API/channels/${FORUM_ID}/threads" --data-binary @-)"
HTTP="$(printf '%s' "$RESP" | tail -n1)"
JSON="$(printf '%s' "$RESP" | sed '$d')"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "[ERROR] could not post forum question (HTTP ${HTTP:-none}): $(printf '%s' "$JSON" | head -c 200)" >&2
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "forum post failed" --context "http=${HTTP:-none} resp=$(printf '%s' "$JSON" | head -c 80)" >/dev/null 2>&1
exit 3
fi
THREAD="$(printf '%s' "$JSON" | jq -r '.id // empty')"
STARTER="$(printf '%s' "$JSON" | jq -r '.message.id // empty')"
if [ -z "$THREAD" ]; then
echo "[ERROR] posted but no thread id in response: $(printf '%s' "$JSON" | head -c 200)" >&2
exit 3
fi
# --- post any overflow chunks as follow-up messages (check each; warn if one fails) ---
rest="$OVERFLOW"
while [ -n "$rest" ]; do
piece="$(trim_at_newline "${rest:0:$LIMIT_CHARS}")"
fhttp="$(printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \
curl -s -m 15 -o /dev/null -w '%{http_code}' "${auth[@]}" -X POST "$API/channels/${THREAD}/messages" --data-binary @-)"
[ "$fhttp" != "200" ] && echo "[WARNING] overflow chunk failed (HTTP ${fhttp}) — question may be truncated in the forum" >&2
rest="${rest:${#piece}}"; rest="${rest#$'\n'}"
done
echo "[INFO] asked in #ct-forum (thread=${THREAD}) — waiting up to ${TIMEOUT}s for a human reply..." >&2
# --- block-poll the thread for the first NON-BOT reply ---
AFTER="${STARTER:-$THREAD}" # starter id == thread id for a forum post
DEADLINE=$(( $(date +%s) + TIMEOUT ))
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
sleep "$POLL"
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}&limit=100")"
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
D="$(poll_disposition "$MSGS")"
case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac
continue
fi
HUMAN="$(emit_human "$MSGS")"
if [ -n "$HUMAN" ]; then
echo "[OK] answer received in thread ${THREAD}:"; printf '%s\n' "$HUMAN"; echo "THREAD_ID=${THREAD}"; exit 0
fi
NID="$(newest_id "$MSGS")"; [ -n "$NID" ] && AFTER="$NID" # page forward past bot-only batches
done
echo "[TIMEOUT] no human reply within ${TIMEOUT}s. Thread stays open for a later read:" >&2
echo "THREAD_ID=${THREAD}" >&2
echo " re-read: bash .claude/scripts/ask-forum.sh --read ${THREAD} | resume wait: --wait ${THREAD}" >&2
exit 4

View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Shared helpers for the backup-verification skills (seafile / owncloud / datto-workplace).
Three sibling skills answer the same GPS-audit question from different backends:
"which customers/machines are actually backing up here?" Each skill has its own
server-side client (Seafile Web API, ownCloud occ over SSH, Datto Workplace file
API). What they SHARE — and what lives here — is:
* ClaudeTools repo-root resolution (env -> identity.json -> derived).
* A one-field SOPS vault read via the canonical vault.sh wrapper.
* An RmmClient for the "endpoint" half of the "server + RMM endpoint" cross-check:
log in to GuruRMM, list agents, and run a detection PowerShell on an agent to
prove a given sync client (SeaDrive / ownCloud / Datto Workplace) is actually
installed and signed in on the machine.
Standalone: stdlib-only transport (urllib), no third-party dependency. Skills add
this file's directory to sys.path and `import backup_common`.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Optional
# backup_common.py lives at <root>/.claude/scripts/backup_common.py
_THIS = Path(__file__).resolve()
_DERIVED_ROOT = _THIS.parent.parent.parent # scripts -> .claude -> repo root
RMM_BASE = os.environ.get("GURURMM_API", "http://172.16.3.30:3001")
RMM_VAULT_ENTRY = "infrastructure/gururmm-server.sops.yaml"
RMM_FIELD_EMAIL = "credentials.gururmm-api.admin-email"
RMM_FIELD_PASSWORD = "credentials.gururmm-api.admin-password"
HTTP_TIMEOUT = 60.0
class BackupError(RuntimeError):
"""Transport / auth / API error, with an optional HTTP status."""
def __init__(self, message: str, *, status: Optional[int] = None):
super().__init__(message)
self.status = status
# --- repo-root + vault --------------------------------------------------------
def resolve_root() -> Path:
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived."""
env_root = os.environ.get("CLAUDETOOLS_ROOT")
if env_root:
return Path(env_root)
identity = _DERIVED_ROOT / ".claude" / "identity.json"
if identity.exists():
try:
data = json.loads(identity.read_text(encoding="utf-8"))
root = data.get("claudetools_root")
if root:
return Path(root)
except (json.JSONDecodeError, OSError):
pass
return _DERIVED_ROOT
def vault_get_field(entry: str, field: str) -> str:
"""Read one field from a SOPS vault entry via the canonical vault.sh wrapper."""
root = resolve_root()
vault = root / ".claude" / "scripts" / "vault.sh"
if not vault.exists():
raise BackupError(f"vault wrapper not found at {vault}")
try:
done = subprocess.run(
["bash", str(vault), "get-field", entry, field],
capture_output=True, text=True, timeout=60,
)
except FileNotFoundError as exc:
raise BackupError("'bash' not found on PATH (need Git Bash for vault reads).") from exc
except subprocess.TimeoutExpired as exc:
raise BackupError(f"vault read timed out for {entry}:{field}") from exc
if done.returncode != 0:
raise BackupError(
f"vault read failed for {entry}:{field} (exit {done.returncode}): {done.stderr.strip()}"
)
value = done.stdout.strip()
if not value:
raise BackupError(f"vault returned empty for {entry}:{field}")
return value
# --- minimal JSON HTTP --------------------------------------------------------
def http_json(method: str, url: str, *, headers: Optional[dict] = None,
body: Optional[dict] = None, form: Optional[dict] = None,
timeout: float = HTTP_TIMEOUT,
basic: Optional[tuple[str, str]] = None) -> tuple[int, Any]:
"""Do an HTTP request and parse a JSON response. Returns (status, parsed).
`body` sends a JSON payload; `form` sends application/x-www-form-urlencoded
(list values expand via doseq, e.g. repeated fields the Seafile admin share
endpoint reads with getlist). Pass at most one. 4xx/5xx are returned (not
raised) so callers can inspect the error body; transport failures raise.
"""
hdrs = dict(headers or {})
data = None
if body is not None and form is not None:
raise BackupError("http_json: pass body OR form, not both")
if body is not None:
data = json.dumps(body).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json")
elif form is not None:
data = urllib.parse.urlencode(form, doseq=True).encode("utf-8")
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
if basic is not None:
import base64
tok = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode("ascii")
hdrs["Authorization"] = f"Basic {tok}"
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return resp.getcode(), _parse(raw)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return exc.code, _parse(raw)
except urllib.error.URLError as exc:
raise BackupError(f"request to {url} failed: {exc}") from exc
def _parse(text: str) -> Any:
if not text:
return {}
try:
return json.loads(text)
except json.JSONDecodeError:
return {"_raw": text[:600]}
# --- GuruRMM client (endpoint cross-check) ------------------------------------
# One detection script, parametrized by the product name-fragments to look for.
# Returns a single JSON line describing installed products, running processes, and
# per-user config folders that match — enough to say "this client is installed and
# signed in" without guessing.
_DETECT_PS = r'''
$ErrorActionPreference='SilentlyContinue'
$patterns = @(__PATTERNS__)
function match($s){ if(-not $s){return $false}; foreach($p in $patterns){ if($s -match [regex]::Escape($p)){return $true} }; return $false }
$installed=@()
foreach($hive in @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')){
Get-ItemProperty $hive | Where-Object { match($_.DisplayName) } | ForEach-Object {
$installed += [pscustomobject]@{ name=$_.DisplayName; version=$_.DisplayVersion; location=$_.InstallLocation }
}
}
$procs = Get-Process | Where-Object { match($_.ProcessName) } | Select-Object -Expand ProcessName -Unique
$cfg=@()
foreach($d in @('Seafile','SeaDrive','ownCloud','ownCloudSync','Datto','Workplace','Workplace2')){
foreach($base in @($env:APPDATA,$env:LOCALAPPDATA,$env:USERPROFILE,"$env:USERPROFILE\Desktop")){
$pth = Join-Path $base $d
if(Test-Path $pth){ if(match($d) -or match($pth)){ $cfg += $pth } }
}
}
# signed-in heuristic: config/db files with recent writes
$fresh=@()
foreach($c in $cfg){
Get-ChildItem $c -Recurse -File -ErrorAction SilentlyContinue -Depth 2 |
Where-Object { $_.LastWriteTime -gt (Get-Date).AddDays(-14) } |
Select-Object -First 1 | ForEach-Object { $fresh += $c }
}
[pscustomobject]@{
host=$env:COMPUTERNAME
installed=$installed
processes=@($procs)
config_dirs=@($cfg)
active_config_dirs=@($fresh | Select-Object -Unique)
detected=([bool]($installed.Count -or $procs.Count))
} | ConvertTo-Json -Depth 5 -Compress
'''
class RmmClient:
"""Thin GuruRMM API client: login, list agents, run a detection PowerShell."""
def __init__(self, base: str = RMM_BASE):
self.base = base.rstrip("/")
self._token: Optional[str] = None
self._agents: Optional[list[dict]] = None
def login(self) -> None:
email = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_EMAIL)
pw = vault_get_field(RMM_VAULT_ENTRY, RMM_FIELD_PASSWORD)
status, body = http_json("POST", f"{self.base}/api/auth/login",
body={"email": email, "password": pw})
if status != 200 or not isinstance(body, dict) or not body.get("token"):
raise BackupError(f"GuruRMM login failed (HTTP {status}): {body}", status=status)
self._token = body["token"]
def _auth(self) -> dict:
if not self._token:
self.login()
return {"Authorization": f"Bearer {self._token}"}
def list_agents(self) -> list[dict]:
if self._agents is None:
status, body = http_json("GET", f"{self.base}/api/agents", headers=self._auth())
if status != 200 or not isinstance(body, list):
raise BackupError(f"GET /api/agents failed (HTTP {status})", status=status)
self._agents = body
return self._agents
def find_agents(self, client_substr: Optional[str] = None,
hostname_substr: Optional[str] = None) -> list[dict]:
out = []
for a in self.list_agents():
if client_substr and client_substr.lower() not in (a.get("client_name") or "").lower():
continue
if hostname_substr and hostname_substr.lower() not in (a.get("hostname") or "").lower():
continue
out.append(a)
return out
def run_ps(self, agent_id: str, script: str, timeout_seconds: int = 90,
poll_seconds: int = 4, max_polls: int = 30) -> dict:
"""Dispatch a PowerShell command to an agent and poll to completion."""
status, body = http_json(
"POST", f"{self.base}/api/agents/{agent_id}/command", headers=self._auth(),
body={"command_type": "powershell", "command": script,
"timeout_seconds": timeout_seconds})
if status not in (200, 201) or not isinstance(body, dict):
raise BackupError(f"command dispatch failed (HTTP {status}): {body}", status=status)
cid = body.get("command_id") or body.get("id")
if not cid:
raise BackupError(f"no command_id in dispatch response: {body}")
terminal = {"completed", "failed", "cancelled", "interrupted", "timeout"}
last: dict = {}
for _ in range(max_polls):
s, cb = http_json("GET", f"{self.base}/api/commands/{cid}", headers=self._auth())
if s == 200 and isinstance(cb, dict):
last = cb
if (cb.get("status") or "") in terminal:
break
time.sleep(poll_seconds)
return last
def detect_client(self, agent: dict, patterns: list[str], **kw) -> dict:
"""Run the detection script on one agent for the given product name-fragments."""
pat_lits = ",".join("'" + p.replace("'", "''") + "'" for p in patterns)
script = _DETECT_PS.replace("__PATTERNS__", pat_lits)
res = self.run_ps(agent["id"], script, **kw)
out = {"hostname": agent.get("hostname"), "client_name": agent.get("client_name"),
"agent_id": agent.get("id"), "status": res.get("status"),
"exit_code": res.get("exit_code"), "detected": None, "detail": None}
stdout = (res.get("stdout") or "").strip()
if stdout:
try:
parsed = json.loads(stdout)
out["detected"] = bool(parsed.get("detected"))
out["detail"] = parsed
except json.JSONDecodeError:
out["detail"] = {"_raw": stdout[:600]}
return out
def eprint(*a: Any) -> None:
print(*a, file=sys.stderr)

View File

@@ -6,6 +6,7 @@
# Usage:
# discord-dm.sh <recipient> "message text"
# echo "message text" | discord-dm.sh <recipient>
# discord-dm.sh read <recipient> [limit] # READ recent messages — SEE replies (default 15)
# discord-dm.sh list # show known users + channels
#
# <recipient> is one of:
@@ -51,11 +52,29 @@ if [ -z "$RECIPIENT" ] || [ "$RECIPIENT" = "-h" ] || [ "$RECIPIENT" = "--help" ]
sed -n '2,30p' "$0"; exit 1
fi
if [ "$RECIPIENT" = "list" ]; then print_dir; exit 0; fi
# --- read mode: fetch recent messages so we can SEE replies (mike/winter/etc.).
# `discord-dm.sh read <user|#channel> [limit]` — reuses the recipient resolution,
# token, and DM-channel-open below, then prints history instead of sending. The
# bot participates in each DM channel it opened, so REST history returns full
# content (the Message Content intent only gates gateway events, not this fetch).
ACTION=send
if [ "$RECIPIENT" = "read" ] || [ "$RECIPIENT" = "inbox" ]; then
ACTION=read; shift
RECIPIENT="${1:-}"
[ -z "$RECIPIENT" ] && { echo "[ERROR] usage: discord-dm.sh read <user|#channel> [limit]" >&2; exit 1; }
fi
shift
# --- message (remaining args, else stdin) ---
if [ "$#" -gt 0 ]; then MSG="$*"; elif [ ! -t 0 ]; then MSG="$(cat)"; else MSG=""; fi
if [ -z "$MSG" ]; then echo "[ERROR] empty message — nothing sent" >&2; exit 1; fi
# --- message (send) or limit (read) ---
if [ "$ACTION" = "read" ]; then
READ_LIMIT="${1:-15}"
printf '%s' "$READ_LIMIT" | grep -qE '^[0-9]+$' || READ_LIMIT=15
[ "$READ_LIMIT" -gt 100 ] && READ_LIMIT=100
else
if [ "$#" -gt 0 ]; then MSG="$*"; elif [ ! -t 0 ]; then MSG="$(cat)"; else MSG=""; fi
if [ -z "$MSG" ]; then echo "[ERROR] empty message — nothing sent" >&2; exit 1; fi
fi
# --- resolve recipient -> mode (dm|channel) + target id ---
MODE=""; TARGET=""; LABEL=""
@@ -99,6 +118,22 @@ if [ "$MODE" = "dm" ]; then
TARGET="$CHID"
fi
# --- read mode: fetch + print recent messages, then exit (no send) ---
if [ "$ACTION" = "read" ]; then
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${TARGET}/messages?limit=${READ_LIMIT}")"
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
echo "[ERROR] could not read $LABEL: $(printf '%s' "$MSGS" | head -c 200)" >&2
bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "read history failed for $LABEL" --context "resp=$(printf '%s' "$MSGS" | head -c 80)" >/dev/null 2>&1
exit 3
fi
echo "[OK] discord-dm: last ${READ_LIMIT} message(s) in ${LABEL} (oldest first; '>>>' = reply from them, ' ' = us):"
printf '%s' "$MSGS" | jq -r 'reverse[] |
((if (.author.bot // false) then " " else ">>> " end)
+ (.timestamp[0:16]) + " " + .author.username + ": "
+ ((.content // "") | gsub("\n";" ")))'
exit 0
fi
# --- chunk: Discord caps content at 2000 chars (code 50035 on overflow).
# This tool's job is delivering LONG content intact, so split into <=1900-char
# pieces (preferring newline boundaries) and send them in order — no markers

View File

@@ -0,0 +1,6 @@
' edr-isolation-watch-hidden.vbs — launch the EDR isolation watcher with no visible window.
' Run via the "ClaudeTools - EDR Isolation Watcher" scheduled task through wscript.exe (a GUI
' host, no console), which starts bash with window style 0 (hidden) so the every-10-min watch
' no longer flashes a console window on the desktop. Runtime env is identical to a direct bash
' launch. Mirrors gps-rmm-progress-hidden.vbs.
CreateObject("WScript.Shell").Run """C:\Program Files\Git\bin\bash.exe"" -lc ""/c/claudetools/.claude/scripts/edr-isolation-watch.sh""", 0, False

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# edr-isolation-watch.sh — interim EDR auto-isolation alerter.
#
# Polls Datto EDR for detections whose automated response included `isolate-host`
# (i.e. a machine was auto-isolated / "quarantined") and posts each NEW one to the
# private #dev-alerts channel (Mike + Howard). Runs as a plain scheduled job — no
# LLM, no tokens. Retire once the GuruRMM EDR webhook integration (Feature 6) lands.
#
# Schedule (every 10 min), e.g. cron:
# */10 * * * * bash /path/to/claudetools/.claude/scripts/edr-isolation-watch.sh >/dev/null 2>&1
#
# State: .edr-watch-state.json (gitignored) holds already-alerted alert IDs so each
# isolation is announced exactly once, even though Mike may un-isolate before the poll.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
EDR="bash $ROOT/.claude/scripts/py.sh $ROOT/.claude/skills/datto-edr/scripts/edr.py"
DM="bash $ROOT/.claude/scripts/discord-dm.sh"
export STATE="${STATE:-$ROOT/.claude/scripts/.edr-watch-state.json}"
TARGET="dev" # #dev-alerts = Mike + Howard, private
# Pull last 24h of detections (list includes responseData) and emit any NEW
# isolate-host events as one pipe-delimited line each; update state in-place.
new_events="$($EDR --json detections --days 1 --limit 500 2>/dev/null | python -c '
import sys, json, os
state_path = os.environ["STATE"]
try:
rows = json.load(sys.stdin)
rows = rows if isinstance(rows, list) else rows.get("data", rows.get("alerts", []))
except Exception:
sys.exit(0) # API hiccup: emit nothing, do not churn state
try:
seen = set(json.load(open(state_path)))
except Exception:
seen = set()
fresh = []
for a in rows:
rd = a.get("responseData") or []
names = [r.get("name") for r in rd] if isinstance(rd, list) else []
if "isolate-host" not in names:
continue
aid = a.get("id")
if not aid or aid in seen:
continue
seen.add(aid)
fresh.append("|".join(str(a.get(k, "")) for k in
("id", "hostname", "organizationName", "severity", "sourceName", "eventTime")))
# keep state bounded
json.dump(sorted(seen)[-500:], open(state_path, "w"))
print("\n".join(fresh))
' 2>/dev/null)"
[ -z "$new_events" ] && exit 0
while IFS='|' read -r aid host org sev rule ts; do
[ -z "$aid" ] && continue
msg="[EDR AUTO-ISOLATION] **$host** was auto-isolated by Datto EDR
- Client: $org
- Rule: $rule (severity: $sev)
- Time: $ts
- This machine was cut off the network by an EDR automated response. Verify it is intended before restoring.
- Console: https://azcomp4587.infocyte.com (alert id: $aid)"
if [ "${DRY_RUN:-0}" = "1" ]; then
printf '[DRY_RUN] would post to #%s:\n%s\n\n' "$TARGET" "$msg"
continue
fi
if ! printf '%s' "$msg" | $DM "$TARGET" 2>/dev/null; then
bash "$ROOT/.claude/scripts/log-skill-error.sh" "edr-isolation-watch" "discord post failed for $host ($aid)" 2>/dev/null || true
fi
done <<< "$new_events"

View File

@@ -1,5 +1,5 @@
"""Generate base64 of the WatchdogAlertsSection component."""
import base64, sys
import base64, os, sys
BT = chr(96)
BULLET = "\xb7"
@@ -195,7 +195,9 @@ lines = [
component = "".join(lines)
b64 = base64.b64encode(component.encode("utf-8")).decode("ascii")
with open("D:/claudetools/.claude/scripts/component.b64", "w") as f:
# Write next to this script — the repo root is machine-specific, never hardcode it.
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "component.b64")
with open(out_path, "w") as f:
f.write(b64)
print(f"Component: {len(component)} chars")

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# get-identity.sh — Read identity.json and export user/machine vars for attribution
#
# Source this at the start of any skill that needs attribution (bot alerts, commits,
# logs, RMM operations). Exports $USER_NAME, $USER_SHORT, $MACHINE, $USER_EMAIL.
#
# Usage:
# source .claude/scripts/get-identity.sh
# echo "[RMM] $USER_SHORT deployed to X machines..."
#
# Soft-fails: if identity.json is missing, exports "Unknown" values and returns 1
# (but does NOT exit, so the caller continues). This ensures skills never break on
# missing identity - they just attribute to "Unknown".
# Resolve the repo root from this script's own location (mirrors vault.sh) so callers
# don't have to know it. An already-set CLAUDETOOLS_ROOT still wins, and identity.json's
# claudetools_root overrides both. Never hardcode a drive letter.
if [ -z "${CLAUDETOOLS_ROOT:-}" ]; then
export CLAUDETOOLS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fi
IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
if [ ! -f "$IDENTITY_FILE" ]; then
echo "[WARNING] identity.json not found at $IDENTITY_FILE - attribution will be 'Unknown'" >&2
export USER_NAME="Unknown User"
export USER_SHORT="unknown"
export MACHINE="unknown-machine"
export USER_EMAIL="unknown@unknown.com"
return 1
fi
export USER_NAME=$(jq -r '.full_name // .user // "Unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "Unknown")
export USER_SHORT=$(jq -r '.user // "unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown")
export MACHINE=$(jq -r '.machine // "unknown"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown")
export USER_EMAIL=$(jq -r '.email // "unknown@unknown.com"' "$IDENTITY_FILE" 2>/dev/null || echo "unknown@unknown.com")
# identity.json is authoritative for the root and the vault location.
_root_override=$(jq -r '.claudetools_root // empty' "$IDENTITY_FILE" 2>/dev/null)
[ -n "$_root_override" ] && [ -d "$_root_override" ] && export CLAUDETOOLS_ROOT="$_root_override"
export VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null)
unset _root_override
# Success
return 0

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# gps-rmm-autoenroll.sh — auto-enroll loop for the GPS->RMM audit.
# Every run: push the generic Staging installer (DARK-STORM-3150) to GPS-customer
# machines that are ONLINE in ScreenConnect but missing from GuruRMM, wait for them
# to enroll, then reassign Staging agents to their real client (hostname->Syncro).
# Registered as Windows task GPS-RMM-AutoEnroll (every 30 min). Remove the task when
# targets.json is fully enrolled. Safe to re-run: server v6.77+ dedups by device_id.
set -uo pipefail
cd /c/claudetools || exit 1
LOG="projects/gps-rmm-audit/autoenroll.log"
TS="$(date '+%Y-%m-%d %H:%M')"
eval "$(bash .claude/scripts/rmm-auth.sh 2>/dev/null)" >/dev/null 2>&1
if [ -z "${TOKEN:-}" ]; then echo "$TS auth FAILED" >> "$LOG"; exit 0; fi
SEC="$(bash .claude/scripts/vault.sh get-field msp-tools/screenconnect.sops.yaml credentials.api_secret 2>/dev/null | tr -d '\r\n')"
SK="$(bash .claude/scripts/vault.sh get-field msp-tools/syncro-howard credentials.credential 2>/dev/null | tr -d '\r\n')"
if [ -z "$SK" ]; then echo "$TS syncro key read FAILED" >> "$LOG"; exit 0; fi
OUT="$(RMM="$RMM" TOK="$TOKEN" SK="$SK" SC_SECRET="$SEC" python projects/gps-rmm-audit/tools/rebuild-and-push.py 2>/dev/null | tail -2)"
PUSHED="$(echo "$OUT" | grep -oE 'pushed: [0-9]+' | grep -oE '[0-9]+' | head -1 || true)"
PUSHED="${PUSHED:-0}"
echo "$TS sweep: $(echo "$OUT" | head -1)" >> "$LOG"
if [ "${PUSHED:-0}" -gt 0 ]; then
sleep 150
RES="$(RMM="$RMM" TOK="$TOKEN" SK="$SK" python projects/gps-rmm-audit/tools/reassign-staging.py 2>/dev/null)"
MOVED="$(echo "$RES" | grep -cE '^ .+ -> ' || true)"
MOVED="${MOVED:-0}"
echo "$TS pushed $PUSHED, reassigned $MOVED:" >> "$LOG"
echo "$RES" | grep -E '^ ' >> "$LOG"
if [ "${MOVED:-0}" -gt 0 ]; then
NAMES="$(echo "$RES" | grep -E '^ .+ -> ' | sed 's/^ //' | tr '\n' '; ')"
bash .claude/scripts/post-bot-alert.sh "[RMM] auto-enroll: $NAMES" >/dev/null 2>&1
fi
fi
exit 0

View File

@@ -0,0 +1,5 @@
' gps-rmm-progress-hidden.vbs — launch the GPS->RMM progress check with no visible window.
' Run via the GPS-RMM-Progress scheduled task through wscript.exe (GUI host, no console),
' which starts bash with window style 0 (hidden) so the daily check no longer flashes a
' console window on the desktop. Runtime env is identical to a direct bash launch.
CreateObject("WScript.Shell").Run """C:\Program Files\Git\bin\bash.exe"" -lc ""cd /c/claudetools && bash .claude/scripts/gps-rmm-progress-check.sh""", 0, False

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env bash
# guruscan-agent-test.sh - Deploy GuruScan to a Windows GuruRMM agent and run an
# end-to-end smoke test (full 3-engine chain, EICAR-seeded, clean mode), pulling
# every log back for review.
# end-to-end smoke test (full 3-engine chain, clean mode), pulling every log back
# for review.
#
# Phases:
# prep - upload module to C:\GuruScan, Defender-exclude tool/test dirs,
# download RKill+Emsisoft, fetch HitmanPro, seed EICAR, verify ready
# prep - upload module to C:\GuruScan, Defender-exclude tool dirs,
# download RKill+Emsisoft, fetch HitmanPro, verify ready
# scan - dispatch Invoke-GuruScan.ps1 -Headless (clean mode) and poll
# collect - pull results.json + per-scanner logs into the repo
# all - prep then scan then collect
@@ -14,10 +14,9 @@
# bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>
#
# Mirrors the RMM plumbing in run-onboarding-diagnostic.sh (vault auth -> JWT ->
# chunked base64 upload -> dispatch -> poll). EICAR is the standard harmless AV
# test file; it is assembled on the endpoint (never written to this host) and
# dropped only into a Defender-excluded folder so GuruScan's own engines are what
# detect it.
# chunked base64 upload -> dispatch -> poll). Detection testing uses the real
# malware-samples set via the scan-one / verify-each phases. Prep's EICAR seed is
# DISABLED by default (kept for future scanners) -- enable with SEED_EICAR=1.
set -u
@@ -25,6 +24,11 @@ TARGET="${1:-}"
PHASE="${2:-all}"
SCANNER_ARG="${3:-}"
# EICAR test-file seeding in the prep phase. DISABLED by default -- kept (not
# deleted) so it can be switched back on to smoke-test detection as new scanners
# are added to the chain. Enable per-run with: SEED_EICAR=1 bash guruscan-agent-test.sh ...
SEED_EICAR="${SEED_EICAR:-0}"
if [ -z "$TARGET" ]; then
echo "[ERROR] Usage: bash guruscan-agent-test.sh <hostname|uuid> <prep|scan|collect|all>" >&2
exit 1
@@ -273,12 +277,12 @@ phase_prep() {
upload_file "$GS_DIR/$f" "C:\\GuruScan\\$f" || { _logerr "upload failed" --context "file=$f host=$AGENT_HOST"; return 1; }
done
# 2) Defender exclusions for tool + downloads + test dirs (so Defender does not
# nuke the scanner EXEs or grab EICAR before GuruScan's engines run).
# 2) Defender exclusions for tool + downloads dirs (so Defender does not nuke
# the scanner EXEs before GuruScan's engines run).
local sf="$WORK_DIR/defender.ps1"
cat > "$sf" <<'PS'
$ErrorActionPreference='Continue'
foreach($p in @('C:\GuruScan','C:\GuruScan\downloads','C:\GuruScanTest','C:\EmsisoftCmd')){
foreach($p in @('C:\GuruScan','C:\GuruScan\downloads','C:\EmsisoftCmd')){
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop; Write-Output "EXCLUDED $p" }
catch { Write-Output ("EXCLUDE-SKIP " + $p + " : " + $_.Exception.Message) }
}
@@ -310,12 +314,16 @@ try {
PS
run_ps "$sf3" 300 70 "download-hitmanpro" || echo "[WARN] HitmanPro download dispatch issue"
# 5) seed EICAR into the Defender-excluded test folder (assembled on endpoint)
local sf4="$WORK_DIR/eicar.ps1"
cat > "$sf4" <<'PS'
# 5) [OPTIONAL] seed EICAR into a Defender-excluded test folder (assembled on
# the endpoint). DISABLED unless SEED_EICAR=1 -- retained so detection can be
# smoke-tested as new scanners are added to the chain.
if [ "$SEED_EICAR" = "1" ]; then
local sfe="$WORK_DIR/eicar.ps1"
cat > "$sfe" <<'PS'
$ErrorActionPreference='Continue'
$dir='C:\GuruScanTest'
if(-not (Test-Path $dir)){New-Item -ItemType Directory -Path $dir -Force|Out-Null}
try { Add-MpPreference -ExclusionPath $dir -ErrorAction Stop } catch {}
# Standard EICAR test signature, assembled from fragments so it is never stored
# contiguously anywhere except the on-disk test file.
$e = 'X5O!P%@AP[4\PZX54(P^)7CC)7}' + '$EICAR' + '-STANDARD-ANTIVIRUS-' + 'TEST-FILE!$H+H*'
@@ -329,9 +337,12 @@ if(Test-Path $f){
Write-Output "EICAR MISSING after write - Defender (or other AV) grabbed it despite exclusion"
}
PS
run_ps "$sf4" 60 24 "seed-eicar" || { _logerr "EICAR seed failed" --context "host=$AGENT_HOST"; return 1; }
run_ps "$sfe" 60 24 "seed-eicar" || { _logerr "EICAR seed failed" --context "host=$AGENT_HOST"; return 1; }
else
echo "[INFO] EICAR seeding disabled (set SEED_EICAR=1 to enable)"
fi
# 6) readiness check - confirm all three scanner binaries present + EICAR present
# 6) readiness check - confirm all three scanner binaries + module present
local sf5="$WORK_DIR/ready.ps1"
cat > "$sf5" <<'PS'
$ErrorActionPreference='Continue'
@@ -339,8 +350,7 @@ $need=@{
'RKill' ='C:\GuruScan\downloads\rkill.exe';
'Emsisoft' ='C:\GuruScan\downloads\EmsisoftCommandlineScanner64.exe';
'HitmanPro' ='C:\GuruScan\downloads\HitmanPro_x64.exe';
'Module' ='C:\GuruScan\GuruScan.psm1';
'EICAR' ='C:\GuruScanTest\eicar_test.com'
'Module' ='C:\GuruScan\GuruScan.psm1'
}
$ok=$true
foreach($k in $need.Keys){
@@ -353,7 +363,7 @@ PS
if grep -q 'ALL-READY' "$WORK_DIR/last_result.json" 2>/dev/null || \
echo "$(jq -r '.stdout' "$WORK_DIR/last_result.json" 2>/dev/null)" | grep -q 'ALL-READY'; then
echo "[OK] prep complete - endpoint is READY for scan"
post_alert "[RMM] GuruScan test: prepped $AGENT_HOST (module+3 scanners+EICAR staged)"
post_alert "[RMM] GuruScan test: prepped $AGENT_HOST (module+3 scanners staged)"
else
echo "[WARN] prep finished but not all components READY - review output above before scanning"
fi

View File

@@ -12,6 +12,11 @@
# bash post-bot-alert.sh "message text" bot # force #bot-alerts
# echo "message text" | bash post-bot-alert.sh
#
# Identity: This script sources get-identity.sh, making $USER_SHORT, $USER_NAME,
# $MACHINE, $USER_EMAIL available. Callers can use these in messages for correct
# attribution (e.g., "[RMM] $USER_SHORT deployed..."). The script itself doesn't
# auto-inject identity - callers must explicitly use the vars when needed.
#
# Token resolution (first hit wins):
# 1. SOPS vault: projects/discord-bot/bot-token.sops.yaml field credentials.bot_token
# 2. projects/discord-bot/.env key DISCORD_TOKEN
@@ -27,6 +32,9 @@ BOT_CHANNEL_ID="624710699771232265" # #bot-alerts — default (Syncro + gene
DEV_CHANNEL_ID="1509998508198068484" # #dev-alerts — private RMM/Dev alerts (Howard + Mike only)
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
# Load identity for attribution (soft-fail if missing)
source "$ROOT/.claude/scripts/get-identity.sh" 2>/dev/null || true
# --- message (arg or stdin) ---
MSG="${1:-}"
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi

View File

@@ -38,7 +38,20 @@ read_script() { # $1 = file path or "-"
}
encode_b64() { # stdin: UTF-8 script -> stdout: UTF-16LE base64 (no BOM, no wraps)
iconv -f UTF-8 -t UTF-16LE | base64 -w0
if command -v iconv >/dev/null 2>&1; then
iconv -f UTF-8 -t UTF-16LE | base64 -w0
else
# Git-for-Windows ships the iconv *library* (msys-iconv-2.dll) but NOT iconv.exe,
# and has no pacman to install it. Fall back to Python (present fleet-wide) for the
# UTF-16LE -> base64 encode. Binary stdin/stdout so no CRLF translation.
local py
py="$(command -v py || command -v python3 || command -v python || true)"
if [ -z "$py" ]; then
echo "[ERROR] neither iconv nor python available for UTF-16LE encoding" >&2
return 1
fi
"$py" -c "import sys,base64; sys.stdout.write(base64.b64encode(sys.stdin.buffer.read().decode('utf-8').encode('utf-16-le')).decode())"
fi
}
size_gate() { # $1 = encoded string, $2 = force flag (0/1)

View File

@@ -0,0 +1,51 @@
# register-edr-watcher.ps1
# Register the "ClaudeTools - EDR Isolation Watcher" scheduled task on this Windows
# machine (the same box that runs GPS-RMM-AutoEnroll et al). The task runs
# edr-isolation-watch.sh every 10 minutes, which polls Datto EDR for hosts auto-
# isolated by an EDR response and posts each NEW one to #dev-alerts (Mike + Howard).
#
# Interim alerting until the GuruRMM EDR webhook integration (Feature 6) lands.
# Idempotent: -Force replaces any existing task with the same name.
#
# Run from an ordinary (non-admin) PowerShell:
# powershell -ExecutionPolicy Bypass -File C:\claudetools\.claude\scripts\register-edr-watcher.ps1
$ErrorActionPreference = "Stop"
$TaskName = "ClaudeTools - EDR Isolation Watcher"
# Launch via a wscript.exe VBS wrapper (GUI-subsystem host) so bash starts hidden and
# NO console window flashes on the desktop every 10 min. Launching bash.exe directly is
# a console-subsystem app with LogonType=Interactive + Hidden=False -> visible flash.
# Mirrors the gps-rmm-progress-hidden.vbs fix. Do NOT revert to a raw bash.exe action.
$BashExe = "C:\Program Files\Git\bin\bash.exe"
if (-not (Test-Path $BashExe)) { Write-Host "[ERROR] Git bash not found at $BashExe" -ForegroundColor Red; exit 1 }
$ScriptWin = "C:\claudetools\.claude\scripts\edr-isolation-watch.sh"
if (-not (Test-Path $ScriptWin)) { Write-Host "[ERROR] Watcher not found at $ScriptWin" -ForegroundColor Red; exit 1 }
$WScript = "C:\Windows\System32\wscript.exe"
$Vbs = "C:\claudetools\.claude\scripts\edr-isolation-watch-hidden.vbs"
if (-not (Test-Path $Vbs)) { Write-Host "[ERROR] Hidden wrapper not found at $Vbs" -ForegroundColor Red; exit 1 }
$Action = New-ScheduledTaskAction -Execute $WScript -Argument "`"$Vbs`""
# Every 10 minutes, indefinitely (10-year duration avoids the MaxValue quirk).
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).Date `
-RepetitionInterval (New-TimeSpan -Minutes 10) `
-RepetitionDuration (New-TimeSpan -Days 3650)
# Run as the current user, only when logged on (needs the interactive vault/env,
# same posture as the GrepAI watcher). No admin required.
$Principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
$Settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-MultipleInstances IgnoreNew -Hidden
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger `
-Principal $Principal -Settings $Settings `
-Description "Polls Datto EDR every 10 min for auto-isolated hosts; posts new ones to #dev-alerts. Interim until GuruRMM EDR webhook (Feature 6)." -Force | Out-Null
Write-Host "[OK] Registered scheduled task: $TaskName (every 10 min)" -ForegroundColor Green
Get-ScheduledTask -TaskName $TaskName | Select-Object TaskName, State | Format-Table -AutoSize

View File

@@ -47,14 +47,26 @@ if (-not (Test-Path $Script)) {
exit 1
}
# Resolve the py launcher's full path (the action's Execute wants an absolute
# path; "py" alone usually resolves but we pin it for reliability under the
# Task Scheduler's environment).
$PyCmd = Get-Command py -ErrorAction SilentlyContinue
# Resolve pythonW.exe (the GUI-subsystem Python host) so the detector runs with NO
# console window flashing on the desktop at logon / every 4h. Using py.exe or
# python.exe (console subsystem) draws a visible window each run. Prefer pythonw next
# to the active interpreter; fall back to a PATH lookup, then py.exe as a last resort.
$PyPath = $null
$PyCmd = Get-Command py -ErrorAction SilentlyContinue
if ($null -ne $PyCmd) {
$PyPath = $PyCmd.Source
} else {
$PyPath = "py" # fall back to PATH resolution at run time
try {
$exe = (& py -c "import sys;print(sys.executable)").Trim()
$wexe = Join-Path (Split-Path $exe) "pythonw.exe"
if (Test-Path $wexe) { $PyPath = $wexe }
} catch { }
}
if ($null -eq $PyPath) {
$PwCmd = Get-Command pythonw -ErrorAction SilentlyContinue
if ($null -ne $PwCmd) { $PyPath = $PwCmd.Source }
}
if ($null -eq $PyPath) {
if ($null -ne $PyCmd) { $PyPath = $PyCmd.Source } else { $PyPath = "py" }
Write-Host "[WARNING] pythonw.exe not found; task may flash a console window." -ForegroundColor Yellow
}
$Action = New-ScheduledTaskAction `
@@ -76,7 +88,8 @@ $Settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-MultipleInstances IgnoreNew `
-StartWhenAvailable `
-DontStopOnIdleEnd
-DontStopOnIdleEnd `
-Hidden
Register-ScheduledTask `
-TaskName $TaskName `

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# syncro-env.sh — Resolve the repo root and the caller's Syncro API key from the SOPS vault.
#
# Source this instead of hardcoding a repo path or an API key:
# source "$(dirname "${BASH_SOURCE[0]}")/syncro-env.sh" # or by absolute path
# curl -s "$SYNCRO_BASE/customers?api_key=$SYNCRO_API_KEY"
#
# Exports: CLAUDETOOLS_ROOT, VAULT_ROOT, SYNCRO_USER, SYNCRO_BASE, SYNCRO_API_KEY
#
# Root resolution mirrors vault.sh: derive from this script's own location, then let
# identity.json's claudetools_root override. Never hardcode a drive letter — the repo
# lives at C:/claudetools on some machines and D:/claudetools on others.
#
# Soft-fails like get-identity.sh: on any failure it warns, leaves SYNCRO_API_KEY empty,
# and returns 1 without exiting, so a sourcing skill degrades instead of dying.
_syncro_env_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CLAUDETOOLS_ROOT="$(cd "$_syncro_env_dir/../.." && pwd)"
_identity="$CLAUDETOOLS_ROOT/.claude/identity.json"
export SYNCRO_BASE="https://computerguru.syncromsp.com/api/v1"
export SYNCRO_API_KEY=""
export SYNCRO_USER=""
export VAULT_ROOT=""
if [ ! -f "$_identity" ]; then
echo "[WARNING] identity.json not found at $_identity — Syncro calls will be unauthenticated" >&2
return 1 2>/dev/null || exit 1
fi
# identity.json is authoritative for both the repo root and the vault location.
_root_override=$(jq -r '.claudetools_root // empty' "$_identity" 2>/dev/null)
[ -n "$_root_override" ] && [ -d "$_root_override" ] && export CLAUDETOOLS_ROOT="$_root_override"
export VAULT_ROOT=$(jq -r '.vault_path // empty' "$_identity" 2>/dev/null)
export SYNCRO_USER=$(jq -r '.user // empty' "$_identity" 2>/dev/null)
# Per-user token — attribution in Syncro depends on using the right one.
# Map a user name to their vaulted key path; empty = no key vaulted for them.
_syncro_key_path() {
case "$1" in
mike) echo "msp-tools/syncro" ;;
howard) echo "msp-tools/syncro-howard" ;;
winter) echo "msp-tools/syncro-winter" ;;
*) echo "" ;;
esac
}
# Prefer the requester's key when running on someone's behalf (e.g. the Discord
# bot sets CLAUDETOOLS_REQUESTER_USER per thread) so Syncro attributes actions
# to the person who asked. Fall back to the identity.json user when the
# requester has no vaulted key (e.g. rob) — never hard-fail on the preference.
_vault_path=""
_requester="${CLAUDETOOLS_REQUESTER_USER:-}"
if [ -n "$_requester" ] && [ "$_requester" != "$SYNCRO_USER" ]; then
_req_path=$(_syncro_key_path "$_requester")
if [ -n "$_req_path" ]; then
_vault_path="$_req_path"
export SYNCRO_USER="$_requester"
echo "[INFO] Using Syncro key for requester '$_requester' (attribution)" >&2
fi
fi
[ -z "$_vault_path" ] && _vault_path=$(_syncro_key_path "$SYNCRO_USER")
if [ -z "$_vault_path" ]; then
echo "[WARNING] No Syncro key vaulted for user '$SYNCRO_USER' — enrichment skipped" >&2
return 1 2>/dev/null || exit 1
fi
SYNCRO_API_KEY=$(bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
get-field "$_vault_path" credentials.credential 2>/dev/null | tail -1)
export SYNCRO_API_KEY
if [ -z "$SYNCRO_API_KEY" ]; then
echo "[ERROR] Could not read $_vault_path from vault — is SOPS/age configured?" >&2
bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "syncro-env" \
"vault read failed for $_vault_path" --context "user=$SYNCRO_USER" >/dev/null 2>&1 || true
return 1 2>/dev/null || exit 1
fi
unset _syncro_env_dir _identity _root_override _vault_path _requester _req_path
unset -f _syncro_key_path
return 0 2>/dev/null || true

View File

@@ -0,0 +1,128 @@
---
name: ask-forum
description: "Ask a teammate (Mike/Winter/Howard/anyone with forum access) a question in the private #ct-forum Discord forum and get their human answer back IN THIS SAME SESSION, then act on it - a live three-way between the user, this Claude session, and the teammate. No second bot, no DMs, one tool call (the wait runs server-side in bash, zero tokens while waiting). Triggers: ask Mike/Winter in the forum, ask the team, post a question to ct-forum, get a human answer/decision/sign-off, human-in-the-loop, run something by <person>, wait for their reply."
---
# ask-forum — human-in-the-loop questions via #ct-forum
Thin wrapper over `.claude/scripts/ask-forum.sh`. Lets THIS session put a question to a
human in the private **#ct-forum** Discord forum and receive their answer back in-session,
so it can keep going. Use it whenever you need a person's answer, decision, or sign-off
mid-task and want it to flow back to the running session — instead of stopping and asking
the user to relay it.
**USE THIS SKILL — do not hand-roll `curl` against the Discord API or the bot token.**
Free-handing the raw call is exactly what produced the `&`-on-background bug (a wait that
exited instantly and never delivered the answer). The script + this contract encode the
correct thread-create, the non-bot reply filter, and the background-wait discipline. Reach
for raw API only if this skill genuinely can't do what's needed — and say so.
## When to use
- You need a teammate's **answer / decision / approval** and want it back in this session
(e.g. "ask Mike whether to deploy", "run this naming by Winter", "get a go/no-go").
- A back-and-forth where the human's reply should drive the session's next action.
Not for: one-way copy-paste delivery of a link/command (that's `discord-dm``mike`), or
routine `[SYNCRO]`/`[RMM]` status alerts (`post-bot-alert.sh`).
## Usage
```bash
# Ask + BLOCK until a human replies, then act on the answer:
bash .claude/scripts/ask-forum.sh "your question" --tag @264814939619721216
# Re-read a thread you already asked in (one-shot, no waiting):
bash .claude/scripts/ask-forum.sh --read <thread_id>
# Resume blocking on an already-posted thread (e.g. after a timeout):
bash .claude/scripts/ask-forum.sh --wait <thread_id> --timeout 1800 --poll 10
# add --after <msg_id> to wait for replies STRICTLY AFTER a message you already
# consumed (default baseline catches any reply since the thread's starter).
```
## Robustness (hardened after the 2026-07-08 review)
- **Pages past the 100-message window:** the poll advances its `after` cursor each
cycle, so a reply is found even behind many bot/overflow messages (no false timeout).
- **Rate-limit aware:** a `429` is honored (sleeps `retry_after`); a permanent Discord
error (Unknown Channel 10003, Missing Access 50001, etc.) **bails immediately** instead
of burning the full timeout; only genuine transients keep polling.
- **Long questions** (>1900 chars) chunk into the starter + follow-up messages, and each
overflow POST is status-checked (warns if one drops, so the question isn't silently
truncated).
- **`--wait` baseline** is the thread's starter id (starter id == thread id for a forum
post), so a reply that arrived *before* the wait started is caught, not missed. Because
of this, `--wait` on a thread that already holds a consumed answer re-returns it — pass
`--after <msg_id>` when you only want the NEXT reply.
- Residual caveat: content slicing assumes a UTF-8 locale (the script sets
`LC_ALL=C.UTF-8`); a forced byte-counting `LC_ALL=C` could split a multibyte char at the
1900-char cut. Not an issue under the fleet default locale.
Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the
question) · `--timeout SEC` (default 600) · `--poll SEC` (default 8) · `--tag @<id>`
(repeatable — pings that person). Discord IDs: mike `264814939619721216`,
howard `624667664501178379`, winter `624666486362996755`, rob `261978810713505792`
(also in `.claude/users.json`).
## HARD RULE — long waits run as a proper background task (no shell `&`)
A blocking `--wait`/ask can sit for many minutes. Run it with the tool's
**`run_in_background: true`** and **nothing else** — do NOT append a shell `&`, `disown`,
or `nohup`. Adding `&` forks the wait off and the shell exits 0 immediately, orphaning the
poll loop so the answer is never delivered (logged friction, twice). Done right, the wait
costs **zero tokens** while pending and the harness notifies you the instant a human
replies — then you surface the answer to the user **unprompted**. See
[[feedback_background_task_no_ampersand]].
## How it works / correlation
- Posts a forum post via `POST /channels/<forum>/threads`, returns a **`thread_id`**.
- The session reads only **its own** `thread_id`, so an answer can never be confused with
another question's. Replies route to whoever asked — Mike's thread → Mike's session,
yours → yours, no crossover.
- **Any non-bot reply counts as the answer** (first human wins). Anyone with #ct-forum
access can answer; the script never filters by person. Bot chatter is filtered out.
## Model & scope (decided with Mike, 2026-07-08)
- **Forum-only.** No DM replies — answers stay team-visible.
- **Good-enough durability:** answers are captured while the session is open; Discord keeps
the history, so any thread is cheap to re-read (`--read`) later in the same session. It is
NOT restart-safe (close the session → nothing is watching); that was an accepted tradeoff
for the cheapest option. Full restart/close-safe capture would be the coord-backed
registry or the event-driven bot-router — build only if asked.
- The **BEAST bot ignores #ct-forum** (patch in `projects/discord-bot/bot/main.py`) so it
doesn't auto-answer and collide with the asking session.
## Access & permissions
- **Channel:** #ct-forum (`1522960388432465950`), private — `@everyone` View DENY; Howard +
the bot explicitly allowed; Mike sees it as owner. To let someone else answer, they need
**View Channel** on #ct-forum (grant a role like Techs, or the person). Winter/Rob are not
in by default.
### Bot capability map — the limit of control (tested 2026-07-08)
What the bot can do in #ct-forum with its current permissions, and where the wall is:
| Bot CAN (no grant needed) | Bot CANNOT (needs a permission grant) |
|---|---|
| Read messages / thread / members / guild channels | **Pin** a message → 403 (needs *Manage Messages*) |
| Create a forum post (thread), post follow-up messages | **Delete** a thread → 403 (needs *Manage Threads*) |
| Edit its OWN messages; add/remove reactions; typing | **Archive** a thread → 403 (needs *Manage Threads*) |
| Rename / lock a thread it owns (see trap below) | **Unlock** a thread once locked → 403 (needs *Manage Threads*) |
- **One-way trap:** as thread owner the bot can *lock* a thread (200) but then **cannot
unlock or rename it** (403) — locking without *Manage Threads* is irreversible. So the
skill does NOT lock/rename threads; treat thread lifecycle as off-limits until granted.
- **To enable cleanup:** owner grants the ClaudeTools bot **Manage Threads** on #ct-forum
(Edit Channel → Permissions → bot → Manage Threads → Allow). Then delete/archive/unlock
work. **Manage Messages** additionally enables pin.
- Reactions + own-message edits DO work with no grant — usable for lightweight
acknowledgement (react to a reply, or edit the question to "[answered]") if ever wanted.
## Exit codes
`0` answered · `1` usage · `2` no token · `3` Discord API error · `4` timeout (thread stays
open — resume with `--wait <thread_id>`).
## Implementation notes
- Bot token resolves from the SOPS vault
(`projects/discord-bot/bot-token.sops.yaml` field `credentials.bot_token`), falling back
to `projects/discord-bot/.env` `DISCORD_TOKEN` — works from any machine.
- Engine: `.claude/scripts/ask-forum.sh`. Command entry point: `/ask-forum`. Related:
[[discord-dm]] (person-targeted DMs / channel posts).

View File

@@ -130,6 +130,16 @@ erroring `a value is required for '--url'`. Push via **GuruRMM** (`/rmm`) or any
remote-exec channel. `RegKey` auto-approves + adds to the key's target group; without
it the agent registers but awaits manual approval.
**Offline endpoint whose GuruRMM agent is down but ScreenConnect is up:** the one-liner
is a single self-contained command, so queue it via the `screenconnect` skill's
`send-command --session <id> --command "powershell -NoProfile -ExecutionPolicy Bypass
-Command \"...Install-EDR...\"" --confirm` — SC holds it in the session's one command slot
and runs it on reconnect (verify by `agents --org` / `sweep` afterward; SC returns no
output). Used 2026-07-08 to stage REPAIRADMIN (IMC) while it was offline overnight.
**AV-swap ordering:** when replacing another AV (e.g. Bitdefender), install + verify EDR
live FIRST, then remove the old AV — never leave an unprotected window. Note Bitdefender's
API has NO uninstall (console-only); pull it via its local uninstall tool over `/rmm`.
## Safety gating
- Reads never mutate and run without confirmation.

View File

@@ -108,6 +108,57 @@ installed, hook, network, events`, plus `extensions:[{id,args,order}]`.
the extension id via `GET /Extensions` (e.g. `Host Isolation [Win/Linux]`) and test on
an ACG-internal box first.
## Alert Suppression Rules (verified live 2026-07-07)
Suppress a false-positive detection so it stops firing (and stops triggering any
attached auto-response like `isolate-host`). LoopBack models are API-reachable:
| Op | Method/path |
|---|---|
| List rules | `GET /SuppressionRules` (`[id, alertId, name, versionCount, description, organizationId, locationId, active, deleted]`) |
| Count | `GET /SuppressionRules/count` |
| Match criteria (per rule) | `GET /SuppressionRules/{id}/versions` → `[{id, suppressionRuleId, name, description, metadata:{...}}]` |
**`metadata`** is the full match-field map; each key is `{value, active, display, dataType,
operator}`. A field participates in the match ONLY when `active:true`; all active fields are
**AND**-ed. Available fields (`display`): Alert Type, Item Type, Organization Name, Location
Name, Hostname, IP Address, Name, File Path, File SHA1, File SHA256, File Signature Issuer,
**Process Command Line**, AV Threat Name, Operating System, Threat Status, Severity, Rule
Name, EPP Type, Threat Category, Process Owner, Process Owner UID, AV Hits, Parent Process
Name, Grand Parent Process Name.
**Scoping guidance (learned from the vwp-qbs RMM false-positive incident):** the tightest
safe suppression matches on **Process Command Line** (a unique fingerprint of the exact
script) plus **Grand Parent Process Name** (the launching agent, e.g. `gururmm-agent.exe`).
That trusts one exact known-good automation without blinding a whole binary. NEVER suppress
on `Name`/`File Path`/`File SHA*` of a LOLBin (powershell/rundll32/etc.) alone — it disables
the rule for that binary everywhere. Add `Rule Name` to limit to one rule; add `Hostname`/
`Organization Name`/`Location Name` to limit scope (empty `organizationId`/`locationId` on the
rule = fleet-wide, gated by the metadata match). **Do NOT blanket-whitelist an RMM agent as
grandparent by itself** — the RMM is a SYSTEM-level RCE channel and the top MSP attack path;
whitelisting all of it blinds EDR exactly where it matters.
**[DANGER] API create footgun (verified live 2026-07-07).** `POST /SuppressionRules` on this
tenant (a) **ignores `active:false` and forces the rule LIVE immediately**, and (b) auto-creates
an **EMPTY-metadata version that matches EVERYTHING**. So the two-step "create rule → then add
version" path leaves a live match-all suppression window (observed ~2 min → deleted). Also note
`GET`/`PATCH /SuppressionRules/{id}` (single-resource route) **HTTP 400** "undefined is not valid
JSON" on this tenant — read via `GET /SuppressionRules?filter={"where":{"id":"..."}}` instead;
**`DELETE /SuppressionRules/{id}` DOES work** (returns null, sets `deleted:true`). Until an
**atomic** create (metadata embedded in the POST body) is verified, **create suppressions in the
CONSOLE**, not via `raw POST`. If you must POST, verify the version's active fields IMMEDIATELY
and `DELETE` on any doubt.
**Create — CONSOLE-observed, API create RUN-unverified.** Console flow: open the alert →
**Create Suppression Rule** → check the desired Match fields → Save. This POSTs a
`SuppressionRule` (carrying `alertId`, `name`, `description`, `organizationId`, `locationId`,
`active`) plus a version whose `metadata` has the chosen fields flipped to `active:true`. To
replicate via API, POST `/SuppressionRules` then the version to `/SuppressionRules/{id}/
versions` with that metadata shape — verify the exact envelope on the next real create (build
→ read back via the GET above → delete if wrong) before relying on it. Example on this tenant:
rule `e4dd55bf-…` (name "Exfiltration Over HTTP Protocol", alertId `5d5f39b1-…`) matches
Process Command Line + Grand Parent = `gururmm-agent.exe`.
## Deployment (not a REST call)
The agent installs by running the binary on the endpoint:

View File

@@ -267,7 +267,7 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("method", help="GET/POST/PUT/DELETE")
sp.add_argument("path", help="e.g. Agents or Agents/scan")
sp.add_argument("--filter", help="LoopBack filter JSON (GET)")
sp.add_argument("--data", help="request body JSON")
sp.add_argument("--data", help="request body JSON, or @path to read JSON from a file")
sp.add_argument("--confirm", action="store_true",
help="required for non-GET methods")
return p
@@ -408,7 +408,13 @@ def main(argv=None) -> int:
print(f"[BLOCKED] {method} requires --confirm.", file=sys.stderr)
return 2
filt = json.loads(args.filter) if args.filter else None
body = json.loads(args.data) if args.data else None
if args.data and args.data.startswith("@"):
with open(args.data[1:], "r", encoding="utf-8") as _f:
body = json.load(_f)
elif args.data:
body = json.loads(args.data)
else:
body = None
# Same tenant-wide footgun guard the scan command has: a POST to any
# */scan endpoint with no non-empty `where` scans the ENTIRE tenant.
if method == "POST" and args.path.rstrip("/").lower().endswith("scan"):

View File

@@ -0,0 +1,3 @@
# No live secrets are cached (Basic auth is per-request), but keep scratch out.
.cache/
__pycache__/

View File

@@ -0,0 +1,74 @@
---
name: datto-workplace
description: "Read-only Datto Workplace backup verification for the GPS audit: confirm the file API is reachable under Basic auth, and (primary path) detect which GuruRMM endpoints actually run the Datto Workplace sync client. The file API is file-access only and this key sees no shared projects, so RMM endpoint detection is the real answer. Triggers: datto workplace, dattoworkplace, dwp, who backs up to datto workplace, datto sync."
---
# Datto Workplace Skill
Read-only client for ACG's live **Datto Workplace** tenant, built for GPS backup
verification. Answers: **which enrolled machines actually run the Datto Workplace
sync client** (and, where the file API allows, what projects/files are visible).
This skill is one of the backup-verification siblings alongside `seafile`,
`b2` (Backblaze), and `owncloud`. It never writes.
## Running
```bash
DWP=".claude/skills/datto-workplace/scripts/datto_workplace.py"
py "$DWP" status # file API reachable + endpoint/project count
py "$DWP" projects [--json] # file-API projects (likely empty for this key)
py "$DWP" audit --rmm --client "Birth" # GPS view: detect the client on GuruRMM endpoints
py "$DWP" audit --rmm --client Birth --json
```
Add `--json` to any command for machine-readable output. `--url` overrides the
file-API base.
## Credentials
Loaded at runtime from the SOPS vault (never hardcoded):
`msp-tools/datto-workplace.sops.yaml` -> `credentials.api-key` / `credentials.api-secret`.
Auth is **HTTP Basic**: username = api-key, password = api-secret, sent on every
request (no token exchange, nothing cached).
## The file API is limited (why RMM detection is primary)
Datto Workplace exposes a **file-access REST API v1** only. Base:
`https://acg.workplace.datto.com/api/v1` (the team path `/6/api/v1` is normalized
by the server to `/api/v1`). Endpoints (confirm with `GET /openapi.json`, surfaced
by `status`): `GET /file/projects`, `GET /file/{parentID}/files`,
`GET /file/{fileID}`, `GET /file/{fileID}/data`, `GET /file/search`, plus
`/webhook*`. There are **NO** user / device / member / team endpoints.
GOTCHA: `GET /file/projects` currently returns `{"result": []}` for this key -- the
API principal has no projects shared to it. So the file API alone **cannot
enumerate the team's backups**. `projects` handles this and says so.
Because of that, **RMM endpoint detection is the PRIMARY source of truth**.
## The `--rmm` cross-check (primary path)
`audit --rmm` logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and,
for each agent (scope with `--client` so it doesn't sweep all 350+ agents), runs a
**read-only** detection PowerShell that reports installed products / running
processes / config folders matching the Datto Workplace client. An agent is
reported only if the client is actually present.
**Detection patterns must be precise:** this skill uses `["Datto Workplace",
"Workplace2"]`. A bare `"Datto"` false-matches **Datto EDR Agent**, Datto RMM, and
Datto AV -- do NOT use it. The v10 client installs as DisplayName
"Datto Workplace v10.x" in `C:\Program Files\Datto\Workplace2\` (process
`Workplace.exe`); the legacy v8 is "Datto Workplace Desktop". Matching
`Datto Workplace` + `Workplace2` catches both without catching EDR.
## Notes / gotchas
- Always scope `audit --rmm` with `--client` unless you truly mean to sweep the
whole fleet -- detection dispatches a live command to each endpoint.
- Shared plumbing (repo-root resolve, vault read, HTTP, GuruRMM client + endpoint
detection) lives in `.claude/scripts/backup_common.py`, used by all the
backup-verification siblings. Detection patterns are per-backend; this skill's
are the precise Datto Workplace set above.
- Known user: **Birth Biologic** backs up to Datto Workplace (agent hostnames
include `BB-*`, `KSTEEN*`, `EVO-X1`) -- a good `--client "Birth"` smoke test.

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Datto Workplace REST API v1 client for the `datto-workplace` skill.
Talks to ACG's live Datto Workplace tenant file API. Read-only: this is a
FILE-ACCESS API (projects + files), NOT an admin/reporting API -- there are no
user/device/member/team endpoints. It answers "what shared projects + files does
this API principal see" for the GPS backup audit.
Auth: HTTP Basic, username = api-key, password = api-secret (no token exchange;
Basic is sent on every request). Credentials come from the SOPS vault.
Base URL defaults to the team file-API host. The team path /6/api/v1 is normalized
by the server to /api/v1; override with DATTO_WORKPLACE_URL if needed.
IMPORTANT GOTCHA: GET /file/projects currently returns {"result": []} for this
key -- the principal has no projects shared to it, so the file API alone CANNOT
enumerate the team's backups. Handle empty gracefully. Because of that, RMM
endpoint detection (see datto_workplace.py `audit --rmm`) is the PRIMARY source
of "who backs up to Datto Workplace".
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Optional
# Import the shared backup helpers from .claude/scripts.
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/datto-workplace
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
sys.path.insert(0, str(_SCRIPTS_DIR))
import backup_common as bc # noqa: E402
VAULT_ENTRY = "msp-tools/datto-workplace.sops.yaml"
VAULT_FIELD_KEY = "credentials.api-key"
VAULT_FIELD_SECRET = "credentials.api-secret"
DEFAULT_URL = os.environ.get("DATTO_WORKPLACE_URL", "https://acg.workplace.datto.com/api/v1")
class DattoWorkplaceClient:
"""Basic-auth GET client for the Datto Workplace file API.
No token caching is needed -- HTTP Basic is sent per request. Credentials are
read once from the vault on first use and reused for the process lifetime.
"""
def __init__(self, base: Optional[str] = None):
self.base = (base or DEFAULT_URL).rstrip("/")
self._basic: Optional[tuple[str, str]] = None
# -- auth ------------------------------------------------------------------
def _creds(self) -> tuple[str, str]:
if self._basic is None:
key = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_KEY)
secret = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_SECRET)
self._basic = (key, secret)
return self._basic
def _get_raw(self, path: str, params: Optional[dict] = None) -> tuple[int, Any]:
"""Low-level GET returning (status, body); callers decide how to react."""
url = f"{self.base}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
return bc.http_json("GET", url, basic=self._creds())
def _get(self, path: str, params: Optional[dict] = None) -> Any:
status, body = self._get_raw(path, params)
if status == 401:
raise bc.BackupError(
f"Datto Workplace auth failed (HTTP 401) for {path}: check api-key/api-secret",
status=status)
if status != 200:
raise bc.BackupError(
f"Datto Workplace GET {path} failed (HTTP {status}): {body}", status=status)
return body
# -- read methods ----------------------------------------------------------
def openapi(self) -> dict:
"""Fetch the OpenAPI spec (confirms reachability + exact endpoint list)."""
body = self._get("/openapi.json")
return body if isinstance(body, dict) else {"_raw": body}
def endpoint_paths(self) -> list[str]:
"""Sorted list of paths the OpenAPI spec exposes."""
spec = self.openapi()
paths = spec.get("paths") if isinstance(spec, dict) else None
return sorted(paths.keys()) if isinstance(paths, dict) else []
def projects(self) -> list[dict]:
"""Top-level file projects visible to this API principal.
Returns [] gracefully -- for this key the principal currently has no
projects shared to it. The server is inconsistent here: some responses
are 200 {"result": []}, others are 403 "Access denied" for the exact same
Basic auth that authorizes /openapi.json seconds earlier. Both mean the
same thing for the audit (no project-level file access), so a 403/401 on
this endpoint is treated as empty rather than a hard error. A genuine
credential failure surfaces on /openapi.json (status/openapi).
"""
status, body = self._get_raw("/file/projects")
if status in (401, 403):
return []
if status != 200:
raise bc.BackupError(
f"Datto Workplace GET /file/projects failed (HTTP {status}): {body}",
status=status)
return self._as_list(body)
def files(self, parent_id: str) -> list[dict]:
"""List files/folders under a parent project or folder ID."""
body = self._get(f"/file/{parent_id}/files")
return self._as_list(body)
def file_info(self, file_id: str) -> dict:
"""Metadata for a single file/folder ID."""
body = self._get(f"/file/{file_id}")
if isinstance(body, dict):
return body.get("result") if isinstance(body.get("result"), dict) else body
return {"_raw": body}
def search(self, query: str, parent_id: Optional[str] = None) -> list[dict]:
"""Search files by name. Optional parent scope."""
params = {"query": query}
if parent_id:
params["parentID"] = parent_id
body = self._get("/file/search", params)
return self._as_list(body)
@staticmethod
def _as_list(body: Any) -> list[dict]:
"""Normalize the Datto file-API envelope to a list.
Responses wrap payloads as {"result": [...]} (or a bare list on some
endpoints); anything else yields [].
"""
if isinstance(body, dict):
res = body.get("result")
if isinstance(res, list):
return res
if isinstance(res, dict):
return [res]
return []
if isinstance(body, list):
return body
return []
def main() -> int:
try:
c = DattoWorkplaceClient()
paths = c.endpoint_paths()
projects = c.projects()
print(f"[OK] Datto Workplace file API reachable at {c.base}; "
f"{len(paths)} endpoints, {len(projects)} project(s) visible")
return 0
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""CLI for the `datto-workplace` skill -- read-only Datto Workplace backup
verification for the GPS audit.
Commands:
status file API reachable under Basic auth + endpoint/project count
projects [--json] list file-API projects (likely empty for this principal)
audit [--client NAME] [--rmm] [--json]
the GPS view: server-side projects (if any), optionally
cross-checked against GuruRMM endpoints (Datto Workplace
client installed + signed in?)
The Datto Workplace file API is FILE-ACCESS ONLY and this principal sees no shared
projects, so it CANNOT enumerate the team's backups on its own. RMM endpoint
detection (`audit --rmm`) is therefore the PRIMARY source: it detects the Datto
Workplace client on GuruRMM agents using precise patterns that do NOT false-match
Datto EDR / Datto RMM / Datto AV.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from datto_client import DattoWorkplaceClient # noqa: E402
import backup_common as bc # noqa: E402
# Precise detection patterns. "Datto Workplace" catches the DisplayName of both the
# v10 client ("Datto Workplace v10.x") and legacy v8 ("Datto Workplace Desktop");
# "Workplace2" catches the v10 install path / process family. A bare "Datto" would
# false-match "Datto EDR Agent", Datto RMM, and Datto AV -- do NOT use it.
DETECT_PATTERNS = ["Datto Workplace", "Workplace2"]
def cmd_status(c: DattoWorkplaceClient, args) -> int:
paths = c.endpoint_paths()
projects = c.projects()
if args.json:
print(json.dumps({"base": c.base, "reachable": True,
"endpoints": paths, "project_count": len(projects)}, indent=2))
return 0
print(f"[OK] Datto Workplace file API reachable at {c.base} (Basic auth 200)")
print(f"[INFO] {len(paths)} endpoints exposed:")
for p in paths:
print(f" {p}")
print(f"[INFO] {len(projects)} project(s) visible to this API principal", end="")
if not projects:
print(" - file API cannot enumerate backups; use `audit --rmm`")
else:
print()
return 0
def cmd_projects(c: DattoWorkplaceClient, args) -> int:
projects = c.projects()
if args.json:
print(json.dumps(projects, indent=2))
return 0
if not projects:
print("[INFO] no file-API projects visible to this principal.")
print("[INFO] The api-key has no projects shared to it, so GET /file/projects "
"returns []. This is expected - the Datto Workplace file API is file-access "
"only. Use `audit --rmm` to verify backups via GuruRMM endpoint detection.")
return 0
print(f"{'NAME':40} {'ID':38} {'FILES':>8}")
for p in projects:
print(f"{str(p.get('name') or '')[:40]:40} "
f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} "
f"{str(p.get('fileCount') or p.get('count') or '?'):>8}")
print(f"\n{len(projects)} project(s)")
return 0
def cmd_audit(c: DattoWorkplaceClient, args) -> int:
projects = c.projects()
result = {"backend": "datto-workplace", "base": c.base,
"file_api_projects": projects, "file_api_limited": not projects}
if args.rmm:
rmm = bc.RmmClient()
rmm.login()
checks = []
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
for a in agents:
det = rmm.detect_client(a, DETECT_PATTERNS)
if det.get("detected"):
checks.append(det)
result["rmm_endpoints_with_client"] = checks
result["rmm_agents_scanned"] = len(agents)
if args.json:
print(json.dumps(result, indent=2))
return 0
print(f"=== Datto Workplace backup audit - {c.base} ===")
if projects:
print(f"{'PROJECT':40} {'ID':38} {'FILES':>8}")
for p in projects:
print(f"{str(p.get('name') or '')[:40]:40} "
f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} "
f"{str(p.get('fileCount') or p.get('count') or '?'):>8}")
print(f"\n{len(projects)} file-API project(s)")
else:
print("[INFO] file API shows 0 projects (principal has none shared) - "
"server-side enumeration not available; relying on RMM detection.")
if args.rmm:
eps = result.get("rmm_endpoints_with_client", [])
scanned = result.get("rmm_agents_scanned", 0)
scope = f"client~'{args.client}'" if args.client else "ALL agents"
print(f"\n--- GuruRMM endpoints running the Datto Workplace client "
f"({len(eps)} of {scanned} scanned, {scope}) ---")
for e in eps:
det = e.get("detail") or {}
installed = det.get("installed") or []
prod = "-"
if isinstance(installed, list) and installed:
first = installed[0]
if isinstance(first, dict):
prod = f"{first.get('name') or '?'} {first.get('version') or ''}".strip()
elif isinstance(installed, dict):
prod = f"{installed.get('name') or '?'} {installed.get('version') or ''}".strip()
print(f" {str(e.get('hostname') or '')[:25]:25} "
f"client={str(e.get('client_name') or ''):20} "
f"product={prod}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="datto-workplace",
description="Datto Workplace backup verification (GPS audit)")
p.add_argument("--url", help="override file-API base URL (default team file-API host)")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("status"); s.add_argument("--json", action="store_true")
pr = sub.add_parser("projects"); pr.add_argument("--json", action="store_true")
a = sub.add_parser("audit")
a.add_argument("--client", help="scope RMM detection to agents whose client_name matches")
a.add_argument("--rmm", action="store_true", help="cross-check GuruRMM endpoints (primary path)")
a.add_argument("--json", action="store_true")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
c = DattoWorkplaceClient(args.url)
handlers = {"status": cmd_status, "projects": cmd_projects, "audit": cmd_audit}
try:
return handlers[args.cmd](c, args)
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
# best-effort error logging per house rule
try:
root = bc.resolve_root()
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
"datto-workplace", str(exc)[:200]], timeout=15)
except Exception:
pass
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,6 +1,6 @@
---
name: discord-dm
description: "Send a Discord message to an org member DM or team channel via the ClaudeTools bot - copy-paste-friendly delivery of links/commands the terminal would mangle; address people by name (mike/howard/rob/winter). Triggers: DM <person> in discord, send that link to my discord, ping <person>."
description: "Send OR read Discord messages to/from an org member DM or team channel via the ClaudeTools bot - copy-paste-friendly delivery of links/commands the terminal would mangle, and READ replies (coord/repo sync does not carry Discord answers); address people by name (mike/howard/rob/winter). Triggers: DM <person> in discord, send that link to my discord, ping <person>, did <person> reply/answer, read discord replies, check discord DMs."
---
# discord-dm — direct Discord messaging to the org
@@ -27,9 +27,26 @@ DMs** and deliberate channel posts.
```bash
bash .claude/scripts/discord-dm.sh <recipient> "message text"
echo "message text" | bash .claude/scripts/discord-dm.sh <recipient>
bash .claude/scripts/discord-dm.sh read <recipient> [limit] # READ recent messages — SEE replies (default 15)
bash .claude/scripts/discord-dm.sh list # print known users + channels
```
## Reading replies (`read`)
Sends are one-way, but people **answer** — always `read` before assuming silence. The bot
participates in every DM channel it opened, so `GET /channels/<id>/messages` returns full
content (the Message Content privileged intent only gates *gateway* events, not this REST
fetch). Works for users and channels:
```bash
bash .claude/scripts/discord-dm.sh read mike 10 # last 10 in the mike DM
bash .claude/scripts/discord-dm.sh read #dev-alerts # last 15 in #dev-alerts
```
Output is oldest-first; lines from **them** are marked `>>>`, lines from us are indented.
After DMing someone a question, `read` that user to check for their answer — coord/repo sync
does NOT carry Discord replies, so this is the only way to see them.
`<recipient>`:
| Form | Effect |

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

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

View File

@@ -0,0 +1,103 @@
---
name: msp360
description: "Manage ACG's MSP360 Managed Backup Service (MBS) tenant via the provider REST API: the authoritative fleet backup-status source (who is backing up, last run, failures, stale plans) plus user/company/license provisioning. Reads run freely; writes (create/delete user or company, grant/release/revoke license) are gated --confirm. Triggers: msp360, mspbackups, managed backup, is X backing up, backup status, backup failures, create backup user, add backup company, grant backup license."
---
# MSP360 Managed Backup Service Skill
Live client for ACG's **MSP360 Managed Backup Service (MBS)** provider tenant. This is the
**authoritative answer to "is client X actually backing up"** — the monitoring feed reports
every backup plan's last run, status, and data across the whole fleet. B2/Seafile/ownCloud
are just where the data lands; MSP360 is where you see whether a backup *ran*.
Built as a sibling to the `b2` / `seafile` / `owncloud` / `datto-workplace` backup skills;
shares `.claude/scripts/backup_common.py` for repo-root/vault/HTTP plumbing.
## Running
```bash
MSP=".claude/skills/msp360/scripts/msp360.py"
# --- reads (free) ---
py "$MSP" status # reachable + counts + plans needing attention
py "$MSP" monitoring # every plan: company/computer/status/last-run/data
py "$MSP" monitoring --failed # only plans NOT in a clean state (the triage view)
py "$MSP" monitoring --company Dataforth # scope to one client
py "$MSP" monitoring --stale 30 # plans whose last run is >=30 days old (or never)
py "$MSP" companies # 37 companies with ids + storage limits
py "$MSP" users --company "Business Services" # backup users (+ space used) in a company
py "$MSP" licenses --available # free vs taken licenses
py "$MSP" billing --json # billing feed
py "$MSP" raw GET /api/Users/{id} # generic passthrough for any GET endpoint
# --- writes (require --confirm; without it you get a dry-run preview) ---
py "$MSP" create-company --name "New Client LLC" --confirm
py "$MSP" create-user --email backup@client.com --company "New Client LLC" \
--first Jane --last Doe --notify admin@acg.com --confirm
py "$MSP" grant-license --user-id <UUID> --license-id <UUID> --confirm
py "$MSP" release-license --user-id <UUID> --confirm
py "$MSP" delete-user --id <UUID> [--keep-data] --confirm
py "$MSP" delete-company --id <UUID> --confirm
py "$MSP" raw POST /api/Accounts/CreateDestination --data '{...}' --confirm
```
Add `--json` to any read for machine-readable output. `--base` overrides the API URL.
## Credentials
Loaded at runtime from the SOPS vault (never hardcoded):
**`msp-tools/msp360-api.sops.yaml`** -> `credentials.login` / `credentials.password`.
These are the **API-specific** creds
generated in the MSP360 console (Settings > General) by the main admin account — NOT the
console login. Auth flow: `POST /api/Provider/Login {UserName, Password}` -> Bearer
`access_token` (per-run, never cached to disk) -> `Authorization: Bearer <token>` on every
request. Base: `https://api.mspbackups.com`.
## What "backing up?" looks like
`monitoring` returns one row per backup plan with `Status` (0/1=Success, 2=Warning,
3=Failed, 4=Running, 6=Interrupted, 7=Completed-with-warnings), `LastStart`, `DataCopied`,
`CompanyName`, `ComputerName`, `PlanName`. Key reads:
- **A company with 0 rows in `monitoring` is not backing up** even if it exists (this is how
the GPS audit found Arizona Medical Transit + T&C Sorensen billed-but-unprotected).
- `--failed` surfaces failing/warning plans; `--stale N` surfaces plans that stopped running.
- A B2 bucket with files does NOT prove a recent backup — always confirm with `monitoring`.
## Writes & safety
- **Every write is gated `--confirm`.** Without it the command prints a `[DRY-RUN]` line (and,
for create-*, the exact JSON payload) and changes nothing.
- `delete-user` defaults to removing metadata **and all backup data**; pass `--keep-data` to
drop only the metadata (`DELETE /api/Users/{id}/Account`) and retain the backups.
- `delete-company` / `delete-user` print a `[WARNING]` describing the blast radius first.
- create-user / grant-license payloads are built from the live API schema (User fields:
Email/FirstName/LastName/Company/Enabled/NotificationEmails; grant: UserID + LicenseID).
On first real provisioning, eyeball the API's response — it returns the created object or a
validation error, which the command prints verbatim.
## Full endpoint surface (via `raw` when a typed command doesn't exist)
The MBS API groups (all reachable through `raw <METHOD> <path>`): **Provider** (login),
**Users** (GET/POST/PUT, DELETE `/{id}` or `/{id}/Account`), **Companies** (GET/POST/PUT/DELETE),
**Monitoring** (GET), **Licenses** (GET, POST Grant/Release/Revoke), **Destinations**
(GET/POST/PUT/DELETE), **Accounts** (GET/POST/PUT + Add/Create/Edit/RemoveDestination),
**Packages** (GET/POST/PUT/DELETE), **Billing** (GET/PUT), **Builds** (GET, POST
RequestCustomBuilds), **Administrators** (GET/POST/PUT/DELETE). Reference:
`https://help.mspbackups.com/mbs-api-specification/methods`.
## Notes / gotchas
- **`/api/Computers` returns HTTP 400 "Remote Management API methods are not enabled"** —
that feature is off on this account. `computers` prints a clean [INFO] and points you at
`users` + `monitoring`, which cover endpoint/backup data without it.
- **Git-Bash path mangling:** a leading-slash `raw` path like `/api/Users` gets rewritten to
`C:/Program Files/Git/api/Users` by MSYS. The `raw` command auto-recovers the `/api/...`
tail, so pass the path normally.
- MSP360 CompanyName is the join key to B2 buckets (e.g. `ACG-PST`=Peaceful Spirit,
`ACG-TCA`=Tucson Coin and Autograph) — useful when reconciling destinations.
- Backup *targets* are a per-client decision: report mismatches, don't provision jobs off a
billing count. See memory `feedback_backup_targets_never_guessed` +
`reference_msp360_backup_monitoring`.
- Failures are logged to the fleet `errorlog.md` via the canonical helper; expected
conditions (feature-gated endpoint, no matching rows) are not.

View File

@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""MSP360 Managed Backup Service (MBS) CLI.
Read the fleet backup state and manage users / companies / licenses via the MBS
provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is
printed without it). See SKILL.md for the trigger list and examples.
Reads: status | monitoring | companies | users | computers | licenses | billing
Writes: create-company | delete-company | create-user | delete-user
grant-license | release-license | revoke-license | raw
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS # noqa: E402
import backup_common as bc # noqa: E402 (msp360_client put .claude/scripts on sys.path)
# --- helpers ------------------------------------------------------------------
def _root() -> Path:
# honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing.
return bc.resolve_root()
def _as_list(data, what: str) -> list:
"""A read endpoint promised a JSON array; a non-list 2xx body is anomalous."""
if isinstance(data, list):
return data
raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}")
def log_error(brief: str, context: str = "") -> None:
"""Best-effort fleet error log; never breaks the caller (per CLAUDE.md)."""
try:
script = _root() / ".claude" / "scripts" / "log-skill-error.sh"
if not script.exists():
return
cmd = ["bash", str(script), "msp360", brief]
if context:
cmd += ["--context", context]
subprocess.run(cmd, capture_output=True, text=True, timeout=20)
except Exception:
pass
def out(obj, as_json: bool) -> None:
if as_json:
print(json.dumps(obj, indent=2, default=str))
def human(n) -> str:
try:
n = float(n)
except (TypeError, ValueError):
return str(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(n) < 1024:
return f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}PB"
def _days_since(ts: str):
"""Whole days since an ISO timestamp. None if empty OR unparseable (callers must
distinguish 'never ran' from 'could not parse' via the raw value themselves)."""
if not ts:
return None
try:
dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_dt.timezone.utc)
return (_dt.datetime.now(_dt.timezone.utc) - dt).days
except Exception:
return None
def require_confirm(args, action: str) -> bool:
"""Return True to proceed; if --confirm absent, print the dry-run and return False."""
if getattr(args, "confirm", False):
return True
print(f"[DRY-RUN] Would: {action}")
print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.")
return False
# --- read commands ------------------------------------------------------------
def cmd_status(c: Msp360Client, args) -> int:
comps = c.companies()
users = c.users()
mon = c.monitoring()
if args.json:
out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True)
return 0
bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS)
print(f"[OK] MSP360 MBS reachable at {c.base}")
print(f"[INFO] companies={len(comps)} users={len(users)} backup plans={len(mon)} "
f"plans needing attention={bad}")
return 0
def cmd_monitoring(c: Msp360Client, args) -> int:
mon = _as_list(c.monitoring(), "monitoring")
rows = []
for m in mon:
if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower():
continue
status = m.get("Status")
last_raw = m.get("LastStart") or ""
never = not last_raw
age = _days_since(last_raw)
# --failed = triage: anything that is NOT a clean completed run and is not
# in-progress. Includes never-run, Unknown(5) and null status (dead plans).
if args.failed:
clean_success = status in (0, 1) and not never
if clean_success or status == 4: # 4 = Running
continue
# --stale = last run older than N days, OR never ran. An unparseable-but-present
# timestamp is NOT claimed stale (avoid a false "not backing up").
if args.stale is not None:
if never:
pass
elif age is not None and age >= args.stale:
pass
else:
continue
rows.append((m, age))
if args.json:
out([r[0] for r in rows], True)
return 0
if not rows:
print("[INFO] no plans match the filter.")
return 0
print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA")
for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(),
r[0].get('ComputerName') or '')):
st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status")))
last = m.get("LastStart") or "never"
agestr = f" ({age}d)" if age is not None and age >= 30 else ""
print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} "
f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} "
f"{human(m.get('DataCopied', 0))}")
print(f"\n[INFO] {len(rows)} plan(s). Filters: "
f"company={args.company or '-'} failed={args.failed} stale>={args.stale}")
return 0
def cmd_companies(c: Msp360Client, args) -> int:
comps = _as_list(c.companies(), "companies")
if args.json:
out(comps, True)
return 0
for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()):
lim = co.get("StorageLimit")
limstr = "unlimited" if lim in (-1, None) else human(lim)
print(f" {(co.get('Name') or ''):<34} id={co.get('Id')} storage_limit={limstr}")
print(f"\n[INFO] {len(comps)} companies")
return 0
def cmd_users(c: Msp360Client, args) -> int:
users = _as_list(c.users(), "users")
if args.company:
users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()]
if args.json:
out(users, True)
return 0
for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")):
en = "enabled" if u.get("Enabled") else "DISABLED"
print(f" {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} "
f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}")
print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else ""))
return 0
def cmd_computers(c: Msp360Client, args) -> int:
try:
comps = _as_list(c.computers(), "computers")
except Msp360Error as exc:
msg = str(exc).lower()
if "remote management" in msg or "not enabled" in msg or "not available" in msg:
print("[INFO] /api/Computers is gated behind the Remote Management API feature, "
"which is not enabled on this MSP360 account. Use `users` + `monitoring` "
"for endpoint/backup data instead.")
return 0
raise
if args.json:
out(comps, True)
return 0
for m in comps:
print(f" {(m.get('Name') or m.get('ComputerName') or ''):<24} "
f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}")
print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers")
return 0
def cmd_licenses(c: Msp360Client, args) -> int:
lic = _as_list(c.licenses(available=True if args.available else None), "licenses")
if args.json:
out(lic, True)
return 0
taken = sum(1 for x in lic if x.get("IsTaken"))
for x in lic:
state = "TAKEN" if x.get("IsTaken") else "free"
num = x.get("Number")
num = "" if num is None else num
print(f" #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} "
f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}")
print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)")
return 0
def cmd_billing(c: Msp360Client, args) -> int:
out(c.billing(), True) # billing has no table form; always JSON
return 0
# --- write commands -----------------------------------------------------------
def cmd_create_company(c: Msp360Client, args) -> int:
if not require_confirm(args, f"create company '{args.name}'"):
return 0
res = c.create_company(args.name)
print(f"[OK] created company '{args.name}'")
out(res, True)
return 0
def cmd_delete_company(c: Msp360Client, args) -> int:
print(f"[WARNING] Deleting company id={args.id} removes the company record.")
if not require_confirm(args, f"DELETE company id={args.id}"):
return 0
res = c.delete_company(args.id)
print(f"[OK] deleted company id={args.id}")
out(res, True)
return 0
def cmd_create_user(c: Msp360Client, args) -> int:
payload = {
"Email": args.email,
"FirstName": args.first or "",
"LastName": args.last or "",
"Company": args.company,
"Enabled": True,
"NotificationEmails": [args.notify] if args.notify else [],
}
if args.password:
payload["Password"] = args.password
if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"):
preview = dict(payload)
if "Password" in preview:
preview["Password"] = "***redacted***" # never echo a secret to the transcript
out(preview, True)
return 0
res = c.create_user(payload)
print(f"[OK] created user '{args.email}'")
out(res, True)
return 0
def cmd_delete_user(c: Msp360Client, args) -> int:
scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data"
print(f"[WARNING] Deleting user id={args.id}: {scope}.")
if not require_confirm(args, f"DELETE user id={args.id} ({scope})"):
return 0
res = c.delete_user(args.id, keep_data=args.keep_data)
print(f"[OK] deleted user id={args.id}")
out(res, True)
return 0
def cmd_grant_license(c: Msp360Client, args) -> int:
payload = {"UserID": args.user_id}
if args.license_id:
payload["LicenseID"] = args.license_id
if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"):
out(payload, True)
return 0
res = c.grant_license(payload)
print("[OK] license granted")
out(res, True)
return 0
def cmd_release_license(c: Msp360Client, args) -> int:
payload = {"UserID": args.user_id}
if args.license_id:
payload["LicenseID"] = args.license_id
if not require_confirm(args, f"release license from user {args.user_id}"):
return 0
res = c.release_license(payload)
print("[OK] license released")
out(res, True)
return 0
def cmd_revoke_license(c: Msp360Client, args) -> int:
payload = {"UserID": args.user_id}
if args.license_id:
payload["LicenseID"] = args.license_id
if not require_confirm(args, f"revoke license from user {args.user_id}"):
return 0
res = c.revoke_license(payload)
print("[OK] license revoked")
out(res, True)
return 0
def cmd_raw(c: Msp360Client, args) -> int:
method = args.method.upper()
body = json.loads(args.data) if args.data else None
# Git-Bash rewrites a leading-slash arg into a Windows path (e.g.
# '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail.
path = args.path
if "/api/" in path and not path.startswith("/api"):
path = path[path.index("/api/"):]
elif not path.startswith("/"):
path = "/" + path
if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"):
return 0
res = c.request(method, path, body=body)
out(res, True)
return 0
# --- argparse -----------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI")
p.add_argument("--base", help="override API base URL")
sub = p.add_subparsers(dest="command", required=True)
def add_json(sp):
sp.add_argument("--json", action="store_true", help="machine-readable JSON output")
return sp
add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status)
sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)")
sp.add_argument("--company", help="filter by company name substring")
sp.add_argument("--failed", action="store_true", help="only plans not in a clean state")
sp.add_argument("--stale", type=int, metavar="DAYS",
help="only plans whose last run is >= DAYS old (or never)")
add_json(sp)
sp.set_defaults(func=cmd_monitoring)
add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies)
sp = sub.add_parser("users", help="list backup users")
sp.add_argument("--company", help="filter by company name substring")
add_json(sp)
sp.set_defaults(func=cmd_users)
add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers)
sp = sub.add_parser("licenses", help="list licenses")
sp.add_argument("--available", action="store_true", help="only free/available licenses")
add_json(sp)
sp.set_defaults(func=cmd_licenses)
sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing)
# writes
sp = sub.add_parser("create-company", help="create a company [--confirm]")
sp.add_argument("--name", required=True)
sp.add_argument("--confirm", action="store_true")
sp.set_defaults(func=cmd_create_company)
sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]")
sp.add_argument("--id", required=True)
sp.add_argument("--confirm", action="store_true")
sp.set_defaults(func=cmd_delete_company)
sp = sub.add_parser("create-user", help="create a backup user [--confirm]")
sp.add_argument("--email", required=True)
sp.add_argument("--company", required=True)
sp.add_argument("--first")
sp.add_argument("--last")
sp.add_argument("--password")
sp.add_argument("--notify", help="notification email")
sp.add_argument("--confirm", action="store_true")
sp.set_defaults(func=cmd_create_user)
sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]")
sp.add_argument("--id", required=True)
sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)")
sp.add_argument("--confirm", action="store_true")
sp.set_defaults(func=cmd_delete_user)
for name, fn, verb in (("grant-license", cmd_grant_license, "grant"),
("release-license", cmd_release_license, "release"),
("revoke-license", cmd_revoke_license, "revoke")):
sp = sub.add_parser(name, help=f"{verb} a license [--confirm]")
sp.add_argument("--user-id", required=True)
sp.add_argument("--license-id")
sp.add_argument("--confirm", action="store_true")
sp.set_defaults(func=fn)
sp = sub.add_parser("raw", help="raw API call (writes need --confirm)")
sp.add_argument("method")
sp.add_argument("path", help="e.g. /api/Users/{id}")
sp.add_argument("--data", help="JSON request body")
sp.add_argument("--confirm", action="store_true")
sp.set_defaults(func=cmd_raw)
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
client = Msp360Client(base=args.base)
try:
return args.func(client, args)
except bc.BackupError as exc: # includes Msp360Error + vault/HTTP transport errors
print(f"[ERROR] {exc}", file=sys.stderr)
log_error(str(exc)[:200], context=f"command={args.command}")
return 1
except Exception as exc: # noqa: BLE001
print(f"[ERROR] unexpected: {exc}", file=sys.stderr)
log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""MSP360 Managed Backup Service (MBS) API client.
Full CRUD over the provider REST API at https://api.mspbackups.com. Auth is a Bearer
token from POST /api/Provider/Login (login+password generated in MSP360 console
Settings > General, vaulted at msp-tools/msp360-api.sops.yaml).
Reuses `.claude/scripts/backup_common.py` for repo-root resolution, the canonical SOPS
vault read, and stdlib JSON HTTP -- same plumbing as the seafile/owncloud/datto-workplace
sibling skills. Standalone: stdlib-only transport.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Optional
# backup_common lives at <root>/.claude/scripts/ ; this file is
# <root>/.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == <root>/.claude
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts"))
import backup_common as bc # noqa: E402
VAULT_ENTRY = "msp-tools/msp360-api.sops.yaml"
DEFAULT_BASE = "https://api.mspbackups.com"
# Monitoring plan Status codes (observed + MBS convention).
STATUS_TEXT = {
0: "Success", 1: "Success", 2: "Warning", 3: "Failed",
4: "Running", 5: "Unknown", 6: "Interrupted", 7: "Completed/warnings",
}
# Statuses that mean "this run did NOT produce a clean backup".
BAD_STATUS = {2, 3, 6, 7}
class Msp360Error(bc.BackupError):
"""MSP360 transport / auth / API error (carries optional HTTP status)."""
class Msp360Client:
"""Thin MSP360 MBS API client: login once, then typed helpers over the endpoints."""
def __init__(self, base: Optional[str] = None):
self.base = (base or DEFAULT_BASE).rstrip("/")
self._token: Optional[str] = None
# --- auth -----------------------------------------------------------------
def login(self) -> None:
login = bc.vault_get_field(VAULT_ENTRY, "credentials.login")
pw = bc.vault_get_field(VAULT_ENTRY, "credentials.password")
st, body = bc.http_json(
"POST", f"{self.base}/api/Provider/Login",
body={"UserName": login, "Password": pw})
if st != 200 or not isinstance(body, dict) or not body.get("access_token"):
raise Msp360Error(f"MSP360 login failed (HTTP {st}): {body}", status=st)
self._token = body["access_token"]
def _auth(self) -> dict:
if not self._token:
self.login()
return {"Authorization": "Bearer " + str(self._token)}
def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
"""One API call. Raises Msp360Error on any non-2xx (with the API's error body)."""
st, data = bc.http_json(method, f"{self.base}{path}", headers=self._auth(), body=body)
if st < 200 or st >= 300:
raise Msp360Error(f"{method} {path} -> HTTP {st}: {data}", status=st)
return data
# --- reads ----------------------------------------------------------------
def monitoring(self, user_id: Optional[str] = None) -> list:
return self.request("GET", f"/api/Monitoring/{user_id}" if user_id else "/api/Monitoring")
def companies(self) -> list:
return self.request("GET", "/api/Companies")
def users(self) -> list:
return self.request("GET", "/api/Users")
def computers(self, offset: int = 0, count: int = 1000) -> list:
return self.request("GET", f"/api/Computers/{offset}/{count}")
def licenses(self, available: Optional[bool] = None) -> list:
path = "/api/Licenses"
if available is not None:
path += f"?isAvailable={'true' if available else 'false'}"
return self.request("GET", path)
def billing(self) -> Any:
return self.request("GET", "/api/Billing")
# --- writes (companies) ---------------------------------------------------
def create_company(self, name: str, **extra: Any) -> Any:
payload = {"Name": name, "StorageLimit": -1}
payload.update(extra)
return self.request("POST", "/api/Companies", body=payload)
def delete_company(self, company_id: str) -> Any:
return self.request("DELETE", f"/api/Companies/{company_id}")
# --- writes (users) -------------------------------------------------------
# Property edits (PUT /api/Users, /api/Companies) are reachable via the CLI's
# `raw PUT ...` passthrough; not wrapped here until a typed edit command exists.
def create_user(self, payload: dict) -> Any:
return self.request("POST", "/api/Users", body=payload)
def delete_user(self, user_id: str, keep_data: bool = False) -> Any:
# /{id}/Account = drop metadata but KEEP backup data; /{id} = drop everything.
suffix = f"/api/Users/{user_id}/Account" if keep_data else f"/api/Users/{user_id}"
return self.request("DELETE", suffix)
# --- writes (licenses) ----------------------------------------------------
def grant_license(self, payload: dict) -> Any:
return self.request("POST", "/api/Licenses/Grant", body=payload)
def release_license(self, payload: dict) -> Any:
return self.request("POST", "/api/Licenses/Release", body=payload)
def revoke_license(self, payload: dict) -> Any:
return self.request("POST", "/api/Licenses/Revoke", body=payload)

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

@@ -0,0 +1,3 @@
# Skill scratch/cache - never commit.
.cache/
__pycache__/

View File

@@ -0,0 +1,175 @@
---
name: owncloud
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
Client for ACG's live **ownCloud 10.16.0 Community** server
(`cloud.acghosting.com`, VM `172.16.3.22`, Rocky Linux 9.6), driven entirely
through the `occ` admin CLI over SSH.
Two halves:
- **Read (GPS audit, ungated):** which customers back up here and how much data
they hold - and, with `--rmm`, which enrolled machines actually run the
ownCloud desktop client.
- **Manage (gated):** user/quota/group lifecycle, apps/config, maintenance mode,
filesystem scans, ownership transfer, trashbin/versions cleanup. Every
state-changing command **refuses without `--confirm`** (prints the WOULD line,
exits 3), mirroring the `b2` / `seafile` fleet convention.
This skill is one of the backup-verification siblings alongside `seafile`,
`b2` (Backblaze) and `datto-workplace`.
## Running - read (GPS audit)
```bash
OWNCLOUD=".claude/skills/owncloud/scripts/owncloud.py"
py "$OWNCLOUD" status # SSH ok + occ version / edition
py "$OWNCLOUD" users [--json] # all users + enabled/quota/used/last login
py "$OWNCLOUD" usage [--json] # per-user rollup, used GB desc (the audit view)
py "$OWNCLOUD" audit [--client NAME] [--rmm] [--json]
```
Add `--json` to `users` / `usage` / `audit` for machine-readable output.
`--host` overrides the server IP (default `172.16.3.22`).
## Running - manage (writes gated behind `--confirm`)
```bash
# users
py "$OWNCLOUD" user-add jdoe --display-name "J Doe" --email j@x.com -g Clients --gen-password --confirm
py "$OWNCLOUD" user-disable jdoe --confirm # user-enable to re-enable
py "$OWNCLOUD" user-reset-password jdoe --password-env OC_NEW_PW --confirm
py "$OWNCLOUD" user-modify jdoe --email new@x.com --confirm
py "$OWNCLOUD" user-delete jdoe --confirm # DELETES the user AND all their data
# quota (quota <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 --confirm
py "$OWNCLOUD" versions-cleanup --user jdoe --confirm
# fleet-wide purge needs the extra ack flag on top of --confirm:
py "$OWNCLOUD" versions-cleanup --all-users --force-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`).
- `--all-users` on those two also **hard-fails exit 2** unless
`--force-all-users` is passed **in addition to** `--confirm` - a single stray
`--confirm` must never be enough to irreversibly purge every user's history.
- invalid uid on `user-add` -> **exit 2**.
## Access channel
There is **no ownCloud admin web / OCS account** in the vault, so the channel is
**SSH + the `occ` CLI**, not the OCS HTTP API (the OCS endpoint is reachable at
`https://cloud.acghosting.com/status.php` but we lack an OCS admin cred). The
client SSHes to `root@172.16.3.22:22` with **paramiko** (password auth,
AutoAddPolicy) and runs occ **as the web user**:
```
sudo -u apache php /var/www/owncloud/occ <cmd>
```
Credentials load lazily from the SOPS vault (never hardcoded):
`infrastructure/owncloud-vm.sops.yaml` -> `credentials.username` (root) /
`credentials.password`.
## The occ / DB commands it settled on
- **status:** `occ status --output=json` (+ `occ -V` for the display version).
- **users:** `occ user:list --show-all-attributes --output=json` -> per user:
`uid, displayName, email, quota, enabled, lastLogin, creationTime, home,
backend`. `lastLogin`/`creationTime` are Unix epoch seconds (0 = never); the
client renders them as UTC.
- **used bytes:** occ has **no `user:info` / per-user usage** on this version, so
used storage is read from ownCloud's own **filecache** (the exact figure the
UI shows). DB creds are read live from `config.php` (we already have root), then:
`SELECT s.id, fc.size FROM oc_storages s JOIN oc_filecache fc ON
fc.storage=s.numeric_id WHERE s.id LIKE 'home::%' AND fc.path='';`
Each row is `home::<uid>` -> root size in bytes.
## What the data means
- **used_bytes** is the authoritative per-account storage used; an `enabled` user
with non-zero usage is actively backing up here. `usage`/`audit` roll this up,
sorted desc, with a total.
- **quota** is a string: `default`, `none` (unlimited), or a byte/size value.
- Usage is concentrated: as of the build (2026-07-05) a single account (`pavon`)
holds ~14 TB and `sysadmin` ~2.9 TB, dwarfing the rest - ownCloud is used by a
handful of clients, not the whole GPS base.
## The "both" cross-check (`--rmm`)
Server-side (occ) tells you who has an account; `--rmm` adds the endpoint half. It
logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and, for each agent
(filter with `--client`), runs a **read-only** detection PowerShell reporting
installed products / running processes / config folders matching **`ownCloud`**.
An agent is reported only if the client is actually present. This dispatches a
command to live endpoints - **always scope with `--client`** rather than sweeping
all 350+ agents.
## Notes / gotchas / limitations
- **Dirty filecache (-1):** if a user's cached root size is `-1` (cache not yet
computed), the client falls back to `du -sb <home>/files` for that one user.
That du can take minutes on a multi-hundred-GB home (timeout is generous, 900s);
`used_source` in `--json` reports `filecache` vs `du` per user. At build time
one account (`anaise`) was dirty and resolved via du (~0.6 TB).
- **Orphan storages:** the filecache can contain `home::<uid>` rows for deleted
users (e.g. a former `QWM-Sheila`); the skill keys off the live `occ user:list`,
so orphans do not appear in `users`/`usage`.
- **No OCS/web API:** deeper per-file / sharing queries would need an OCS admin
cred we do not have; occ + filecache cover the audit needs.
- **Shares are read-only here:** occ on this version exposes no share create/list
(only `sharing:cleanup-remote-storages`), so `shares` reads straight from the
`oc_share` DB table (joined to `oc_filecache` for the real path). Creating a
share would need the OCS API / an admin web cred we do not have.
- **occ command surface was verified live** against this exact 10.16 box (`occ
list` / `occ help <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
detection) lives in `.claude/scripts/backup_common.py`, used by all the
server-backup skills. Detection patterns must be precise per backend - this
skill uses `ownCloud` (not a bare `cloud`, which would over-match).

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Non-destructive verification for the owncloud skill.
# - every WRITE command is run WITHOUT --confirm -> must refuse (rc 3), no SSH write
# - hard-fail guards are run (validation precedes the gate, so no execution) -> rc 2
# - read-only commands must succeed (rc 0) and JSON reads must parse
# NOTHING here runs a state change on the server.
OC=".claude/skills/owncloud/scripts/owncloud.py"
pass=0; fail=0
chk() { # chk <expected_rc> <label> -- <cmd...>
local exp="$1" label="$2"; shift 3
"$@" >/dev/null 2>&1; local rc=$?
if [ "$rc" = "$exp" ]; then printf ' [PASS] %-40s (rc %s)\n' "$label" "$rc"; pass=$((pass+1))
else printf ' [FAIL] %-40s (got rc %s, want %s)\n' "$label" "$rc" "$exp"; fail=$((fail+1)); fi
}
jchk() { # jchk <label> -- <cmd...> (stdout must be valid JSON)
local label="$1"; shift 2
if "$@" 2>/dev/null | py -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
printf ' [PASS] %-40s (valid JSON)\n' "$label"; pass=$((pass+1))
else printf ' [FAIL] %-40s (bad JSON)\n' "$label"; fail=$((fail+1)); fi
}
echo "== WRITE commands without --confirm must REFUSE (rc 3) =="
chk 3 "user-add" -- py "$OC" user-add zz.dry --gen-password
chk 3 "user-delete" -- py "$OC" user-delete zz.dry
chk 3 "user-enable" -- py "$OC" user-enable zz.dry
chk 3 "user-disable" -- py "$OC" user-disable zz.dry
chk 3 "user-reset-password" -- py "$OC" user-reset-password zz.dry --gen-password
chk 3 "user-modify" -- py "$OC" user-modify zz.dry --email a@b.com
chk 3 "quota-set" -- py "$OC" quota-set zz.dry "5 GB"
chk 3 "quota-reset" -- py "$OC" quota-reset zz.dry
chk 3 "group-add" -- py "$OC" group-add zz.dry
chk 3 "group-delete" -- py "$OC" group-delete zz.dry
chk 3 "group-add-member" -- py "$OC" group-add-member zz.dry -m sysadmin
chk 3 "group-remove-member" -- py "$OC" group-remove-member zz.dry -m sysadmin
chk 3 "maintenance on" -- py "$OC" maintenance on
chk 3 "maintenance off" -- py "$OC" maintenance off
chk 3 "app-enable" -- py "$OC" app-enable activity
chk 3 "app-disable" -- py "$OC" app-disable activity
chk 3 "config-set" -- py "$OC" config-set zz.key --value v
chk 3 "files-scan --user" -- py "$OC" files-scan --user zz.dry
chk 3 "files-scan --all" -- py "$OC" files-scan --all
chk 3 "transfer-ownership" -- py "$OC" transfer-ownership a b
chk 3 "trashbin-cleanup" -- py "$OC" trashbin-cleanup --user zz.dry
chk 3 "versions-cleanup" -- py "$OC" versions-cleanup --user zz.dry
echo "== hard-fail guards (validation precedes the gate; no execution) rc 2 =="
chk 2 "user-add bad uid" -- py "$OC" user-add "bad uid!" --gen-password --confirm
chk 2 "user-add no password" -- py "$OC" user-add validuid --confirm
chk 2 "user-modify no fields" -- py "$OC" user-modify zz.dry --confirm
chk 2 "files-scan no target" -- py "$OC" files-scan --confirm
chk 2 "trashbin-cleanup no target"-- py "$OC" trashbin-cleanup --confirm
chk 2 "versions-cleanup no target"-- py "$OC" versions-cleanup --confirm
# --all-users must NOT execute on --confirm alone (needs --force-all-users) -> rc 2
chk 2 "versions --all-users no force" -- py "$OC" versions-cleanup --all-users --confirm
chk 2 "trashbin --all-users no force" -- py "$OC" trashbin-cleanup --all-users --confirm
echo "== read-only commands must succeed (rc 0) =="
chk 0 "status" -- py "$OC" status
chk 0 "groups" -- py "$OC" groups
chk 0 "group-members" -- py "$OC" group-members admin
chk 0 "user-groups" -- py "$OC" user-groups sysadmin
chk 0 "shares" -- py "$OC" shares
chk 0 "shares --user" -- py "$OC" shares --user sysadmin
chk 0 "apps" -- py "$OC" apps
chk 0 "maintenance status" -- py "$OC" maintenance status
chk 0 "config-get key" -- py "$OC" config-get maintenance
chk 0 "quota (read)" -- py "$OC" quota sysadmin
echo "== JSON read outputs must parse =="
jchk "groups --json" -- py "$OC" groups --json
jchk "apps --json" -- py "$OC" apps --json
jchk "shares --json" -- py "$OC" shares --json
echo
echo "==== $pass passed, $fail failed ===="
[ "$fail" = 0 ]

View File

@@ -0,0 +1,626 @@
#!/usr/bin/env python3
"""CLI for the `owncloud` skill - read-only ownCloud 10.16 inventory for the GPS
backup audit (server: cloud.acghosting.com / 172.16.3.22, driven via occ over SSH).
Commands:
status SSH ok + occ version / edition
users [--json] all users + display name, enabled, quota, used, last login
usage [--json] per-user rollup: used GB (desc) + quota + last login (audit view)
audit [--client NAME] [--rmm] [--json]
active users holding data; with --rmm cross-check GuruRMM
endpoints for the ownCloud desktop client (scope with --client)
Server-side (occ) is the source of "who has an account + how much data"; --rmm adds
the "which enrolled machine actually runs the ownCloud client" half.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import secrets
import string
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from owncloud_client import OwncloudClient # noqa: E402
import backup_common as bc # noqa: E402
GB = 1_000_000_000
# occ enforces this uid charset (a-z A-Z 0-9 and + _ . @ - ').
UID_RE = re.compile(r"^[A-Za-z0-9+_.@'\-]+$")
def _gb(n) -> float:
try:
return round(int(n) / GB, 2)
except (TypeError, ValueError):
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):
"""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 getattr(args, "gen_password", False):
return _gen_password(), True
return None, False
def _vault_reminder(uid: str, stream=None) -> None:
out = stream or sys.stdout
print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:",
file=out)
print(f" bash .claude/scripts/vault.sh set-field "
f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password '<paste-here>'",
file=out)
def cmd_status(c: OwncloudClient, args) -> int:
s = c.server_status()
print(f"[OK] {c.host}")
print(json.dumps(s, indent=2))
return 0
def cmd_users(c: OwncloudClient, args) -> int:
users = c.list_users()
users.sort(key=lambda u: int(u.get("used_bytes") or 0), reverse=True)
if args.json:
print(json.dumps(users, indent=2))
return 0
print(f"{'UID':20} {'NAME':22} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
for u in users:
print(f"{(u.get('uid') or '')[:20]:20} "
f"{(u.get('display_name') or '')[:22]:22} "
f"{'yes' if u.get('enabled') else 'NO':3} "
f"{_gb(u.get('used_bytes')):>7} GB "
f"{str(u.get('quota') or '-')[:12]:>12} "
f"{str(u.get('last_login') or '-')[:19]:20}")
print(f"\n{len(users)} users; total used "
f"{round(sum(_gb(u.get('used_bytes')) for u in users), 1)} GB")
return 0
def _usage_rollup(c: OwncloudClient) -> list[dict]:
users = c.list_users()
rollup = []
for u in users:
rollup.append({
"uid": u.get("uid"),
"display_name": u.get("display_name"),
"email": u.get("email"),
"enabled": bool(u.get("enabled")),
"used_bytes": int(u.get("used_bytes") or 0),
"used_gb": _gb(u.get("used_bytes")),
"used_source": u.get("used_source"),
"quota": u.get("quota"),
"last_login": u.get("last_login"),
})
rollup.sort(key=lambda x: x["used_bytes"], reverse=True)
return rollup
def cmd_usage(c: OwncloudClient, args) -> int:
rollup = _usage_rollup(c)
if args.json:
print(json.dumps(rollup, indent=2))
return 0
print(f"{'UID':20} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
for r in rollup:
print(f"{(r['uid'] or '')[:20]:20} "
f"{'yes' if r['enabled'] else 'NO':3} "
f"{r['used_gb']:>7} GB "
f"{str(r['quota'] or '-')[:12]:>12} "
f"{str(r['last_login'] or '-')[:19]:20}")
print(f"\n{len(rollup)} users; total used "
f"{round(sum(r['used_gb'] for r in rollup), 1)} GB")
return 0
def cmd_audit(c: OwncloudClient, args) -> int:
rollup = _usage_rollup(c)
# enabled users holding data are the ones actually backing up here
active = [r for r in rollup if r["enabled"] and r["used_bytes"] > 0]
result = {"backend": "owncloud", "server": c.host, "accounts": active}
if args.rmm:
rmm = bc.RmmClient()
rmm.login()
checks = []
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
for a in agents:
det = rmm.detect_client(a, ["ownCloud"])
if det.get("detected"):
checks.append(det)
result["rmm_endpoints_with_client"] = checks
if args.json:
print(json.dumps(result, indent=2))
return 0
print(f"=== ownCloud backup accounts - {c.host} ===")
print(f"{'UID':20} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
for r in active:
print(f"{(r['uid'] or '')[:20]:20} {r['used_gb']:>7} GB "
f"{str(r['quota'] or '-')[:12]:>12} {str(r['last_login'] or '-')[:19]:20}")
print(f"\n{len(active)} active accounts with data; "
f"{round(sum(r['used_gb'] for r in active), 1)} GB total")
if args.rmm:
eps = result.get("rmm_endpoints_with_client", [])
print(f"\n--- GuruRMM endpoints running an ownCloud client ({len(eps)}) ---")
for e in eps:
print(f" {e['hostname']:25} client={e.get('client_name')}")
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)
if args.json:
# keep stdout parseable: password + vault reminder go to stderr
if generated:
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
"store in vault", file=sys.stderr)
_vault_reminder(args.uid, stream=sys.stderr)
print(json.dumps({"uid": args.uid, "created": True,
"generated_password": pw if generated else None,
"detail": res}, indent=2))
return 0
print(f"[OK] created user '{args.uid}'")
if generated:
print(f"[CRITICAL] generated password (shown once): {pw}")
_vault_reminder(args.uid)
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)
if args.json:
if generated:
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
"store in vault", file=sys.stderr)
_vault_reminder(args.uid, stream=sys.stderr)
print(json.dumps({"uid": args.uid, "password_reset": True,
"generated_password": pw if generated else None},
indent=2))
return 0
print(f"[OK] reset password for '{args.uid}'")
if generated:
print(f"[CRITICAL] generated password (shown once): {pw}")
_vault_reminder(args.uid)
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
# A fleet-wide, irreversible purge must not be reachable by a single stray
# --confirm: require a dedicated acknowledgement flag on top of it.
if args.all_users and not args.force_all_users:
print(f"[ERROR] {kind}-cleanup --all-users irreversibly purges EVERY "
f"user's {kind} history. This needs --force-all-users IN ADDITION "
"to --confirm to proceed.", file=sys.stderr)
return 2
target = "ALL users" if args.all_users else ", ".join(users)
if not _gated(f"permanently delete {kind} for {target}", args.confirm):
return 3
fn = c.trashbin_cleanup if kind == "trashbin" else c.versions_cleanup
out = fn([] if args.all_users else users)
return _ok(f"{kind} cleaned for {target}",
{"cleanup": kind, "target": target, "detail": out}, args.json)
def cmd_trashbin_cleanup(c: OwncloudClient, args) -> int:
return _cleanup(c, args, "trashbin")
def cmd_versions_cleanup(c: OwncloudClient, args) -> int:
return _cleanup(c, args, "versions")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="owncloud", description="ownCloud backup inventory (GPS audit)")
p.add_argument("--host", help="override server host/IP (default 172.16.3.22)")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("status")
u = sub.add_parser("users"); u.add_argument("--json", action="store_true")
us = sub.add_parser("usage"); us.add_argument("--json", action="store_true")
a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true")
def _pw_opts(sp):
sp.add_argument("--password", help="password literal (avoid: lands in shell history/ps)")
sp.add_argument("--password-env", metavar="VAR", help="read password from this env var")
sp.add_argument("--gen-password", action="store_true", help="generate a strong password")
# --- user lifecycle (writes) ---
ua = sub.add_parser("user-add")
ua.add_argument("uid"); ua.add_argument("--display-name"); ua.add_argument("--email")
ua.add_argument("-g", "--group", action="append", help="group to add to (repeatable)")
_pw_opts(ua); ua.add_argument("--confirm", action="store_true"); ua.add_argument("--json", action="store_true")
ud = sub.add_parser("user-delete"); ud.add_argument("uid")
ud.add_argument("-f", "--force", action="store_true"); ud.add_argument("--confirm", action="store_true"); ud.add_argument("--json", action="store_true")
ue = sub.add_parser("user-enable"); ue.add_argument("uid"); ue.add_argument("--confirm", action="store_true"); ue.add_argument("--json", action="store_true")
udi = sub.add_parser("user-disable"); udi.add_argument("uid"); udi.add_argument("--confirm", action="store_true"); udi.add_argument("--json", action="store_true")
urp = sub.add_parser("user-reset-password"); urp.add_argument("uid")
_pw_opts(urp); urp.add_argument("--confirm", action="store_true"); urp.add_argument("--json", action="store_true")
um = sub.add_parser("user-modify"); um.add_argument("uid")
um.add_argument("--display-name"); um.add_argument("--email")
um.add_argument("--confirm", action="store_true"); um.add_argument("--json", action="store_true")
# --- quota ---
q = sub.add_parser("quota"); q.add_argument("uid"); q.add_argument("--json", action="store_true")
qs = sub.add_parser("quota-set"); qs.add_argument("uid"); qs.add_argument("value", help="e.g. '5 GB', 'none' (unlimited)")
qs.add_argument("--confirm", action="store_true"); qs.add_argument("--json", action="store_true")
qr = sub.add_parser("quota-reset"); qr.add_argument("uid"); qr.add_argument("--confirm", action="store_true"); qr.add_argument("--json", action="store_true")
# --- groups ---
gl = sub.add_parser("groups"); gl.add_argument("--json", action="store_true")
gm = sub.add_parser("group-members"); gm.add_argument("group"); gm.add_argument("--json", action="store_true")
ug = sub.add_parser("user-groups"); ug.add_argument("uid"); ug.add_argument("--json", action="store_true")
gadd = sub.add_parser("group-add"); gadd.add_argument("group"); gadd.add_argument("--confirm", action="store_true"); gadd.add_argument("--json", action="store_true")
gdel = sub.add_parser("group-delete"); gdel.add_argument("group"); gdel.add_argument("--confirm", action="store_true"); gdel.add_argument("--json", action="store_true")
gam = sub.add_parser("group-add-member"); gam.add_argument("group"); gam.add_argument("-m", "--member", action="append", required=True); gam.add_argument("--confirm", action="store_true"); gam.add_argument("--json", action="store_true")
grm = sub.add_parser("group-remove-member"); grm.add_argument("group"); grm.add_argument("-m", "--member", action="append", required=True); grm.add_argument("--confirm", action="store_true"); grm.add_argument("--json", action="store_true")
# --- shares (read-only) ---
sh = sub.add_parser("shares"); sh.add_argument("--user"); sh.add_argument("--json", action="store_true")
# --- server / apps / config ---
mnt = sub.add_parser("maintenance"); mnt.add_argument("state", choices=["status", "on", "off"]); mnt.add_argument("--confirm", action="store_true"); mnt.add_argument("--json", action="store_true")
ap = sub.add_parser("apps"); ap.add_argument("--json", action="store_true")
ape = sub.add_parser("app-enable"); ape.add_argument("app"); ape.add_argument("--confirm", action="store_true"); ape.add_argument("--json", action="store_true")
apd = sub.add_parser("app-disable"); apd.add_argument("app"); apd.add_argument("--confirm", action="store_true"); apd.add_argument("--json", action="store_true")
cg = sub.add_parser("config-get"); cg.add_argument("name", nargs="?", help="omit to dump all system config"); cg.add_argument("--json", action="store_true")
cs = sub.add_parser("config-set"); cs.add_argument("name"); cs.add_argument("--value", required=True); cs.add_argument("--type", default="string", choices=["string", "integer", "double", "boolean", "json"]); cs.add_argument("--confirm", action="store_true"); cs.add_argument("--json", action="store_true")
# --- files admin ---
fs = sub.add_parser("files-scan"); fs.add_argument("--user"); fs.add_argument("--all", action="store_true"); fs.add_argument("--path"); fs.add_argument("--confirm", action="store_true"); fs.add_argument("--json", action="store_true")
to = sub.add_parser("transfer-ownership"); to.add_argument("source"); to.add_argument("dest"); to.add_argument("--path"); to.add_argument("--confirm", action="store_true"); to.add_argument("--json", action="store_true")
tc = sub.add_parser("trashbin-cleanup"); tc.add_argument("--user", action="append"); tc.add_argument("--all-users", action="store_true"); tc.add_argument("--force-all-users", action="store_true", help="required with --all-users + --confirm to purge everyone"); 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("--force-all-users", action="store_true", help="required with --all-users + --confirm to purge everyone"); vc.add_argument("--confirm", action="store_true"); vc.add_argument("--json", action="store_true")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
c = OwncloudClient(args.host)
handlers = {"status": cmd_status, "users": cmd_users,
"usage": cmd_usage, "audit": cmd_audit,
"user-add": cmd_user_add, "user-delete": cmd_user_delete,
"user-enable": cmd_user_enable, "user-disable": cmd_user_disable,
"user-reset-password": cmd_user_reset_password,
"user-modify": cmd_user_modify,
"quota": cmd_quota, "quota-set": cmd_quota_set,
"quota-reset": cmd_quota_reset,
"groups": cmd_groups, "group-members": cmd_group_members,
"user-groups": cmd_user_groups,
"group-add": cmd_group_add, "group-delete": cmd_group_delete,
"group-add-member": cmd_group_add_member,
"group-remove-member": cmd_group_remove_member,
"shares": cmd_shares,
"maintenance": cmd_maintenance, "apps": cmd_apps,
"app-enable": cmd_app_enable, "app-disable": cmd_app_disable,
"config-get": cmd_config_get, "config-set": cmd_config_set,
"files-scan": cmd_files_scan,
"transfer-ownership": cmd_transfer_ownership,
"trashbin-cleanup": cmd_trashbin_cleanup,
"versions-cleanup": cmd_versions_cleanup}
try:
return handlers[args.cmd](c, args)
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
# best-effort error logging per house rule
try:
root = bc.resolve_root()
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
"owncloud", str(exc)[:200]], timeout=15)
except Exception:
pass
return 1
finally:
c.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,510 @@
#!/usr/bin/env python3
"""ownCloud server client for the `owncloud` skill (GPS backup verification).
Talks to ACG's live ownCloud 10.16 server (cloud.acghosting.com / 172.16.3.22)
over SSH and drives the `occ` admin CLI. Read-only: enumerates users, their
quota + actual storage used, and last login so the GPS backup audit can see who
is actually backing up to ownCloud and how much data they hold.
There is NO ownCloud admin WEB/OCS account in the vault, so the access channel is
SSH (root@172.16.3.22, paramiko password auth) + `occ`, not the OCS HTTP API.
`occ` must run as the web/service user, so every invocation is:
sudo -u apache php /var/www/owncloud/occ <cmd>
Per-user "used bytes" is not exposed by occ on this version (no `user:info`).
Instead we read the authoritative figure straight from ownCloud's filecache: the
size ownCloud itself reports for each user's root. DB creds are read live from
the server's config.php (we already have root), so nothing is duplicated and it
survives a DB-password rotation. If a user's cached size is dirty (-1), we fall
back to `du -sb <home>/files` for that one user (slower, generous timeout).
Credentials load lazily from the SOPS vault (never hardcoded):
`infrastructure/owncloud-vm.sops.yaml` -> credentials.username / credentials.password.
"""
from __future__ import annotations
import json
import shlex
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
import paramiko
# Import the shared backup helpers from .claude/scripts.
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/owncloud
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
sys.path.insert(0, str(_SCRIPTS_DIR))
import backup_common as bc # noqa: E402
VAULT_ENTRY = "infrastructure/owncloud-vm.sops.yaml"
VAULT_FIELD_USER = "credentials.username"
VAULT_FIELD_PASS = "credentials.password"
DEFAULT_HOST = "172.16.3.22"
SSH_PORT = 22
# occ must run as the web user (apache) with the system php against the install dir.
OCC_INSTALL_DIR = "/var/www/owncloud"
OCC = f"sudo -u apache php {OCC_INSTALL_DIR}/occ"
CONFIG_PHP = f"{OCC_INSTALL_DIR}/config/config.php"
EXEC_TIMEOUT = 60 # seconds for normal occ / mysql calls
DU_TIMEOUT = 900 # du on a dirty-cache home can be hundreds of GB
class OwncloudClient:
def __init__(self, host: Optional[str] = None):
self.host = host or DEFAULT_HOST
self._ssh: Optional[paramiko.SSHClient] = None
self._db: Optional[dict] = None
# -- ssh -------------------------------------------------------------------
def _connect(self) -> paramiko.SSHClient:
if self._ssh is not None:
return self._ssh
user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER)
pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS)
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
cli.connect(self.host, port=SSH_PORT, username=user, password=pw,
timeout=20, look_for_keys=False, allow_agent=False)
except Exception as exc: # paramiko raises several distinct types
raise bc.BackupError(f"SSH connect to {user}@{self.host} failed: {exc}") from exc
self._ssh = cli
return cli
def run(self, cmd: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
"""Run a shell command over SSH; return (exit_status, stdout, stderr)."""
cli = self._connect()
try:
_in, out, err = cli.exec_command(cmd, timeout=timeout)
stdout = out.read().decode("utf-8", "replace")
stderr = err.read().decode("utf-8", "replace")
rc = out.channel.recv_exit_status()
except Exception as exc:
raise bc.BackupError(f"SSH command failed ({cmd[:60]}...): {exc}") from exc
return rc, stdout, stderr
def occ(self, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
return self.run(f"{OCC} {args}", timeout=timeout)
def close(self) -> None:
if self._ssh is not None:
try:
self._ssh.close()
finally:
self._ssh = None
# -- server status ---------------------------------------------------------
def server_status(self) -> dict:
"""occ status (installed/version/edition) plus the `occ -V` version string."""
rc, out, err = self.occ("status --output=json")
if rc != 0:
raise bc.BackupError(f"occ status failed (rc={rc}): {err.strip()[:300]}")
try:
status = json.loads(out)
except json.JSONDecodeError:
raise bc.BackupError(f"occ status returned non-JSON: {out.strip()[:300]}")
rc2, ver, _ = self.occ("-V")
status["occ_version"] = ver.strip() if rc2 == 0 else None
status["host"] = self.host
return status
# -- storage used (filecache primary, du fallback) -------------------------
def _db_config(self) -> dict:
"""Read DB name/user/password/prefix live from config.php (we have root)."""
if self._db is not None:
return self._db
rc, out, err = self.run(
"grep -E \"'db(name|user|password|tableprefix)'\" " + CONFIG_PHP)
if rc != 0:
raise bc.BackupError(f"reading {CONFIG_PHP} failed (rc={rc}): {err.strip()[:200]}")
cfg = {"dbname": "owncloud", "dbuser": "owncloud",
"dbpassword": "", "dbtableprefix": "oc_"}
for line in out.splitlines():
# form: 'dbname' => 'value',
if "=>" not in line:
continue
key, _, val = line.partition("=>")
k = key.strip().strip("'\" ")
v = val.strip().rstrip(",").strip().strip("'\"")
if k in cfg:
cfg[k] = v
self._db = cfg
return cfg
def _mysql(self, sql: str) -> str:
cfg = self._db_config()
cmd = (f"MYSQL_PWD={shlex.quote(cfg['dbpassword'])} "
f"mysql -N -B -u {shlex.quote(cfg['dbuser'])} "
f"{shlex.quote(cfg['dbname'])} -e {shlex.quote(sql)}")
rc, out, err = self.run(cmd)
if rc != 0:
raise bc.BackupError(f"mysql query failed (rc={rc}): {err.strip()[:200]}")
return out
def _filecache_sizes(self) -> dict[str, int]:
"""uid -> cached used bytes for each per-user home storage (ownCloud's own
figure). A size of -1 means the cache is dirty; such uids are omitted here
and handled by the du fallback."""
pfx = self._db_config()["dbtableprefix"]
sql = (f"SELECT s.id, fc.size FROM {pfx}storages s "
f"JOIN {pfx}filecache fc ON fc.storage=s.numeric_id "
f"WHERE s.id LIKE 'home::%' AND fc.path='';")
out = self._mysql(sql)
sizes: dict[str, int] = {}
for row in out.splitlines():
if "\t" not in row:
continue
sid, _, val = row.partition("\t")
if not sid.startswith("home::"):
continue
uid = sid[len("home::"):]
try:
n = int(val.strip())
except ValueError:
continue
if n >= 0:
sizes[uid] = n
return sizes
def _du_bytes(self, uid: str, home: str) -> Optional[int]:
"""Fallback used-bytes via du on the on-disk home (dirty-cache users only)."""
if not home:
return None
rc, out, err = self.run(
f"du -sb {shlex.quote(home + '/files')} 2>/dev/null | cut -f1",
timeout=DU_TIMEOUT)
try:
return int(out.strip())
except (ValueError, AttributeError):
return None
# -- users -----------------------------------------------------------------
def list_users(self, with_usage: bool = True) -> list[dict]:
"""All users with attributes (uid, display name, email, quota, enabled,
last login, home, backend). When with_usage, enrich each with used bytes
from the filecache, falling back to du for dirty-cache users."""
rc, out, err = self.occ("user:list --show-all-attributes --output=json")
if rc != 0:
raise bc.BackupError(f"occ user:list failed (rc={rc}): {err.strip()[:300]}")
try:
raw = json.loads(out)
except json.JSONDecodeError:
raise bc.BackupError(f"occ user:list returned non-JSON: {out.strip()[:300]}")
users: list[dict] = []
for uid, attrs in raw.items():
attrs = attrs or {}
users.append({
"uid": uid,
"display_name": attrs.get("displayName"),
"email": attrs.get("email"),
"quota": attrs.get("quota"),
"enabled": _as_bool(attrs.get("enabled")),
"last_login": _epoch(attrs.get("lastLogin")),
"creation_time": _epoch(attrs.get("creationTime")),
"backend": attrs.get("backend"),
"home": attrs.get("home"),
"used_bytes": None,
"used_source": None,
})
if with_usage:
sizes = self._filecache_sizes()
for u in users:
uid = u["uid"]
if uid in sizes:
u["used_bytes"] = sizes[uid]
u["used_source"] = "filecache"
else:
# dirty cache (-1) or no storage row: compute on disk
du = self._du_bytes(uid, u.get("home"))
u["used_bytes"] = du
u["used_source"] = "du" if du is not None else None
return users
# -- occ helpers for management ops ----------------------------------------
def occ_env(self, env: dict, args: str,
timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
"""Run occ with extra environment (e.g. OC_PASS) exported for the apache
user. `sudo -u apache env KEY=val php occ ...` — the explicit `env` sets
the var inside apache's context regardless of sudo's env_reset policy.
Caveat: the value is briefly visible in `ps` on the server to root; this
is occ's own documented mechanism for passwords and the box is root-only.
"""
prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items())
cmd = f"sudo -u apache env {prefix} php {OCC_INSTALL_DIR}/occ {args}"
return self.run(cmd, timeout=timeout)
def _occ_ok(self, args: str, timeout: int = EXEC_TIMEOUT) -> str:
"""Run occ, raise BackupError on non-zero, return stdout."""
rc, out, err = self.occ(args, timeout=timeout)
if rc != 0:
label = args.split()[0] if args else "occ"
raise bc.BackupError(f"occ {label} failed (rc={rc}): "
f"{(err or out).strip()[:300]}")
return out
def _occ_env_ok(self, env: dict, args: str,
timeout: int = EXEC_TIMEOUT) -> str:
"""occ_env variant of _occ_ok (occ_env is not wrapped by _occ_ok)."""
rc, out, err = self.occ_env(env, 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
def _occ_json(self, args: str, timeout: int = EXEC_TIMEOUT) -> Any:
"""occ with --output=json; parse defensively so a non-JSON banner
(maintenance notice, PHP deprecation, update line) surfaces as a clean
BackupError rather than an uncaught JSONDecodeError traceback."""
out = self._occ_ok(args, timeout=timeout).strip()
try:
return json.loads(out or "null")
except json.JSONDecodeError:
label = args.split()[0] if args else "occ"
raise bc.BackupError(f"occ {label} returned non-JSON: {out[:300]}")
# -- 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:
# options first, then `--`, then the positional uid so a uid that
# happens to start with '-' is never parsed by occ as an option flag.
parts = ["user:add --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"--group={shlex.quote(g)}")
parts.append(f"-- {shlex.quote(uid)}")
out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts))
return {"uid": uid, "output": out.strip()}
def delete_user(self, uid: str, force: bool = False) -> str:
return self._occ_ok(f"user:delete{' -f' if force else ''} -- "
f"{shlex.quote(uid)}", 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:
return self._occ_env_ok(
{"OC_PASS": password},
f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").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)} "
f"{shlex.quote(value)}").strip()
# -- quota (occ user:setting <uid> files quota) ----------------------------
def get_quota(self, uid: str) -> Optional[str]:
"""Return the user's quota string, or None when it is unset (= default).
A non-existent user or a genuine occ failure raises BackupError - occ
returns rc=1 for both 'setting unset' and 'user missing', so we key off
the message ('setting does not exist' == unset)."""
rc, out, err = self.occ(f"user:setting -- {shlex.quote(uid)} files quota")
if rc == 0:
return out.strip() or None
msg = (out or err).strip()
if "setting does not exist" in msg.lower():
return None
raise bc.BackupError(f"occ user:setting quota failed (rc={rc}): {msg[:300]}")
def set_quota(self, uid: str, value: str) -> str:
return self._occ_ok(
f"user:setting --value={shlex.quote(value)} "
f"-- {shlex.quote(uid)} files quota").strip()
def reset_quota(self, uid: str) -> str:
return self._occ_ok(
f"user:setting --delete -- {shlex.quote(uid)} files quota").strip()
# -- groups ----------------------------------------------------------------
def list_groups(self) -> Any:
return self._occ_json("group:list --output=json")
def group_members(self, group: str) -> Any:
return self._occ_json(
f"group:list-members --output=json -- {shlex.quote(group)}")
def user_groups(self, uid: str) -> Any:
return self._occ_json(
f"user:list-groups --output=json -- {shlex.quote(uid)}")
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:
# --member= binds each value even if it starts with '-'.
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
return self._occ_ok(
f"group:add-member {ms} -- {shlex.quote(group)}").strip()
def remove_group_members(self, group: str, members: list[str]) -> str:
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
return self._occ_ok(
f"group:remove-member {ms} -- {shlex.quote(group)}").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:
# reuse config_get so a genuine occ failure raises (not a false 'off');
# the maintenance key is always set, so None would itself be anomalous.
val = self.config_get("maintenance")
return (val or "").strip().lower() in ("true", "1", "yes")
def list_apps(self) -> Any:
return self._occ_json("app:list --output=json")
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]:
"""Return the system config value, or None when the key is genuinely
unset (occ rc=1 with empty output). A non-zero rc that carries an error
message is a real failure and raises, so callers never mistake a broken
occ read for 'value is unset'."""
rc, out, err = self.occ(f"config:system:get -- {shlex.quote(name)}")
if rc == 0:
return out.strip()
msg = (err or out).strip()
if not msg:
return None
raise bc.BackupError(f"occ config:system:get failed (rc={rc}): {msg[:300]}")
def config_list(self) -> Any:
return self._occ_json("config:list system --output=json")
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:
opts = "files:scan"
if path:
opts += f" --path={shlex.quote(path)}"
if all_users:
args = f"{opts} --all"
elif uid:
args = f"{opts} -- {shlex.quote(uid)}"
else:
raise bc.BackupError("files_scan requires a uid or all_users=True")
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
def transfer_ownership(self, src: str, dst: str,
path: Optional[str] = None) -> str:
args = "files:transfer-ownership -s"
if path:
args += f" --path={shlex.quote(path)}"
args += f" -- {shlex.quote(src)} {shlex.quote(dst)}"
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)) if users else ""
return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip()
def versions_cleanup(self, users: list[str]) -> str:
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip()
def _int(v: Any) -> Optional[int]:
try:
return int(str(v).strip())
except (TypeError, ValueError):
return None
def _epoch(v: Any) -> Optional[str]:
"""occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never."""
try:
n = int(v)
except (TypeError, ValueError):
return None
if n <= 0:
return None
return datetime.fromtimestamp(n, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
def _as_bool(v: Any) -> bool:
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().lower() in ("true", "yes", "1", "enabled")
return bool(v)
def main() -> int:
c = OwncloudClient()
try:
s = c.server_status()
print(f"[OK] ownCloud reachable at {c.host}; version={s.get('versionstring')} "
f"edition={s.get('edition')}")
return 0
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
finally:
c.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,464 @@
# VoIP Client Onboarding Workflow - Complete Guide
**Last Updated:** 2026-07-09
**Validated Against:** russo.91912.service (live production), vwp.91912.service (test)
**Platforms:** PacketDial/NetSapiens v44.4.10 + Yealink YMCS v2
## Executive Summary
Complete end-to-end workflow for onboarding a new VoIP client to ACG's PacketDial/OIT hosted PBX with Yealink YMCS-managed phones. **~95% API-automatable** with proper credential management.
---
## Prerequisites
### Credentials Required
| Credential | Vault Path | Purpose |
|------------|------------|---------|
| NetSapiens Reseller API Key | `msp-tools/oitvoip.sops.yaml``credentials.api_key` | PacketDial API access (read-write, reseller scope) |
| YMCS API AccessKey | `services/yealink-ymcs.sops.yaml``credentials.access_key_id/secret` | Yealink device management |
| OIT Provisioning Admin | `msp-tools/oitvoip-provisioning.sops.yaml``credentials.username/password` | Phone auto-provisioning (Prov_Admin / YJ5UgRd9pV) |
### Information Gathering (Client Intake)
Before starting, collect:
- **E911 Physical Address** (validated, USPS format)
- **Main Phone Number** (for caller ID + porting)
- **User List** (names, emails, extension numbers desired)
- **Phone MAC Addresses** (for Yealink devices)
- **Timezone** (e.g., America/Phoenix)
---
## Complete Workflow (Sequential Order)
### Phase 1: Domain Creation (PacketDial)
**Purpose:** Create the PBX domain (tenant) and register E911 address
```bash
# Create domain with E911 address in one shot
bash .claude/scripts/py.sh .claude/skills/packetdial/scripts/ns.py onboard-domain \
--body-file client-config.json \
--confirm
```
**`client-config.json` Template:**
```json
{
"domain": "clientname.91912.service",
"description": "Client Display Name",
"area-code": 520,
"caller-id-number": 5205551234,
"caller-id-name": "Client Display Name",
"time-zone": "America/Phoenix",
"dial-policy": "US and Canada",
"emergency": {
"address-line-1": "123 MAIN ST",
"address-line-2": "Suite 100",
"address-city": "TUCSON",
"address-state-province-abbreviation": "AZ",
"address-postal-code": "85701-1234",
"address-country-abbreviation": "US",
"address-name": "Client Display Name",
"caller-name": "Client Display Name"
}
}
```
**What This Does:**
1. `POST /domains` - Creates domain with basic config + limits
2. `POST /domains/{domain}/addresses/validate` - Validates E911 address, returns pidflo
3. `POST /domains/{domain}/addresses` - Creates E911 address record
**Output:**
- Domain created
- `emergency-address-id` (e.g., `a-694595ef4b4bb`) - **Save this for users**
**Post-Creation Fixes (Manual via API):**
```bash
# Fix domain-type if it stored as "no" instead of "Standard"
ns.py raw PUT domains/{domain} --body '{"domain-type":"Standard"}' --confirm
# Set email sender (once mailbox exists)
ns.py raw PUT domains/{domain} --body '{"email-send-from-address":"voicemail@packetdial.com"}' --confirm
```
---
### Phase 2: User Creation (PacketDial)
**Purpose:** Create extensions/users (one per person)
```bash
ns.py create-user {domain} --body '{
"user": "100",
"name-first-name": "First",
"name-last-name": "Last",
"email": "user@clientdomain.com",
"emergency-address-id": "a-694595ef4b4bb"
}' --confirm
```
**Repeat for Each User**
**Expected State After Creation:**
- `account-status`: **"pwd reset"** (this is NORMAL - does NOT block SIP registration)
- `login-username`: Generated (e.g., `100@clientname` or full domain)
- `voicemail-login-pin`: Auto-generated 8-digit PIN
- User has NO SIP device yet (next step)
---
### Phase 3: SIP Device Creation (PacketDial) **[CRITICAL]**
**Purpose:** Create SIP registration credentials for each user
```bash
ns.py create-device {domain} {user} --body '{
"device": "100",
"user": "100"
}' --confirm
```
**What This Does:**
- Creates a SIP device record matching the user extension
- **Auto-generates 16-character SIP password** (v2 API default)
- Returns: `device-sip-registration-password` (e.g., `ySjl5J2Tiv2FBT6M`)
**Retrieve the Password:**
```bash
ns.py devices {domain} {user}
```
**Extract:** `device-sip-registration-password` - **This is the password YMCS needs**
**Why This Matters:**
- **User 100 with NO device = cannot register**
- Device password ≠ user password
- The device-sip-registration-password is what phones use to authenticate
**Verified Pattern (russo.91912.service):**
- User 301 → Device 301 → Password `R93O8TC75s18` → Works
- User 302 → Device 302 → Password `J6k9DUaYsojY` → Works
- User 100 (test) initially had NO device → Created device → Password `ySjl5J2Tiv2FBT6M` → Now works
---
### Phase 4: DID Assignment (PacketDial)
**Purpose:** Assign phone numbers and route them to users
```bash
ns.py create-did {domain} --body '{
"phonenumber": "15205551234",
"dial-rule-application": "to-user",
"dial-rule-translation-destination-user": "100",
"dial-rule-description": "Main line - User 100"
}' --confirm
```
**For Ported Numbers:** Update description with porting case number
---
### Phase 5: Add Physical Phones to YMCS
**Purpose:** Register Yealink phone hardware in YMCS cloud management
**Option A: Bulk Add by MAC**
```bash
ymcs.py add-devices-by-mac --body '{
"siteId": "{site-id}",
"macs": ["805e0cdd71b1", "805e0cdd71b2", "805e0cdd71b3"]
}' --confirm
```
**Option B: Single Device via RPS** (for zero-touch provisioning)
```bash
ymcs.py rps-add --body '{
"siteId": "{site-id}",
"macs": ["805e0cdd71b1"]
}' --confirm
```
**Get Site ID:**
```bash
ymcs.py sites
# Find client site, extract "id" field
```
**Verify:**
```bash
ymcs.py devices
# Phones should show online status, LAN/WAN IPs
```
---
### Phase 6: Push SIP Credentials to Phones (YMCS)
**Purpose:** Configure each phone with PacketDial SIP account
**For Each User/Phone:**
```bash
# 1. Create SIP account in YMCS with PacketDial credentials
ymcs.py add-sipaccount --body '{
"registerName": "100@clientname.91912.service",
"username": "100@clientname.91912.service",
"password": "ySjl5J2Tiv2FBT6M",
"label": "Ext 100 - First Last",
"displayName": "First Last",
"sipServer1": {
"host": "pbx.packetdial.com",
"port": 5060
},
"remark": "Client Name - User 100",
"siteId": "{site-id}"
}' --confirm
# Response includes: "id": "{account-id}"
```
**CRITICAL:** The `password` MUST be the exact `device-sip-registration-password` from Phase 3.
**Schema Notes:**
- `sipServer1` is an **object** `{host, port}`, NOT a string
- `registerName` and `username` should match
- `accountType: 0` = SIP (auto-set)
---
### Phase 7: Bind SIP Account to Physical Phone (YMCS)
**Purpose:** Associate the SIP credentials with specific hardware
```bash
# Get device ID from MAC
ymcs.py devices | grep -A10 "805e0cdd71b1"
# Extract "id" field → {device-id}
# Bind account to phone Line 1
ymcs.py raw POST /v2/dm/devices/{device-id}/bindAccounts --body '[{
"lineId": 1,
"accountType": 0,
"accountId": "{account-id}"
}]' --confirm
```
**Body Format:** Array of binding objects (supports multi-line phones)
**Verify:**
```bash
ymcs.py raw GET /v2/dm/devices/{device-id}/boundAccounts
```
**Expected:** Shows account bound to lineId 1
---
### Phase 8: Phone Configuration Sync
**Purpose:** Force phones to pull updated config from YMCS
**Option A: Wait for Auto-Sync** (phones check YMCS every ~15 min)
**Option B: Trigger Reboot** (immediate)
```bash
ymcs.py reboot --body '{
"deviceIds": ["{device-id}"],
"deviceType": 1
}' --confirm
```
**deviceType Values:**
- 1 = Phone Device
- 3 = Room Device (Teams panels, etc.)
---
### Phase 9: Verification
**Check SIP Registration (PacketDial):**
```bash
ns.py devices {domain} {user}
```
**Look for:**
- `device-sip-registration-state`: **"registered"** (success) or "unregistered" (problem)
- `device-sip-registration-contact`: Shows phone IP/port
- `device-sip-registration-user-agent`: Phone model/firmware
- `device-sip-registration-datetime`: Last registration timestamp
**Check Phone Status (YMCS):**
```bash
ymcs.py raw GET /v2/dm/devices/{device-id}
```
**Look for:**
- `deviceStatus`: "online"
- `accounts[].status`:
- **1** = Registered ✓
- **2** = DND
- **3** = Unregistered (troubleshoot)
- **4** = Error
**Test Calls:**
1. Dial extension-to-extension
2. Dial out to external number (verify caller ID)
3. Dial in from external number (verify DID routing)
4. Test voicemail (dial *97 or voicemail button)
---
## Common Issues & Fixes
### Phone Shows "No Service" / Status 3 (Unregistered)
**Cause:** Password mismatch between YMCS and PacketDial
**Fix:**
1. Get correct password: `ns.py devices {domain} {user}`
2. Update YMCS account:
```bash
ymcs.py raw PATCH /v2/dm/sipAccounts/{account-id} --body '{
"registerName": "100@domain.91912.service",
"username": "100@domain.91912.service",
"password": "{correct-password}",
"sipServer1": {"host": "pbx.packetdial.com", "port": 5060}
}' --confirm
```
3. Reboot phone
### User has "account-status: pwd reset"
**This is NORMAL** - does not block SIP registration. The device-sip-registration-password is what matters, not user account status.
### Phone Not Pulling Config from YMCS
1. Check `ymcs.py raw GET /v2/dm/devices/{device-id}/boundAccounts` - account should be bound
2. Reboot phone manually or via `ymcs.py reboot`
3. Check firewall - phone needs HTTPS access to `us-api.ymcs.yealink.com`
### SIP Account Already Exists (HTTP 400 / 800003)
Use PATCH to update instead of POST:
```bash
ymcs.py raw PATCH /v2/dm/sipAccounts/{account-id} --body '{...}' --confirm
```
### domain-type Stored as "no"
Known PacketDial behavior. Fix post-creation:
```bash
ns.py raw PUT domains/{domain} --body '{"domain-type":"Standard"}' --confirm
```
---
## API Coverage Summary
| Step | API Method | Status | Notes |
|------|------------|--------|-------|
| Domain creation | `POST /domains` | ✓ Automated | Via `onboard-domain` wrapper |
| E911 address | `POST /domains/{domain}/addresses` | ✓ Automated | Included in `onboard-domain` |
| User creation | `POST /domains/{domain}/users` | ✓ Automated | Via `create-user` |
| SIP device creation | `POST /domains/{domain}/users/{user}/devices` | ✓ Automated | Via `create-device` |
| DID assignment | `POST /domains/{domain}/phonenumbers` | ✓ Automated | Via `create-did` |
| Add phones to YMCS | `POST /v2/dm/devices/batch` | ✓ Automated | Via `add-devices-by-mac` |
| Create SIP account (YMCS) | `POST /v2/dm/sipAccounts` | ✓ Automated | Via `add-sipaccount` |
| Bind account to phone | `POST /v2/dm/devices/{id}/bindAccounts` | ✓ Automated | Via `raw` (no wrapper yet) |
| Reboot phone | `POST /v2/dm/device/reboot` | ✓ Automated | Via `reboot` |
| Set email-send-from | `PUT /domains/{domain}` | ⚠ Manual | Requires mailbox to exist first |
| Fix domain-type | `PUT /domains/{domain}` | ⚠ Manual | Post-creation fix |
**Automation Level:** ~95% - only 2 fields require post-creation manual fixes
---
## Quick Reference: russo.91912.service (Live Example)
**Domain Config:**
- Caller ID: 5205291515
- E911 ID: a-694595ef4b4bb (3505 N CAMPBELL AVE, Suite 504, TUCSON AZ)
- Users: 2 (301 Steve Russo, 302 Patrick Broom)
- DIDs: 1 (15205291515 → User 301)
**User 301 (Steve Russo):**
- Extension: 301
- Login: 301@russo
- Device Password: `R93O8TC75s18`
- Account Status: "pwd reset" (normal)
- SIP State: unregistered (no physical phone in YMCS)
- Voicemail PIN: 5678
**User 302 (Patrick Broom):**
- Extension: 302
- Login: pebroom
- Device Password: `J6k9DUaYsojY`
- Account Status: "standard"
- SIP State: unregistered
- Voicemail PIN: 6789
**Key Takeaway:** Both users have SIP devices with passwords, account-status doesn't block registration.
---
## Skill Commands Quick Reference
**PacketDial (`ns.py`):**
```bash
ns.py onboard-domain --body-file client.json --confirm
ns.py create-user {domain} --body '{...}' --confirm
ns.py create-device {domain} {user} --body '{...}' --confirm
ns.py devices {domain} {user} # Get SIP password
ns.py create-did {domain} --body '{...}' --confirm
ns.py user {domain} {user} # Check user status
```
**Yealink YMCS (`ymcs.py`):**
```bash
ymcs.py sites # List sites, get IDs
ymcs.py devices # List all phones
ymcs.py add-devices-by-mac --body '{...}' --confirm
ymcs.py add-sipaccount --body '{...}' --confirm
ymcs.py raw POST /v2/dm/devices/{id}/bindAccounts --body '[{...}]' --confirm
ymcs.py raw GET /v2/dm/devices/{id}/boundAccounts
ymcs.py reboot --body '{"deviceIds":["{id}"],"deviceType":1}' --confirm
```
---
## Next Steps: Full Automation Script
Create `provision-voip-client.sh` that:
1. Takes client intake JSON
2. Executes all 9 phases sequentially
3. Validates each step before proceeding
4. Generates final report with all credentials/IDs
5. Updates vault with credentials
6. Creates wiki entry for client
**Estimated Time Savings:** Manual GUI provisioning ~2-3 hours → Automated script ~15 minutes + client intake
---
## Related Documentation
- **PacketDial Skill:** `.claude/skills/packetdial/SKILL.md`
- **YMCS Skill:** `.claude/skills/yealink-ymcs/SKILL.md`
- **Wiki:** `wiki/systems/packetdial.md`
- **YMCS SIP Schema:** `.claude/skills/yealink-ymcs/docs/SIPSERVER_SCHEMA.md`
- **NetSapiens API Docs:** https://docs.ns-api.com
- **NetSapiens v1→v2 Migration:** https://docs.ns-api.com/docs/v1-migration-to-v2
- **OIT/PacketDial Docs:** https://pbx.readme.io
---
**Sources:**
- [NetSapiens API v1→v2 Migration](https://docs.ns-api.com/docs/v1-migration-to-v2)
- [NetSapiens Docs Homepage](https://docs.ns-api.com/)
- [Create User Device API](https://pbx.readme.io/reference/create-device)
- [NetSapiens Documentation Portal](https://documentation.netsapiens.com/ns/)

View File

@@ -0,0 +1,90 @@
# OIT/PacketDial Client Onboarding Checklist (reusable template)
Reusable runbook for onboarding a new client to VoIP on the **OIT/PacketDial (NetSapiens)**
platform with **Yealink YMCS** device management. Structured on the NetSapiens 17-step
white-label onboarding flow (voipdocs.io/onboarding-recommendations), annotated with the
ACG/OIT specifics and gotchas learned in the field.
**How to use:** copy this file to `clients/<slug>/voip/ONBOARDING-CHECKLIST.md`, fill the
placeholders, and work the boxes. Keep client-specific detail in the client copy; keep this
template generic.
Legend: `[x]` done · `[ ]` not started · `[~]` in progress · `[?]` verify
---
## Key references (fill per client)
| Item | Value |
|---|---|
| Client / slug | `<name>` / `<slug>` |
| PacketDial domain | `<domain>.<reseller>.service` |
| Reseller | `91912.service` (ACG) |
| Main number | `<E.164 / 10-digit>` |
| Timezone / dial policy | `America/Phoenix` / `US and Canada` |
| **RPS provisioning URL** | `http://ndp.ucaasnetwork.com/cfg` (OIT/PacketDial) |
| YMCS site / site ID | `<site name>` / `<site id>` |
| E911 address ID | `<a-...>` |
| Credentials | vault `msp-tools/oitvoip-provisioning.sops.yaml` (Prov_Admin) + `msp-tools/oitvoip.sops.yaml` (reseller API key) |
---
## Client intake / prep (steps 1-8)
- [ ] **1. Welcome correspondence** sent to the client.
- [ ] **2. Payment authorization (CC/ACH)** on file — NetSapiens flags this as the prerequisite for every step after. Do not go live without it.
- [ ] **3. Service start timing** decided — port-dependent (wait for numbers) or immediate with temporary numbers.
- [ ] **4. Recent billing docs** collected from the current carrier (bill copy / CSR) for the port.
- [ ] **5. Numbers-to-port list** — every DID + its intended purpose (main line, fax, direct dials).
- [ ] **6. User roster** — names, contact info, desired extensions, voicemail-to-email preference. Match to the client's M365 tenant where possible (emails drive vmail delivery).
- [ ] **7. Equipment source + MAC addresses** — who supplies phones (ACG vs client), models, tracking, and **MACs** (Yealink RPS + YMCS bind key on the MAC).
- [ ] **8. Client orientation call** scheduled/held.
## Platform build-out (steps 9-17)
- [ ] **9. Billing account / domain** created under the ACG reseller (`<domain>.<reseller>.service`), timezone + dial policy set.
- [ ] **10. Port application** submitted (after steps 4-5); track FOC date.
- [ ] **11. Domain infrastructure** configured — domain settings, dial plan, main caller ID.
- [ ] **12. DID numbers** created and ready to route.
- [ ] **13. User accounts** registered. For >5 users use the **CSV bulk import** (PacketDial: Domains → `<domain>` → Users → Import). Create **users only, no devices** — assign devices later as phones are physically distributed.
- [ ] **14. Auto-attendants + call queues** built (reception queue, main AA; add dept queues as needed).
- [ ] **15. Number routing** configured — point DIDs at the AA/queue; add business-hours vs after-hours **timeframes** for time-based routing.
- [ ] **16. E911 / emergency services** activated — register the physical service address, capture the E911 address ID, confirm each device/extension maps to a valid address.
- [ ] **17. Hardware deployed** — provision + register each phone (device-assignment workflow below).
---
## Per-phone device-assignment workflow (step 17 detail)
Users are created without SIP devices; bind a device to a user when the physical phone is handed out.
1. **User provides the last 4 of the phone's MAC.**
2. **Create the SIP device in PacketDial** (auto-generates the SIP registration password):
```bash
bash .claude/scripts/py.sh ns.py create-device <domain> <ext> \
--body '{"device": "sip:<ext>a@<domain>"}' --confirm
```
3. **Read the SIP password** back from the device (`device-sip-registration-password`).
4. **Create the YMCS SIP account** with that password, pointing `sipServer1.host` at `pbx.packetdial.com:5060` and `siteId` at the client's YMCS site.
5. **Find the physical phone** in YMCS by the last-4 MAC (`ymcs.py devices | grep <last4>`).
6. **Bind** the account to the phone (`/v2/dm/devices/{id}/bindAccounts`) and **reboot** to apply.
Detailed commands: copy the client's `DEVICE-ASSIGNMENT-COMMANDS.md` (see the VWP example).
---
## Gotchas learned in the field
- **TWO whitelabel accounts — use the right RPS URL.** ACG has both Whitelabel Communication (WLC, `ftp://p.packetdials.net`) and OIT/PacketDial (`http://ndp.ucaasnetwork.com/cfg`). A phone provisioned against the wrong RPS URL stays **unregistered** with otherwise-correct SIP creds. OIT clients MUST use `http://ndp.ucaasnetwork.com/cfg`. (Root cause of the VWP registration failure, 2026-07-09.)
- **YMCS `sipServer1` schema is strict.** The SIP-account create expects `sipServer1` as an object (`{"host": ..., "port": 5060}`), not a string; a bad shape returns HTTP 412 "Parameter error". Confirm against `.claude/skills/yealink-ymcs/docs/SIPSERVER_SCHEMA.md`.
- **Match extensions to M365 by email** so voicemail-to-email lands correctly; park unmatched/shared extensions (kitchen, conference, warehouse) and confirm names with the client before creating accounts.
- **Bulk import = users only.** Import creates users without devices by design; devices are bound per-phone at hand-out. This keeps unassigned phones from registering to the wrong person.
## Tooling & support
- **Skills:** `.claude/skills/packetdial/` (PacketDial/NetSapiens API — `ns.py`), `.claude/skills/yealink-ymcs/` (YMCS — `ymcs.py`)
- **Credentials:** vault `msp-tools/oitvoip*.sops.yaml`
- **PacketDial UI:** https://pbx.packetdial.com/ · **API:** `https://pbx.packetdial.com/ns-api/v2` (fallback `https://api.ucaasnetwork.com/ns-api/v2`) · **docs:** https://docs.ns-api.com
- **YMCS UI:** https://us.ymcs.yealink.com/
- **OIT support:** support@oitvoip.com · **Onboarding best-practices:** https://voipdocs.io/onboarding-recommendations
- **Worked example:** `clients/valleywide/voip/` (Valley Wide Plastering)

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Create SIP devices for VWP extensions 101-118
# This auto-generates the device-sip-registration-password for each user
DOMAIN="vwp.91912.service"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY_WRAPPER="bash $SCRIPT_DIR/../../../.claude/scripts/py.sh"
EXTENSIONS=(101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118)
echo "[INFO] Creating SIP devices for 18 VWP extensions..."
echo ""
SUCCESS=0
FAILED=0
for ext in "${EXTENSIONS[@]}"; do
echo "[INFO] Creating device for extension $ext"
# Create device (device name = user extension)
BODY="{\"device\":\"$ext\",\"user\":\"$ext\"}"
RESULT=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" create-device "$DOMAIN" "$ext" --body "$BODY" --confirm 2>&1)
if echo "$RESULT" | grep -q "HTTP 400.*already exists"; then
echo " [SKIP] Device $ext already exists"
((SUCCESS++))
elif echo "$RESULT" | grep -qE "HTTP [45]"; then
echo " [ERROR] Failed to create device $ext: $RESULT"
((FAILED++))
else
echo " [OK] Created device $ext"
((SUCCESS++))
fi
done
echo ""
echo "[SUMMARY] Devices: $SUCCESS created/existing, $FAILED failed"
echo ""
echo "[INFO] Fetching all device passwords..."
echo ""
# Get all passwords
for ext in "${EXTENSIONS[@]}"; do
PASSWORD=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" devices "$DOMAIN" "$ext" 2>/dev/null | grep -o '"device-sip-registration-password": "[^"]*"' | cut -d'"' -f4)
if [ -n "$PASSWORD" ]; then
echo "Extension $ext: $PASSWORD"
else
echo "Extension $ext: [ERROR - Could not retrieve password]"
fi
done

View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Provision 11 matched VWP extensions with correct M365 emails
# Domain: vwp.91912.service
# E911 Address: a-6a395c03d4cfe
DOMAIN="vwp.91912.service"
E911="a-6a395c03d4cfe"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY_WRAPPER="bash $SCRIPT_DIR/../../../.claude/scripts/py.sh"
# Matched users: extension|first_name|last_name|email
USERS=(
"102|Jesse|Guerrero|jesse@valleywideplastering.com"
"103|Rose|Guerrero|rose@valleywideplastering.com"
"104|Shelly|Dooley|shelly@valleywideplastering.com"
"105|JR|Guerrero|j-r@valleywideplastering.com"
"107|Payroll|Department|payroll@valleywideplastering.com"
"109|Kayla|Guerrero|kayla@valleywideplastering.com"
"110|Ron|Winger|ron@valleywideplastering.com"
"113|Chris|Guerrero|chris@valleywideplastering.com"
"114|Ty|Fetters|Ty@CASARICA.NET"
"115|Toni|Billing|billing@valleywideplastering.onmicrosoft.com"
"116|Bart|Graffin|estimating@valleywideplastering.com"
)
echo "[INFO] Creating 11 matched VWP users in PacketDial..."
echo ""
SUCCESS=0
FAILED=0
for user_data in "${USERS[@]}"; do
IFS='|' read -r ext first last email <<< "$user_data"
echo "[INFO] Creating extension $ext: $first $last ($email)"
# Build JSON body (name-last-name is required)
BODY="{\"user\":\"$ext\",\"name-first-name\":\"$first\",\"name-last-name\":\"$last\",\"email\":\"$email\",\"emergency-address-id\":\"$E911\"}"
# Create user
RESULT=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" create-user "$DOMAIN" --body "$BODY" --confirm 2>&1)
if echo "$RESULT" | grep -q "HTTP 400.*already exists"; then
echo " [SKIP] Extension $ext already exists"
((SUCCESS++))
elif echo "$RESULT" | grep -qE "HTTP [45]"; then
echo " [ERROR] Failed to create extension $ext: $RESULT"
((FAILED++))
else
echo " [OK] Created extension $ext"
((SUCCESS++))
fi
# Small delay to avoid rate limiting
sleep 1
done
echo ""
echo "[SUMMARY] Users: $SUCCESS created/existing, $FAILED failed"
echo "[INFO] Waiting 30 seconds for async processing..."
sleep 30
echo ""
echo "[INFO] Verifying user creation..."
ACTUAL=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" users "$DOMAIN" 2>/dev/null | grep -c '"user"')
echo "[INFO] PacketDial reports $ACTUAL users total"

View File

@@ -0,0 +1,64 @@
#!/bin/bash
# Provision VWP extensions 101-118
# Domain: vwp.91912.service
# E911 Address: a-6a395c03d4cfe
DOMAIN="vwp.91912.service"
E911="a-6a395c03d4cfe"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY_WRAPPER="bash $SCRIPT_DIR/../../../.claude/scripts/py.sh"
# User list: extension|first_name|last_name|email
USERS=(
"101|Natalya||natalya@vwp.com"
"102|Jesse||jesse@vwp.com"
"103|Rose||rose@vwp.com"
"104|Shelly||shelly@vwp.com"
"105|J.R.||jr@vwp.com"
"106|Tammy||tammy@vwp.com"
"107|Payroll|Department|payroll@vwp.com"
"108|Shannon||shannon@vwp.com"
"109|Kayla||kayla@vwp.com"
"110|Ron||ron@vwp.com"
"111|Kitchen|Phone|kitchen@vwp.com"
"112|Conference|Room|conferenceroom@vwp.com"
"113|Chris||chris@vwp.com"
"114|TY||ty@vwp.com"
"115|Toni||toni@vwp.com"
"116|Bart||bart@vwp.com"
"117|Jesse|III|jesse3@vwp.com"
"118|Warehouse|Phone|warehouse@vwp.com"
)
echo "[INFO] Creating 18 VWP users in PacketDial..."
echo ""
SUCCESS=0
FAILED=0
for user_data in "${USERS[@]}"; do
IFS='|' read -r ext first last email <<< "$user_data"
echo "[INFO] Creating extension $ext: $first $last"
# Build JSON body (name-last-name is required even if empty)
LAST_NAME="${last:-.}"
BODY="{\"user\":\"$ext\",\"name-first-name\":\"$first\",\"name-last-name\":\"$LAST_NAME\",\"email\":\"$email\",\"emergency-address-id\":\"$E911\"}"
# Create user
RESULT=$($PY_WRAPPER "$SCRIPT_DIR/ns.py" create-user "$DOMAIN" --body "$BODY" --confirm 2>&1)
if echo "$RESULT" | grep -q "HTTP 400.*already exists"; then
echo " [SKIP] Extension $ext already exists"
((SUCCESS++))
elif echo "$RESULT" | grep -qE "HTTP [45]"; then
echo " [ERROR] Failed to create extension $ext: $RESULT"
((FAILED++))
else
echo " [OK] Created extension $ext"
((SUCCESS++))
fi
done
echo ""
echo "[SUMMARY] Users: $SUCCESS created/existing, $FAILED failed"

View File

@@ -13,13 +13,18 @@ extension. Read-only by default; writes gated behind `--confirm`.
```bash
SC="bash $CLAUDETOOLS_ROOT/.claude/scripts/py.sh C:/claudetools/.claude/skills/screenconnect/scripts/sc.py"
$SC status # auth check + instance info
$SC sessions --name "<hostname>" # find sessions by Name
$SC session <sessionID> # full session detail
$SC status # auth check + fleet counts
$SC sessions # WHOLE Access fleet (all agents)
$SC sessions --company "X" # one client's machines (CP1)
$SC sessions --like DELL # partial-name match
$SC sessions --name "<hostname>" # exact Name match
$SC sessions --filter "SessionType = 'Access'" # raw session-filter expression
$SC sessions --limit 25 # cap printed rows (--json is always full)
$SC session <sessionID> # full session detail
$SC build-installer --platform msi --name HOST --company "X" --site "Y" --tag "Z"
$SC send-command --session <id> --command "..." --confirm
$SC set-properties --session <id> --props-json '["Company","Site","Tag"]' --confirm
$SC raw --method GetSessionsByName --body '{"sessionName":""}'
$SC raw --method GetSessionsByFilter --body '{"sessionFilter":"SessionType = '"'"'Access'"'"'"}'
```
Transport auto-selects httpx, else stdlib urllib (no hard dependency).
@@ -53,21 +58,58 @@ This is the headline use case - set a device for SC and have it land correctly:
VERIFIED end-to-end on RMM-TEST-MACHINE 2026-06-22 (installed, self-tagged
Company/Site/Tag, ran a command, re-tagged via set-properties).
### Deploying the GuruRMM agent via SC (verified 2026-07-08)
Different from the SC-installer push above: to enroll a box that is in SC but NOT in GuruRMM,
`send-command` the server's own site-preconfigured one-liner. The site code is baked into the
downloaded signed binary (GRMM_CFG trailer) so it self-enrolls straight into that site — no
Staging, no reassign:
```bash
# site_code from GET /api/sites/<id>/install-info (e.g. Main = INNER-BRIDGE-8354)
CMD='powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; iex (irm '\''https://rmm.azcomputerguru.com/install/<SITE_CODE>/windows'\'')"'
$SC send-command --session <sessionID> --command "$CMD" --confirm
```
The install script auto-relaunches into native 64-bit PowerShell (handles SC's 32-bit command
runner via Sysnative). Verified: DESKTOP-NFU17AJ enrolled into IMC → Main this way 2026-07-08.
- **`send-command` returns `{}`** — SC gives no command output via the API (documented below).
It is queued, not confirmed. **Verify by enrollment**, not by the send response: poll
`rmm-search.sh -c <client>` for the new agent (pipe `--json` with `2>/dev/null` — the `[OK]
Authenticated` banner is on stderr; `2>&1 | jq` breaks jq).
- **Offline targets:** the one-liner is a single self-contained command, so it queues in the
session's one event slot and runs on reconnect. It will NOT run until the box is online.
- **Gotcha:** the install script calls `Get-CimInstance Win32_Processor`; a box with corrupt
WMI dies there (bit CP-QB). A box that black-holes outbound TLS installs but never connects
(bit IMC-PRINTSERVER) — SC still works because its relay is not TLS.
- **CP values are under `.CustomProperties.CustomPropertyN` / `.CustomPropertyValues[]`** on the
raw session object (CP1=Company, CP2=Site, ...). `--company "X"` matches CP1 **exactly**, so
pass the full tagged value (IMC's CP1 is `IMC - Instrumental Music Center`, not `Instrumental
Music Center`) or enumerate with `--like` / a raw `CustomProperty1 = '...'` filter first.
SC truncates session Name to 15 chars — `IMC-M-EDSERVICE` (SC) is `IMC-M-EdServices1` (RMM).
## Method surface (probed live 2026-06-22)
**Available (CLI-exposed):**
- Reads: `GetSessionsByName` (matches the Name field; "" -> blank-name sessions),
`GetSessionDetailsBySessionID`, `GetSessionBySessionID`.
- Full-fleet listing: `GetSessionsByFilter` `{"sessionFilter":"<expr>"}` (probed live
2026-07-06) - THE enumeration path. `SessionType = 'Access'` returns every
unattended agent (958 on this instance); `SessionType = 'Support'` the on-demand
sessions (24). Filter language: single-quoted literals, `=`/`LIKE` (with `*`
wildcards), `AND`/`OR`. Per-client `CustomProperty1 = 'Safesite'`; partial name
`Name LIKE '*DELL*'`. The CLI `sessions` command wraps this (default = whole fleet).
- Reads: `GetSessionsByName` (EXACT Name match; "" -> only blank-name sessions, NOT
the fleet - use the filter for listing), `GetSessionDetailsBySessionID`,
`GetSessionBySessionID`.
- Writes (gated): `SendCommandToSession` `[sessionID, command]`,
`SendMessageToSession` `[sessionID, message]`,
`UpdateSessionCustomProperties` `[sessionID, [cp1,cp2,cp3,...]]`,
`CreateSession` (array; not the primary path - the installer creates access sessions).
**MISSING on this extension version (NOT a deploy/control blocker):**
- `GetSessions` / `GetAllSessions` / `GetSessionGroups` (full-fleet inventory) ->
"web method does not exist". Listing ALL agents needs Mike to update the RESTful
API Manager extension to expose a list-all method. Until then, find sessions by
Name (the installer sets the Name = machine name, so by-name lookup works).
**Absent aliases (not needed):**
- `GetSessions` / `GetAllSessions` / `GetSessionGroups` -> "web method does not
exist". No extension update required: `GetSessionsByFilter` covers full-fleet
inventory. (Earlier notes flagged this as a gap pending Mike; it is resolved.)
## Safety gating
@@ -121,5 +163,5 @@ Advance via RMM_THOUGHTS -> `/shape-spec`. Do NOT build until Mike gives the go.
## Reference
Verified method/param spec, auth, installer params, and the GetSessions gap:
Verified method/param spec, auth, installer params, and the session-filter language:
`references/api-reference.md`.

View File

@@ -22,14 +22,27 @@ probing 2026-06-22.
| Method | Params | Status | Notes |
|---|---|---|---|
| `GetSessionsByName` | `{"sessionName":"<name>"}` | VERIFIED | Matches the session Name field. "" returns blank-Name (unattended) sessions. CLI `sessions`. |
| `GetSessionsByFilter` | `{"sessionFilter":"<expr>"}` | VERIFIED | **Full-fleet listing.** Session-filter expression. `SessionType = 'Access'` -> 958 agents; `SessionType = 'Support'` -> 24. CLI `sessions`. See filter syntax below. |
| `GetSessionsByName` | `{"sessionName":"<name>"}` | VERIFIED | EXACT Name match. "" returns only blank-Name sessions (NOT the fleet). CLI `sessions --name`. |
| `GetSessionDetailsBySessionID` | `{"sessionID":"<id>"}` | VERIFIED | Full session object (CustomPropertyValues, ActiveConnections, ...). CLI `session`. |
| `GetSessionBySessionID` | `{"sessionID":"<id>"}` | VERIFIED | Returns the session (or [] if none). |
| `SendCommandToSession` | `["<sessionID>","<command>"]` | VERIFIED (gated) | Runs a backstage command on the guest. CLI `send-command`. STATE-CHANGING. |
| `SendMessageToSession` | `["<sessionID>","<message>"]` | wired (array) | Chat message to the guest. CLI `send-message`. STATE-CHANGING. |
| `UpdateSessionCustomProperties` | `["<sessionID>",["cp1","cp2","cp3",...]]` | VERIFIED (gated) | Set CP1=Company/CP2=Site/CP3=Tag. CLI `set-properties`. STATE-CHANGING. |
| `CreateSession` | array (signature TBD) | EXISTS | Not the primary path - the installer creates access sessions. `raw` only. |
| `GetSessions` / `GetAllSessions` / `GetSessionGroups` | - | MISSING | "web method does not exist". Full-fleet inventory needs an extension update (Mike). |
| `GetSessions` / `GetAllSessions` / `GetSessionGroups` | - | ABSENT | "web method does not exist" - but not needed; `GetSessionsByFilter` covers inventory. |
## Session-filter language (GetSessionsByFilter, probed live 2026-07-06)
Param name is `sessionFilter` (not `filter`). Empty/null -> "Session filter cannot be
null". String literals are single-quoted (double-quotes also work but need JSON
escaping). Verified operators/forms:
- `SessionType = 'Access'` (=2, the unattended agent fleet) | `'Support'` (=0) | `'Meeting'` (none here).
- `CustomProperty1 = 'Safesite'` -> per-client (CP1=Company, CP2=Site, CP3=Tag; up to CP8).
- `Name = '0124-DELL3540'` (exact) | `Name LIKE '*DELL*'` (partial, `*` wildcard, case-insensitive).
- Compound: `CustomProperty1 = 'Safesite' AND SessionType = 'Access'`; `AND`/`OR` supported.
- A bare token (`"DELL"`) or `"*"` matches nothing - always name a field.
## Parameterized access installer (the deploy capability)

View File

@@ -5,8 +5,12 @@ Read-only subcommands run freely. State-changing subcommands (send-command,
send-message, set-properties) refuse to run without --confirm.
Usage:
python sc.py status # auth check + instance info
python sc.py sessions [--name NAME] [--json]
python sc.py status # auth check + fleet counts
python sc.py sessions # whole Access fleet
python sc.py sessions --company "Safesite" # one client
python sc.py sessions --like DELL # partial name match
python sc.py sessions --name HOST # exact name
python sc.py sessions --filter "SessionType = 'Access'" # raw filter
python sc.py session <sessionID> # full session detail
python sc.py send-command --session <id> --command "..." --confirm
python sc.py send-message --session <id> --message "..." --confirm
@@ -97,20 +101,57 @@ def _gated(action_desc: str, confirm: bool) -> bool:
# --- handlers ---
def cmd_status(client, args):
# Auth check via the one verified method; report instance + custom-property map.
sessions = client.get_sessions_by_name("")
n = len(sessions) if isinstance(sessions, list) else 0
# Auth check via the most basic verified method (GetSessionsByName), so status
# confirms credentials even on an extension version without GetSessionsByFilter.
client.get_sessions_by_name("")
print(f"[OK] Authenticated to {SC_BASE_URL}")
print(f" extension: {client.extension_guid}")
print(f" custom properties: {CUSTOM_PROPERTIES}")
print(f" GetSessionsByName('') returned {n} session(s)")
# Fleet counts are a best-effort add-on: the filter method may be absent, and it
# must never fail the auth check that status exists to perform.
try:
access = client.list_sessions(session_type="access")
support = client.list_sessions(session_type="support")
na = len(access) if isinstance(access, list) else 0
ns = len(support) if isinstance(support, list) else 0
print(f" fleet: {na} Access (unattended) + {ns} Support session(s)")
except ScreenConnectError as exc:
print(f" fleet: (listing unavailable via GetSessionsByFilter: {exc})")
return 0
def _effective_type(args) -> str:
"""Resolve the SessionType filter for `sessions`. Explicit --type wins. Otherwise
a bare listing defaults to the Access fleet, but a TARGETED search (by name/like/
company/site/tag) spans all types - matching the old GetSessionsByName behavior,
where a by-name lookup found Support sessions too."""
if args.type is not None:
return args.type
if any([args.name, args.like, args.company, args.site, args.tag]):
return "all"
return "access"
def cmd_sessions(client, args):
_print_sessions(client.get_sessions_by_name(args.name)) if not args.json else _emit(
client.get_sessions_by_name(args.name), True
)
# Full-fleet listing is the default (no selector -> every Access session).
# A raw --filter wins; otherwise build a filter from the structured flags.
if args.filter:
data = client.get_sessions_by_filter(args.filter)
else:
data = client.list_sessions(
session_type=_effective_type(args),
name=args.name or None,
name_like=args.like,
company=args.company,
site=args.site,
tag=args.tag,
)
# --limit only caps the human-readable print; --json is always the full set.
if not args.json and isinstance(data, list) and args.limit and len(data) > args.limit:
_print_sessions(data[: args.limit])
print(f" ... {len(data) - args.limit} more (raise --limit or use --json)")
return 0
_emit(data, True) if args.json else _print_sessions(data)
return 0
@@ -192,8 +233,20 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("status", help="Auth check + instance info.", parents=[common])
sp = sub.add_parser("sessions", help="List sessions by Name (verified).", parents=[common])
sp.add_argument("--name", default="", help="Session Name filter (blank = unattended).")
sp = sub.add_parser("sessions",
help="List sessions (default = whole Access fleet).",
parents=[common])
sp.add_argument("--name", default="", help="Exact session Name match.")
sp.add_argument("--like", help="Partial Name match (wrapped in *...*).")
sp.add_argument("--company", help="Filter by CP1 = Company.")
sp.add_argument("--site", help="Filter by CP2 = Site.")
sp.add_argument("--tag", help="Filter by CP3 = Tag.")
sp.add_argument("--type", default=None,
help="access | support | meeting | all. Default: access for a "
"bare listing, all when a name/company/etc selector is given.")
sp.add_argument("--filter", help="Raw session-filter expression (overrides the flags).")
sp.add_argument("--limit", type=int, default=0,
help="Cap rows printed (0 = no cap; --json is always full).")
sp = sub.add_parser("session", help="Session detail.", parents=[common])
sp.add_argument("session_id")

View File

@@ -16,13 +16,20 @@ application/json and the body is the method's parameters (object or array).
Credentials: never hardcoded. api_secret loaded at runtime from the SOPS vault,
or the SCREENCONNECT_API_SECRET env var (testing override).
INSTANCE METHOD SURFACE (probed live 2026-06-22): control IS available -
GetSessionsByName, GetSessionDetailsBySessionID, GetSessionBySessionID (reads,
JSON-object params); SendCommandToSession, SendMessageToSession,
UpdateSessionCustomProperties, CreateSession (writes, POSITIONAL-ARRAY params).
MISSING on this extension version: GetSessions/GetAllSessions/GetSessionGroups
(full-fleet inventory) - "web method does not exist"; pending an admin update of
the RESTful API Manager extension. `raw()` probes arbitrary methods.
INSTANCE METHOD SURFACE (probed live 2026-06-22, extended 2026-07-06): control IS
available - GetSessionsByFilter, GetSessionsByName, GetSessionDetailsBySessionID,
GetSessionBySessionID (reads, JSON-object params); SendCommandToSession,
SendMessageToSession, UpdateSessionCustomProperties, CreateSession (writes,
POSITIONAL-ARRAY params).
FULL-FLEET LISTING (fixed 2026-07-06): GetSessionsByFilter({"sessionFilter": <expr>})
IS exposed and is the enumeration path. It takes a ScreenConnect session-filter
expression, e.g. SessionType = 'Access' (all 958 unattended agents), CustomProperty1
= 'Safesite' (per-client), Name LIKE '*DELL*' (partial name). SessionType 'Access'
(=2) is the agent fleet; 'Support' (=0) the on-demand sessions. The bare aliases
GetSessions/GetAllSessions/GetSessionGroups remain absent, but GetSessionsByFilter
covers the inventory use case fully - no extension update needed. `raw()` probes
arbitrary methods.
"""
from __future__ import annotations
@@ -63,6 +70,10 @@ SKILL_DIR = Path(__file__).resolve().parent.parent
# Custom-property mapping on this instance (from the vault notes).
CUSTOM_PROPERTIES = {"CP1": "Company", "CP2": "Site", "CP3": "Tag"}
# SessionType filter literals (verified live 2026-07-06): 'Access' (=2) is the
# unattended agent fleet; 'Support' (=0) the on-demand sessions.
SESSION_TYPE_LITERALS = {"access": "Access", "support": "Support", "meeting": "Meeting"}
class ScreenConnectError(RuntimeError):
"""Raised for transport or API errors."""
@@ -200,17 +211,79 @@ class ScreenConnectClient:
# VERIFIED methods (work on the current instance)
# ======================================================================
def get_sessions_by_name(self, session_name: str = "") -> Any:
"""List sessions whose Name matches `session_name` (RESTful API Manager
GetSessionsByName). VERIFIED LIVE. Empty string returns sessions with a
blank Name (the unattended access agents on this instance)."""
"""List sessions whose Name EXACTLY equals `session_name` (RESTful API
Manager GetSessionsByName). VERIFIED LIVE. This is an exact match, not a
contains match ("" returns only the blank-Name sessions, not the fleet).
For full-fleet or partial-name listing use list_sessions()/get_sessions_by_filter()."""
return self.call("GetSessionsByName", {"sessionName": session_name})
def get_sessions_by_filter(self, session_filter: str) -> Any:
"""Enumerate sessions matching a ScreenConnect session-filter expression
(GetSessionsByFilter, param `sessionFilter`). VERIFIED LIVE 2026-07-06 -
this IS the full-fleet listing path. Examples:
SessionType = 'Access' -> every unattended agent (the fleet)
CustomProperty1 = 'Safesite' -> one client's machines
Name LIKE '*DELL*' -> partial name (case-insensitive)
Combine with AND/OR. String literals are single-quoted."""
return self.call("GetSessionsByFilter", {"sessionFilter": session_filter})
@staticmethod
def _filter_literal(value: str) -> str:
"""Single-quote a value for the session-filter language, escaping any
embedded single quote by doubling it. VERIFIED LIVE 2026-07-06:
`CustomProperty1 LIKE '*Andy''s*'` matched the "Andy's Mobile Fuel" session,
so doubling is the correct escape for this filter grammar."""
return "'" + str(value).replace("'", "''") + "'"
def build_session_filter(
self,
session_type: Optional[str] = "access",
name: Optional[str] = None,
name_like: Optional[str] = None,
company: Optional[str] = None,
site: Optional[str] = None,
tag: Optional[str] = None,
extra: Optional[str] = None,
) -> str:
"""Assemble a session-filter expression from structured parts (AND-joined).
`session_type`: access | support | meeting | all (None/all -> no type clause).
`name` exact, `name_like` partial (wrapped in *...*), company/site/tag map to
CP1/CP2/CP3, `extra` is a raw clause appended verbatim. Defaults to the whole
Access fleet when nothing else is specified."""
clauses: list[str] = []
if session_type and session_type.lower() == "all":
# 'all' = every session type. Only Access + Support exist on this
# instance; an OR clause is the verified match-all (a bare '*' matches
# nothing). VERIFIED LIVE 2026-07-06 -> 982 = 958 Access + 24 Support.
clauses.append("(SessionType = 'Access' OR SessionType = 'Support')")
elif session_type:
literal = SESSION_TYPE_LITERALS.get(session_type.lower(), session_type)
clauses.append(f"SessionType = {self._filter_literal(literal)}")
if name:
clauses.append(f"Name = {self._filter_literal(name)}")
if name_like:
clauses.append(f"Name LIKE {self._filter_literal('*' + name_like + '*')}")
if company:
clauses.append(f"CustomProperty1 = {self._filter_literal(company)}")
if site:
clauses.append(f"CustomProperty2 = {self._filter_literal(site)}")
if tag:
clauses.append(f"CustomProperty3 = {self._filter_literal(tag)}")
if extra:
clauses.append(extra)
return " AND ".join(clauses) if clauses else "SessionType = 'Access'"
def list_sessions(self, **kwargs) -> Any:
"""Convenience: build a filter from keyword parts and enumerate. See
build_session_filter for the accepted keywords."""
return self.get_sessions_by_filter(self.build_session_filter(**kwargs))
# ======================================================================
# Control + detail methods (all VERIFIED LIVE 2026-06-22 on the ACG instance).
# Reads take a JSON object {sessionID:...}; the POST/write methods take a
# POSITIONAL ARRAY. NOTE: full-fleet inventory (GetSessions) is NOT exposed by
# this extension version (returns "web method does not exist") - pending an
# admin update of the RESTful API Manager extension (see SKILL.md).
# POSITIONAL ARRAY. Full-fleet inventory is served by GetSessionsByFilter
# (see get_sessions_by_filter / list_sessions above) - the bare GetSessions
# alias is absent but not needed.
# ======================================================================
def get_session_details(self, session_id: str) -> Any:
"""GetSessionDetailsBySessionID - full detail for one session. VERIFIED."""

View File

@@ -27,16 +27,18 @@ def run(args):
return p.returncode, p.stdout, p.stderr
def check(name, args, *, want_rc=None, out_has=None, out_json_ok=False):
def check(name, args, *, want_rc=None, out_has=None, out_json_ok=False, out_json_min_len=None):
rc, out, err = run(args)
problems = []
if want_rc is not None and rc != want_rc:
problems.append(f"rc={rc} want {want_rc}")
if out_has and out_has not in out:
problems.append(f"stdout missing {out_has!r}")
if out_json_ok:
if out_json_ok or out_json_min_len is not None:
try:
json.loads(out)
parsed = json.loads(out)
if out_json_min_len is not None and len(parsed) < out_json_min_len:
problems.append(f"json len {len(parsed)} < {out_json_min_len}")
except Exception as e:
problems.append(f"stdout not JSON: {e}")
results.append(("PASS" if not problems else "FAIL", name, "; ".join(problems)))
@@ -44,8 +46,21 @@ def check(name, args, *, want_rc=None, out_has=None, out_json_ok=False):
# --- reads: succeed (rc 0) [hit the live instance] ---
check("status", ["status"], want_rc=0, out_has="Authenticated")
check("sessions", ["sessions"], want_rc=0, out_has="Sessions:")
check("status shows fleet", ["status"], want_rc=0, out_has="fleet:")
check("sessions (full fleet)", ["sessions"], want_rc=0, out_has="Sessions:")
check("sessions json", ["sessions", "--json"], want_rc=0, out_json_ok=True)
check("sessions --limit", ["sessions", "--limit", "5"], want_rc=0, out_has="Sessions:")
check("sessions --like", ["sessions", "--like", "DELL", "--json"], want_rc=0, out_json_ok=True)
check("sessions --type all", ["sessions", "--type", "all", "--json"], want_rc=0, out_json_ok=True)
check("sessions --filter", ["sessions", "--filter", "SessionType = 'Access'", "--json"],
want_rc=0, out_json_ok=True)
check("sessions --company", ["sessions", "--company", "Safesite", "--json"],
want_rc=0, out_json_ok=True)
# --limit must NOT truncate --json (documented "--json is always full" contract):
# Safesite has 62 machines; even with --limit 1 the JSON must return them all.
check("sessions --limit does not truncate json",
["sessions", "--company", "Safesite", "--limit", "1", "--json"],
want_rc=0, out_json_min_len=10)
# --- build-installer: pure URL build (no network) ---
check("build-installer", ["build-installer", "--name", "HOST", "--company", "AZ Computer Guru",

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

@@ -0,0 +1,3 @@
# Holds a live Seafile API token — never commit.
.cache/
__pycache__/

View File

@@ -0,0 +1,118 @@
---
name: seafile
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
Admin client for ACG's live **Seafile Pro** server — the backend clients call
"SeaCloud" (the server) and "SeaDrive" (the desktop virtual-drive client). Runs in
Docker on Jupiter (`172.16.3.20:8082`, public `https://sync.azcomputerguru.com`).
Answers the GPS backup-audit question: **which customers back up here and how
much data do they hold** — and, with `--rmm`, which enrolled machines actually run
the client.
This skill is one of the backup-verification siblings alongside `b2` (Backblaze),
`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
```bash
SEAFILE=".claude/skills/seafile/scripts/seafile.py"
py "$SEAFILE" status # server reachable + version
py "$SEAFILE" usage # per-user storage + library count (audit view)
py "$SEAFILE" users [--inactive] # all accounts + used/quota + last login
py "$SEAFILE" libraries # all libraries (repos) + owner + size
py "$SEAFILE" devices [--user EMAIL] # desktop/sync devices the server has seen
py "$SEAFILE" audit # active accounts with data (the GPS slice)
py "$SEAFILE" audit --rmm # + cross-check GuruRMM endpoints for the client
py "$SEAFILE" audit --client Russo --rmm --json
```
Add `--json` to any command for machine-readable output. `--url` overrides the
server base (default is the internal Docker endpoint; use the public host when
off-LAN).
## Write ops (admin) — preview by default, `--confirm` to apply
Every write is **gated**: run it once to see the `[PLAN]`, add `--confirm` to
execute. Preview is fully offline (no auth, no network), so it is safe to eyeball
first. All writes go out **form-encoded** (the share endpoint reads `share_to` via
`getlist`, which requires form encoding, not JSON) against the Seafile Pro 12.0
admin API (`/api/v2.1/admin/...`).
```bash
# users
py "$SEAFILE" user-create client@acme.com --password 'PW' --name 'Acme' --quota-gb 500 --confirm
py "$SEAFILE" user-set client@acme.com --inactive --confirm # deactivate
py "$SEAFILE" user-set client@acme.com --quota-gb 1024 --confirm # bump quota
py "$SEAFILE" quota client@acme.com --gb 1024 --confirm # quota shortcut
py "$SEAFILE" user-delete client@acme.com --confirm # IRREVERSIBLE
# libraries
py "$SEAFILE" lib-create "Acme Backup" --owner client@acme.com --confirm
py "$SEAFILE" lib-transfer <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 **decimal GB** — deliberately the same
unit the read commands (`users` / `usage` / `audit`) display, so a quota you set
round-trips with the quota you later see here. Internally Seafile stores quota in
MB (1 MB = 1 MiB); the preview shows the exact MB it will send (e.g.
`500 GB (-> 476837 MB stored)`) so you can verify before `--confirm`. Caveat:
Seafile's own web admin UI renders quota in binary GiB, so the same quota reads
~7% smaller there — trust this tool's `users` view for the round-trip.
**Any password you set is a credential**`user-create`/`user-set` print a
reminder; store it in the SOPS vault via the `vault` skill (house rule). Never
hand out or log a Seafile password without vaulting it.
## Credentials
Loaded at runtime from the SOPS vault (never hardcoded):
`services/seafile-pro.sops.yaml` -> `credentials.username` / `credentials.password`
(the Seafile admin account). Auth is `POST /api2/auth-token/` (form) -> a
long-lived API token used as `Authorization: Token <tok>`. The token is a secret;
it is cached at `.claude/skills/seafile/.cache/token.json` (gitignored, mode 0600)
and re-fetched weekly or on a 401/403.
## What the data means
- **quota_usage** (bytes) is the authoritative per-account storage used. A user
with `is_active: true` and non-zero usage is actively backing up here.
- **libraries** (repos) are the sync roots; `size` + `file_count` + `last_modified`
show freshness. `usage` rolls libraries up per owner.
- Accounts are emails, so the customer is usually obvious from the domain
(e.g. `russo@rrs-law.com` = Russo Law, `greg@gstoltzlaw.com` = Stoltz Law).
- As of the build (2026-07-05) only a **handful** of accounts hold data
(~5 TB total) — Seafile is used by a few clients, not the whole GPS base.
## The "both" cross-check (`--rmm`)
Server-side tells you who has an account; `--rmm` adds the endpoint half. It logs
in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`), and for each agent
(optionally filtered by `--client`) runs a **read-only** detection PowerShell that
reports installed products / running processes / config folders matching
`Seafile` / `SeaDrive`. An agent is reported only if the client is actually
present. Detection dispatches a command to live endpoints — scope with `--client`
rather than sweeping all 350+ agents unless you mean to.
## Notes / gotchas
- Base defaults to `http://172.16.3.20:8082` (direct, no Cloudflare); set
`SEAFILE_URL=https://sync.azcomputerguru.com` when off the LAN/Tailscale.
- `devices` depends on the admin devices endpoint, which varies by Seafile
version; it returns `[]` (with a note) rather than failing if unsupported.
- Shared plumbing (repo-root resolve, vault read, GuruRMM client + endpoint
detection) lives in `.claude/scripts/backup_common.py`, used by all three
server-backup skills. Detection patterns must be precise per backend — this
skill uses `Seafile`/`SeaDrive`.
- Seafile Pro also has a MySQL backend on Jupiter (vault `credentials.database.*`)
for deeper queries; not used here (the admin API covers the audit needs).

View File

@@ -0,0 +1,435 @@
#!/usr/bin/env python3
"""CLI for the `seafile` skill — read-only Seafile Pro (SeaCloud/SeaDrive) inventory
for the GPS backup audit.
Commands:
status server reachable + version
users [--inactive] [--json] all accounts + storage used + last login
libraries [--json] all libraries (repos) + owner + size
usage [--json] per-user rollup: storage + library count (the audit view)
devices [--user EMAIL] [--json] desktop/sync devices the server has seen
audit [--client NAME] [--rmm] [--json]
the GPS view: users/usage, optionally cross-checked
against GuruRMM endpoints (SeaDrive/Seafile installed?)
Write ops (admin) -- ALL preview by default; add --confirm to apply:
user-create EMAIL --password P [--name N] [--quota-gb G] [--role R] [--inactive]
user-set EMAIL [--active|--inactive] [--quota-gb G] [--name N] [--role R] [--password P]
quota EMAIL --gb G set storage quota (decimal GB, as shown by `users`)
user-delete EMAIL remove account (irreversible)
lib-create NAME [--owner EMAIL] create a library
lib-transfer REPO_ID --to EMAIL transfer ownership
lib-delete REPO_ID delete a library (irreversible)
share REPO_ID --to EMAIL... [--perm r|rw] [--group]
unshare REPO_ID --to EMAIL [--group]
Server-side is the source of "who has an account + how much data"; --rmm adds the
"which enrolled machine actually runs the client" half (the 'both' cross-check).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from seafile_client import SeafileClient # noqa: E402
import backup_common as bc # noqa: E402
GB = 1_000_000_000
def _gb(n) -> float:
try:
return round(int(n) / GB, 2)
except (TypeError, ValueError):
return 0.0
def _fmt_quota(total) -> str:
try:
t = int(total)
except (TypeError, ValueError):
return "?"
return "unlimited" if t < 0 else f"{_gb(t)} GB"
def cmd_status(c: SeafileClient, args) -> int:
info = c.server_info()
print(f"[OK] {c.base}")
print(json.dumps(info, indent=2))
return 0
def cmd_users(c: SeafileClient, args) -> int:
users = c.list_users()
if args.inactive:
users = [u for u in users if not u.get("is_active")]
users.sort(key=lambda u: int(u.get("quota_usage") or 0), reverse=True)
if args.json:
print(json.dumps(users, indent=2))
return 0
print(f"{'EMAIL':40} {'ACTIVE':6} {'USED':>10} {'QUOTA':>10} {'LAST LOGIN':20}")
for u in users:
print(f"{(u.get('email') or '')[:40]:40} "
f"{'yes' if u.get('is_active') else 'NO':6} "
f"{_gb(u.get('quota_usage')):>7} GB "
f"{_fmt_quota(u.get('quota_total')):>10} "
f"{(u.get('last_login') or '-')[:19]:20}")
print(f"\n{len(users)} users; total used "
f"{round(sum(_gb(u.get('quota_usage')) for u in users), 1)} GB")
return 0
def cmd_libraries(c: SeafileClient, args) -> int:
repos = c.list_libraries()
repos.sort(key=lambda r: int(r.get("size") or 0), reverse=True)
if args.json:
print(json.dumps(repos, indent=2))
return 0
print(f"{'LIBRARY':30} {'OWNER':35} {'SIZE':>10} {'FILES':>8} {'MODIFIED':20}")
for r in repos:
print(f"{(r.get('name') or '')[:30]:30} "
f"{(r.get('owner_email') or r.get('owner') or '')[:35]:35} "
f"{_gb(r.get('size')):>7} GB "
f"{str(r.get('file_count') or '?'):>8} "
f"{(r.get('last_modified') or '-')[:19]:20}")
print(f"\n{len(repos)} libraries; total "
f"{round(sum(_gb(r.get('size')) for r in repos), 1)} GB")
return 0
def _usage_rollup(c: SeafileClient) -> list[dict]:
users = {u.get("email"): u for u in c.list_users()}
repos = c.list_libraries()
by_owner: dict[str, dict] = {}
for r in repos:
owner = r.get("owner_email") or r.get("owner")
d = by_owner.setdefault(owner, {"libraries": 0, "lib_bytes": 0})
d["libraries"] += 1
d["lib_bytes"] += int(r.get("size") or 0)
rollup = []
for email, u in users.items():
o = by_owner.get(email, {"libraries": 0, "lib_bytes": 0})
rollup.append({
"email": email,
"name": u.get("name"),
"is_active": bool(u.get("is_active")),
"quota_used_bytes": int(u.get("quota_usage") or 0),
"quota_used_gb": _gb(u.get("quota_usage")),
"quota_total": u.get("quota_total"),
"libraries": o["libraries"],
"library_gb": _gb(o["lib_bytes"]),
"last_login": u.get("last_login"),
"last_access": u.get("last_access_time"),
})
rollup.sort(key=lambda x: x["quota_used_bytes"], reverse=True)
return rollup
def cmd_usage(c: SeafileClient, args) -> int:
rollup = _usage_rollup(c)
if args.json:
print(json.dumps(rollup, indent=2))
return 0
print(f"{'USER':40} {'ACT':4} {'USED':>9} {'LIBS':>5} {'LAST LOGIN':20}")
for r in rollup:
print(f"{(r['email'] or '')[:40]:40} "
f"{'yes' if r['is_active'] else 'NO':4} "
f"{r['quota_used_gb']:>6} GB "
f"{r['libraries']:>5} "
f"{(r['last_login'] or '-')[:19]:20}")
return 0
def cmd_devices(c: SeafileClient, args) -> int:
devices = c.list_devices(args.user)
if args.json:
print(json.dumps(devices, indent=2))
return 0
if not devices:
print("[INFO] no devices returned (endpoint may be unsupported on this Seafile version)")
return 0
print(f"{'USER':35} {'DEVICE':25} {'PLATFORM':12} {'LAST ACCESS':20}")
for d in devices:
print(f"{(d.get('user') or '')[:35]:35} "
f"{(d.get('device_name') or '')[:25]:25} "
f"{(d.get('platform') or '')[:12]:12} "
f"{(d.get('last_accessed') or d.get('last_login_ip') or '-')[:19]:20}")
return 0
def cmd_audit(c: SeafileClient, args) -> int:
rollup = _usage_rollup(c)
# active accounts with data are the ones actually backing up here
active = [r for r in rollup if r["is_active"] and r["quota_used_bytes"] > 0]
result = {"backend": "seafile", "server": c.base, "accounts": active}
if args.rmm:
rmm = bc.RmmClient()
rmm.login()
checks = []
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
for a in agents:
det = rmm.detect_client(a, ["Seafile", "SeaDrive"])
if det.get("detected"):
checks.append(det)
result["rmm_endpoints_with_client"] = checks
if args.json:
print(json.dumps(result, indent=2))
return 0
print(f"=== Seafile (SeaCloud/SeaDrive) backup accounts - {c.base} ===")
print(f"{'USER':40} {'USED':>9} {'LIBS':>5} {'LAST LOGIN':20}")
for r in active:
print(f"{(r['email'] or '')[:40]:40} {r['quota_used_gb']:>6} GB "
f"{r['libraries']:>5} {(r['last_login'] or '-')[:19]:20}")
print(f"\n{len(active)} active accounts with data; "
f"{round(sum(r['quota_used_gb'] for r in active), 1)} GB total")
if args.rmm:
eps = result.get("rmm_endpoints_with_client", [])
print(f"\n--- GuruRMM endpoints running a Seafile/SeaDrive client ({len(eps)}) ---")
for e in eps:
print(f" {e['hostname']:25} client={e.get('client_name')}")
return 0
# --- write ops (gated) --------------------------------------------------------
# Quota units: Seafile's admin API takes quota_total in MB, where 1 MB == 1 MiB
# (get_file_size_unit('MB') == 2**20). This tool's read commands (users/usage/audit)
# render quota as decimal GB (bytes / 1e9, see _gb/_fmt_quota). To make a quota you
# SET round-trip with the quota you later SEE here, --gb is decimal GB and we convert
# to the stored MB count accordingly. (Note: Seafile's own web admin UI shows quota
# in binary GiB, so the same quota reads ~7% smaller there.)
MB_BYTES = 1 << 20 # Seafile's MB unit (1 MiB)
GB_BYTES = 1_000_000_000 # this tool's display GB (matches _gb)
def _gb_to_mb(gb) -> int:
return int(round(float(gb) * GB_BYTES / MB_BYTES))
def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None,
on_result=None) -> int:
"""Print the plan; execute only with --confirm. Preview is fully offline.
`on_result(result) -> int`: optional post-execute check that may print its own
warnings and return a non-zero exit code (e.g. partial-success responses that
still come back HTTP 200). A non-zero return suppresses the [OK] line.
"""
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()
if on_result is not None:
rc = on_result(result)
if rc:
return rc
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"{args.quota_gb} GB (-> {quota_mb} MB stored)"
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"{args.quota_gb} GB (-> {quota_mb} MB stored)"
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"{args.gb} GB (-> {quota_mb} MB stored)"}
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 _share_result_check(result) -> int:
"""Seafile's admin bulk-share returns HTTP 200 with success[]/failed[] arrays;
surface any per-recipient failure instead of reporting a blanket [OK]."""
if isinstance(result, dict):
failed = result.get("failed") or []
if failed:
print("[WARNING] some recipients were NOT shared:")
for f in failed:
who = f.get("email") or f.get("group_id") or f if isinstance(f, dict) else f
emsg = f.get("error_msg") or f.get("error") or "failed" if isinstance(f, dict) else "failed"
print(f" {who}: {emsg}")
ok = result.get("success") or result.get("shared_to") or []
print(f"[PARTIAL] shared to {len(ok)}, failed {len(failed)}")
return 1
return 0
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),
on_result=_share_result_check)
def cmd_unshare(c: SeafileClient, args) -> int:
stype = "group" if args.group else "user"
detail = {"repo_id": args.repo_id, "share_to": args.to, "type": stype}
return _do_write(args, f"revoke share on {args.repo_id}", detail,
lambda: c.unshare_library(args.repo_id, args.to, share_type=stype))
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory + admin")
p.add_argument("--url", help="override server base URL (default internal Docker endpoint)")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("status")
u = sub.add_parser("users"); u.add_argument("--inactive", action="store_true"); u.add_argument("--json", action="store_true")
l = sub.add_parser("libraries"); l.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")
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 (decimal GB)")
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
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
c = SeafileClient(args.url)
handlers = {"status": cmd_status, "users": cmd_users, "libraries": cmd_libraries,
"usage": cmd_usage, "devices": cmd_devices, "audit": cmd_audit,
"user-create": cmd_user_create, "user-set": cmd_user_set,
"quota": cmd_quota, "user-delete": cmd_user_delete,
"lib-create": cmd_lib_create, "lib-transfer": cmd_lib_transfer,
"lib-delete": cmd_lib_delete, "share": cmd_share, "unshare": cmd_unshare}
try:
return handlers[args.cmd](c, args)
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
# best-effort error logging per house rule
try:
root = bc.resolve_root()
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
"seafile", str(exc)[:200]], timeout=15)
except Exception:
pass
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Seafile Pro Web API v2.1 client for the `seafile` skill.
Talks to ACG's live Seafile Pro server (the "SeaCloud" / "SeaDrive" backend) that
runs in Docker on Jupiter. Read-only: enumerates users, libraries (repos) and
per-user storage so the GPS backup audit can see who is actually backing up here.
Auth: POST /api2/auth-token/ (form username+password) -> a long-lived API token,
then Bearer-style `Authorization: Token <tok>` on every call. The token is a
secret; it is cached under the skill's .cache/ (gitignored) like the b2 skill.
Base URL defaults to the internal Docker endpoint (http://172.16.3.20:8082) which
is directly reachable on the LAN/Tailscale; override with SEAFILE_URL (e.g. the
public https://sync.azcomputerguru.com).
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
# Import the shared backup helpers from .claude/scripts.
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/seafile
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
sys.path.insert(0, str(_SCRIPTS_DIR))
import backup_common as bc # noqa: E402
VAULT_ENTRY = "services/seafile-pro.sops.yaml"
VAULT_FIELD_USER = "credentials.username"
VAULT_FIELD_PASS = "credentials.password"
DEFAULT_URL = os.environ.get("SEAFILE_URL", "http://172.16.3.20:8082")
CACHE_DIR = _SKILL_DIR / ".cache"
TOKEN_CACHE = CACHE_DIR / "token.json"
# Seafile API tokens don't rotate on their own; re-auth weekly to stay safe.
TOKEN_TTL_SECONDS = 7 * 24 * 3600
class SeafileClient:
def __init__(self, base: Optional[str] = None):
self.base = (base or DEFAULT_URL).rstrip("/")
self._token: Optional[str] = None
# -- auth ------------------------------------------------------------------
def _read_cache(self) -> Optional[str]:
if not TOKEN_CACHE.exists():
return None
try:
c = json.loads(TOKEN_CACHE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
if c.get("base") != self.base:
return None
ts = c.get("authorized_at")
try:
when = datetime.fromisoformat(ts)
except (TypeError, ValueError):
return None
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
if (datetime.now(timezone.utc) - when).total_seconds() > TOKEN_TTL_SECONDS:
return None
return c.get("token")
def _write_cache(self, token: str) -> None:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_CACHE.write_text(json.dumps({
"base": self.base, "token": token,
"authorized_at": datetime.now(timezone.utc).isoformat(),
}, indent=2), encoding="utf-8")
try:
os.chmod(TOKEN_CACHE, 0o600)
except OSError:
pass
def _authorize(self) -> str:
user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER)
pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS)
form = urllib.parse.urlencode({"username": user, "password": pw}).encode()
req = bc.urllib.request.Request(
f"{self.base}/api2/auth-token/", data=form, method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp:
body = json.loads(resp.read().decode("utf-8", errors="replace"))
except bc.urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
raise bc.BackupError(f"Seafile auth-token failed (HTTP {exc.code}): {raw[:300]}")
except bc.urllib.error.URLError as exc:
raise bc.BackupError(f"Seafile auth request failed: {exc}")
token = body.get("token")
if not token:
raise bc.BackupError(f"Seafile auth-token response missing token: {body}")
self._write_cache(token)
return token
def _tok(self) -> str:
if not self._token:
self._token = self._read_cache() or self._authorize()
return self._token
def _get(self, path: str, params: Optional[dict] = None, retry_auth: bool = True) -> Any:
url = f"{self.base}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
status, body = bc.http_json("GET", url, headers={"Authorization": f"Token {self._tok()}"})
if status in (401, 403) and retry_auth:
self._token = self._authorize()
return self._get(path, params, retry_auth=False)
if status != 200:
raise bc.BackupError(f"Seafile GET {path} failed (HTTP {status}): {body}", status=status)
return body
# -- read methods ----------------------------------------------------------
def server_info(self) -> dict:
return self._get("/api2/server-info/")
def list_users(self) -> list[dict]:
"""All users via the admin API, paginated. Includes quota_usage (bytes)."""
users: list[dict] = []
page = 1
while True:
body = self._get("/api/v2.1/admin/users/", {"page": page, "per_page": 100})
batch = body.get("data", []) if isinstance(body, dict) else []
users.extend(batch)
info = body.get("page_info") if isinstance(body, dict) else None
# Admin users endpoint returns has_next_page in page_info on some
# versions; else stop when a short page comes back.
if info and not info.get("has_next_page"):
break
if not info and len(batch) < 100:
break
if not batch:
break
page += 1
return users
def list_libraries(self) -> list[dict]:
"""All libraries (repos) via the admin API, paginated. Includes size + owner."""
repos: list[dict] = []
page = 1
while True:
body = self._get("/api/v2.1/admin/libraries/", {"page": page, "per_page": 100})
batch = body.get("repos", []) if isinstance(body, dict) else []
repos.extend(batch)
info = body.get("page_info") if isinstance(body, dict) else None
if info and not info.get("has_next_page"):
break
if not info and len(batch) < 100:
break
if not batch:
break
page += 1
return repos
def list_devices(self, user_email: Optional[str] = None) -> list[dict]:
"""Sync/desktop devices seen by the server (admin). Optional per-user filter.
Proves a machine actually connected a Seafile/SeaDrive client. Endpoint
availability varies by Seafile version; returns [] if unsupported.
"""
try:
body = self._get("/api/v2.1/admin/devices/", {"page": 1, "per_page": 500})
except bc.BackupError:
return []
devices = body.get("devices", []) if isinstance(body, dict) else []
if user_email:
devices = [d for d in devices if d.get("user") == user_email]
return devices
# -- write plumbing --------------------------------------------------------
def _write(self, method: str, path: str, form: Optional[dict] = None,
retry_auth: bool = True) -> Any:
"""POST/PUT/DELETE against the admin API using form-encoding.
The Seafile admin endpoints read from request.data; the share endpoints
use QueryDict.getlist('share_to'), which only works for form-encoded
bodies (not JSON) -- so all writes go out as form-urlencoded via the
shared transport (bc.http_json form=). Any 2xx is success; on error we
surface the server's message.
"""
status, parsed = bc.http_json(
method, f"{self.base}{path}", form=form or {},
headers={"Authorization": f"Token {self._tok()}"})
if status in (401, 403) and retry_auth:
self._token = self._authorize()
return self._write(method, path, form, retry_auth=False)
if not (200 <= status < 300):
msg = ""
if isinstance(parsed, dict):
msg = parsed.get("error_msg") or parsed.get("detail") or ""
if not msg:
msg = json.dumps(parsed)[:400] if parsed else ""
raise bc.BackupError(f"Seafile {method} {path} failed (HTTP {status}): {msg}",
status=status)
return parsed
@staticmethod
def _uenc(email: str) -> str:
return urllib.parse.quote(email, safe="")
# -- user writes -----------------------------------------------------------
def create_user(self, email: str, password: str, name: Optional[str] = None,
quota_mb: Optional[int] = None, role: Optional[str] = None,
is_active: bool = True) -> dict:
form: dict = {"email": email, "password": password,
"is_active": "true" if is_active else "false"}
if name:
form["name"] = name
if quota_mb is not None:
form["quota_total"] = str(int(quota_mb))
if role:
form["role"] = role
return self._write("POST", "/api/v2.1/admin/users/", form)
def update_user(self, email: str, is_active: Optional[bool] = None,
password: Optional[str] = None, name: Optional[str] = None,
quota_mb: Optional[int] = None, role: Optional[str] = None) -> dict:
form: dict = {}
if is_active is not None:
form["is_active"] = "true" if is_active else "false"
if password is not None:
form["password"] = password
if name is not None:
form["name"] = name
if quota_mb is not None:
form["quota_total"] = str(int(quota_mb))
if role is not None:
form["role"] = role
if not form:
raise bc.BackupError("update_user: nothing to change")
return self._write("PUT", f"/api/v2.1/admin/users/{self._uenc(email)}/", form)
def delete_user(self, email: str) -> dict:
return self._write("DELETE", f"/api/v2.1/admin/users/{self._uenc(email)}/")
# -- library writes --------------------------------------------------------
def create_library(self, name: str, owner: Optional[str] = None) -> dict:
form: dict = {"name": name}
if owner:
form["owner"] = owner
return self._write("POST", "/api/v2.1/admin/libraries/", form)
def transfer_library(self, repo_id: str, new_owner: str) -> dict:
return self._write("PUT", f"/api/v2.1/admin/libraries/{repo_id}/",
{"owner": new_owner})
def delete_library(self, repo_id: str) -> dict:
return self._write("DELETE", f"/api/v2.1/admin/libraries/{repo_id}/")
# -- sharing writes --------------------------------------------------------
def share_library(self, repo_id: str, share_to, permission: str = "rw",
share_type: str = "user") -> dict:
recipients = share_to if isinstance(share_to, list) else [share_to]
form = {"share_type": share_type, "permission": permission,
"share_to": recipients}
return self._write("POST", f"/api/v2.1/admin/libraries/{repo_id}/shares/", form)
def unshare_library(self, repo_id: str, share_to: str,
share_type: str = "user") -> dict:
return self._write("DELETE", f"/api/v2.1/admin/libraries/{repo_id}/shares/",
{"share_type": share_type, "share_to": share_to})
def main() -> int:
try:
c = SeafileClient()
info = c.server_info()
print(f"[OK] Seafile reachable at {c.base}; version={info.get('version')} "
f"edition={'pro' if 'seafile-pro' in (info.get('features') or []) else 'ce'}")
return 0
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,171 @@
---
name: tailscale
description: "Manage an ACG-administered Tailscale tailnet via the Tailscale REST API v2: list/inspect devices, delete a device/node, authorize a device, list/create/revoke tagged pre-auth keys. ADMIN rights (add + delete). Reads run freely; every device delete/authorize and key create/delete is gated --confirm. Triggers: tailscale, tailnet, add device, delete device/node, revoke/create auth key, tailscale offboard."
---
# Tailscale Skill
Standalone bash CLI for a Tailscale tailnet that ACG administers, via the **Tailscale
REST API v2** (`https://api.tailscale.com/api/v2`). Read-only by default; every
destructive or state-changing operation (delete a device, authorize a device, create an
auth key, revoke an auth key) is **gated behind `--confirm`** and prints a preview first.
This skill implements the API side of ACG's **per-client tailnet doctrine** — see
`wiki/patterns/tailscale-client-management.md`. One tailnet per client, ACG holds an
Admin/Owner seat, devices enroll as **tagged nodes via reusable + pre-approved pre-auth
keys** pushed from GuruRMM. This skill is how you drive that tailnet from the terminal:
mint the tagged keys, watch devices land, authorize/prune nodes, and revoke keys at
offboarding.
## Running the CLI
```bash
TS="bash $CLAUDETOOLS_ROOT/.claude/skills/tailscale/scripts/tailscale-api.sh"
$TS status # auth check + tailnet + device count
$TS devices # list devices (table)
$TS devices --json # machine output
$TS device <name|id|100.x> # device detail
$TS keys # list auth keys
$TS create-key --tag tag:roberts --reusable --preauth --expiry-days 90 --desc "onboard" --confirm
$TS delete-key <keyId> --confirm # revoke an auth key
$TS delete-device <name|id|100.x> --confirm # remove a node
$TS authorize <name|id|100.x> --confirm # approve a device
$TS --help # full usage (runs with no creds)
```
`--vault <path>` selects a different vault entry (per-client tailnets each have their own).
`CLAUDETOOLS_ROOT` resolves from the env var, else the repo root via `git rev-parse`,
else `C:/claudetools`.
## Credentials & auth (vault-first, OAuth preferred)
The bearer is NEVER hardcoded. At runtime it is resolved from the SOPS vault entry
`tailscale/api-access.sops.yaml` (override with `--vault` or `TAILSCALE_VAULT`). Two
credential shapes are supported, **OAuth preferred**:
- **OAuth client (preferred, non-expiring):** `credentials.client_id` +
`credentials.client_secret`. The CLI POSTs
`grant_type=client_credentials&client_id=...&client_secret=...` (secret fed over
**stdin**, never argv) to `/oauth/token` and uses the returned short-lived
`access_token` as the bearer. Required scopes: **`devices:core`** (list/delete/authorize
devices) and **`auth_keys`** (create/list/delete keys).
- **Raw API access token (fallback):** `credentials.api_key` (a `tskey-api-...` token)
used directly as `Authorization: Bearer <token>`.
**Tailnet** is a path segment, resolved (first hit wins) from: env `TAILSCALE_TAILNET`,
`credentials.tailnet`, top-level plaintext `tailnet:`, else default **`-`** (the default
tailnet of the authenticated principal). Set a specific org name (e.g. `example.com`) only
when the account administers more than one tailnet.
The bearer is written to a `0600` temp curl config (`-K`) and removed on exit, so it never
appears in argv, the process list, or stdout.
### Provision the vault entry (admin, one-time — entry does NOT exist yet)
OAuth client (preferred):
```bash
bash .claude/skills/vault/scripts/vault-helper.sh new tailscale/api-access.sops.yaml \
--kind oauth-client --name "ACG Tailscale API (OAuth client)" \
--url https://api.tailscale.com/api/v2 --tag tailscale \
--set client_id='<OAUTH_CLIENT_ID>' \
--set client_secret='<OAUTH_CLIENT_SECRET>' \
--set tailnet='-'
```
Raw API access token (fallback):
```bash
bash .claude/skills/vault/scripts/vault-helper.sh new tailscale/api-access.sops.yaml \
--kind api-key --name "ACG Tailscale API (access token)" \
--url https://api.tailscale.com/api/v2 --tag tailscale \
--set api_key='tskey-api-XXXXXXXXXXXXXXXX' \
--set tailnet='-'
```
`vault-helper.sh new` places every `--set` under the encrypted `credentials:` block, so
`tailnet` reads back via `credentials.tailnet` (the CLI also honors a top-level plaintext
`tailnet:` if an admin prefers it in cleartext). After creating: `vault-helper.sh verify
tailscale/api-access.sops.yaml`, then publish with `bash .claude/scripts/sync.sh`. For a
**per-client** tailnet, store its own entry (e.g. `tailscale/roberts.sops.yaml`) and pass
`--vault tailscale/roberts.sops.yaml`.
## Endpoint map (Tailscale REST API v2)
| Subcommand | Method + path | Notes |
|---|---|---|
| `devices` | `GET /tailnet/{tailnet}/devices?fields=all` | full device inventory |
| `device <q>` | `GET /device/{deviceId}?fields=all` | detail after name->id resolve |
| `delete-device <q>` | `DELETE /device/{deviceId}` | **GATED** |
| `authorize <q>` | `POST /device/{deviceId}/authorized` `{"authorized":true}` | **GATED** |
| `keys` | `GET /tailnet/{tailnet}/keys` | list auth keys |
| `create-key` | `POST /tailnet/{tailnet}/keys` | **GATED**; tagged reusable/preauth pattern |
| `delete-key <keyId>` | `DELETE /tailnet/{tailnet}/keys/{keyId}` | **GATED** revoke |
| `status` | `GET .../devices` | reachability + counts |
`create-key` body (matches the wiki's reusable + pre-approved + tagged pattern):
```json
{"capabilities":{"devices":{"create":{"reusable":true,"ephemeral":false,"preauthorized":true,"tags":["tag:roberts"]}}},"expirySeconds":7776000,"description":"onboard roberts"}
```
Built from `--tag` (repeatable), `--reusable`, `--preauth`, `--ephemeral`,
`--expiry-days N` (default 90), `--desc`. The created key secret is returned **once** — the
CLI prints a `[WARNING]` and the secret; store it in the vault immediately, never in
chat/commit.
## Hard rules
- **Read before write.** Device ops accept a NAME, the stable numeric/node id, or a 100.x
address; the name is resolved to an id against the live device list before any action.
- **Gated destructive ops.** `delete-device`, `authorize`, `create-key`, `delete-key`
refuse to run without `--confirm` — they print the exact preview and exit 3. Never run a
delete casually; deleting a node drops it off the tailnet.
- **Ambiguous-match STOP.** If a device query matches >1 device, the CLI lists the
candidates and refuses to act (exit 4). Re-run with a specific id or a unique name/address.
- **Per-client isolation.** Never merge a client into ACG's own tailnet and never share one
tailnet across clients — one tailnet per client, one vault entry per tailnet
(`wiki/patterns/tailscale-client-management.md`). Confirm you are pointed at the right
`--vault`/tailnet before a write.
- **Secrets stay out of argv/stdout/logs.** Bearer via `-K` config file; OAuth secret via
stdin. Do not paste key secrets into tickets/commits — vault the path.
## Bot alert (after every write)
Mirroring the `/rmm` and `screenconnect` skills, every successful **write** posts a
one-line `[TAILSCALE] ...` alert via `.claude/scripts/post-bot-alert.sh` (soft-fail,
ASCII only). Read operations (`status`, `devices`, `device`, `keys`) do NOT alert. Covered
writes: `delete-device`, `authorize`, `create-key`, `delete-key`.
## Verified API response shapes
- **Device (list item / detail):** `id` (stable numeric string), `nodeId`, `name` (MagicDNS
FQDN), `hostname`, `addresses` (`["100.x.y.z", "fd7a:..."]`), `os`, `user`, `tags`
(`["tag:roberts"]`), `authorized` (bool), `lastSeen`, `clientVersion`, `updateAvailable`.
Device ops use `id`.
- **Auth key (list / create):** `id` (keyId), `key` (the secret — **create only, once**),
`created`, `expires`, `capabilities.devices.create.{reusable,ephemeral,preauthorized,tags}`,
`description`.
- **Errors:** JSON `{"message":"..."}` with a non-2xx status.
## Error table
| Symptom | Cause | Fix |
|---|---|---|
| `[BLOCKED] no Tailscale credential` | vault entry missing / wrong fields | provision per above; check `--vault` path |
| `OAuth token exchange failed (HTTP 401)` | bad client_id/secret or missing scopes | verify OAuth client has `devices:core` + `auth_keys` |
| `HTTP 403` on a write | token/OAuth scope lacks the capability | add `auth_keys` (keys) / `devices:core` (devices) scope |
| `HTTP 404` on device/key | wrong id, or wrong tailnet | check `status` tailnet; re-resolve by name |
| `AMBIGUOUS (>N matches)` | name matches several nodes | pass a specific id or a unique name/address |
| `no device matches` | typo / device gone | run `devices` to see current inventory |
On a GENUINE functional error (auth failure, non-2xx API response) the CLI logs to
`errorlog.md` via `log-skill-error.sh` (soft-fail). Expected/handled conditions (a `--confirm`
gate blocking, an empty match, a preview) are NOT logged.
## Reference
- Doctrine: `wiki/patterns/tailscale-client-management.md` (per-client tailnets, tagged
pre-auth keys, enrollment via GuruRMM, offboarding).
- Enrollment script: `wiki/patterns/tailscale-client-enroll.ps1`.
- Tailscale API docs: https://tailscale.com/api and https://tailscale.com/kb/1085/auth-keys

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# tailscale-api.sh — CLI for the ACG Tailscale tailnet via the Tailscale REST API v2
# (base: https://api.tailscale.com/api/v2). Read-only by default; every device
# delete/authorize and every auth-key create/delete is GATED behind --confirm.
#
# AUTH (resolved from the SOPS vault, OAuth preferred, token fallback):
# vault path: tailscale/api-access.sops.yaml (override: --vault <path> or env TAILSCALE_VAULT)
# credentials.client_id + credentials.client_secret -> OAuth client (preferred, non-expiring)
# POST /oauth/token -> short-lived bearer
# credentials.api_key -> raw API access token (tskey-api-...) fallback
# tailnet (path segment): credentials.tailnet | top-level plaintext `tailnet:` | env
# TAILSCALE_TAILNET | default `-` (the authenticated principal's default tailnet).
#
# Secrets never touch argv/stdout: the bearer is written to a 0600 curl config file
# (-K) and the OAuth secret is fed to curl over stdin (--data @-).
#
# Subcommands:
# status auth check + tailnet + device count
# devices [--json] list devices (name/id/addr/os/user/lastSeen/tags/update/online)
# device <name|id|100.x> [--json] device detail
# delete-device <name|id|100.x> [--confirm] DELETE a device (GATED)
# authorize <name|id|100.x> [--confirm] POST authorized=true (GATED)
# keys [--json] list auth keys
# create-key [--tag tag:x]... [--reusable] [--preauth] [--ephemeral]
# [--expiry-days N] [--desc "..."] [--confirm] create an auth key (GATED)
# delete-key <keyId> [--confirm] DELETE/revoke an auth key (GATED)
#
# Device args accept a NAME, the stable numeric/node id, or a 100.x address; the name is
# resolved to an id against the live device list. On an AMBIGUOUS match (>1 device) the
# candidates are printed and the op STOPS — it never acts on a guess.
#
# Usage: bash .claude/skills/tailscale/scripts/tailscale-api.sh <subcommand> [args] [--confirm]
# Runs `--help` with no credentials.
set -uo pipefail
API="https://api.tailscale.com/api/v2"
OAUTH_URL="https://api.tailscale.com/api/v2/oauth/token"
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDETOOLS_ROOT:-C:/claudetools}")"
VAULT_SH="$REPO/.claude/scripts/vault.sh"
ALERT_SH="$REPO/.claude/scripts/post-bot-alert.sh"
logerr() { bash "$REPO/.claude/scripts/log-skill-error.sh" "tailscale" "$1" ${2:+--context "$2"} >/dev/null 2>&1 || true; }
usage() {
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
# ---- arg parse (global) -------------------------------------------------------
VAULT_PATH="${TAILSCALE_VAULT:-tailscale/api-access.sops.yaml}"
CONFIRM=0; JSON=0; TAGS=(); REUSABLE=false; PREAUTH=false; EPHEMERAL=false
EXPIRY_DAYS=90; DESC=""; POS=()
while [ $# -gt 0 ]; do
case "$1" in
-h|--help|help) usage 0;;
--vault) VAULT_PATH="${2:?--vault needs a path}"; shift 2;;
--confirm) CONFIRM=1; shift;;
--json) JSON=1; shift;;
--tag) TAGS+=("${2:?--tag needs a value e.g. tag:roberts}"); shift 2;;
--reusable) REUSABLE=true; shift;;
--preauth) PREAUTH=true; shift;;
--ephemeral) EPHEMERAL=true; shift;;
--expiry-days) EXPIRY_DAYS="${2:?--expiry-days needs N}"; shift 2;;
--desc) DESC="${2:-}"; shift 2;;
*) POS+=("$1"); shift;;
esac
done
SUB="${POS[0]:-}"; [ -n "$SUB" ] || usage 2
command -v jq >/dev/null 2>&1 || { echo "[ERROR] jq is required"; exit 2; }
command -v curl >/dev/null 2>&1 || { echo "[ERROR] curl is required"; exit 2; }
# ---- credentials + bearer -----------------------------------------------------
CURL_CFG="$(mktemp)"; chmod 600 "$CURL_CFG" 2>/dev/null || true
trap 'rm -f "$CURL_CFG"' EXIT
vget() { bash "$VAULT_SH" get-field "$VAULT_PATH" "$1" 2>/dev/null; }
TAILNET="${TAILSCALE_TAILNET:-}"
BEARER=""
resolve_auth() {
[ -n "$TAILNET" ] || TAILNET="$(vget credentials.tailnet)"
[ -n "$TAILNET" ] || TAILNET="$(vget tailnet)"
[ -n "$TAILNET" ] || TAILNET="-"
local cid csec key
cid="$(vget credentials.client_id)"; csec="$(vget credentials.client_secret)"
if [ -n "$cid" ] && [ -n "$csec" ]; then
# OAuth: secret over stdin (not argv); scopes devices:core + auth_keys.
local out code body
out="$(printf 'grant_type=client_credentials&client_id=%s&client_secret=%s' "$cid" "$csec" \
| curl -sS --data @- -w $'\n%{http_code}' "$OAUTH_URL" 2>/dev/null)"
code="${out##*$'\n'}"; body="${out%$'\n'*}"
if [ "$code" = "200" ]; then
BEARER="$(printf '%s' "$body" | jq -r '.access_token // empty')"
fi
[ -n "$BEARER" ] || { echo "[ERROR] OAuth token exchange failed (HTTP $code) at vault:$VAULT_PATH"; logerr "OAuth token exchange failed" "http=$code vault=$VAULT_PATH"; exit 2; }
return 0
fi
key="$(vget credentials.api_key)"
if [ -n "$key" ]; then BEARER="$key"; return 0; fi
echo "[BLOCKED] no Tailscale credential at vault:$VAULT_PATH"
echo " need credentials.client_id + credentials.client_secret (OAuth) OR credentials.api_key (token)."
exit 2
}
resolve_auth
printf 'header = "Authorization: Bearer %s"\n' "$BEARER" > "$CURL_CFG"
# ---- API wrapper (HTTP_CODE / HTTP_BODY) --------------------------------------
HTTP_CODE=""; HTTP_BODY=""
api() { # METHOD PATH [JSON_BODY]
local method="$1" path="$2" data="${3:-}" out
local args=(-sS -X "$method" -H "Accept: application/json" -K "$CURL_CFG" -w $'\n%{http_code}')
if [ -n "$data" ]; then
out="$(printf '%s' "$data" | curl "${args[@]}" -H "Content-Type: application/json" --data-binary @- "$API/$path" 2>/dev/null)"
else
out="$(curl "${args[@]}" "$API/$path" 2>/dev/null)"
fi
HTTP_CODE="${out##*$'\n'}"; HTTP_BODY="${out%$'\n'*}"
}
api_ok() { case "$HTTP_CODE" in 2*) return 0;; *) return 1;; esac; }
api_fail() { # context
echo "[ERROR] Tailscale API $1 -> HTTP $HTTP_CODE"
printf '%s\n' "$HTTP_BODY" | jq -r '.message // .' 2>/dev/null || printf '%s\n' "$HTTP_BODY"
logerr "API $1 HTTP $HTTP_CODE" "tailnet=$TAILNET vault=$VAULT_PATH"
}
alert() { [ -f "$ALERT_SH" ] && bash "$ALERT_SH" "$1" >/dev/null 2>&1 || true; }
# ---- device list + name->id resolution ----------------------------------------
DEVLIST_JSON=""
load_devices() {
[ -n "$DEVLIST_JSON" ] && return 0
api GET "tailnet/$TAILNET/devices?fields=all"
api_ok || { api_fail "list devices"; exit 2; }
DEVLIST_JSON="$HTTP_BODY"
}
# echoes the resolved device id on success; on none/ambiguous prints to stderr, non-zero.
resolve_device() {
local q="$1"; load_devices
local filt='.devices[] | select(%s) | .id'
local exact
exact="$(printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
'.devices[] | select((.id==$q) or (.nodeId==$q) or ((.addresses//[])|index($q))
or ((.hostname//""|ascii_downcase)==($q|ascii_downcase))
or ((.name//""|ascii_downcase|split(".")[0])==($q|ascii_downcase))) | .id')"
local n; n="$(printf '%s\n' "$exact" | grep -c . || true)"
if [ "$n" -eq 1 ]; then printf '%s' "$exact"; return 0; fi
if [ "$n" -eq 0 ]; then
# substring fallback on name/hostname
exact="$(printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
'.devices[] | select(((.name//""|ascii_downcase)|contains($q|ascii_downcase))
or ((.hostname//""|ascii_downcase)|contains($q|ascii_downcase))) | .id')"
n="$(printf '%s\n' "$exact" | grep -c . || true)"
fi
if [ "$n" -eq 1 ]; then printf '%s' "$exact"; return 0; fi
if [ "$n" -eq 0 ]; then echo "[ERROR] no device matches '$q' in tailnet $TAILNET" >&2; return 3; fi
echo "[BLOCKED] '$q' is AMBIGUOUS ($n matches) — refusing to act. Candidates:" >&2
printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
'.devices[] | select(((.name//""|ascii_downcase)|contains($q|ascii_downcase))
or ((.hostname//""|ascii_downcase)|contains($q|ascii_downcase)))
| " " + .id + " " + (.name//"?") + " " + ((.addresses//[])|join(","))' >&2
return 4
}
dev_label() { # id -> "name (addr)"
printf '%s' "$DEVLIST_JSON" | jq -r --arg id "$1" \
'.devices[] | select(.id==$id) | (.name//"?") + " (" + ((.addresses//[])|join(","))+")"'
}
# ---- subcommands --------------------------------------------------------------
case "$SUB" in
status)
load_devices
local_count="$(printf '%s' "$DEVLIST_JSON" | jq -r '.devices | length')"
echo "[OK] Tailscale API reachable"
echo " tailnet : $TAILNET"
echo " auth : $([ -n "$(vget credentials.client_id)" ] && echo OAuth-client || echo api-token)"
echo " devices : $local_count"
;;
devices)
load_devices
if [ "$JSON" = 1 ]; then printf '%s\n' "$DEVLIST_JSON" | jq '.'; exit 0; fi
printf '%s' "$DEVLIST_JSON" | jq -r '
.devices | sort_by(.name) | .[]
| [ (.name//"?"), .id, ((.addresses//[])|join(",")), (.os//"?"),
(.user//"?"), (.lastSeen//"?"), ((.tags//[])|join(",")),
(if .updateAvailable then "update" else "-" end) ] | @tsv' \
| { echo -e "NAME\tID\tADDRESSES\tOS\tUSER\tLASTSEEN\tTAGS\tUPDATE"; cat; } | column -t -s $'\t'
;;
device)
Q="${POS[1]:?usage: device <name|id|100.x>}"
ID="$(resolve_device "$Q")" || exit $?
api GET "device/$ID?fields=all"; api_ok || { api_fail "device detail"; exit 2; }
if [ "$JSON" = 1 ]; then printf '%s\n' "$HTTP_BODY" | jq '.'; else
printf '%s\n' "$HTTP_BODY" | jq -r '
"name : " + (.name//"?"),
"id : " + (.id//"?"),
"nodeId : " + (.nodeId//"?"),
"addresses : " + ((.addresses//[])|join(", ")),
"os : " + (.os//"?"),
"user : " + (.user//"?"),
"tags : " + ((.tags//[])|join(", ")),
"authorized : " + ((.authorized//false)|tostring),
"lastSeen : " + (.lastSeen//"?"),
"clientVer : " + (.clientVersion//"?"),
"update : " + ((.updateAvailable//false)|tostring)'
fi
;;
delete-device)
Q="${POS[1]:?usage: delete-device <name|id|100.x> [--confirm]}"
ID="$(resolve_device "$Q")" || exit $?
LBL="$(dev_label "$ID")"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would DELETE device $ID $LBL from tailnet $TAILNET"
echo " re-run with --confirm to delete."
exit 3
fi
api DELETE "device/$ID"; api_ok || { api_fail "delete device"; exit 2; }
echo "[OK] deleted device $ID $LBL"
alert "[TAILSCALE] deleted device $LBL ($ID) from tailnet $TAILNET"
;;
authorize)
Q="${POS[1]:?usage: authorize <name|id|100.x> [--confirm]}"
ID="$(resolve_device "$Q")" || exit $?
LBL="$(dev_label "$ID")"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would AUTHORIZE device $ID $LBL on tailnet $TAILNET"
echo " re-run with --confirm to authorize."
exit 3
fi
api POST "device/$ID/authorized" '{"authorized":true}'; api_ok || { api_fail "authorize device"; exit 2; }
echo "[OK] authorized device $ID $LBL"
alert "[TAILSCALE] authorized device $LBL ($ID) on tailnet $TAILNET"
;;
keys)
api GET "tailnet/$TAILNET/keys"; api_ok || { api_fail "list keys"; exit 2; }
if [ "$JSON" = 1 ]; then printf '%s\n' "$HTTP_BODY" | jq '.'; exit 0; fi
printf '%s' "$HTTP_BODY" | jq -r '
.keys // [] | .[]
| [ .id, (.description//"-"),
((.capabilities.devices.create.tags//[])|join(",") // "-"),
((.capabilities.devices.create.reusable//false)|tostring),
(.created//"?"), (.expires//"?") ] | @tsv' \
| { echo -e "KEYID\tDESC\tTAGS\tREUSABLE\tCREATED\tEXPIRES"; cat; } | column -t -s $'\t'
;;
create-key)
TAGS_JSON="$(printf '%s\n' "${TAGS[@]:-}" | jq -R . | jq -s 'map(select(length>0))')"
EXPIRY_SECONDS=$(( EXPIRY_DAYS * 86400 ))
BODY="$(jq -n \
--argjson reusable "$REUSABLE" --argjson ephemeral "$EPHEMERAL" --argjson preauth "$PREAUTH" \
--argjson expiry "$EXPIRY_SECONDS" --arg desc "$DESC" --argjson tags "$TAGS_JSON" \
'{capabilities:{devices:{create:{reusable:$reusable,ephemeral:$ephemeral,preauthorized:$preauth,tags:$tags}}},expirySeconds:$expiry,description:$desc}')"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would CREATE auth key on tailnet $TAILNET with:"
printf '%s\n' "$BODY" | jq '.'
echo " re-run with --confirm to create."
exit 3
fi
api POST "tailnet/$TAILNET/keys" "$BODY"; api_ok || { api_fail "create key"; exit 2; }
KID="$(printf '%s' "$HTTP_BODY" | jq -r '.id // "?"')"
echo "[OK] created auth key $KID (tags: $(printf '%s' "$TAGS_JSON" | jq -r 'join(",")'))"
echo "[WARNING] the key secret is shown ONCE below — store it in the vault, never in chat/commit:"
printf '%s' "$HTTP_BODY" | jq -r '.key // "(no key field returned)"'
alert "[TAILSCALE] created auth key $KID on tailnet $TAILNET (tags: $(printf '%s' "$TAGS_JSON" | jq -r 'join(",")'))"
;;
delete-key)
KID="${POS[1]:?usage: delete-key <keyId> [--confirm]}"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would DELETE/revoke auth key $KID on tailnet $TAILNET"
echo " re-run with --confirm to revoke."
exit 3
fi
api DELETE "tailnet/$TAILNET/keys/$KID"; api_ok || { api_fail "delete key"; exit 2; }
echo "[OK] revoked auth key $KID"
alert "[TAILSCALE] revoked auth key $KID on tailnet $TAILNET"
;;
*)
echo "[ERROR] unknown subcommand: $SUB"
usage 2
;;
esac

View File

@@ -0,0 +1,116 @@
# YMCS SIP Account Schema - Critical Field Formats
**Source:** Open API for Yealink Management Cloud Service V4X, pages 59-60 (chunk-03)
## Add SIP Account Endpoint
```
POST /v2/dm/sipAccounts
```
## Complete Request Body Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| registerName | String | Yes | Registered name, max 128 chars |
| username | String | Yes | Username, max 128 chars |
| password | String | Yes | Password, max 128 chars |
| displayName | String | No | Display name, max 128 chars |
| label | String | No | Label, max 128 chars |
| **sipServer1** | **SipServer** | **Yes** | **SIP server 1 address (OBJECT, not string)** |
| sipServer2 | SipServer | No | SIP server 2 address (object) |
| remark | String | No | Note, max 512 chars |
| siteId | String | No | Site ID to assign |
## SipServer Object Definition
**CRITICAL:** sipServer1/sipServer2 are **objects**, not strings.
| Field | Type | Description |
|-------|------|-------------|
| host | String | Server address, max 256 chars |
| port | Integer | Server port, 0-65535 |
## Working Example (from official docs)
```json
POST /v2/dm/sipAccounts HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"registerName": "2552",
"username": "2552",
"password": "******",
"label": "2552",
"displayName": "2552",
"sipServer1": {
"host": "ume.yealink.com",
"port": 5061
}
}
```
## PacketDial Integration Example
For provisioning to PacketDial/NetSapiens PBX:
```json
{
"registerName": "100@vwp.91912.service",
"username": "100@vwp.91912.service",
"password": "TestPass123",
"label": "Extension 100",
"displayName": "Test User",
"sipServer1": {
"host": "pbx.packetdial.com",
"port": 5060
}
}
```
## HTTP 412 Error Resolution
**Error:** `HTTP 412: {"code": "900444", "details": [{"field": "sipServer1", "message": "Parameter error, please refer to documentation related to interface parameters"}]}`
**Cause:** Passing sipServer1 as a string instead of an object
**Fix:** Use the SipServer object format with both `host` and `port` fields
## Response (201 Created)
```json
{
"id": "604e67944c7c43fe8b66099254ec3439",
"username": "2552",
"registerInfo": "2552",
"serverAddress": "ume.yealink.com:5061",
"accountType": 0,
"remark": "",
"createTime": 1234567890000
}
```
Response fields:
- `id`: Account ID (use for binding to devices)
- `accountType`: 0=SIP, 1=H323, 2=SFB
- `serverAddress`: Combined host:port from sipServer1
- `createTime`: Unix timestamp in milliseconds
## Next Step: Bind Account to Device
After creating the SIP account, bind it to a phone:
```
POST /v2/dm/devices/{deviceId}/bindAccounts
```
Body:
```json
{
"accountIds": ["604e67944c7c43fe8b66099254ec3439"],
"lineId": 1
}
```
See chunk-03 pages 52-56 for bindAccounts endpoint details.

View File

@@ -0,0 +1,104 @@
# YMCS Documentation Chunks
Large PDF documentation broken into bite-sized markdown files for easier processing.
## Generated From
4 YMCS PDFs downloaded 2026-07-09, totaling ~300MB:
1. **Open API for Yealink Management Cloud Service V4X.pdf** (164 pages, 1.1MB)
- 9 chunks, 20 pages each
- Directory: `open-api-for-yealink-management-cloud-service-v4x/`
- **KEY:** SIP account schema (chunk-03, pages 41-60)
2. **YMCS 4X Enterprise Open API Webhook.pdf** (5 pages, 47KB)
- 1 chunk
- Directory: `ymcs-4x-enterprise-open-api-webhook/`
3. **Yealink Management Cloud Service(YMCS)_User Guide.pdf** (953 pages, 90MB)
- 48 chunks, 20 pages each
- Directory: `yealink-management-cloud-serviceymcs_user-guide/`
4. **Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf** (84 pages, 210MB)
- 5 chunks, 20 pages each
- Directory: `yealink-management-cloud-serviceymcs_channel-user-guide/`
**Total:** 63 markdown files, ~1MB
## Quick Reference
### Find SIP Account Provisioning
- **chunk-03** (pages 41-60): Add SIP account endpoint and schema
- **chunk-04** (pages 61-80): SIP account management operations
- See: `../SIPSERVER_SCHEMA.md` for critical field formats
### Find Device Management
- **chunk-02** (pages 21-40): Device operations
- **chunk-03** (pages 41-60): Bind/unbind accounts to devices
### Search All Chunks
```bash
# Search for a topic across all chunks
grep -r "your search term" .
# Find which chunk contains specific API endpoint
grep -r "POST /v2/dm/" .
# List all chunks for a specific PDF
ls -1 open-api-for-yealink-management-cloud-service-v4x/
```
## How Chunks Were Created
Script: `../scripts/chunk-pdf-docs.py`
```bash
# Process all PDFs
python3 ../scripts/chunk-pdf-docs.py --all --pages-per-chunk 20
# Process single PDF
python3 ../scripts/chunk-pdf-docs.py ~/Downloads/example.pdf --output-dir ./custom-output
# Extract just table of contents
python3 ../scripts/chunk-pdf-docs.py ~/Downloads/example.pdf --toc-only
```
## File Naming Convention
```
{PDF-stem}-chunk-{number:02d}-p{start}-{end}.md
```
Examples:
- `Open API for Yealink Management Cloud Service V4X-chunk-03-p41-60.md`
- `Yealink Management Cloud Service(YMCS)_User Guide-chunk-01-p1-20.md`
Each chunk includes:
- Source PDF name
- Page range (X-Y of total)
- Chunk number
- Full extracted text with layout preserved
## Critical Findings
### SIP Account sipServer1 Field (HTTP 412 Fix)
**Problem:** YMCS API returns HTTP 412 "Parameter error" on sipServer1 field
**Solution:** sipServer1 is an **object**, not a string:
```json
"sipServer1": {
"host": "pbx.packetdial.com",
"port": 5060
}
```
See `../SIPSERVER_SCHEMA.md` for complete schema and examples.
## Tools Used
- **poppler-utils** (pdftotext, pdfinfo) - PDF text extraction
- Install: `brew install poppler`
- **Python 3** - Chunking script

View File

@@ -0,0 +1,825 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 1-20
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 1-20 of 164
**Chunk:** 1
---
Open API for Yealink Management Cloud Service V4X
Open API for Yealink Management
Cloud Service V4X
1. YMCS APIs
1.1 Introduction
YMCS Open API provides third-party developers with a portal to securely use
device management services. Through YMCS APIs, developers can use YMCS's
device management, account management, configuration management and
other functions.
YMCS API is a REST-like API based on HTTP. The REST-like style indicates that the
resource is tagged using a URI and allows access to the API via the HTTP
protocol. The API relies on HTTP semantics and methods.
In order to ensure the security of using the YMCS API, the transport protocol is
unified using HTTPS and all requests need to be authenticated. If the YMCS API
is used without carrying the correct identity credentials information, the request
will be rejected outright. More information about YMCS authentication will be
detailed in 1.2.2 Authentication.
The YMCS platform limits the frequency of API calls to ensure system stability.
Most endpoints are rate-limited to 50 requests per second. This rate limit is at
the enterprise level, for each enterprise that supports the YMCS API, we allow 50
requests per second. More information on rate limiting is detailed in Invoking Rate
Limiting.
1.2 Use YMCS APIs
YMCS APIs allow developers to access and manipulate resources under YMCS,
including but not limited to device management, account management,
configuration management, and other operations. This chapter will describe
how to correctly call the YMCS API.
YMCS API
1 / 164
Open API for Yealink Management Cloud Service V4X
Version: 1.0.0
Host:
If your enterprise is in the EU region: eu-api.ymcs.yealink.com
If your enterprise is in the US region: us-api.ymcs.yealink.com
If your enterprise is in the AU region: au-api.ymcs.yealink.com
Protocols: HTTPS
Accepts: application/json
Responds With: application/json
Authentication
Every HTTP request made to the YMCS API must go through authentication. This
operation is to ensure whether the client accessing the service is a registered
user of the system. During the process of authentication, OAuth2.0 protocol will
be used.
Before calling the API, you need to obtain the Client ID and Client Secret from
the YMCS platform to apply for an access token. An enterprise can only apply for
one set of Client ID and Client Secret.
Process Description
The process for a user to request access tokens and initiate requests is as
follows:
2 / 164
Open API for Yealink Management Cloud Service V4X
1. The third-party application server initiates and sends a request, carrying the Client ID and Client Secret, to the
YMCS API server to access a token.
2. The API server verifies whether the Client ID and Client Secret information is correct.
3. Yealink API server returns access token after successful verification.
4. The third-party application server initiates a business request and carries an access token.
5. Yealink API server checks for the existence of an access token, and then verifies the validity of the access
token.
6. Yealink API server forwards the request to the Yealink business server.
7. Yealink business server will return the processed result to the Yealink API server.
8. The API server will pass the response result to the third-party application server.
If the access token is invalid, you will need to provide the corresponding code
to retrieve the token from the server again.
Apply for access token
Request Method
POST
Request URL
/v2/token
Request parameters
3 / 164
Open API for Yealink Management Cloud Service V4X
Param Parame Data Requ Description
eter ter Type ired
Type
Author Header String Yes Basic
ization base64Encode(client_id:client_secret),
connect Client ID and Client Secret with
a colon, and then perform Base64
encoding.
times Header String Yes Timestamp, the number of
tamp milliseconds from January 1, 1970,
00:00:00 to now.
nonce Header String Yes Random number, maximum length 32
bits
grant_ Body String Yes client_credentials
type
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter transmission exception, see exception response
parameters for details.
401 Authentication failed, see exception response parameters.
500 Server exception, see exception response parameters for details.
Response parameters
Parameter Data Type Description
access_token String access token
token_type String bearer
expires_in Long Access token validity period, in seconds.
Exception response parameters
Par Data Description
ame Type
ter
4 / 164
Open API for Yealink Management Cloud Service V4X
erro String Provide error codes according to the definition of the OAuth2
r protocol. Error coded can be used to categorize and handle
errors.
cod String Server-defined error code, used for quick problem
e localization.
requ String Request ID generated by the server to track the execution
estI status of the request on the server side. It can help
d developers quickly identify issues.
mes String Clear and concise error Description that can be understood
sage by end users.
Example Request
httpPOST /v2/token HTTP/1.1
Content-Type: application/json
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
{
"grant_type": "client_credentials"
}
Example of response parameters
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "[JWT TOKEN]",
"token_type": "bearer",
"expires_in": 86400
}
Exception response parameters
httpHTTP/1.1 400 Bad Request
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
5 / 164
Open API for Yealink Management Cloud Service V4X
Pragma: no-cache
{
"error": "invalid_request",
"code": "70011",
"requestId": "255d1aef",
"message": "The provided value for the input parameter 'grant_type' is not valid."
}
Initiate business request
All API requests must be made via HTTPS. The base URL for requests is https://
api.ymcs.yealink.com/v2/. The complete URL varies depending on the resource of
the operation.
Each time you request the API, you must provide 3 HTTP Request Headers, as
follows:
Name Data Type Description
Authoriza String Authentication information. The format is: Bearer
tion [ACCESS TOKEN]
timestam String Timestamp, the number of milliseconds from
p January 1, 1970, 00:00:00 to now.
nonce String Random number, maximum length 32 bits
HTTP Request Header Example
httpAuthorization: Bearer [ACCESS TOKEN]
timestamp: 1568693976264
nonce: 097e0ac619ba41f68f16f1955787feb9
1.3 Error Definitions
Yealink API uses HTTP status codes to reflect the success or failure of request
operations. 2XX status codes indicate successful operations, while 4XX or 5XX
status codes indicate operation errors. If an erroneous status code is received,
the error code and error message in the response body can be used to
understand the reason for the error.
6 / 164
Open API for Yealink Management Cloud Service V4X
Status code Description Scenario example
2XX Operation successful
400 Request data error Invalid or incomplete
request data.
401Authenticati Authentication error The request did not carry
on error an access token.
403 Access to certain resources Authentication failed
is not allowed
404 No data matching the No data found.
request
429 Request rate exceeds Request too frequent
frequency limit
500 Server Error Internal server error
Error object definition
Error object
Na Data Description
me Typ
e
cod Stri Error codes defined by the server, used for quick problem
e ng localization.
req Stri Request ID generated by the server, used for tracking the
uest ng execution status of requests on the server. It helps developers
Id quickly locate issues.
mes Stri A simple and clear error Description that can be understood by
sag ng the end user.
e
deta Error List of details that caused the error, may be empty
ils Detai
l[]
ErrorDetail object
Name Data Description
Type
7 / 164
Open API for Yealink Management Cloud Service V4X
field String Error in the request parameter name
messag String A simple and clear error Description that can be
e understood by the end user.
Error Response Example
json{
"code": "{errorCode}",
"requestId": "{requestId}",
"message": "Validation Failed",
"details": [
{
"field": "email",
"message": "Invalid field"
},
{
"field": "type",
"message": "Invalid field"
}
]
}
Opera Description Description
tional
code
90020 Operation successful Operate Successfully
0
90040 Incorrect request parameters Request parameters
0 are incorrect
90040 The user has not logged in or the login has User is not logged in
1 expired, please log in again or the account has
expired, please log in
again
90040 The request has been forbidden. This request is
3 forbidden
90040 The requested resource cannot be found. Requested resource is
8 / 164
Open API for Yealink Management Cloud Service V4X
4 not found
90040 Request timed out, please try again later. Time out, please try
8 again
90040 Conflict request. Request conflict
9
90041 Concurrent editing error. Concurrent editing
2 error
90042 Too many requests. Too Many Requests
9
90044 Session expired. Login Time-out
0
90050 The server is busy, please try again later. The server is busy,
0 please try again later
90050 This operation is not supported. Not Implemented
1
90050 When a server acting as a gateway or proxy Bad Gateway
2 attempts to process a request, it receives an
invalid response from the upstream server.
90050 Service Unavailable. Service Unavailable
3
90050 The upstream server is not responding. Gateway Timeout
4
90051 Internal server error Server Internal Error
1
90059 Unknown error unknown mistake
9
90040 Parameters cannot be null Cannot be null
0
90040 Parameters cannot be null Cannot be empty
0
90040 The parameter length is incorrect. Incorrect length
0
90040 ID cannot be empty. ID cannot be empty
9 / 164
Open API for Yealink Management Cloud Service V4X
0
90040 Resource does not exist The resource does not
0 exist or has been
deleted
80000 Mac is not legal. Invalid MAC
1
80000 Invalid SN. SN is invalid
2
80000 Resource already exists. Resource already
3 exists
80000 The device has been added by another MAC has been added
4 company. by another
enterprise/
organization
80000 Device type illegal Device Type is invalid
5
80000 The number of devices added in a batch The number of added
6 exceeds the limit devices exceeds the
limit
80000 Invalid parameters. Incorrect parameter
7 format
80000 Data exceeds limit. Data exceeds limit
8
80013 Account already exists Account already exists
0
80020 The authentication username and Username and
0 password must appear in pairs. password must
appear in pairs
1.4 Invoke Rate Limiting
In order to maintain the reliability of the YMCS API platform, our API has the
following rate limits.
The rate limit for the normal API is 50 requests/second unless otherwise noted.
Please control the frequency of your application calls and do not exceed the
10 / 164
Open API for Yealink Management Cloud Service V4X
access rate limit or you will receive a 429 status response.
Invokers of the YMCS API shall retry Error 429 using the exponential backoff
algorithm and with a minimum delay of 30 seconds.
2. Device Management
2.1 Device Basic Information Management
2.1.1 Add Devices
Request Method
POST
Request URL
/v2/dm/devices
Body parameter
Paramet Data Required Description
er Type
mac String Yes Device MAC, minimum length 12,
maximum length 17 characters
sn String Yes SN code, maximum length 128
deviceT Integer Yes Device Type 1: Phone Device 3: Room
ype Device
modelId String Yes Model ID
name String No Device name, maximum length 128
siteId String No Site ID
HTTP status code
Code Description
201 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
11 / 164
Open API for Yealink Management Cloud Service V4X
Parameter Data Type Description
id String The device ID.
mac String The device MAC address.
sn String The group ID.
name String The device name.
modelId String The device model ID.
siteId String The site ID.
programVersion String The version number of the device
firmware.
Example Request
httpPOST /v2/dm/devices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"name":"my t54s",
"deviceType":1
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"name":"my t54s",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"siteId":"a624453e1cbb44ecb9bb6ee31731a856",
"programVersion":"70.83.0.68"
}
12 / 164
Open API for Yealink Management Cloud Service V4X
2.1.2 Add Device in Batches
Note: You can add up to 100 devices at a time.
Request Method
POST
Request URL
/v2/dm/addDevices
Body parameter
Paramete Data Type Required Description
r
mac String Yes Device MAC, minimum length 12,
maximum length 17
sn String Yes SN code, maximum length 128
deviceTyp Integer Yes Device Type 1: Phone Device 3:
e Room Device
modelId String Yes Model ID
name String No Device name, maximum length
128
siteId String No Site ID
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
total Integer The total number of batch additions.
successCoun Integer The total number of successful additions.
t
13 / 164
Open API for Yealink Management Cloud Service V4X
failureCount Integer The total number of failures.
errors AddError[] Error message.
AddError object
parameter Data Type Description
mac String The device MAC address.
sn String The device SN address.
errorInfo String Error message.
Example Request
httpPOST /v2/dm/addDevices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
[{
"mac":"3a1565bbb1a9",
"sn":"1106312113402006",
"name":"my t54s",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"deviceType":1
}]
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 1,
"successCount":0,
"failureCount":1,
"errors":[{
"mac":"3a1565bbb1a9",
"sn":"1106312113402006",
"errorInfo":"device.mac.invalid"
}]
}
14 / 164
Open API for Yealink Management Cloud Service V4X
2.1.3 Add device without SN
Note: You can add up to 100 devices at a time.
Request Method
POST
Request URL
/v2/dm/addDevicesByMac
Body parameter
Param ParameterT Data Requi Description
eter ype Type red
mac Body String Yes Device MAC, minimum length
12, maximum length 17
device Body Integer Yes Device Type 1: Phone Device 3:
Type Room Device
model Body String Yes Model ID
Id
name Body String No Device name, maximum length
128
siteId Body String No Site ID
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
total Integer The total number of batch additions.
successCoun Integer The total number of successful additions.
t
failureCount Integer The total number of failures.
15 / 164
Open API for Yealink Management Cloud Service V4X
errors AddError[] Error message.
Parameter Data Type Description
mac String The device MAC address.
sn String The device SN address.
errorInfo String Error message.
Example Request
httpPOST /v2/dm/addDevicesByMac HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
[{
"mac":"3a1565bbb1a9",
"name":"my t54s",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"deviceType":1
}]
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 1,
"successCount":0,
"failureCount":1,
"errors":[{
"mac":"3a1565bbb1a9",
"sn":"1106312113402006",
"errorInfo":"device.mac.invalid"
}]
}
2.1.4 Delete Device
Request Method
16 / 164
Open API for Yealink Management Cloud Service V4X
DELETE
Request URL
/v2/dm/devices/{deviceId}
PATH parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpDELETE /v2/dm/devices/e33b8f25247e45de84dd4c74503b241a HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
2.1.5 Delete Devices in Batches
Request Method
POST
Request URL
/v2/dm/delDevices
Body parameter
Parame Data Req Description
ter Type uire
d
17 / 164
Open API for Yealink Management Cloud Service V4X
device Integ Yes Device Type 1: Phone Device 3: Room Device
Type er
deviceI Strin No Indicates the type of device identifier, default id,
dType g optional mac, id
deviceI Strin Yes Device identifier, maximum length 200
ds g[]
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/delDevices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
18 / 164
Open API for Yealink Management Cloud Service V4X
"deviceType":1,
"deviceIds":["001565bbb1a9","001567"],
"deviceIdType":"mac"
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"001567",
"msg":"Invalid MAC"
}]
}
2.1.6 Device List
Request Method
POST
Request URL
/v2/dm/listDevices
Body parameter
Par Data Req Description
ame Type uir
ter ed
ski Long No Number of skipped records, default is 0
p
limi Long No Maximum number of records to retrieve, default is 10,
t maximum is 100.
aut Bool No Whether to respond with the total count, it is
oCo ean recommended to set it to true when querying the first
unt page, and pass false for subsequent page turns.
19 / 164
Open API for Yealink Management Cloud Service V4X
filte Filter No search parameters
r
Filter object definition
Para Dat Re Description
mete a qu
r Ty ire
pe d
mac Str No Device MAC fuzzy search keyword, maximum length 17,
ing supports with : or -, such as 00:15:65:bb:b1:a9
model Str No Model ID
Id ing
devic Int No Device status, 1: online, 0: offline, -1: not reported
eStat ege
us r
accou Int No Account status, 1: Registered 2: dnd 3: Unregistered
ntSta ege
tus r
devic Int No Device type status, 1: Phone Device 3: Room Device, if
eType ege not filled in, it means all.
r
siteId Str No Site ID
ing
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long The offset value.
20 / 164

View File

@@ -0,0 +1,886 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 21-40
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 21-40 of 164
**Chunk:** 2
---
Open API for Yealink Management Cloud Service V4X
limit Long The maximum returned number.
total Long The total number.
data Device[] Device information array
Device object definition
Paramete Data Description
r Type
id Strin device id
g
mac Strin Device MAC
g
sn Strin Device SN
g
name Strin Device Name
g
modelId Strin Model ID
g
siteId Strin Site ID
g
programV Strin Device firmware version number
ersion g
deviceSta Strin Device status, online: online, offline: offline, pending:
tus g not reported
Example Request
httpPOST /v2/dm/listDevices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 20,
"autoCount": true,
"filter":{
"deviceStatus":1,
21 / 164
Open API for Yealink Management Cloud Service V4X
"siteId":"a624453e1cbb44ecb9bb6ee31731a856"
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"name":"my t54s",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"siteId":"a624453e1cbb44ecb9bb6ee31731a856",
"programVersion":"70.83.0.68",
"deviceStatus":"online"
}]
}
2.1.7 Edit Device
Request Method
PATCH
Request URL
/v2/dm/devices/{deviceId}
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID address.
Body parameter
Paramete Data Require Description
r Type d
22 / 164
Open API for Yealink Management Cloud Service V4X
name String No Device name, maximum length 128
siteId String No Site ID
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/dm/devices/8d07a56207074d26b61026099625b9e2 HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name":"my t54s2"
}
Example Response
httpHTTP/1.1 204
2.1.8 Device Details
Request Method
GET
Request URL
/v2/dm/devices/{deviceId}
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
23 / 164
Open API for Yealink Management Cloud Service V4X
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Paramet Data Description
er Type
id String device id
mac String Device MAC
sn String group id
name String Device Name
modelId String Model ID
modelN String Model Name
ame
siteId String Site ID
siteNam String Site Name
e
lanIp String Intranet IP
deviceSt String Device status, online: online, offline: offline,
atus pending: not reported
programV String Firmware version
ersion
lastRepo long Last reporting time
rtTime
accounts ReportAcc Reported account information
ount[]
ReportAccount object definition
Paramet Data Description
er Type
24 / 164
Open API for Yealink Management Cloud Service V4X
accountI String Account ID
d
lineId Intege Account line, starting from 1
r
accountT Intege Account type: 0: SIP, 1: H323, 2: SFB
ype r
accountS String Account server address
erver
register String Registration Name
Name
usernam String User Name
e
status Intege Account status: 1: Registered 2: dnd 3: Not registered 4:
r Unknown
Example Request
httpGET /v2/dm/devices/8d07a56207074d26b61026099625b9e2 HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"name":"my t54s",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"modelName":"SIP-T54S",
"siteId":"a624453e1cbb44ecb9bb6ee31731a856",
"siteName":"mysite",
"lanIp":"10.50.198.6",
"deviceStatus":"online",
"programVersion":"70.83.0.68",
"lastReportTime":1711093807136,
"accounts":[{
25 / 164
Open API for Yealink Management Cloud Service V4X
"accountId":"604e67944c7c43fe8b66099254ec3439"
"lineId":1,
"accountType":0,
"accountServer":"10.200.108.48",
"registerName":"1000",
"username":"1000",
"status":0
},{
"accountId":"a45a6b4a7e0447b8a6c3f2839d902acd"
"lineId":2,
"accountType":0,
"accountServer":"10.200.108.48",
"registerName":"2000",
"username":"2000",
"status":0
}]
}
2.1.9 Device Combination Configuration Query
Request Method
GET
Request URL
/v2/dm/devices/{deviceId}/configs
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
26 / 164
Open API for Yealink Management Cloud Service V4X
Parameter Data Description
Type
deviceConfig String Device MAC Configuration
siteConfig String Device Site Configuration
globalConfig String Enterprise Configurations
enforceConfig String Site forced inheritance configuration
Example Request
httpGET /v2/dm/devices/8d07a56207074d26b61026099625b9e2/configs HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"deviceConfig": "account.1.auth_name = test1101",
"siteConfig":"lang.gui=English",
"globalConfig":"auto_provision.server.url=\r\nlang.gui=English",
"enforceConfig":"security.user_password=user2"
}
2.2 Device Group Management
2.2.1 Add Group
Request Method
POST
Request URL
/v2/dm/deviceGroups
Body parameter
Paramete Data Requi Description
r Type red
name String Yes Group name, length 64
deviceTyp Integer Yes Device Type 1: Phone Device 3: Room
27 / 164
Open API for Yealink Management Cloud Service V4X
e Device
Descripti String No Group Description information, length 256
on
HTTP status code
Code Description
201 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The group ID.
name String Group Name
deviceType Integer Device Types
description String Group description information
Example Request
httpPOST /v2/dm/deviceGroups HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name":"t54sgroup",
"deviceType":1,
"Description":"test"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
28 / 164
Open API for Yealink Management Cloud Service V4X
{
"id": "00884cef841e48f4acd213c084e65f67",
"name":"t54sgroup",
"deviceType":1,
"Description":"test"
}
2.2.2 Edit Group
Request Method
PATCH
Request URL
/v2/dm/deviceGroups/{deviceGroupId}
Path parameter
parameter Data Type Required Description
deviceGroupId String Yes Group ID
Body parameter
Paramete Data Requi Description
r Type red
name String Yes Group name, length 64
deviceTyp Integer Yes Device Type 1: Phone Device 3: Room
e Device
Descripti String No Group Description information, length 256
on
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
29 / 164
Open API for Yealink Management Cloud Service V4X
httpPATCH /v2/dm/deviceGroups/00884cef841e48f4acd213c084e65f67/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name":"t54sgroup2",
"deviceType":1,
"Description":"test2"
}
Example Response
httpHTTP/1.1 204
2.2.3 Group List
Request Method
POST
Request URL
/v2/dm/listDeviceGroups
Body parameter
Param Data Requir Description
eter Type ed
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve,
default is 10, maximum is 500.
autoCo Boolean No Whether to respond with the total count, it is
unt recommended to set it to true when querying
the first page, and pass false for subsequent
page turns.
filter Filter No search parameters
Filter object definition
Para Data Req Description
mete uir
30 / 164
Open API for Yealink Management Cloud Service V4X
r Typ ed
e
devi Inte No Device type status, 1: Phone Device 3: Room Device, if
ceTy ger not filled in, it means all.
pe
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data DeviceGroup[] DeviceGroup information array
DeviceGroup object definition
Parameter Data Description
Type
id String Group ID
name String Group name, length 64
deviceType Integer Device Type 1: Phone Device 3: Room Device
Description String Group Description information, length 256
Example Request
httpPOST /v2/dm/listDeviceGroups HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
31 / 164
Open API for Yealink Management Cloud Service V4X
{
"skip": 0,
"limit": 10,
"autoCount": true,
"filter":{
"deviceType":1
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "00884cef841e48f4acd213c084e65f67",
"name":"t54sgroup",
"deviceType":1,
"Description":"test"
}]
}
2.2.4 Delete Group
Request Method
DELETE
Request URL
/v2/dm/deviceGroups/{deviceGroupId}
PATH parameter
parameter Data Type Required Description
deviceGroupId String Yes Group ID
HTTP status code
Code Description
32 / 164
Open API for Yealink Management Cloud Service V4X
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpDELETE /v2/dm/deviceGroups/00884cef841e48f4acd213c084e65f67 HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
2.2.5 Add Device to Group
Request Method
POST
Request URL
/v2/dm/deviceGroups/{deviceGroupId}/addDevices
PATH parameter
parameter Data Type Required Description
deviceGroupId String Yes Group ID
Body parameter
Paramete Data Require Description
r Type d
deviceIds String[] Yes Device ID list, limited to 200.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
33 / 164
Open API for Yealink Management Cloud Service V4X
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/deviceGroups/00884cef841e48f4acd213c084e65f67/addDevices
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIds":
["0006572538f74e8683716cf961caa95b","00099642675e4d4bb5e91fd9ae5ce585"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
34 / 164
Open API for Yealink Management Cloud Service V4X
"field":"00099642675e4d4bb5e91fd9ae5ce585",
"msg":"The resource does not exist or has been deleted"
}]
}
2.2.6 Remove devices from the group
Request Method
POST
Request URL
/v2/dm/deviceGroups/{deviceGroupId}/delDevices
PATH parameter
parameter Data Type Required Description
deviceGroupId String Yes Group ID
Body parameter
Paramet Data Require Description
er Type d
deviceIds String[] Yes Device ID list, maximum length 200
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
35 / 164
Open API for Yealink Management Cloud Service V4X
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/deviceGroups/00884cef841e48f4acd213c084e65f67/delDevices
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIds":
["0006572538f74e8683716cf961caa95b","00099642675e4d4bb5e91fd9ae5ce585"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"00099642675e4d4bb5e91fd9ae5ce585",
"msg":"The resource does not exist or has been deleted"
}]
}
2.2.7 Query Device List within Group
Request Method
POST
Request URL
36 / 164
Open API for Yealink Management Cloud Service V4X
/v2/dm/deviceGroups/{deviceGroupId}/listDevices
PATH parameter
parameter Data Type Required Description
deviceGroupId String Yes Group ID
Body parameter
Pa Da Re Description
ra ta qu
me Ty ir
te p e
r e d
sk Lo N Number of skipped records, default is 0
ip ng o
li Lo N Maximum number of records to retrieve, default is 10,
mi ng o maximum is 500.
t
au Bo N Whether to respond with the total count, it is recommended
to ol o to set it to true when querying the first page, and pass false
Co ea for subsequent page turns.
un n
t
fil Fil N search parameters
te te o
r r
Filter object definition
Para Dat Re Description
mete a qu
r Ty ire
pe d
mac Str No Device MAC fuzzy search keyword, maximum length 17,
ing supports with : or -, such as 00:15:65:bb:b1:a9
mode Str No Model ID
lId ing
37 / 164
Open API for Yealink Management Cloud Service V4X
devic Int No Device status, 1: online, 0: offline, -1: not reported
eStat ege
us r
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data Device[] Device information array
Device object definition
Paramete Data Description
r Type
id Strin device id
g
mac Strin Device MAC
g
sn Strin Device SN
g
name Strin Device Name
g
modelId Strin Model ID
g
siteId Strin Site ID
g
38 / 164
Open API for Yealink Management Cloud Service V4X
programV Strin Device firmware version number
ersion g
deviceSta Strin Device status, online: online, offline: offline, pending:
tus g not reported
httpPOST /v2/dm/deviceGroups/00884cef841e48f4acd213c084e65f67/listDevices
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"name":"my t54s",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"siteId":"a624453e1cbb44ecb9bb6ee31731a856",
"programVersion":"70.83.0.68",
"deviceStatus":"online"
}]
}
2.3 Device Control
2.3.1 Device Restart
Request Method
39 / 164
Open API for Yealink Management Cloud Service V4X
POST
Request URL
/v2/dm/device/reboot
Body parameter
Paramet Data Requi Description
er Type red
deviceIds String[] Yes Device ID list, maximum length 200
deviceTy Integer Yes Device Type 1: Phone Device 3: Room
pe Device
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Total number of factory restored devices
successCoun Integer Number of successful factory restored
t
failureCount Integer Number of failed factory restored
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
40 / 164

View File

@@ -0,0 +1,891 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 41-60
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 41-60 of 164
**Chunk:** 3
---
Open API for Yealink Management Cloud Service V4X
httpPOST /v2/dm/device/reboot HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIds":
["0006572538f74e8683716cf961caa95b","00099642675e4d4bb5e91fd9ae5ce585"],
"deviceType":1
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"0006572538f74e8683716cf961caa95b",
"msg":"The resource does not exist or has been deleted"
}]
}
2.3.2 Device Factory Reset
Request Method
POST
Request URL
/v2/dm/device/reset
Body parameter
Paramet Data Requi Description
er Type red
deviceIds String[] Yes Device ID list
deviceTy Integer Yes Device Type 1: Phone Device 3: Room
pe Device
41 / 164
Open API for Yealink Management Cloud Service V4X
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Total number of factory restored devices
successCoun Integer Number of successful factory restored
t
failureCount Integer Number of failed factory restored
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/device/reset HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIds":
["0006572538f74e8683716cf961caa95b","00099642675e4d4bb5e91fd9ae5ce585"],
"deviceType":1
}
Example Response
42 / 164
Open API for Yealink Management Cloud Service V4X
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"0006572538f74e8683716cf961caa95b",
"msg":"The resource does not exist or has been deleted"
}]
}
2.4 Device Identification Management
2.4.1 Obtain Device ID
Request Method
POST
Request URL
/v2/dm/deviceId
Body parameter
Param Data Req Description
eter Type uir
ed
device Integ Yes Device Type 1: Phone Device 3: Room Device
Type er
device Strin No Indicates the type of device identifier, default is
IdType g mac, optional is mac.
deviceI Strin Yes Device ID, limit 200 characters
ds g[]
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
43 / 164
Open API for Yealink Management Cloud Service V4X
500 Server exception
Response parameters
parameter Data Type Description
key String Device identification
deviceId String The device ID.
Example Request
httpPOST /v2/dm/deviceId HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceType":1,
"deviceIds":["001565bbb1a9"],
"deviceIdType":"mac"
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
[{
"key":"001565bbb1a9",
"deviceId":"8d07a56207074d26b61026099625b9e2"
}]
2.5 Device Accessories Management
2.5.1 Accessory List
Request Method
POST
Request URL
/v2/dm/devices/{deviceId}/listParts
Path parameter
44 / 164
Open API for Yealink Management Cloud Service V4X
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
Pa Da Re Description
ra ta qu
me Ty ir
te p e
r e d
sk Lo N Number of skipped records, default is 0
ip ng o
li Lo N Maximum number of records to retrieve, default is 10,
mi ng o maximum is 500.
t
au Bo N Whether to respond with the total count, it is recommended
to ol o to set it to true when querying the first page, and pass false
Co ea for subsequent page turns.
un n
t
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
id String accessory id
mac String Accessory MAC
sn String Accessory SN
45 / 164
Open API for Yealink Management Cloud Service V4X
modelId String Model ID
connectWa String Connection methods for accessories, including USB,
y BT, etc.
connStatus Intege Status, 1: online, 0: offline
r
lanIp String Intranet IP
programVer String Firmware version number
sion
lastReport Long Last reporting time
Time
Example Request
httpPOST /v2/dm/devices/8d07a56207074d26b61026099625b9e2/listParts HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 20,
"autoCount": true
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "5d6017f6fe004eb7ac1107c80c1c44b7",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"modelId":"e7c5c0c406cf4a02bd9ed64183d1de05",
46 / 164
Open API for Yealink Management Cloud Service V4X
"modelName": "CP700",
"connectWay":"USB",
"connStatus":1,
"lanIp":"10.60.50.22",
"programVersion":"153.433.0.5",
"lastReportTime":1709577211630
}]
}
2.5.2 Accessory Details
Request Method
GET
Request URL
/v2/dm/devices/{deviceId}/parts/{partId}
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
partId String Yes Accessories ID
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Par Da Description
ame ta
ter Ty
p
e
id St accessory id
47 / 164
Open API for Yealink Management Cloud Service V4X
ri
ng
mac St Accessory MAC
ri
ng
sn St Accessory SN
ri
ng
mod St Model ID
elId ri
ng
con St Connection methods for accessories, including USB, BT, etc.
nec ri
tWa ng
y
conn In Status, 1: online, 0: offline
Stat te
us ge
r
lanI St Intranet IP
p ri
ng
prog St Firmware version number
ramV ri
ersi ng
on
last Lo Last reporting time
Rep ng
ortT
ime
extr Ex Additional information related to the model, when the model is
aInf tr a sensor, the response content includes temperature, battery
o aI level, and other information.
nf
o
48 / 164
Open API for Yealink Management Cloud Service V4X
ExtraInfo object definition
When the model is a sensor, the content is as follows.
parameter Data Type Description
batteryLevel Integer Battery Level
humidity Integer Humidity
irradiance IntegerIlluminance
temperature Integer Temperature
numPeople Integer People Count Statistics
Example Request
httpGET /v2/dm/devices/8d07a56207074d26b61026099625b9e2/
parts/104aa43ca9994de0825ec8d32f3a8c60 HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "104aa43ca9994de0825ec8d32f3a8c60",
"mac":"84fd27df8045",
"sn":"803111D070000358",
"modelId":"50b2674518a648968367ea2bb61a5572",
"modelName":"RoomSensor",
"connectWay":"BT",
"connStatus":1,
"lanIp":"10.60.50.21",
"programVersion":"153.433.0.5",
"lastReportTime":1709577211630,
"extraInfo":{
"batteryLevel":100,
"humidity":55,
"irradiance":27,
"temperature":31,
"numPeople":2
}
49 / 164
Open API for Yealink Management Cloud Service V4X
}
2.5.2 Accessory Restart
Request Method
POST
Request URL
/v2/dm/devices/{deviceId}/parts/reboot
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
Pa Dat Re Description
ram a qu
ete Ty ir
r pe ed
par Stri No Accessory ID list, maximum length 200, not passing
tId ng[ indicates a restart of all accessories under this device.
s ]
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
total Integer Total number of rebooted devices
successCount Integer Number of successful reboots
failureCount Integer Number of failed reboots
50 / 164
Open API for Yealink Management Cloud Service V4X
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/device/0006572538f74e8683716cf961caa95b/parts/reboot HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"partIds":["00ab7c8da8ff4345af3857feb68aedef","0299d1254f7042068c4ef1c5895b7e5a"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"00ab7c8da8ff4345af3857feb68aedef",
"msg":"The resource does not exist or has been deleted"
}]
}
2.5.4 Restore Accessories to Factory Settings
Request Method
POST
Request URL
/v2/dm/devices/{deviceId}/parts/reset
Path parameter
51 / 164
Open API for Yealink Management Cloud Service V4X
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
Pa Da Re Description
ra ta qu
me Ty ir
te p e
r e d
pa St N Accessory ID list, maximum length of 200, if not provided,
rt ri o indicates that all accessories under this device will be
Id ng restored to factory settings.
s []
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Total number of factory restored devices
successCoun Integer Number of successful factory restored
t
failureCount Integer Number of failed factory restored
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
52 / 164
Open API for Yealink Management Cloud Service V4X
msg String Error message.
Example Request
httpPOST /v2/dm/device/0006572538f74e8683716cf961caa95b/parts/reset HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"partIds":["00ab7c8da8ff4345af3857feb68aedef","0299d1254f7042068c4ef1c5895b7e5a"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"00ab7c8da8ff4345af3857feb68aedef",
"msg":"The resource does not exist or has been deleted"
}]
}
2.6 Device Account Management
2.6.1 Device Binding Account
Request Method
POST
Request URL
/v2/dm/devices/{deviceId}/bindAccounts
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
53 / 164
Open API for Yealink Management Cloud Service V4X
Parame Data Type Requi Description
ter red
account BindAccount Yes List of account information to be bound
s []
BindAccount object definition
Parameter Data Description
Type
lineId integer Account line, starting from 1
accountType Integer Account type: 0: SIP, 1: H323, 2: SFB
accountId String Account ID
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
total Integer Total number of bindings
successCount Integer Number of successful bindings
failureCount Integer Number of failed bindings
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
54 / 164
Open API for Yealink Management Cloud Service V4X
httpPOST /v2/dm/devices/8d07a56207074d26b61026099625b9e2/bindAccounts
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
[{
"lineId":1,
"accountType":0,
"accountId":"604e67944c7c43fe8b66099254ec3439"
},{
"lineId":2,
"accountType":0,
"accountId":"a45a6b4a7e0447b8a6c3f2839d902acd"
}]
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"604e67944c7c43fe8b66099254ec3439",
"msg":"The resource does not exist or has been deleted"
}]
}
2.6.2 Device Unbinding Account
Request Method
POST
Request URL
/v2/dm/devices/{deviceId}/unbindAccounts
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
55 / 164
Open API for Yealink Management Cloud Service V4X
Body parameter
Paramete Data Require Description
r Type d
accountIds String[] Yes List of account IDs to be unbound
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
total Integer The total number of unbundled
successCount Integer Number of successful unbindings
failureCount Integer Number of failed unbindings
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/devices/8d07a56207074d26b61026099625b9e2/unbindAccounts
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"accountIds":
["604e67944c7c43fe8b66099254ec3439","a45a6b4a7e0447b8a6c3f2839d902acd"]
56 / 164
Open API for Yealink Management Cloud Service V4X
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"604e67944c7c43fe8b66099254ec3439",
"msg":"The resource does not exist or has been deleted"
}]
}
2.6.3 List of accounts bound to the device
Request Method
GET
Request URL
/v2/dm/devices/{deviceId}/boundAccounts
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
57 / 164
Open API for Yealink Management Cloud Service V4X
data BindAccount[] Bind account information array
BindAccount object definition
Parameter Data Description
Type
accountId String Account ID
lineId Integer Account line, starting from 1
accountType Integer Account type: 0: SIP, 1: H323, 2: SFB
accountServer String Account server address
registerName String Registration Name
username String User Name
Example Request
httpGET /v2/dm/devices/8d07a56207074d26b61026099625b9e2/boundAccounts
HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json
[{
"accountId":"604e67944c7c43fe8b66099254ec3439"
"lineId":1,
"accountType":0,
"accountServer":"10.200.108.48",
"registerName":"1000",
"username":"1000"
},{
"accountId":"a45a6b4a7e0447b8a6c3f2839d902acd"
"lineId":2,
"accountType":0,
"accountServer":"10.200.108.48",
"registerName":"2000",
58 / 164
Open API for Yealink Management Cloud Service V4X
"username":"2000"
}]
3.Account Management
3.1 Add SIP account
Request Method
POST
Request URL
/v2/dm/sipAccounts
Body parameter
Paramet Data Requ Description
er Type ired
register String Yes The registered name, with no more than 128
Name characters.
usernam String Yes Username, maximum length 128
e
passwor String Yes Password, maximum length 128
d
display String No Display name, maximum length 128
Name
label String No Label, maximum length 128
sipServe SipSer Yes sip server 1 address
r1 ver
sipServe SipSer No sip server 2 address
r2 ver
remark String No Note, maximum length 512
siteId String No Site ID to be assigned
SipServer object definition
Parameter Data Type Description
host String Server address, maximum length 256
59 / 164
Open API for Yealink Management Cloud Service V4X
port Integer Server port, 0~65535
HTTP status code
Code Description
201 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
id String account id
username String username
registerInfo String Registration Information
serverAddress String Server address
accountType Integer Account type, 0: SIP, 1: H323, 2: SFB
remark String Note
createTime Long Creation time
Example Request
httpPOST /v2/dm/sipAccounts HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"registerName": "2552",
"username": "2552",
"password": "******",
"label": "2552",
"displayName": "2552",
"sipServer1": {
"host": "ume.yealink.com",
"port":5061
},
60 / 164

View File

@@ -0,0 +1,883 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 61-80
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 61-80 of 164
**Chunk:** 4
---
Open API for Yealink Management Cloud Service V4X
"siteId":"0006d62003684754b11c09c5d94ea687",
"remark":"test"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "e33b8f25247e45de84dd4c74503b241a",
"registerInfo": "2552",
"username": "2552",
"serverAddress": "ume.yealink.com",
"accountType": 0,
"createTime": 1698052518923
}
3.2 Edit SIP Account
Request Method
PATCH
Request URL
/v2/dm/sipAccounts/{accountId}
Path parameter
parameter Data Type Required Description
accountId String Yes Account
Body parameter
Parameter Data Requir Description
Type ed
registerNam String Yes Namemaximum length 128
e
username String Yes Username, maximum length 128
password String Yes Password, maximum length 128
61 / 164
Open API for Yealink Management Cloud Service V4X
displayName String No Display name, maximum length 128
label String No Label, maximum length 128
sipServer1 SipServe Yes sip server 1 address
r
sipServer2 SipServe No sip server 2 address
r
remark String No Note, maximum length 512
siteId String No Site ID to be assigned
SipServer object definition
Parameter Data Type Description
host String Server address, maximum length 256
port Integer Server port, 0~65535
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/dm/sipAccounts/e33b8f25247e45de84dd4c74503b241a HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"registerName": "2553",
"username": "2552",
"password": "******",
"label": "2552",
"displayName": "2552",
"sipServer1": {
"host": "ume.yealink.com",
"port":5061
62 / 164
Open API for Yealink Management Cloud Service V4X
},
"siteId":"0006d62003684754b11c09c5d94ea687",
"remark":"test"
}
Example Response
httpHTTP/1.1 204
3.3 Account List
Request Method
POST
Request URL
/v2/dm/listAccounts
Body parameter
Par Dat Req Description
ame a uir
ter Ty ed
pe
skip Lo No Number of skipped records, default is 0
ng
limit Lo No Maximum number of records to retrieve, default is 10,
ng maximum is 500.
aut Boo No Whether to respond with the total count, it is
oCo lea recommended to set it to true when querying the first
unt n page, and pass false for subsequent page turns.
filte Filt No search parameters
r er
Filter object definition
Paramete Data Type Require Description
r d
username String No Username fuzzy search keyword
63 / 164
Open API for Yealink Management Cloud Service V4X
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data Account[] Account information array
Account object definition
Parameter Data Description
Type
id String account id
username String username
registerInfo String Registration Information
serverAddress String Server address
accountType Integer Account type, 0: SIP, 1: H323, 2: SFB
remark String Note
createTime Long Creation time
Example Request
httpPOST /v2/dm/listAccounts HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 20,
64 / 164
Open API for Yealink Management Cloud Service V4X
"autoCount": true,
"filter":{
"username":"2552"
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "e33b8f25247e45de84dd4c74503b241a",
"registerInfo": "2552",
"username": "h323",
"accountType": 1,
"createTime": 1698052518923
}]
}
3.4 Delete Account
Request Method
POST
Request URL
/v2/dm/delAccounts
Body parameter
Paramete Data Requir Description
r Type ed
accountId String[] Yes Account ID list, maximum length 200
s
HTTP status code
Code Description
65 / 164
Open API for Yealink Management Cloud Service V4X
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/delAccounts HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"accountIds":
["e33b8f25247e45de84dd4c74503b241a","2d3e77b736e240eabf25aa1f5448aa0c"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
66 / 164
Open API for Yealink Management Cloud Service V4X
"successCount":1,
"failureCount":1,
"errors":[{
"field":"2d3e77b736e240eabf25aa1f5448aa0c",
"msg":"The resource does not exist or has been deleted"
}]
}
4.Configuration Management
4.1 Telephone Device Configuration Management
4.1.1 Device Configuration List
Request Method
POST
Request URL
/v2/dm/listDeviceConfigs
Body parameter
Para Data Requ Description
mete Type ired
r
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve, default is
10, maximum is 500.
auto Bool No Whether to return the total number of records
Coun ean
t
filter Filter No filter condition
Filter object definition
Par Dat Re Description
ame a qu
ter Typ ire
e d
ma Stri No Device MAC fuzzy search keyword, maximum length 17,
c ng supports with : or -, such as 00:15:65:bb:b1:a9
67 / 164
Open API for Yealink Management Cloud Service V4X
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total data volume
data Config[] Config information array
Config object definition
parameter Data Description
Type
id String The primary key ID.
name String The configuration name.
modelId String The device model ID.
Description String Enter a description for the configuration.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/listDeviceConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true,
"filter":{
"mac":"001565bbb1a9"
}
68 / 164
Open API for Yealink Management Cloud Service V4X
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "8d07a56207074d26b61026099625b9e2",
"name":"my config",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"Description":"my t54s config"
}]
}
4.1.2 Add Device Configuration
Request Method
POST
Request URL
/v2/dm/deviceConfigs
Body parameter
Paramete Data Requ Description
r Type ired
deviceId Strin Yes Device ID
g
content Strin Yes cfg file content
g
autoPush Bool No Whether to automatically push this device
ean configuration when the phone device is
powered on for the first time or restored to
factory settings, true: yes false: no
HTTP status code
69 / 164
Open API for Yealink Management Cloud Service V4X
Code Description
201 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The device configuration ID.
Example Request
httpPOST /v2/dm/deviceConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceId": "8d07a56207074d26b61026099625b9e2",
"content": "lang.wui=English\nlang.gui=English",
"autoPush":true
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "d916a46b4557464c87c278cb37477bef"
}
4.1.3 Delete Device Configuration
Request Method
POST
Request URL
/v2/dm/delDeviceConfigs
70 / 164
Open API for Yealink Management Cloud Service V4X
Body parameter
Parame Data Requi Description
ter Type red
configI String[] Yes Device configuration ID list, maximum length
ds 200
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/delDeviceConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"configIds":
71 / 164
Open API for Yealink Management Cloud Service V4X
["e33b8f25247e45de84dd4c74503b241a","8d07a56207074d26b61026099625b9e2"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"e33b8f25247e45de84dd4c74503b241a",
"msg":"The resource does not exist or has been deleted"
}]
}
4.1.4 Device Configuration Details
Request Method
GET
Request URL
/v2/dm/deviceConfigs/{configId}
Path parameter
Paramete Data Type Require Description
r d
configId String Yes The device configuration ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
72 / 164
Open API for Yealink Management Cloud Service V4X
parameter Data Description
Type
id String The device configuration ID.
name String The configuration name.
modelId String The device model ID.
content String Configuration Content
Description String Enter a description for the configuration.
Example Request
httpGET /v2/dm/deviceConfigs/e33b8f25247e45de84dd4c74503b241a HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"name":"my config",
"modelId":"61e659e4d78d42ebada88ef1eb751b64",
"content":"#!version:1.0.0.1\naccount.1.codec.g722.enable=1"
"Description":"my t54s config"
}
4.1.5 Push Device Configuration
Request Method
POST
Request URL
/v2/dm/deviceConfigs/{configId}/push
Path parameter
parameter Data Type Required Description
configId String Yes The configuration ID.
HTTP status code
73 / 164
Open API for Yealink Management Cloud Service V4X
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/deviceConfigs/e33b8f25247e45de84dd4c74503b241a/push HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
4.2 Telephone Substation Configuration Management
4.2.1 Add Sub-site Configuration
Request Method
POST
Request URL
/v2/dm/siteConfigs
Body parameter
Paramet Data Requi Description
er Type red
name String Yes Sub-site configuration name, length 64
siteId String Yes Site ID
deviceTy Integer Yes Device Type 1: Phone Device
pe
modelId String No Model ID, leaving it blank indicates all models
content String No Configuration file content
Descripti String No Description, length 256
on
74 / 164
Open API for Yealink Management Cloud Service V4X
HTTP status code
Code Description
201 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The device configuration ID.
Example Request
httpPOST /v2/dm/siteConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name": "site1 config",
"siteId":"048a97f00ece46bd8d8bf97f5002992a",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"content": "lang.wui=English\nlang.gui=English",
"Description":"test"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "8b7f1739ad6d4a578267d09b53a262a3"
}
4.2.2 Edit Subsite Configuration
Request Method
75 / 164
Open API for Yealink Management Cloud Service V4X
PATCH
Request URL
/v2/dm/siteConfigs/{configId}
Path parameter
Parameter Data Type Required Description
configId String Yes Subsite configuration ID.
Body parameter
Paramet Data Requi Description
er Type red
name String Yes Sub-site configuration name, length 64
siteId String Yes Site ID
deviceTy Integer Yes Device Type 1: Phone Device
pe
modelId String No Model ID, leaving it blank indicates all models
content String No Configuration file content
Descripti String No Description, length 256
on
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/dm/siteConfigs/8b7f1739ad6d4a578267d09b53a262a3 HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
76 / 164
Open API for Yealink Management Cloud Service V4X
"name": "site1 config2",
"siteId":"048a97f00ece46bd8d8bf97f5002992a",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"content": "lang.wui=English\nlang.gui=English",
"Description":"test2"
}
Example Response
httpHTTP/1.1 204
4.2.3 Delete Subsite Configuration
Request Method
POST
Request URL
/v2/dm/delSiteConfigs
Body parameter
Param Data Requi Description
eter Type red
configI String[] Yes Sub-site configuration ID list, maximum length
ds 200
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
77 / 164
Open API for Yealink Management Cloud Service V4X
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/delSiteConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"configIds":
["8b7f1739ad6d4a578267d09b53a262a3","e33b8f25247e45de84dd4c74503b241a"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"e33b8f25247e45de84dd4c74503b241a",
"msg":"The resource does not exist or has been deleted"
}]
}
4.2.4 Subsite Configuration Details
Request Method
GET
78 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/dm/siteConfigs/{configId}
Path parameter
Parameter Data Type Required Description
configId String Yes Subsite configuration ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
id String Sub-site Configuration ID
name String Sub-site configuration name, length 64
siteId String Site ID
deviceType Integer Device Type 1: Phone Device
modelId String Model ID
content String Configuration file content
Description String Description, length 256
Example Request
httpGET /v2/dm/siteConfigs/8b7f1739ad6d4a578267d09b53a262a3 HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
79 / 164
Open API for Yealink Management Cloud Service V4X
{
"id": "8b7f1739ad6d4a578267d09b53a262a3",
"name": "site1 config2",
"siteId":"048a97f00ece46bd8d8bf97f5002992a",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"content": "lang.wui=English\nlang.gui=English",
"Description":"test2"
}
4.2.5 Substation Configuration List
Request Method
POST
Request URL
/v2/dm/listSiteConfigs
Body parameter
Para Data Requ Description
mete Type ired
r
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve, default is
10, maximum is 500.
auto Bool No Whether to return the total number of records
Coun ean
t
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total data volume
data Config[] Config information array
Config definition
80 / 164

View File

@@ -0,0 +1,884 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 81-100
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 81-100 of 164
**Chunk:** 5
---
Open API for Yealink Management Cloud Service V4X
Parameter Data Description
Type
id String Sub-site Configuration ID
name String Sub-site configuration name, length 64
siteId String Site ID
deviceType Integer Device Type 1: Phone Device
modelId String Model ID
Description String Description, length 256
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/listSiteConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
81 / 164
Open API for Yealink Management Cloud Service V4X
"data":[{
"id": "8b7f1739ad6d4a578267d09b53a262a3",
"name": "site1 config2",
"siteId":"048a97f00ece46bd8d8bf97f5002992a",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"Description":"test2"
}]
}
4.2.6 Push Subsite Configuration
Request Method
POST
Request URL
/v2/dm/siteConfigs/{configId}/push
Path parameter
parameter Data Type Required Description
configId String Yes The configuration ID.
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/siteConfigs/8b7f1739ad6d4a578267d09b53a262a3/push HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
82 / 164
Open API for Yealink Management Cloud Service V4X
4.3 Phone Group Configuration Management
4.3.1 Add Group Configuration
Request Method
POST
Request URL
/v2/dm/groupConfigs
Body parameter
Paramete Data Requi Description
r Type red
name String Yes Group configuration name, length 64
deviceGro String Yes Device Group ID
upId
deviceTyp Integer Yes Device Type 1: Phone Device
e
modelId String No Model ID, leaving it blank indicates all
models
content String No Configuration file content
Descriptio String No Description, length 256
n
Response parameters
Parameter Data Type Description
id String Group Configuration ID
HTTP status code
Code Description
201 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
83 / 164
Open API for Yealink Management Cloud Service V4X
httpPOST /v2/dm/groupConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name": "group config",
"deviceGroupId":"1185861ed00840ea99a7b07aa0f28f88",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"content": "lang.wui=English\nlang.gui=English",
"Description":"test"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "8faa39e34929425f85b4cd9925a2bbcb"
}
4.3.2 Edit Group Configuration
Request Method
PATCH
Request URL
/v2/dm/groupConfigs/{configId}
Path parameter
Parameter Data Type Required Description
configId String Yes Group Configuration ID
Body parameter
Paramete Data Requi Description
r Type red
name String Yes Group configuration name, length 64
deviceGro String Yes Device Group ID
84 / 164
Open API for Yealink Management Cloud Service V4X
upId
deviceTyp Integer Yes Device Type 1: Phone Device
e
modelId String No Model ID, leaving it blank indicates all
models
content String No Configuration file content
Descriptio String No Description, length 256
n
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/dm/groupConfigs/8faa39e34929425f85b4cd9925a2bbcb HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name": "group config2",
"deviceGroupId":"1185861ed00840ea99a7b07aa0f28f88",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"content": "lang.wui=English\nlang.gui=English",
"Description":"test2"
}
Example Response
httpHTTP/1.1 204
4.3.3 Delete Group Configuration
Request Method
85 / 164
Open API for Yealink Management Cloud Service V4X
POST
Request URL
/v2/dm/delGroupConfigs
Body parameter
Parame Data Requi Description
ter Type red
configI String[] Yes Group configuration ID list, maximum length
ds 200
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpDELETE /v2/dm/delGroupConfigs HTTP/1.1
Host: api.ymcs.yealink.com
86 / 164
Open API for Yealink Management Cloud Service V4X
Content-Type: application/json
{
"configIds":
["8faa39e34929425f85b4cd9925a2bbcb","e33b8f25247e45de84dd4c74503b241a"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"e33b8f25247e45de84dd4c74503b241a",
"msg":"The resource does not exist or has been deleted"
}]
}
4.3.4 Group Configuration Details
Request Method
GET
Request URL
/v2/dm/groupConfigs/{configId}
Path parameter
Parameter Data Type Required Description
configId String Yes Group Configuration ID
Response parameters
Parameter Data Description
Type
id String Group Configuration ID
name String Group configuration name, length 64
87 / 164
Open API for Yealink Management Cloud Service V4X
deviceGroupId String Group ID
deviceType Integer Device Type 1: Phone Device
modelId String Model ID
content String Configuration file content
Description String Description, length 256
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpGET /v2/dm/groupConfigs/8faa39e34929425f85b4cd9925a2bbcb HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "8faa39e34929425f85b4cd9925a2bbcb",
"name": "group config2",
"deviceGroupId":"1185861ed00840ea99a7b07aa0f28f88",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"content": "lang.wui=English\nlang.gui=English",
"Description":"test2"
}
4.3.5 Group Configuration List
Request Method
POST
88 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/dm/listGroupConfigs
Body parameter
Para Data Requ Description
mete Type ired
r
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve, default is
10, maximum is 500.
auto Bool No Whether to return the total number of records
Coun ean
t
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total data volume
data Config[] Config information array
Config definition
Parameter Data Description
Type
id String Group Configuration ID
name String Group configuration name, length 64
deviceGroupId String Group ID
deviceType Integer Device Type 1: Phone Device
modelId String Model ID
Description String Description, length 256
HTTP status code
Code Description
89 / 164
Open API for Yealink Management Cloud Service V4X
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/listGroupConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "8faa39e34929425f85b4cd9925a2bbcb",
"name": "group config2",
"deviceGroupId":"1185861ed00840ea99a7b07aa0f28f88",
"deviceType":1,
"modelId":"db249ca8f83f425baeda09214288d0a9",
"Description":"test2"
}]
}
4.3.6 Push Group Configuration
Request Method
POST
90 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/dm/groupConfigs/{configId}/push
Path parameter
parameter Data Type Required Description
configId String Yes The configuration ID.
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/groupConfigs/8faa39e34929425f85b4cd9925a2bbcb/push HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
4.4 Conference Room Equipment Configuration Management
4.4.1 Save Device Configuration
Request Method
PUT
Request URL
/v2/dm/rooms/deviceConfigs
Body parameter
Parameter Data Type Required Description
deviceId String Yes Device ID
content String Yes cfg file content
91 / 164
Open API for Yealink Management Cloud Service V4X
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The device configuration ID.
Example Request
httpPUT /v2/dm/rooms/deviceConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceId": "8d07a56207074d26b61026099625b9e2",
"content": "lang.wui=English\nlang.gui=English"
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"id": "d916a46b4557464c87c278cb37477bef"
}
4.4.2 Device Configuration Details
Request Method
GET
Request URL
/v2/dm/rooms/deviceConfigs/{configId}
92 / 164
Open API for Yealink Management Cloud Service V4X
Path parameter
Paramete Data Type Require Description
r d
configId String Yes The device configuration ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The device configuration ID.
content String Configuration ID
Example Request
httpGET /v2/dm/rooms/deviceConfigs/e33b8f25247e45de84dd4c74503b241a HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"content":"#!version:1.0.0.1\naccount.1.codec.g722.enable=1"
}
4.4.3 Push Device Configuration
Request Method
POST
93 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/dm/rooms/deviceConfigs/{configId}/push
Path parameter
parameter Data Type Required Description
configId String Yes The configuration ID.
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/rooms/deviceConfigs/e33b8f25247e45de84dd4c74503b241a/push
HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
4.4 Conference Room Equipment Configuration Management
4.4.1 Save Device Configuration
Request Method
PUT
Request URL
/v2/dm/rooms/deviceConfigs
Body parameter
Parameter Data Type Required Description
deviceId String Yes Device ID
94 / 164
Open API for Yealink Management Cloud Service V4X
content String Yes cfg file content
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The device configuration ID.
Example Request
httpPUT /v2/dm/rooms/deviceConfigs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceId": "8d07a56207074d26b61026099625b9e2",
"content": "lang.wui=English\nlang.gui=English"
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"id": "d916a46b4557464c87c278cb37477bef"
}
4.4.2 Device Configuration Details
Request Method
GET
95 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/dm/rooms/deviceConfigs/{configId}
Path parameter
Paramete Data Type Require Description
r d
configId String Yes The device configuration ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
id String The device configuration ID.
content String Configuration ID
Example Request
httpGET /v2/dm/rooms/deviceConfigs/e33b8f25247e45de84dd4c74503b241a HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"content":"#!version:1.0.0.1\naccount.1.codec.g722.enable=1"
}
4.4.3 Push Device Configuration
96 / 164
Open API for Yealink Management Cloud Service V4X
Request Method
POST
Request URL
/v2/dm/rooms/deviceConfigs/{configId}/push
Path parameter
parameter Data Type Required Description
configId String Yes The configuration ID.
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPOST /v2/dm/rooms/deviceConfigs/e33b8f25247e45de84dd4c74503b241a/push
HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
5.RPS management
5.1 RPS Device Management
5.1.1 Add Device
Request Method
POST
Request URL
/v2/rps/devices
97 / 164
Open API for Yealink Management Cloud Service V4X
Body parameter
Parameter Data Requ Description
Type ired
mac String Yes Device MAC, minimum length 12, maximum
length 17
sn String Yes SN code, maximum length 128
serverId String No Server ID
uniqueServe String No Server address, maximum length 256
rUrl
authName String No Authentication username, maximum
length 128
password String No Authentication password, maximum length
128
remark String No Note, maximum length 256
HTTP status code
Code Description
201 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
id String The device ID.
mac String The device MAC address.
sn String The group ID.
serverId String The server ID.
uniqueServerUrl String The server address.
authName String The username for authentication.
remark String The remark.
98 / 164
Open API for Yealink Management Cloud Service V4X
Example Request
httpPOST /v2/rps/devices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"serverId":"ba7c7b13ed114a5fa6f12063ea9dff41",
"remark":"SeakeerDevice",
"authName":"Seakeer",
"password":"654321"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"serverId":"ba7c7b13ed114a5fa6f12063ea9dff41",
"remark":"SeakeerDevice",
"authName":"Seakeer",
}
5.1.2 Batch Add Devices
Note: A maximum of 100 items at a time.
Request Method
POST
Request URL
/v2/rps/addDevices
Body parameter
99 / 164
Open API for Yealink Management Cloud Service V4X
Parameter Data Requ Description
Type ired
mac String Yes Device MAC, minimum length 12, maximum
length 17
sn String Yes SN code, maximum length 128
serverId String No Server ID
uniqueServe String No Server address, maximum length 256
rUrl
authName String No Authentication username, maximum
length 128
password String No Authentication password, maximum length
128
remark String No Note, maximum length 256
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
total Integer The total number of batch additions.
successCoun Integer The total number of successful additions.
t
failureCount Integer The total number of failures.
errors AddError[] Error message.
AddError object
parameter Data Type Description
mac String The device MAC address.
100 / 164

View File

@@ -0,0 +1,880 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 101-120
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 101-120 of 164
**Chunk:** 6
---
Open API for Yealink Management Cloud Service V4X
sn String The device SN address.
errorInfo String Error message.
Example Request
httpPOST /v2/rps/addDevices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
[{
"mac":"3a1565bbb1a9",
"sn":"1106312113402006",
"serverId":"ba7c7b13ed114a5fa6f12063ea9dff41",
"remark":"SeakeerDevice",
"authName":"Seakeer",
"password":"654321"
}]
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 1,
"successCount":0,
"failureCount":1,
"errors":[{
"mac":"3a1565bbb1a9",
"sn":"1106312113402006",
"errorInfo":"Invalid MAC"
}]
}
5.1.3 Add device without SN
Note: A maximum of 100 items at a time.
Request Method
POST
101 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/rps/addDevicesByMac
Body parameter
Parameter Data Requ Description
Type ired
mac String Yes Device MAC, minimum length 12, maximum
length 17
serverId String No Server ID
uniqueServe String No Server address, maximum length 256
rUrl
authName String No Authentication username, maximum
length 128
password String No Authentication password, maximum length
128
remark String No Note, maximum length 256
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
total Integer The total number of batch additions.
successCoun Integer The total number of successful additions.
t
failureCount Integer The total number of failures.
errors AddError[] Error message.
AddError object
102 / 164
Open API for Yealink Management Cloud Service V4X
parameter Data Type Description
mac String The device MAC address.
sn String The device SN address.
errorInfo String Error message.
Example Request
httpPOST /v2/rps/addDevicesByMac HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
[{
"mac":"3a1565bbb1a9",
"serverId":"ba7c7b13ed114a5fa6f12063ea9dff41",
"remark":"SeakeerDevice",
"authName":"Seakeer",
"password":"654321"
}]
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 1,
"successCount":0,
"failureCount":1,
"errors":[{
"mac":"3a1565bbb1a9",
"sn":"1106312113402006",
"errorInfo":"Invalid MAC"
}]
}
5.1.4 Edit Device
Request Method
PATCH
103 / 164
Open API for Yealink Management Cloud Service V4X
Request URL
/v2/rps/devices/{deviceId}
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
Parameter Data Requi Description
Type red
serverId String No Server ID
uniqueServe String No Address of server, maximum length 256
rUrl
authName String No Username for authentication, maximum
length 128
password String No Password for authentication, maximum
length 128
remark String No Note, maximum length 256
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/rps/devices/8d07a56207074d26b61026099625b9e2 HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"serverId":"ba7c7b13ed114a5fa6f12063ea9dff41",
"remark":"SeakeerDevice",
104 / 164
Open API for Yealink Management Cloud Service V4X
"authName":"Seakeer",
"password":"654321"
}
Example Response
httpHTTP/1.1 204
5.1.5 Device Paging List
Request Method
POST
Request URL
/v2/rps/listDevices
Body parameter
Paramet Dat Re Description
er a qu
Ty ire
pe d
skip Lon No Number of skipped records, default is 0
g
limit Lon No Maximum number of records to retrieve, default is
g 10, maximum is 500.
autoCou Boo No Whether to respond with the total count, it is
nt lea recommended to set it to true when querying the
n first page, and set it to false for queries on other
pages.
filter Filt No search parameters
er
Filter object definition
Para Data Req Description
met Typ uir
er e ed
105 / 164
Open API for Yealink Management Cloud Service V4X
mac Strin No Device MAC, maximum length 17, supports with : or -,
g such as 00:15:65:bb:b1:a1
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data Device[] Device information array
Device object definition
parameter Data Description
Type
id String The device ID.
mac String The device MAC address.
sn String The device serial number.
serverId String The server ID.
serverName String The server name.
serverUrl String The server address.
uniqueServe String The address of the unique server.
rUrl
ipAddress String The IP address.
remark String The remark.
dateRegiste Long The binding time.
red
106 / 164
Open API for Yealink Management Cloud Service V4X
lastConnect Long The last time when the device is connected to the
ed platform.
Example Request
httpPOST /v2/rps/listDevices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 20,
"autoCount": true,
"filter":{
"mac":"001565bbb1a9"
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"serverId": "b25ac1016caf416a90d5ca1ee438153a",
"serverName": "SeakeerServerTest",
"serverUrl": "https://dm30-devtest.yealinkclient.com/dm.cfg",
"ipAddress": null,
"dateRegistered": 1542680124026,
"lastConnected": null,
"remark": "edit"
}]
}
107 / 164
Open API for Yealink Management Cloud Service V4X
5.1.6 Device Details
Request Method
GET
Request URL
/v2/rps/devices/{deviceId}
PATH parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
Response parameters
parameter Data Description
Type
id String The device ID.
mac String The device MAC address.
sn String The device serial number.
serverId String The server ID.
serverName String The server name.
serverUrl String The server address.
uniqueServe String The address of the unique server.
rUrl
ipAddress String The IP address.
remark String The remark.
dateRegiste Long The binding time.
red
lastConnect Long The last time when the device is connected to the
ed platform.
authName String The username for authentication.
Example Request
httpGET /v2/rps/devices/8d07a56207074d26b61026099625b9e2 HTTP/1.1
Host: api.ymcs.yealink.com
108 / 164
Open API for Yealink Management Cloud Service V4X
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "8d07a56207074d26b61026099625b9e2",
"mac":"001565bbb1a9",
"sn":"1106312113402006",
"serverId": "b25ac1016caf416a90d5ca1ee438153a",
"serverName": "SeakeerServerTest",
"serverUrl": "https://dm30-devtest.yealinkclient.com/dm.cfg",
"ipAddress": null,
"dateRegistered": 1542680124026,
"lastConnected": null,
"remark": "edit",
"authName": "edit",
}
5.1.7 Delete Device
Request Method
POST
Request URL
/v2/rps/delDevices
Body parameter
Paramet Data Require Description
er Type d
deviceIds String Yes Device ID list, maximum length 200
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
109 / 164
Open API for Yealink Management Cloud Service V4X
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/rps/delDevices HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIdType":"mac",
"deviceIds":["001565bbb1a9","001567"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"001567",
"msg":"Invalid MAC"
}]
}
110 / 164
Open API for Yealink Management Cloud Service V4X
5.2 RPS server management
5.2.1 Add Server
Request Method
POST
Request URL
/v2/rps/servers
Body parameters:
Request Parame Re Description
parameters ter qui
Type re
d
serverName String Yes The server name, with no more than 20
characters.
url String Yes The address of server, with no more
than 512 characters.
authName String No The username for authentication, with
no more than 32 characters.
password String No The password for authentication, with
no more than 32 characters.
certificateUrl String No The certificate URL.
serverCertificateUr String No The URL of the server certificate.
l
serverCertificateE Boolea No Set whether to enable the custom
nable n certificate or not.
serverCertificate Boolea No Enable this when the certificate type is
EnableWithSHA25 n non-SHA256.
6
HTTP status code
Code Description
201 Operation successful, see response parameters.
111 / 164
Open API for Yealink Management Cloud Service V4X
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
id String The server ID.
serverName String The server name.
url String The server address.
authName String The username for authentication.
Example Request
httpPOST /v2/rps/servers HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"serverName":"TestServer",
"url":"https://https://www.yealink.com",
"serverCertificateEnable": true,
"serverCertificateEnableWithSHA256": true,
"authName":"Seakeer",
"password":"123456",
"certificateUrl":"https://www.yealink.com/certificate"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "b38dea23a4e6458188799833b72d950f",
"serverName": "TestServer",
"url": "https://www.yealink.com",
"authName": "Seakeer"
}
112 / 164
Open API for Yealink Management Cloud Service V4X
5.2.2 Edit Server
Request Method
PATCH
Request URL
/v2/rps/servers/{serverId}
PATH parameter
parameter Data Type Required Description
serverId String Yes The server ID.
Body parameters:
Request Parame Re Description
parameters ter qui
Type re
d
serverName String Yes The server name, with no more than 20
characters.
url String Yes The address of server, with no more
than 512 characters.
authName String No The username for authentication, with
no more than 32 characters.
password String No The password for authentication, with
no more than 32 characters.
certificateUrl String No The certificate URL.
serverCertificateUr String No The URL of the server certificate.
l
serverCertificateE Boolea No Set whether to enable the custom
nable n certificate or not.
serverCertificate Boolea No Enable this when the certificate type is
EnableWithSHA25 n non-SHA256.
6
HTTP status code
113 / 164
Open API for Yealink Management Cloud Service V4X
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/rps/servers/b38dea23a4e6458188799833b72d950f HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"serverName":"YealinkServer",
"url":"http://www.yealink.com",
"authName":"Yealink",
"password":"Yealink",
"certificateUrl":"http://cer/cer.cer",
"serverCertificateEnable": true,
"serverCertificateEnableWithSHA256": true
}
Example Response
httpHTTP/1.1 204
5.2.3 Delete Server
Request Method
POST
Request URL
/v2/rps/delServers
Body parameter
Paramet Data Require Description
er Type d
serverIds String[] Yes Server ID list, maximum length 200
114 / 164
Open API for Yealink Management Cloud Service V4X
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/rps/delServers HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"serverIds":["b38dea23a4e6458188799833b72d950f","1234"]
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"1234",
"msg":"The resource does not exist or has been deleted"
}]
}
115 / 164
Open API for Yealink Management Cloud Service V4X
5.2.4 Server Details
Request Method
GET
Request URL
/v2/rps/servers/{serverId}
Path parameter
parameter Data Type Required Description
serverId String Yes The server ID.
Response parameters
parameter Data Description
Type
id Strin The server ID.
g
serverName Strin The server name.
g
url Strin The server address.
g
authName Strin The username for authentication.
g
certificateUrl Strin The certificate URL.
g
serverCertificateUrl Strin The URL of the server certificate.
g
serverCertificateEnable Bool Set whether to enable the custom
ean certificate or not.
serverCertificateEnable Bool Enable this when the certificate type is
WithSHA256 ean non-SHA256.
Example Request
httpGET /v2/rps/servers/b38dea23a4e6458188799833b72d950f HTTP/1.1
Host: api.ymcs.yealink.com
116 / 164
Open API for Yealink Management Cloud Service V4X
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "b38dea23a4e6458188799833b72d950f",
"serverName": "TestServer",
"url": "https://www.yealink.com",
"authName": "Seakeer",
"certificateUrl":"http://cer/cer.cer",
"serverCertificateEnable": true,
"serverCertificateEnableWithSHA256": true
}
5.2.5 Server Pagination List
Request Method
POST
Request URL
/v2/rps/listServers
Body parameter
Para Data Requ Description
mete Type ired
r
searc Strin No Search keywords, support server name, URL search
hKey g
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve, default is
10, maximum is 500.
auto Bool No Whether to return the total number of records
Coun ean
t
filter Filter No search parameters
Filter object definition
117 / 164
Open API for Yealink Management Cloud Service V4X
Parameter Data Type Required Description
name String No The server name.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total data volume
data Server[] Server information array
Server object definition
parameter Data Type Description
id String The server ID.
serverName String The server name.
url String The server address.
authName String The username for authentication.
Example Request
httpPOST /v2/rps/listServers HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 20,
"autoCount": true,
"filter":{
118 / 164
Open API for Yealink Management Cloud Service V4X
"name":"test"
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "b38dea23a4e6458188799833b72d950f",
"serverName": "test",
"url": "https://www.yealink.com",
"authName": "Seakeer"
}]
}
6.Call Quality Management
6.1 Pagination List
Request Method
POST
Request URL
/v2/dm/listQoes
Body parameter
Parame Data Req Parameter Description
terNam Type uire
e d
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve, default is
10, maximum is 500.
autoCou Bool No Whether to return the total number of records
nt ean
119 / 164
Open API for Yealink Management Cloud Service V4X
filter Filter No search parameters
Filter object definition
Para Data Req Description
met Typ uir
er e ed
mac Strin No Device MAC, maximum length 17, supports with : or -,
g such as 00:15:65:bb:b1:a9
siteI List fals List of site IDs to query
ds e
star Long fals Start time to query
tTim e
e
endT Long fals End time to query
ime e
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total data volume
data QoeInfo[] QoeInfo information array
QoeInfo object definition
Name Type Description
id String QOE record id
deviceName String Device Name
mac String MAC
modelName String Device Model
firmwareVersion String Firmware version
username String Account \u2014 Username
displayName String Account \u2014 Display Name
120 / 164

View File

@@ -0,0 +1,880 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 121-140
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 121-140 of 164
**Chunk:** 7
---
Open API for Yealink Management Cloud Service V4X
siteName String Affiliated site
quality String Call Quality Good, Poor, Bad
startTime long The call start timestamp.
endTime long The call end timestamp.
callerURI String Caller URL, fromURI
calleeURI String Called URL, toURI
duration long Call duration (ms)
Example Request
httpPOST /v2/dm/listQoes HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip":0,
"limit":10,
"filter":{
"startTime":1700876148000,
"endTime":1703468148329,
"siteIds":["126b25122362470daca91ae8ad038a27"]
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "a26b25122362470daca91ae8ad038a27",
"deviceName": null,
"mac": "805ec091cfc6",
"modelName": "UNKNOW",
"firmwareVersion": "108.86.0.57",
121 / 164
Open API for Yealink Management Cloud Service V4X
"username": "8195",
"displayName": "8195",
"siteName": "global测试-baiyf",
"quality": "Good",
"startTime": 1660542991000,
"endTime": 1660543218000,
"callerURI": "\"8195\" <sip:8195@ume.yealink.com:5061>",
"calleeURI": "<sip:77031@ume.yealink.com>",
"duration": 227000
}]
}
6.2 QOE details
Request Method
GET
Request URL
/v2/dm/qoe/{qoeId}
Path parameter
Parameter Data Type Required Description
qoeId String Yes QOE记录 ID
Response parameters
Name Ty Description
pe
id Str QOE record ID.
ing
sessionId Str The meeting ID.
ing
reportTime lon The reporting time of the call data.
g
deviceName Str The device name.
ing
mac Str MAC
ing
122 / 164
Open API for Yealink Management Cloud Service V4X
modelName Str The device model.
ing
firmwareVersio Str The firmware version.
n ing
ip Str The device IP address.
ing
deviceType Str The device type: audio, video.
ing
username Str Account-username.
ing
displayName Str Account-display name.
ing
serverType Str The account type.
ing
siteName Str The belonging site.
ing
quality Str The call quality, including Good, Poor, and Bad.
ing
startTime lon The call start timestamp.
g
endTime lon The call end timestamp.
g
callId Str The call ID.
ing
callerURI Str Calling URL, fromURI.
ing
calleeURI Str Called URL, toURI.
ing
isCaller boo Check whether the user is the caller of the querying
lea call record or not.
n
callType Str The call type.
ing
123 / 164
Open API for Yealink Management Cloud Service V4X
confURI Str Configure the URL.
ing
time lon The current time.
g
event Str The events.
ing
duration lon The call duration (ms).
g
inJitterAvg int Input the average jitter.
inJitterMax int Input the maximum jitter.
inLossRateAvg dou Input the average loss rate.
ble
inLossRateMax dou Input the maximum loss rate.
ble
inLossTotal int Input the missing value.
inDelayAvg int Input the average delay.
inDelayMax int Input the maximum delay.
inListenMosAvg dou Input the average value for the call answering
ble quality.
inListenMosMin dou Input the minimum value for the call answering
ble quality.
inConversation dou Input the average value for the call quality rating.
alMosAvg ble
inConversation dou Input the minimum value for the call quality rating.
alMosMin ble
inReceivePacke int The total number of input packages.
tTotal
inPayloadNam Str Input the load name.
e ing
outJitterAvg int Output the average jitter.
outJitterMax int Output the maximum jitter.
outLossRateAv dou Output the average loss rate.
g ble
124 / 164
Open API for Yealink Management Cloud Service V4X
outLossRateMa dou Output the maximum loss rate.
x ble
outLossTotal int Output the maximum loss.
outDelayAvg int Output the average delay.
outDelayMax int Output maximum delay.
outListenMosA dou Output the average value for the call answering
vg ble quality.
outListenMosM dou Output the minimum value for the call answering
in ble quality.
outConversati dou Output the average value for the call quality.
onalMosAvg ble
outConversati dou Output the minimum value for the call quality.
onalMosMin ble
outReceivePack int The total number of output packages.
etTotal
outPayloadNa Str Output the load name.
me ing
Example Request
httpGET /v2/dm/qoe/c5a56e74bab546c8bc3cccf2e82aeb0c HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "a26b25122362470daca91ae8ad038a27",
"sessionId": "805ec091cfc6-0_934910374@10.50.144.6",
"reportTime": 1660543237204,
"deviceName": null,
"mac": "805ec091cfc6",
"modelName": "UNKNOW",
125 / 164
Open API for Yealink Management Cloud Service V4X
"firmwareVersion": "108.86.0.57",
"ip": "10.50.144.6",
"deviceType": "UNKNOW",
"username": "8195",
"displayName": "8195",
"serverType": "SIP",
"siteName": "global测试-baiyf",
"quality": "Good",
"startTime": 1660542991000,
"endTime": 1660543218000,
"callId": "0_934910374@10.50.144.6",
"callerURI": "\"8195\" <sip:8195@ume.yealink.com:5061>",
"calleeURI": "<sip:77031@ume.yealink.com>",
"remoteIP": null,
"callType": "p2p",
"confURI": null,
"time": 1660543218000,
"event": "0",
"duration": 227000,
"inJitterAvg": 3,
"inJitterMax": 3,
"inLossRateAvg": 0.0,
"inLossRateMax": 0.0,
"inDelayAvg": 5,
"inDelayMax": 5,
"inListenMosAvg": 4.0,
"inListenMosMin": 4.0,
"inConversationalMosAvg": 4.0,
"inConversationalMosMin": 4.0,
"inReceivePacketTotal": 11299,
"inPayloadName": "G722",
"outJitterAvg": 3,
"outJitterMax": 4,
"outLossRateAvg": 0.0,
"outLossRateMax": 0.0,
"outLossTotal": 0,
"outDelayAvg": 5,
"outDelayMax": 5,
"outListenMosAvg": 4.4,
"outListenMosMin": 4.0,
"outConversationalMosAvg": 4.4,
"outConversationalMosMin": 4.4,
"outReceivePacketTotal": 11333,
126 / 164
Open API for Yealink Management Cloud Service V4X
"outPayloadName": "G722",
}
7.Statistics
7.1 Total number of devices
Request Method
GET
Request URL
/v2/dm/statistics/deviceCount
Query parameter
Para Dat Re Parameter Description
met a qu
erN Ty ir
ame pe ed
devic Int fal Device status, 1: online, 0: offline, -1: not reported, if this
eStat ege se parameter is not passed, get the total count.
us r
devi Int fal Device Type
ceTy ege se
pe r
Response parameters
Name Type Description
total Long Device quantity.
Example Request
httpGET /v2/dm/statistics/deviceCount?deviceStatus=1 HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
127 / 164
Open API for Yealink Management Cloud Service V4X
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"total":100
}
7.2 QOE statistics
Request Method
POST
Request URL
/v2/dm/statistics/qoe
Body parameter
Para Dat Re Parameter Description
met a qu
erN Ty ir
ame pe ed
siteId Str fal List of site IDs to be counted
s ing se
[]
start Lo fal The start time to be counted; this value must be used
Time ng se together with endTime, otherwise it will not take effect.
endT Lo fal The end time to be counted must be used together with
ime ng se startTime; otherwise, it will not take effect.
Response parameters
Name Type Description
total long The total number of calls.
badPercentage doubl The percentage of poor quality.
e
badTotal long Poor call quality.
goodPercentage doubl The percentage of good quality.
e
goodTotal long Good call quality.
128 / 164
Open API for Yealink Management Cloud Service V4X
meetingPercentage doubl The percentage of total meetings.
e
meetingTotal long The total number of meetings.
p2pPercentage doubl The percentage of p2p calls.
e
p2pTotal long The total number of p2p calls.
poorPercentage doubl The percentage of average quality.
e
poorTotal long General call quality.
voiceMailPercentage doubl The total number of voice mails.
e
voiceMailTotal long The total number of voice mails.
Example Request
httpPOST /v2/dm/statistics/qoe HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"startTime":1700876148000,
"endTime":1703468148329,
"siteIds":["126b25122362470daca91ae8ad038a27"]
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"total":25,
"goodTotal": 24,
"poorTotal": 1,
"badTotal": 0,
"goodPercentage": 0.96,
"poorPercentage": 0.04,
"badPercentage": 0.0,
129 / 164
Open API for Yealink Management Cloud Service V4X
"p2pTotal": 25,
"meetingTotal": 0,
"voiceMailTotal": 0,
"p2pPercentage": 1.0,
"meetingPercentage": 0.0,
"voiceMailPercentage": 0.0
}
8.Model Management
8.1 Model List
Request Method
GET
Request URL
/v2/dm/models
Query parameter
Paramet Data Requi Description
er Type red
deviceTy Integer Yes Device Type 1: Phone Device 3: Room
pe Device
Response parameters
Parameter Data Type Description
data Model[] The model information array.
Model object definition
Parameter Data Type Description
id String The model ID.
name String The model name.
Example Request
httpGET /v2/dm/models?deviceType=1 HTTP/1.1
Host: api.ymcs.yealink.com
130 / 164
Open API for Yealink Management Cloud Service V4X
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
[{
"id": "61e659e4d78d42ebada88ef1eb751b64",
"name":"SIP-T54S",
},{
"id":"02c47b640c3046dc86853c9ccfd37dd0",
"name":"SIP-T31P"
}]
9.Site Management
9.1 Add Site
Request Method
POST
Request URL
/v2/dm/sites
Body parameter
Parameter Data Require Description
Type d
name String Yes Site name, maximum length 128
parentId String Yes Parent Site ID
Description String No Description, maximum length 1024
HTTP status code
Code Description
201 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
131 / 164
Open API for Yealink Management Cloud Service V4X
Response parameters
parameter Data Type Description
id String Site ID
name String Site Name
parentId String Parent Site ID
siteNumber String Site number
Example Request
httpPOST /v2/dm/sites HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name":"site1",
"parentId":"d3a6ff43f2154b868ba1157eb3677c1e",
"Description":"site1"
}
Example Response
httpHTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
{
"id": "0006d62003684754b11c09c5d94ea687",
"name":"site1",
"parentId":"d3a6ff43f2154b868ba1157eb3677c1e",
"siteNumber":"ofpb5gvg"
}
9.2 Edit Site
Request Method
PATCH
Request URL
/v2/dm/sites/{siteId}
132 / 164
Open API for Yealink Management Cloud Service V4X
Path parameter
parameter Data Type Required Description
siteId String Yes Site ID
Body parameter
Parameter Data Require Description
Type d
name String No Site Name
parentId String No Parent Site ID
Description String No Descriptionmaximum length 1024.
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPATCH /v2/dm/sites/0006d62003684754b11c09c5d94ea687 HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"name":"site11",
"Description":"site11",
"parentId":"d3a6ff43f2154b868ba1157eb3677c1e"
}
Example Response
httpHTTP/1.1 204
9.3 Delete Site
133 / 164
Open API for Yealink Management Cloud Service V4X
Request Method
DELETE
Request URL
/v2/dm/sites/{siteId}
PATH parameter
parameter Data Type Required Description
siteId String Yes Site ID
Example Request
httpDELETE /v2/dm/sites/e33b8f25247e45de84dd4c74503b241a HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 204
9.4 Site List
Request Method
POST
Request URL
/v2/dm/listSites
Body parameter
Parame Data Req Parameter Description
terNam Type uire
e d
skip Long No Number of skipped records, default is 0
limit Long No Maximum number of records to retrieve, default is
10, maximum is 500.
autoCou Bool No Whether to return the total number of records
nt ean
filter Filter No search parameters
134 / 164
Open API for Yealink Management Cloud Service V4X
Filter object definition
Parameter Data Type Required Description
name String No Site Name
Response parameters
Parameter Data Type Description
data Site[] Site information array
Site object definition
Paramet Data Description
er Type
id String site id
parentId String Parent Site ID
name String Site Name
level Integer Site level, 0 indicates the root site.
sequence Integer “Serial number of the site in the hierarchy”
Example Request
httpPOST /v2/dm/listSites HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip":0,
"limit":10,
"filter":{
"name":"test"
}
}
Example Response
135 / 164
Open API for Yealink Management Cloud Service V4X
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id":"1e876bc0fca24b728eeb3e52bbfebf5e",
"parentId":"23b63cac1f6d4134b5459a4face901c3",
"name":"test",
"level":2,
"sequence":1,
}]
}
9.5 site details
Request Method
GET
Request URL
/v2/dm/sites/{siteId}
Path parameter
parameter Data Type Required Description
siteId String Yes Site ID
Response parameters
parameter Data Type Description
id String Site ID
parentId String Parent Site ID
name String Site Name
siteNumber String Site number
Description String Description information
Example Request
136 / 164
Open API for Yealink Management Cloud Service V4X
httpGET /v2/dm/sites/288ff0d0bd4948b0a95785ba7bc72a8e HTTP/1.1
Host: api.ymcs.yealink.com
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "288ff0d0bd4948b0a95785ba7bc72a8e",
"name":"site1",
"parentId":"d3a6ff43f2154b868ba1157eb3677c1e",
"siteNumber":"ofpb5gvg",
"Description":"site1"
}
10.Resource Management
10.1 Firmware Management
10.1.1 Firmware List
Request Method
POST
Request URL
/v2/dm/listFirmwares
Body parameter
Pa Da Re Description
ra ta qu
me Ty ir
137 / 164
Open API for Yealink Management Cloud Service V4X
te p e
r e d
sk Lo N Number of skipped records, default is 0
ip ng o
li Lo N Maximum number of records to retrieve, default is 10,
mi ng o maximum is 500.
t
au Bo N Whether to respond with the total count, it is recommended
to ol o to set it to true when querying the first page, and pass false
Co ea for subsequent page turns.
un n
t
fil Fil N search parameters
te te o
r r
Filter object definition
Paramete Data Requi Description
r Type red
modelId String No Firmware Model ID
firmwareT Integer No Firmware Type 0: Master Device 1:
ype Accessory
deviceTyp Integer No Device Type 1: Phone Device 3: Room
e Device
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
138 / 164
Open API for Yealink Management Cloud Service V4X
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data Firmware[] Firmware information array
Firmware object definition
Parameter Data Description
Type
id String firmware id
name String Firmware Name
deviceType Integer Device Type 1: Phone Device 3: Room Device
firmwareTyp Integer Firmware Type 0: Master Device 1: Accessory
e
version String Firmware version number
Example Request
httpPOST /v2/dm/listFirmwares HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true,
"filter":{
"firmwareType":0,
"deviceType":1
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
139 / 164
Open API for Yealink Management Cloud Service V4X
"limit": 10,
"total": 1,
"data":[{
"id": "01d7ae1897f7418dad2d26609329be38",
"name":"t54s firmware",
"deviceType":1,
"firmwareType":0,
"version":"108.85.3.19",
}]
}
10.1.2 Firmware Details
Request Method
GET
Request URL
/v2/dm/firmwares/{firmwareId}
Path parameter
parameter Data Type Required Description
firmwareId String Yes firmware ID
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
id String firmware id
name String Firmware Name
filename String Firmware file name
deviceType Integer Device Type 1: Phone Device 3: Room Device
140 / 164

View File

@@ -0,0 +1,892 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 141-160
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 141-160 of 164
**Chunk:** 8
---
Open API for Yealink Management Cloud Service V4X
firmwareTyp Integer Firmware Type 0: Master Device 1: Accessory
e
downloadUrl String Download address
version String Firmware version number
supportMode String[] Supported model list
ls
siteId String Site ID
Description String Firmware Description
Example Request
httpGET /v2/dm/firmwares/01d7ae1897f7418dad2d26609329be38 HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"id": "01d7ae1897f7418dad2d26609329be38",
"name":"t54s firmware",
"filename":"t54s.rom",
"deviceType":1,
"firmwareType":0,
"downloadUrl":"https://resources.yiot.yealink.com/yiot-manager/api/v1/terminal/
resource/download/69847884d3be4327b86bffd0513c4572/1/
T46S(T48S,T42S,T41S)-66.86.0.15.rom",
"version":"108.85.3.19",
"supportModels":["SIP-T54S"],
"siteId":"ee06cddee78948a298fc12565a35cdbe"
}
10.1.3 Official Firmware List
Request Method
POST
Request URL
141 / 164
Open API for Yealink Management Cloud Service V4X
/v2/dm/listOfficalFirmwares
Body parameter
Parame Dat Re Description
ter a qu
Ty ire
pe d
skip Lo No Number of skipped records, default is 0
ng
limit Lo No Maximum number of records to retrieve, default is 10,
ng maximum is 500.
autoCou Boo No Whether to respond with the total count, it is
nt lea recommended to set it to true when querying the first
n page, and pass false for subsequent page turns.
filter Filt Yes search parameters
er
Filter object definition
Parameter Data Type Required Description
modelId String Yes Firmware model ID
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
142 / 164
Open API for Yealink Management Cloud Service V4X
data Firmware[] Firmware information array
Firmware object definition
Parameter Data Type Description
id String Firmware ID
name String Firmware name
version String Firmware version number
Example Request
httpPOST /v2/dm/listOfficalFirmwares HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true,
"filter":{
"modelId":"23a5da55f3534f84abc7956a9f183330"
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "01d7ae1897f7418dad2d26609329be38",
"name":"t54s firmware",
"version":"108.85.3.19"
}]
}
10.1.4 Push Firmware
143 / 164
Open API for Yealink Management Cloud Service V4X
Request Method
POST
Request URL
/v2/dm/firmwares/{firmwareId}/push
PATH parameter
parameter Data Type Required Description
firmwareId String Yes Firmware ID
Body parameter
Paramet Data Requi Description
er Type red
deviceIds String[] Yes Device ID list, maximum length 200
deviceTy Integer Yes Device Type 1: Phone Device 3: Room
pe Device
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
144 / 164
Open API for Yealink Management Cloud Service V4X
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
httpPOST /v2/dm/firmwares/09972370b360461d868e3e02f9cdec77/push HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIds":
["0006572538f74e8683716cf961caa95b","00099642675e4d4bb5e91fd9ae5ce585"],
"deviceType":1
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"00099642675e4d4bb5e91fd9ae5ce585",
"msg":"The resource does not exist or has been deleted"
}]
}
10.1.5 Push Official Firmware
Request Method
POST
Request URL
/v2/dm/officalFirmwares/{officalFirmwareId}/push
PATH parameter
145 / 164
Open API for Yealink Management Cloud Service V4X
parameter Data Type Require Description
d
officalFirmwareId String Yes Official firmware ID
Body parameter
Paramet Data Requi Description
er Type red
deviceIds String[] Yes Device ID list, maximum length 200
deviceTy Integer Yes Device Type 1: Phone Device 3: Room
pe Device
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Description
Type
total Integer Delete total count
successCount Integer Total number of successful deletions.
failureCount Integer The total number of failures.
errors OpError[] Error message.
OpError object
Parameter Data Type Description
field String Error field
msg String Error message.
Example Request
146 / 164
Open API for Yealink Management Cloud Service V4X
httpPOST /v2/dm/officalFirmwares/09972370b360461d868e3e02f9cdec77/push
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"deviceIds":
["0006572538f74e8683716cf961caa95b","00099642675e4d4bb5e91fd9ae5ce585"],
"deviceType":1
}
Example Response
httpHTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"total": 2,
"successCount":1,
"failureCount":1,
"errors":[{
"field":"00099642675e4d4bb5e91fd9ae5ce585",
"msg":"The resource does not exist or has been deleted"
}]
}
11.Device Diagnosis
User Manual: After successfully calling the diagnostic interface, obtain the
diagnosisId from the response, and periodically poll (recommended every 10
seconds) to check the diagnostic status. If the status response is successful
(status=success), you can obtain the download URL for the diagnostic file from
the response and retrieve the file using the URL.
11.1 Get the list of network port types
Request Method
GET
Request URL
/v2/dm/devices/{deviceId}/networkInterfaces
147 / 164
Open API for Yealink Management Cloud Service V4X
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
data String[] Type list
Example Request
httpGET /v2/dm/devices/8d07a56207074d26b61026099625b9e2/networkInterfaces
HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
["wan","wlan0","ext0"]
11.2 Start packet capture
Request Method
PUT
Request URL
/v2/dm/devices/{deviceId}/startPacketCapture
148 / 164
Open API for Yealink Management Cloud Service V4X
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
Pa Da Re Description
ra ta qu
me Ty ir
te p e
r e d
ne St Ye Network port, the specific value is obtained from the network
tw ri s interface type list, generally includes wan (Wide Area Network
or ng port), ext0 (external telephone line port), wlan0 (Wireless
kI Local Area Network port), default is wan.
nt
er
fa
ce
ty In N Packet capture type, 0 Custom, 1 SIP or H245 or H225, 2
pe te o RTP, 3 Not RTP
ge
r
fil St N Capture filtering information, this value is only needed when
te ri o the capture type = 0.
r ng
du In Ye Capture maximum duration, unit seconds, range: 180~3600
ra te s
ti ge
on r
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
149 / 164
Open API for Yealink Management Cloud Service V4X
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
diagnosisId String Diagnosis Session ID
Example Request
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/startPacketCapture
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"networkInterface":"wan",
"type":0
"duration":180
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687",
}
11.3 End Capture
Request Method
PUT
Request URL
/v2/dm/devices/{deviceId}/stopPacketCapture
Path parameter
parameter Data Type Required Description
150 / 164
Open API for Yealink Management Cloud Service V4X
deviceId String Yes The device ID.
Body parameter
parameter Data Type Required Description
diagnosisId String Yes Diagnosis ID
HTTP status code
Code Description
204 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/stopPacketCapture
HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687"
}
Example Response
httpHTTP/1.1 204
11.4 screenshots
Request Method
PUT
Request URL
/v2/dm/devices/{deviceId}/captureScreen
Path parameter
151 / 164
Open API for Yealink Management Cloud Service V4X
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
diagnosisId String Diagnosis ID
Example Request
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/captureScreen
HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687",
}
11.5 Export system log
Request Method
PUT
Request URL
/v2/dm/devices/{deviceId}/exportSyslog
152 / 164
Open API for Yealink Management Cloud Service V4X
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
diagnosisId String Diagnosis ID
Example Request
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/exportSyslog HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687",
}
11.6 Export Configuration File
Request Method
PUT
Request URL
153 / 164
Open API for Yealink Management Cloud Service V4X
/v2/dm/devices/{deviceId}/exportConfig
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Example Request
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/exportConfig HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687",
}
11.7 Detect Network \u2014 ping
Request Method
PUT
Request URL
/v2/dm/devices/{deviceId}/ping
Path parameter
parameter Data Type Required Description
154 / 164
Open API for Yealink Management Cloud Service V4X
deviceId String Yes The device ID.
Body parameter
Paramet Data Requir Description
er Type ed
host String Yes IP/domain name to ping
times Integer Yes Number of times, minimum 1, maximum
30
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
diagnosisId String Diagnosis ID
Example Request
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/ping HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"host":"www.google.com",
"times":1
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
155 / 164
Open API for Yealink Management Cloud Service V4X
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687",
}
11.8 Network Detection \u2014 traceroute
Request Method
PUT
Request URL
/v2/dm/devices/{deviceId}/traceroute
Path parameter
parameter Data Type Required Description
deviceId String Yes The device ID.
Body parameter
Paramet Data Requir Description
er Type ed
host String Yes IP/domain name to traceroute
times Integer Yes Number of times, minimum 1, maximum
30
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
parameter Data Type Description
diagnosisId String Diagnosis ID
Example Request
156 / 164
Open API for Yealink Management Cloud Service V4X
httpPUT /v2/dm/devices/8d07a56207074d26b61026099625b9e2/traceroute HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"host":"www.google.com",
"times":1
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"diagnosisId": "0006d62003684754b11c09c5d94ea687",
}
11.9 Query Diagnostic Status
Request Method
GET
Request URL
/v2/dm/diagnosis/{diagnosisId}/status
Path parameter
parameter Data Type Required Description
diagnosisId String Yes Diagnosis ID
HTTP status code
Code Description
200 Operation successful, see response parameters.
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
157 / 164
Open API for Yealink Management Cloud Service V4X
Para Data Description
mete Type
r
devic Strin Device ID
eId g
statu Strin Diagnosis Status: inprogress: In Progress success: Success
s g failure: Failure
url Strin File download address, available when status = success.
g
Example Request
httpGET /v2/dm/diagnosis/0006d62003684754b11c09c5d94ea687/status HTTP/1.1
Host: api.ymcs.yealink.com
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"deviceId": "0006d62003684754b11c09c5d94ea687",
"status":"success",
"url":https://yealinkussdev.blob.core.windows.net/single-12/allinone-10.122.131.12-uss%2F40ae7a
sv=2021-06-08&spr=https%2Chttp&se=2024-01-10T02%3A06%3A01Z&sr=b&sp=r&sig=JHco76T2o4i
}
12.Alarm Management
12.1 Alarm List
Request Method
POST
Request URL
/v2/dm/listAlarms
Body parameter
Pa Da Re Description
158 / 164
Open API for Yealink Management Cloud Service V4X
ra ta qu
me Ty ir
te p e
r e d
sk Lo N Number of skipped records, default is 0
ip ng o
li Lo N Maximum number of records to retrieve, default is 10,
mi ng o maximum is 500.
t
au Bo N Whether to respond with the total count, it is recommended
to ol o to set it to true when querying the first page, and pass false
Co ea for subsequent page turns.
un n
t
fil Fil N search parameters
te te o
r r
Filter object definition
Par Dat Re Description
ame a qu
ter Typ ire
e d
mac Stri No Device MAC fuzzy search keyword, maximum length 17,
ng supports with : or -, such as 00:15:65:bb:b1:a9
dev Int No Device Type, 1: Phone Device 3: Room Device, leave blank
iceT ege for all
ype r
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
159 / 164
Open API for Yealink Management Cloud Service V4X
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data Alarm[] Alarm information array
Alarm object definition
Parameter Data Description
Type
id String Alarm ID
event String Alarm Event Name
level Integer Alarm Level 1-Minor 2-Major 3-Critical
mac String MAC
model String model
ip String IP
siteName String site
status Integer Processing status 1-active 2-solve 3-ignore
firstAlarmTim Long First alarm time
e
lastAlarmTime Long Last alarm time
Example Request
httpPOST /v2/dm/listAlarms HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true,
"filter":{
160 / 164

View File

@@ -0,0 +1,155 @@
# Open API for Yealink Management Cloud Service V4X.pdf - Pages 161-164
**Source:** Open API for Yealink Management Cloud Service V4X.pdf
**Pages:** 161-164 of 164
**Chunk:** 9
---
Open API for Yealink Management Cloud Service V4X
"mac":"001565"
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"id": "01d7ae1897f7418dad2d26609329be38",
"event":"Offline",
"level":3,
"mac":"001565bbb1a9",
"model":"SIP-T54S",
"ip":"10.50.198.156",
"siteName":"test",
"status":1,
"firstAlarmTime":1737082468768
}]
}
13.Operation Log Management
13.1 Operation Log List
Request Method
POST
Request URL
/v2/dm/listOpLogs
Body parameter
Pa Da Re Description
ra ta qu
me Ty ir
te p e
161 / 164
Open API for Yealink Management Cloud Service V4X
r e d
sk Lo N Number of skipped records, default is 0
ip ng o
li Lo N Maximum number of records to retrieve, default is 10,
mi ng o maximum is 500.
t
au Bo N Whether to respond with the total count, it is recommended
to ol o to set it to true when querying the first page, and pass false
Co ea for subsequent page turns.
un n
t
fil Fil Ye search parameters
te te s
r r
Filter object definition
parameter Data Type Required Description
startTime Long No Start time
endTime Long No End time
HTTP status code
Code Description
200 Operation successful
400 Client parameter exception
401 Authentication failed
500 Server exception
Response parameters
Parameter Data Type Description
skip Long offset
limit Long The maximum returned number.
total Long Total quantity
data Log[] Log information array
162 / 164
Open API for Yealink Management Cloud Service V4X
Log object definition
parameter Data Type Description
module String Log Module
operationType String Log Type
operationObject String Log Operation Object
operator String Operator
ip String IP
createTime Long Time
result String Result
Example Request
httpPOST /v2/dm/listOpLogs HTTP/1.1
Host: api.ymcs.yealink.com
Content-Type: application/json
{
"skip": 0,
"limit": 10,
"autoCount": true,
"filter":{
"startTime":1737099689158,
"endTime":1738205662000
}
}
Example Response
httpHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
"skip": 0,
"limit": 10,
"total": 1,
"data":[{
"module":"i18n.yiot.backend.module.device.management",
"operationTypetype":"i18n.yiot.backend.operation.device.management.restart",
163 / 164
Open API for Yealink Management Cloud Service V4X
"operationObject":"805ec0985cd2",
"operator":"yiot123@yealink.com",
"ip":"10.122.131.12",
"createTime":"1736999161388",
"result":"success"
}]
}
164 / 164

View File

@@ -0,0 +1,431 @@
# Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf - Pages 1-20
**Source:** Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf
**Pages:** 1-20 of 84
**Chunk:** 1
---
Yealink Management Cloud Service(YMCS) Channel User
Guide
Single Sign-On
You can also log in via SSO , which is using your Microsoft account to easily log in
to the YMCS platform, effectively improving login efficiency, and avoiding some
of the security risks.
1 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Sign In with General Account
Sign in with a reseller account
Procedure
1. For the first login, click the login address in the email or enter the login address (https://ymcs.yealink.com/
reseller) in the browser address bar.
2. Optional: Switch the language in the top-right corner.
3. Enter your account and password and click Sign In.
💡 Note:
If you are a first-time user, the system will prompt you to agree to the use of cookies, accept the
privacy policy and terms of service, and update your password if necessary.
If the login protection is enabled to enhance login security, you need to perform identity verification
after logging in. For more information, refer to the login protection.
Sign in with a support account
Prerequisites
You own a support account, and the support account has channel permissions.
2 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
About how to apply for an account with channel permissions, please refer to:
FAQ.
Procedure
1. For the first login, click the login address in the email or enter the login address (https://ymcs.yealink.com/
reseller) in the browser address bar.
2. Optional: Switch the language in the top-right corner.
3. Click Support.
4. Enter your account and password and agree to the privacy policy.
5. Click Login.
3 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
4 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Multiple Data Centers
YMCS has three data centers, EU, US, and AU regions.
The data between each region is isolated, meaning that data belonging to
enterprises under the EU region will be stored in the EU region data center,
while data belonging to enterprises under the US region will be stored in the US
region data center.
You can view and manage the enterprises and devices under different regions. For more information, see
manage enterprise users.
When creating enterprise users, you can select the belonging region for the enterprise account. And the
enterprise data will be stored in the region you select. For more information, see add enterprise users.
5 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Edit Account Information
Introduction
Click and go to the Account Settings page to view and edit the account
information. You can also switch languages, view the privacy policy and terms of
service, download documents, or sign out.
Edit the Login Password
Introduction
To ensure account security, we recommend that you change the password
regularly.
Procedure
1. Sign in to YMCS.
2. Click > Account Settings.
3.
3. In the Password field, click Edit.
4.
6 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. Enter the current password, the new password, and the confirm password.
💡 NOTE
Password requirements:
The password must be between 8 and 32 characters.
The password must contain at least three of the following: numbers, lowercase and uppercase
letters, and special characters.
The password must only contain numbers, lowercase and uppercase letters, and special characters
(excluding space).
Password cannot contain account.
The password cannot contain continuous or repeated more than 3 times characters.
5. Click OK.
6.
7 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Edit Contacts and Contact Number
Introduction
You can modify the contact person and contact phone number associated with
your account, which is used to facilitate communication with your superiors.
Procedure
1. Sign in to YMCS.
2. Click > Account Settings.
3. In the Contact/Phone Number field, click Edit.
4.
8 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. When you finish editing, click Save.
5.
Trusted Devices
Procedure
1. Log in to YMCS.
2. Click System > Account Information.
3. In the Trusted Devices section, click View.
4. In the pop-up window, you can view all the devices where you have previously checked the "Skip re-
verification within 30 days" option during login verification. You can remove devices, and the next time you
9 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
log in with those devices, you will need to undergo MFA verification again.
10 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Configure Login Verification (Sub
Account)
Prerequisite
The super administrator has enabled login protection for you. Otherwise, you cannot
enable the login protection.
Perform Login Verification
Introduction
After the super administrator has enabled the login protection for you, you need
to choose the login verification for the initial login. The selected verification
method will serve as your subsequent login protection method.
Perform Email Verification
If you select email verification during the initial login, it will serve as your
subsequent login protection method.
Procedure
1. Select Email, and enter the dynamic code in the mail you received.
(Optional) Check the "Skip re-verification within 30 days" option, and you will
11 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
not need to undergo verification when logging in to this account using the
same device within 30 days.
2. Click OK.
3.
Perform Virtual MFA Verification
Procedure
1. Select Virtual MFA Device.
2. Download and open Google Authenticator on your phone and click Get
started.
3. Sign in to a Google account.
💡 TIP
After you sign in to a Google account, you can back up the verification codes to your Google
account for easy retrieval. To avoid situations where verification codes cannot be retrieved
after Google Authenticator is uninstalled or data is cleared, it is recommended that you do not
skip the login steps.
4. Do one of the following:
Select Scan a QR code in Google Authenticator and scan the QR code in the
pop-up window on the YMCS portal.
12 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Select Enter a setup key on Google Authenticator, then enter the YMCS
account and the key displayed in the pop-up window, and click Add.
13 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
5. The dynamic code will be displayed in Google Authenticator.
6. Enter the dynamic code into the corresponding area of YMCS.
(Optional) Check the "Skip re-verification within 30 days" option, and you will
not need to undergo verification when logging in to this account using the
same device within 30 days.
7. Click OK.
8.
14 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Change Login Protection Methods
Procedure
1. Sign in to YMCS.
2. Click > Account Settings.
3. In the Login Protection field, click Edit.
4. Do one of the following to select the desired login protection method:
Select Email, enter the dynamic code in the mail you received, and click OK.
i. Select Virtual MFA Device.
ii. Install and open Google Authenticator on the mobile side, and click Get
started.
15 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
iii.
16 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
iii. Sign in to a Google account.
💡 Tip:
After you sign in to a Google account, you can back up the verification codes to your Google
account for easy retrieval. To avoid situations where verification codes cannot be retrieved
after Google Authenticator is uninstalled or data is cleared, it is recommended that you do not
skip the login steps.
iv. Do one of the following:
Select Scan a QR code in Google Authenticator and scan the QR code in
the pop-up window on the YMCS portal.
17 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Select Enter a setup key on Google Authenticator, then enter the YMCS
account and the key displayed in the pop-up window, and click Add.
18 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
v. The dynamic code is displayed in Google Authenticator.
vi. Enter the dynamic code into YMCS and click OK.
19 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Configure Login Verification (Super
Administrator)
Introduction
For single-factor authentication, the passwords are easily cracked by brute
force. To solve that, YMCS supports multi-factor authentication (MFA), requiring
users to pass two authentications before they can sign in to YMCS. By enabling
login protection, you will need to authenticate through both password and
dynamic verification code to gain authorization and sign in to YMCS.
Email Verification
Enable Email Verification
Procedure
1. Sign in to YMCS.
2. Click > Account Settings.
3. In the Login Protection field, click Edit.
4. Select Email, enter the dynamic code in the mail you received, and click OK.
5.
Sign In with Email Verification
After enabling it, users will receive a verification email after entering their
account and password to log in. Enter the verification code in the corresponding
area to complete the login process.
Procedure
1. After clicking Sign In, enter the verification code received in your email in the corresponding area.
20 / 41

View File

@@ -0,0 +1,484 @@
# Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf - Pages 21-40
**Source:** Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf
**Pages:** 21-40 of 84
**Chunk:** 2
---
Yealink Management Cloud Service(YMCS) Channel User
Guide
(Optional) Check the "Skip re-verification within 30 days" option, and you will not need to undergo verification
when logging in to this account using the same device within 30 days.
2. Click OK.
MFA Verification
Enable MFA Verification
Procedure
1. Sign in to YMCS.
2. Click > Account Settings.
3. In the Login Protection field, click Edit.
4. Select Virtual MFA Device.
5.
21 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
5. Hover over Check how to get the dynamic code and do the following to get the dynamic code.
6. Install and open Google Authenticator on the mobile side, and click Get started.
7. Sign in to a Google account.
💡 TIP
After you sign in to a Google account, you can back up the verification codes to your Google
account for easy retrieval. To avoid situations where verification codes cannot be retrieved
after Google Authenticator is uninstalled or data is cleared, it is recommended that you do not
skip the login steps.
8. Do one of the following:
Select Scan a QR code in Google Authenticator and scan the QR code in the pop-up window on the YMCS
portal.
22 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Select Enter a setup key on Google Authenticator, then enter the YMCS account and the key displayed in
the pop-up window, and click Add.
23 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
9. The dynamic code is displayed in Google Authenticator.
10. Enter the dynamic code into YMCS and click OK.
11.
24 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Sign In with MFA Verification
After enabling it, users will be required to enter the dynamic code obtained from
Google Authenticator after entering their account and password to complete the
login process.
Procedure
1. After clicking Sign In, enter the dynamic code into the corresponding area of YMCS.
(Optional) Check the "Skip re-verification within 30 days" option, and you will not need to undergo verification
when logging in to this account using the same device within 30 days.
2. Click OK.
3.
25 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
26 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Channel Users
Add Channel Accounts
Introduction
You can add sub-channels, and the sub-channels can add their sub-channels.
You can also control whether your sub-channel can add their sub-channels.
Procedure
1. Sign in to YMCS.
2. Click User Management > Channel Management > Add.
3. Configure channel information and click OK.
Channel Name: Enter the channel name.
Set Administrator:
If you choose to set it as an administrator, you need to enter an email address, and the sub-channel can
log in using this email address.
If you do not, an email address is not required, and you can manage the sub-channels on their behalf.
Country/Region: Select the country or region where this channel account is located.
Time Zone: Select a time zone.
Contact: Enter a contact.
Phone Number: Enter a contact phone number.
Add Sub-Channel: If you select Enable in the Add Sub-Channel field, the sub-channel can add and
manage their sub-channels (add channel, edit channel information, etc.).
27 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. Click Confirm in the pop-up window to add the channel successfully.
28 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Channels
View the Channel List
Procedure
1. Sign in to YMCS.
2. Click User Management > Channel Management.
3. View the channel name, status and other information in the channel list.
4.
Sign In to YMCS on Behalf of Channel Accounts
Procedure
1. In the channel list, click Manage on the right side of the channel account.
💡 Note
This feature is unavailable to frozen or unauthorized channels.
Edit Channel Account
Procedure
1. In the channel list, click > Edit.
Reset Password for Channel Account
Introduction
29 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
You can reset the password to deal with password leakage and manage
personnel changes to enhance security.
Procedure
1. In the channel list, click > Reset Password.
Freeze Channel Account
Introduction
You can efficiently deal with situations such as service expiration by freezing
channels. After the channel account is frozen, the channel cannot use the
account to log in to YMCS for Channel.
Procedure
1. In the channel list, click > Freeze.
2. Enter the freezing reason in the pop-up window and click OK.
Unfreeze Channel Account
Introduction
You can unfreeze the frozen channel. After unfreezing, the channel can log in to
YMCS for channel again.
Procedure
1. In the channel list, click > Unfreeze.
30 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Enterprise Users
Add Enterprise Accounts
Introduction
You can add enterprise accounts and edit the enterprise information. When an
exception occurs to the enterprise, you can freeze the enterprise account. From
the channel platform, you can log in to YMCS for enterprise or YMCS for RPS
enterprise to manage devices for enterprises. For more information on how to
manage devices on YMCS for enterprise, see the YMCS for enterprise user guide.
Procedure
1. Sign in to YMCS.
2. Click User Management > Enterprise Management > Enterprise List > Add.
3.
3. Configure the information of the enterprise account.
Data Center: The data center is divided into EU, US, and AU regions. The data between the regions is
isolated, meaning that data belonging to companies under the EU region will be stored in the EU region
data center, while data belonging to companies under the US region will be stored in the US region data
center. Please select the data center according to the actual location of the enterprise.
Enterprise Name: Enter the name of the enterprise.
Permission: Assign permissions to the enterprise account. Device Management (RPS): The enterprise
account can log in to YMCS to use device management and RPS features. RPS: The enterprise account can
log in to YMCS but can only use the RPS feature.
31 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Set Administrator:
If you select Yes, enter an email address, and this enterprise account can use the email to log in to YMCS.
If you select No, an email address is not required, and you can use this channel account to manage
devices for the enterprise account.
Email: Enter the email address of this enterprise account. The account information will be sent by email.
Enterprise Info: Set the following information, including country/region, time zone, contact person,
contact number, etc.
32 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. Click OK.
💡 Note
Please confirm the enterprise information and the data center you select. It cannot be
modified after saving.
33 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
5. Click Confirm in the pop-up window to successfully add the enterprise.
💡 Note
When the enterprise administrator logs in to YMCS for the first time, the page will prompt
whether to allow authorization to the channel, and the enterprise administrator can accept or
reject it.
Manage Enterprises
View the Enterprise List
Procedure
1. Sign in to YMCS.
2. Click User Management > Enterprise Management > Enterprise List.
3. View the enterprise name, the permission, the status and other information in the enterprise list.
4.
Sign In to YMCS on Behalf of Enterprise Accounts
Introduction
You can log in to YMCS for Enterprise with enterprise authorization to manage
devices or perform other operations on their behalf.
Prerequisites
34 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
The enterprise has authorized the management to you.
Procedure
1. In the enterprise list, click Manage on the right side of the enterprise account to go to YMCS for Enterprise.
Edit Enterprise Account Information
Procedure
1. In the enterprise list, click > Edit.
Reset Password for Enterprise Account
Introduction
You can reset the password to deal with password leakage and manage
personnel changes to enhance security.
Procedure
1. In the enterprise list, click > Reset Password.
💡 Note
Password reset is unavailable to non-mailbox enterprises.
Freeze Enterprise Account
Introduction
You can efficiently deal with situations such as service expiration by freezing
businesses. After the enterprise account is frozen, the enterprise cannot use the
account to log in to YMCS for Enterprise or RPS Enterprise.
Procedure
1. In the enterprise list, click > Freeze.
2. Enter the freezing reason in the pop-up window and click OK.
Unfreeze Enterprise Account
Introduction
You can unfreeze the frozen enterprise. After unfreezing, the enterprise can log
in to YMCS for Enterprise.
Procedure
1. In the enterprise list, click > Unfreeze.
35 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Enterprise Authorization
Introduction
The enterprises you create can choose whether to authorize the management to
you when they log in to YMCS for the first time.
If the enterprise initially created by you rejects authorizing the management to
you, they also need to use an authorization code to apply for management by
you.
You can manage enterprises not created by you through an authorized code
application, and you can approve or reject the application.
Channel Authorization Code
Introduction
You can send the authorization code to the enterprise that needs to be managed
and manage it after the enterprise is authorized.
Procedure
1. Sign in to YMCS.
2. Click User Management > Enterprise Management > Enterprise Authorization.
3.
Click to copy the authorization code and send it to the enterprise to be managed.
Click to refresh the authorization code.
Approve/Reject Authorization Application
Introduction
36 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
You can view the list of enterprises that have requested you to manage their
devices on their behalf. You can approve or reject the applications.
Procedure
1. Sign in to YMCS.
2. Click User Management > Enterprise Management > Enterprise Authorization.
3. Do one of the following:
Click Agree/Reject on the right side of the enterprise.
Select multiple enterprises and click Agree/Reject.
37 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Devices
Manage Room Devices
Procedure
1. Sign in to YMCS.
2. Click Device Management > Room Device.
3. Click EU Region/US Region/AU Region in the top-right corner to view the devices belonging to the selected
region.
4.
4. On the right side of the device, click Manage to go to the room device list of the corresponding enterprise.
💡 NOTE
This feature is unavailable to frozen enterprises.
5. Manage the device. For more information, refer to YMCS for enterprise user guide - manage room devices.
Manage USB Devices
Procedure
1. Sign in to YMCS.
2. Click Device Management > USB Device.
3. Click EU Region/US Region/AU Region in the top-right corner to view the devices belonging to the selected
38 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
region.
4.
4. On the right side of the device, click Manage to go to the USB device list of the corresponding enterprise.
💡 NOTE
This feature is unavailable to frozen enterprises.
5. Manage the device. For more information, refer to YMCS for enterprise user guide - manage USB devices.
Manage RPS Devices
Procedure
1. Sign in to YMCS.
2. Click Device Management > RPS Device.
3. Click EU Region/US Region/AU Region in the top-right corner to view the devices belonging to the selected
region.
4.
39 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. On the right side of the device, click Manage to go to the RPS device list of the corresponding enterprise.
💡 NOTE
This feature is unavailable to frozen enterprises.
5. Perform RPS device management. For more information, refer to YMCS for enterprise user guide - manage RPS
devices.
40 / 41

View File

@@ -0,0 +1,472 @@
# Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf - Pages 41-60
**Source:** Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf
**Pages:** 41-60 of 84
**Chunk:** 3
---
Yealink Management Cloud Service(YMCS) Channel User
Guide
View Device List
Introduction
You can view and manage the devices of authorized enterprises, including
phone devices, room devices, USB devices and RPS devices.
Prerequisites
You can only view and manage the devices of authorized enterprises.
Procedure
1. Sign in to YMCS.
2. Click Device Management > Phone Device/Room Device/USB Device/RPS Device.
View the device list.
💡 TIP
You can click EU Region/US Region/AU Region in the top-right corner to view the devices
belonging to the selected region.
41 / 41
Yealink Management Cloud Service(YMCS) Channel User
Guide
Monitor Wall
Introduction
By viewing the monitor wall, you can have a comprehensive and real-time grasp
of various data and conditions of the device of each enterprise without the need
for frequent manual refreshes.
Prerequisites
Please contact and provide the email of your account to the Yealink Support in order to enable this feature.
Only super admin can use this feature, normal users can obtain a link from the super admin and enter the link
to view the monitor wall.
Only support viewing the device status within the enterprises authorized and managed by your channel.
View the Monitor Wall
Procedure
1. Log in to YMCS.
2. Click Monitor Wall.
💡 NOTE
Only the device status within the enterprises authorized and managed by
your channel can be displayed.
1. Hover the mouse over the thumbnail of the monitor wall and click Preview.
1 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
2. View various indicators of the device in real time on the monitor wall.
Numbe Description
r
1 Current time.
2 The total number of enterprises and the number of enterprises
authorized to be managed.
3 Device statistics. Count the number of various types of device.
4 Device status. Count the total number of devices and the number
and proportion of online/offline/pending devices.
5 Model statistics. Count different types of device and their
quantities.
6 Displays the number of new alarms today.
7 Active alarm records. Count the enterprises with the largest
number of active alarms and their detailed information, including:
alarm type, company name, device name, model, and last alarm
time.
8 The number of new alarms in the past 7 days. Statistics of the
number and severity of daily alarms in the past 7 days.
2 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
9 Top 5 Active Alarm Enterprises. Count the enterprises with the
largest number of active alarms.
10 Top 5 Offline Devices Enterprises. Count the enterprises with the
largest number of offline devices.
11 Top 5 Active Alarm Types. Count the 5 largest existing alarm types.
Set the Monitor Wall
Procedure
1. Log in to YMCS.
2. Click Monitor Wall.
3. Click > Settings in the lower right corner of the thumbnail of the monitor wall.
Data Refresh Period: Set the time for automatic refresh of the monitor wall. It will be updated with real-
time data at that frequency.
Access Whitelist: If not enabled, devices under any IP can access the monitor wall through links; if enabled,
only IP addresses in the whitelist can access.
3 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Copy the Link of the Monitor Wall
You can obtain the link of the monitor wall, which can be accessed by users in
the IP whitelist. They can view the specific content of the monitor wall, or
display it as material/content on Yealink Digital Signage or Third Party Digital Signage.
Procedure
1. Log in to YMCS.
2. Click Monitor Wall.
3. Click > Copy Link in the lower right corner of the thumbnail of the monitor wall.
4. Make relevant settings, click Save.
4 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
5 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Room Device Calculator
Introduction
You can use Yealink Room Device Calculator to create room projects and
manage them by group.
Procedure
Create a room project on Room Device Calculator
1. Sign in to YMCS.
2. Click Room Device Calculator > Create.
3. About how to use the tool, please refer to: Yealink Room Device Calculator.
Manage room projects by group
1. In the Room Device Calculator page, click Save.
2. Select a group or create a new group in the pop-up window, click Confirm.
6 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
3. Do one of the following to manage room projects by group:
In the Room Device Calculator page, click > My Files.
Enter the Room Device Calculator page of YMCS. All the grouping-related operations you perform on the
Room Device Calculator will be synchronized here.
4. Do one of the following:
💡 NOTE
Deleting the group will delete the files in the group as well.
7 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
In the Group catalog, click to add new group.
In the Group catalog, click > Rename on the right side of the group, renane the group.
In the Group catalog, click > Delete on the right side of the group, delete the group.
In the file list, you can perform Edit, Move, Download and other operations.
8 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Login Management
Login Whitelist
IP Whitelist
Introduction
Support editing login whitelist, only IP addresses within the whitelist can log in
to the enterprise management platform, which further enhance the enterprise
security.
💡 Note Whether the channel end can access the enterprise management
platform is not restricted by the login whitelist.
Procedure
1. Sign in to YMCS.
2. Click System Settings > Login Mangement > Login Whitelist.
3. Click to add and edit the login whitelist, you can add a maximum of 20 IP addresses.
4. Click Save.
9 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
5. Click to enable IP Whitelist.
Password Change Reminder
Introduction
You can set a password change reminder and the frequency of the reminder. If
an administrator's password exceeds the set frequency, they will receive an
10 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
email reminder to change their password.
Procedure
1. Sign in to YMCS.
2. Click System Settings > Login Mangement > Login Whitelist.
3. In the Password Change Reminder section, click to enable it.
4. Edit the reminder frequency and save.
💡 Note
The maximum setting is 365 days.
11 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Single Sign-On
Step 1 Configure Microsoft Azure AD Login Method
Procedure
1. Log in to the Azure admin portal.
2. Click > Microsoft Entra ID in the top-left corner.
3.
12 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
3. Click Enterprise applications in the left navigation bar.
4.
4. Click Manage > Enterprise applications > New application.
5.
13 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
5. Click Create your own application. Enter the application name and check Register an application to
integrate with Microsoft Entra ID(App youre deploying) and click Create.
6.
6. Check Accounts in this organizational directory only (yealinkwmtest only - Single tenant) to support trial
for users in the current tenant directory and click Register.
14 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
7.
7. Go back to the Microsoft Entra ID page, click Manage > App registrations, find and click the application you
just created.
8.
8. Go to the application details page. The Application (client) ID is the Client ID and the directory (tenant) is the
15 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Tenant ID.
9. Click Manage > Authentication > Add a platform in the left navigation bar and select Web.
10.
10. Enter the login address in the Redirect URIs field, the logout address in the Front-channel logout URL field,
16 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
and select the check box of ID tokens (used for implicit and hybrid flows) and click Configure. Please
enter the following login and logout addresses.
Login address: https://reseller.ymcs.yealink.com/reseller/front/auth/signin-oidc
Logout address: https://reseller.ymcs.yealink.com/reseller/front/auth/logout
11. The configuration is complete. You can perform further SSO configuration in the YMCS platform.
Step 2 Enable and Configure SSO
Introduction
After enabling single sign-on and creating third-party administrator accounts,
you can use these accounts to sign in to the YMCS platform with the account and
password of the third-party platform (only Microsoft accounts are supported),
improving login efficiency and avoiding security risks.
Procedure
Enable SSO
1. Sign in to YMCS.
2. Click System Settings > Login Management > Single Sign-On.
3. Click the toggle beside Single Sign-On to enable it.
4. Select an authentication platform (currently, only Microsoft Azure AD is supported).
17 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
5. Refer to the Microsoft AD login configuration method and enter the Client ID of Microsoft Azure AD as well as
the Tenant ID to configure the authentication platform.
6. Click Save.
Create a Sub Account with SSO Feature
Please refer to add sub accounts to create a sub account with SSO feature.
Once configured, this sub account can log in to the YMCS platform using SSO.
Step 3 Sign In via SSO
Introduction
You can use your Microsoft account to easily log in to the YMCS platform,
effectively improving login efficiency and avoiding some of the security risks.
Prerequisites
Your channel has configure Microsoft Azure AD login method and enabled SSO.
The channel administrator has created a third-party administrator account for you.
Procedure
1. For the first login, click the login address in the email or enter the login address in the browser address bar.
💡 Tip: You can use the global address (https://ymcs.yealink.com/manager/
login) to sign in, and YMCS will automatically detect and redirect you to
your region.
2. Optional: Switch the language in the top-right corner.
3. Click SSO.
4. Enter the third-party account and click Continue.
5. On the Microsoft login authentication page, enter your Microsoft account and password. After finishing the
verification, you can log in to YMCS.
18 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Sub Accounts and Their
Permission
Roles
Add Roles
Introduction
The default role and its permission are described below:
Name Description
Super Have all the function permission under the device management,
admin RPS management,
No Only the permission to log in.
permissi
on
In addition to the default roles, you can add other roles and assign the
corresponding permission to the role.
Procedure
1. Sign in to YMCS.
2. Click System Settings > Sub Accounts > Role.
3.
19 / 43

View File

@@ -0,0 +1,490 @@
# Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf - Pages 61-80
**Source:** Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf
**Pages:** 61-80 of 84
**Chunk:** 4
---
Yealink Management Cloud Service(YMCS) Channel User
Guide
3. Do one of the following:
Select a group from the group list on the left panel and click Add. You can also click Add Group if you do
not want to specify a group.
Click on the right side of the group and click Add Role.
20 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. Edit the role information and click Save.
Role Name: Edit the role name.
Group: Select a belonging group for this role for future management.
Description: Enter a description.
Data Permission-Channel: Edit data permissions for this role to only view and manage channel data
within the specified scope.
Data Permission-Enterprise: Edit data permissions for this role to only view and manage enterprise and
related device records within the specified scope.
Permission: Assign management permissions for all functions in User Management, Device Management,
System Settings to the role.
Read Only: Can only browse and cannot perform related operations.
Read & Write: Can browse and perform related operations.
All Invisible: The functionality module will not be displayed to them.
21 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Roles
Go to the Role List
Procedure
1. Sign in to YMCS.
2. Click System Settings > Sub Accounts > Role.
3.
22 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Add Groups
Introduction
You can add groups and manage the roles by group.
Procedure
1. Do one of the following:
In the role list, click Add Group.
In the group list, click > Add Group on the right side of All.
23 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
2. Enter the group name and click Confirm.
3. After adding a group, you can find the group from the group list.
Manage Roles by Group
Introduction
You can organize and manage administrators within the enterprise by grouping
them. Each role can only belong to one group.
Procedure
1. In the role list, click the desired group.
2. Do one of the following:
Check the corresponding role information within this group.
24 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Click on the right side of the group to edit groups, add roles to the group, and delete groups.
View Sub Accounts Associated with a Role
Procedure
1. In the role list, click the desired role.
2. Click Sub Accounts.
3. View the sub accounts associated with this role. Those sub accounts have all the permissions assigned to this
role.
25 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Assign a Role to a Sub Account
For more information, please refer to: Assign a Role to a Sub Account.
Edit Role Permission
Procedure
1. In the role list, click the desired role.
2. Click Permission.
3. Edit the role permission and click Save.
💡 Note
You cannot edit the permission of the default role (super admin/no permission).
Sub Accounts
Add Sub Accounts
Introduction
According to the actual needs, you can add sub-accounts and give them
different function and data permission (data permission are divided based on
the scope of enterprises/channels that the account will manage), so as to
26 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
achieve efficient and convenient permission allocation. After that, you can also
use the sub account to log in to YMCS for channel.
Procedure
Add sub-accounts manually
1. Sign in to YMCS.
2. Click System Settings > Sub Accounts > Sub Accounts > Add Sub Account.
3.
3. Configure the corresponding information.
💡 NoteYou need to configure Single Sign-On (SSO)first to be able to select a third-party
account.
Account Type : Select Ordinary / External.
Edit the registration email, contact person, contact phone, description, and select whether to enable login
protection for this account.
💡 NoteIf you enable login protection for a sub-account, the sub-account will need to choose
between email verification or virtual MFA verification as the login verification method during
the first login. For more information, please refer to Login Verification.
27 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Invite sub-accounts using an invitation link
1. Sign in to YMCS.
2. Click System Settings > Sub Accounts > Sub Accounts > Invite Members.
3. Click to copy the invitation link and send it to the corresponding user.
💡 TIP
Click to regenerate the invitation link. The original link will become
invalid.
You can set an expiration date for the invitation link.
You can enable Joining requires moderation . If enabled, you need to
click Go to audit to approve/reject/delete the application in the review
list. If disabled, users can join directly through the invitation link
without requiring approval.
28 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. In the sub-account list, find the user and click on the Edit button on the right
side to configure the relevant information.
Manage Sub Accounts
Access the Sub Account List
1. Sign in to YMCS.
2. Click System Settings > Sub Accounts > Sub Accounts.
29 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Edit Sub Account Information
1. In the sub account list, click Edit to edit sub account information.
Enable/Disable Login Protection
1. Do one of the following:
In the sub account list, click and select Enable/Disable Login Protection.
In the sub account list, select the desired sub accounts and select Enable/Disable Login Protection.
Reset Password
You can reset the password to deal with password leakage and manage
personnel changes to enhance security.
1. In the sub account list, click > Reset password and confirm the action.
Disable/Enable the Sub Account
The sub account is enabled by default. You can enable/disable the sub account.
When its account status changes, the sub account will receive an email
notification.
1. Do one of the following:
In the sub account list, click > Disable. This sub account cannot be used to log in to YMCS.
In the sub account list, click > Enable. This sub account can use this account to log in to YMCS.
Delete Sub Accounts
1. In the sub account list, click > Delete and confirm the action.
Assign a Role to a Sub Account
Introduction
The allocation of function permissions is achieved by associating sub-accounts
with roles.
Procedure
1. Log in to YMCS.
2. Click System Settings > Sub Accounts.
3. Do one of the following:
On the Sub Accounts page, add a sub account.
30 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
On the Role page, add a role.
4. Perform one of the following operations:
On the Sub Accounts page, click Edit on the right side of the sub-administrator and select the
corresponding role.
31 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
On the Role page, click Add on the right side of the role and select the corresponding role.
32 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Logs
Introduction
Log management records all operation records on the channel side. You can
view the operation information, including modules, types, objects, operators,
IPs, results and time.
Procedure
1. Sign in to YMCS.
2. Click System Settings > Log Management.
3.
3. View all operation logs.
You can view the operation log for a period of time by selecting the time
range.
You can enter the operator/IP in the search box to match the corresponding
operation log.
33 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
34 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Alarms
Introduction
When a problem occurs to the device, for example, the call failure or firmware
update failure will be reported to YMCS. You can quickly locate the problem by
viewing the alarm details and diagnosing the devices.
Prerequisites
You can only manage authorized enterprises. For detailed information, please
refer to Manage Enterprise Authorization.
Go to Alarm List
1. Sign in to YMCS.
2. Click System Settings > Alarm.
3.
Add an Alarm
1. Sign in to YMCS.
2. Click System Settings > Alarm > Add.
3. Set the alarm configuration.
35 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
4. Click Save.
Go to the Enterprise Corresponding to the Alarm
1. Click Manage to quickly enter the enterprise corresponding to the alarm.
Edit an Alarm
1. Click > Edit.
Delete an Alarm
1. Click > Delete.
36 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
37 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Un-migrated Enterprise
Accounts
Introduction
For enterprises you previously created, they can still use the YMCS-Legacy
Edition (Legacy YMCS), and you can manage these enterprises on the new YMCS.
At the same time, we recommend that you assist your enterprise acccounts in
migrating to the new platform for easier management in the future.
💡 NoteEditing the information of un-migrated enterprise accounts is not
available.
View the Un-migrated Enterprise List
Procedure
1. Sign in to YMCS.
2. Click Enterprise Management under the YMCS-Legacy Edition module.
3.
Sign In to YMCS on Behalf of Enterprise Accounts
Introduction
38 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
You can log in to YMCS for Enterprise with enterprise authorization to manage
devices or perform other operations on their behalf.
Prerequisites
The enterprise has authorized the management to you.
Procedure
1. In the enterprise list, click Manage enterprise on the right side of the enterprise account to go to YMCS for
Enterprise.
Reset Password for Enterprise Account
Introduction
You can reset the password to deal with password leakage and manage
personnel changes to enhance security.
Procedure
1. In the enterprise list, click > Reset Password.
💡 NotePassword reset is unavailable to non-mailbox enterprises.
Freeze Enterprise Account
Introduction
You can efficiently deal with situations such as service expiration by freezing
businesses. After the enterprise account is frozen, the enterprise cannot use the
account to log in to YMCS for Enterprise or RPS Enterprise.
Procedure
1. In the enterprise list, click > Freeze.
2. Enter the freezing reason in the pop-up window and click OK.
Unfreeze Enterprise Account
Introduction
You can unfreeze the frozen enterprise. After unfreezing, the enterprise can log
in to YMCS for Enterprise.
Procedure
1. In the enterprise list, click > Unfreeze.
39 / 43

View File

@@ -0,0 +1,84 @@
# Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf - Pages 81-84
**Source:** Yealink Management Cloud Service(YMCS)_Channel User Guide.pdf
**Pages:** 81-84 of 84
**Chunk:** 5
---
Yealink Management Cloud Service(YMCS) Channel User
Guide
Manage Un-migrated Devices
Introduction
For enterprises you previously created, they can still use the YMCS-Legacy
Edition (Classic YMCS), and you can manage these enterprises on the new YMCS.
At the same time, we recommend that you assist your enterprise accounts in
migrating to the new platform for easier management in the future.
Procedure
1. Sign in to YMCS.
2. Click Device Management > Phone Device/USB Device/Room Device/Workspace Device/RPS Device.
3. On the right side of the device, click Manage enterprise to go to the device list of the corresponding
enterprise.
4.
40 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
View Term of Services and Privacy
Policy
View the Latest Version of the Terms of Services and
Privacy Policy
Procedure
1. Do one of the following:
For the initial login, you can check the Terms of Services and Privacy Policy after you enter your account
credentials and click Sign In.
After you sign in to YMCS, you can also click > Term of Services/Privacy Policy to view the
Term of Services and Privacy Policy.
41 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
View the History Version
Introduction
The Terms of Service and Privacy Policy will be periodically refined and
updated. Please read them carefully and confirm whether you wish to continue
using the service.
Procedure
1. After you sign in to YMCS, click > Term of Services/Privacy Policy.
2. Click History to view the history versions of the Terms of Services and Privacy Policy.
42 / 43
Yealink Management Cloud Service(YMCS) Channel User
Guide
43 / 43

View File

@@ -0,0 +1,527 @@
# Yealink Management Cloud Service(YMCS)_User Guide.pdf - Pages 1-20
**Source:** Yealink Management Cloud Service(YMCS)_User Guide.pdf
**Pages:** 1-20 of 953
**Chunk:** 1
---
Yealink Management Cloud Service(YMCS) U...
Switch Between Device
Management Platform
Workspace Management
Platform RPS Service
Introduction
Device Management (DM) Platform: Designed specifically for device administrators, it
provides a powerful and user-friendly solution for batch management and operation. It
includes features such as monitoring, upgrading, configuring, alerting, and diagnostics.
Workspace Management (WM) Platform: It offers a management solution for enterprise
hybrid workspaces. In addition to the existing features, WM will gradually introduce more
functionalities related to enterprise spaces.
RPS Service: Simplifying the deployment and configuration process of devices, it provides a
more efficient, convenient, and reliable solution for device deployment and configuration.
Procedure
1. Sign in to YMCS.
2. You can switch between the DM Platform / the WM Platform / RPS Service in the bottom left
corner.
💡 TIP
You can also set the DM Platform / the WM Platform / RPS Service as
default login platforms in Account Settingsaccording to your needs.
1 / 82
Yealink Management Cloud Service(YMCS) U...
2 / 82
Yealink Management Cloud Service(YMCS) U...
Connect Phone Devices
Via YMCS RPS Service (Batch Deployment)
Procedure
1. Import Devices to the Device List
1. Sign in to YMCS.
2. Click Phone Device > Device > Device List > Import.
3. Select Yes or No to turn on or turn off Import Server.
💡 NOTE
If you select Yes to turn on this feature, the devices will also be imported to the RPS server
you select, and you can see them on RPS Device list.
4. Click Download Template. Edit the device information according to the guidance in the
template.
5. Save the file and upload it.
6. Click Upload.
3 / 82
Yealink Management Cloud Service(YMCS) U...
2. Initialize the Device
For brand-new devices, power up the device to complete initialization.
For devices you have used, restore the device to factory settings to complete initialization.
3. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the phone device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
Via Auto Provisioning Server (Batch Deployment)
Introduction
4 / 82
Yealink Management Cloud Service(YMCS) U...
Prerequisites
If your device's firmware version is too low, do any of the following to upgrade it
to the latest version:
Visit Yealink Support to get the latest device firmware version.
Execute the following command to upgrade the device firmware.
static.firmware.url=http://192.168.1.20/150.86.0.5.rom
💡 NOTE
Please change the above http://192.168.1.20/150.86.0.5.rom to the address of
your auto-provisioning server.
Procedure
1. Configure the Device CFG File
1. Perform the following configuration.
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=eu-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the EU region, configure the following
commands:
5 / 82
Yealink Management Cloud Service(YMCS) U...
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=us-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the US region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=us-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the AU region, configure the following
commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=au-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the UAE region, configure the following
commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=uae-device-scheduler.ymcs.yealink.com
dm.server.port=443
6 / 82
Yealink Management Cloud Service(YMCS) U...
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
💡 NOTE
You can find this address in the DM Server address field by going to System (
) > Enterprise Information on YMCS.
static.dm.enterprise_id=Enterprise ID
static.dm.site_id=site ID
2. If you want to connect the device directly to a specific site in your enterprise, add the
following command to the CFG file:
💡 NOTE
7 / 82
Yealink Management Cloud Service(YMCS) U...
You can go to System (
) > Enterprise Information to get the enterprise ID.
You can go to System (
8 / 82
Yealink Management Cloud Service(YMCS) U...
) > Settings > Device > Site to get the site ID from the site information.
3. The CFG file is configured.
2. Make Devices Access the CFG File
Make the devices access the CFG files stored in your auto-provisioning servers.
The operation of different auto-provisioning servers might vary, so we do not
elaborate on the process here.
3. Trigger the Device to Perform Auto Provisioning
There are various ways to trigger the device to complete the auto-provisioning,
e.g., turn the device on. Please select your desired method.
4. Import Devices to the Device List
9 / 82
Yealink Management Cloud Service(YMCS) U...
💡 NOTE
If you have set the enterprise ID and site ID in the Configure Device CFG
file operation, the device will be automatically added to the device list in
YMCS; if not, see Import devices.
5. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the phone device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
Via DHCP Option (Batch Deployment)
Introduction
You can configure devices to obtain the provisioning server address via DHCP.
Procedure
1. Set Up the DHCP Server
If you do not have a DHCP server, you can see this section to set up one.
Following, we use DHCP Server for Windows to illustrate how to set up a DHCP
server on the Windows operating system.
1. Download DHCP Server.
2. Run dhcpsrv.
3. Click Next.
10 / 82
Yealink Management Cloud Service(YMCS) U...
4. Select your network interface card and click Next.
5. Configure your IP pool, click DHCP Options, and click Add.
11 / 82
Yealink Management Cloud Service(YMCS) U...
6. Do one of the following:
Enter the DHCP option and Yealink auto-provisioning server addresses, and click OK.
Param Introduction
eter
Option Enter the option number.
numbe
r
Option Set the DHCP option value (the address of the auto-provisioning
string server) and enclose it with quotation marks.
You can use Yealink auto-provisioning server addresses. Following
are Yealink auto-provisioning addresses:
If your enterprise is in the EU region, enter https://eu-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
12 / 82
Yealink Management Cloud Service(YMCS) U...
If your enterprise is in the US region, enter https://us-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
If your enterprise is in the AU region, enter https://au-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
If your enterprise is in the UAE region, enter https://uae-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
You can go to System (
) > Enterprise Information to get the Yealink auto-provisioning
address.
Enter the DHCP option and your own auto-provisioning server addresses, and
click OK.
Param Introduction
eter
Option Enter the option number.
numbe
r
13 / 82
Yealink Management Cloud Service(YMCS) U...
Option Set the DHCP option value (the address of the auto-provisioning
string server) and enclose it with quotation marks.
You can use your own auto-provisioning server addresses.
Please follow the steps below to configure the Device CFG File:
7. Perform the following configuration.
If your enterprise is located in the EU region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=eu-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the US region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=us-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the AU region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=au-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the UAE region, configure the following commands:
14 / 82
Yealink Management Cloud Service(YMCS) U...
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=uae-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
💡 NOTE
You can find this address in the DM Server address field by going to
System (
) > Enterprise Information on YMCS.
15 / 82
Yealink Management Cloud Service(YMCS) U...
If you want to connect the device directly to a specific site in your enterprise, add the
following command to the CFG file:
static.dm.enterprise_id=Enterprise ID
static.dm.site_id=site ID
💡 NOTE
You can go to System (
) > Enterprise Information to get the enterprise ID.
You can go to System (
16 / 82
Yealink Management Cloud Service(YMCS) U...
) > Settings > Device > Site to get the site ID from the site information.
i. The CFG file is configured.
8. Click OK > Next.
17 / 82
Yealink Management Cloud Service(YMCS) U...
9. Select Overwrite existing file, click Write INI file, and click Next.
10. Click Start to run the server and click Finish to exit.
2. Make Devices Obtain Server Address through DHCP
Yealink IP phones can obtain the provisioning server address by detecting DHCP
options during startup.
18 / 82
Yealink Management Cloud Service(YMCS) U...
If you do not use the following default option value, you need to configure the
phone to obtain the provisioning server address via a custom DHCP option.
Netw Default Introduction
ork option
type value
IPv4 Option 66, Option 66 is used to identify the TFTP server.
Option 43 Option 43 is a vendor-specific option, which is used to
transfer the vendor-specific information.
IPv6 Option 59 Option 59 is used to specify a URL for the boot file to be
downloaded by the client.
If you use the default option value:
1. Reconnect the network cable of your devices or restart the devices to make them obtain
the server address.
If you use a custom DHCP option:
2. Enter https://phone IP address (for example, https://1.2.3.4) in the browser address bar and
log in to the phone web user interface as an administrator.
3. Click Settings > Auto Provision.
4. Select the On radio box in the DHCP Active field.
5. Do one of the following:
If you are using IPv4 network, enter the desired value in the IPv4 Custom Option field.
If you are using IPv6 network, enter the desired value in the IPv6 Custom Option field.
💡 NOTE
The IPv4 or IPv6 custom DHCP option must be in accordance with the one defined in the
DHCP server.
6. Click Confirm to accept the change.
7. Reconnect the network cable of your devices or restart the devices to make them obtain
the server address.
3. Add Devices to the Device List
💡 NOTE
19 / 82
Yealink Management Cloud Service(YMCS) U...
If you have set the enterprise ID and site ID in Step 1, the device will be
automatically added to the device list in YMCS; if not, see import devices.
4. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the phone device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
Via the Device Web User Interface (for a Single
Device)
Procedure
1. Configure Auto-Provisioning Server Address
1. (Take T41U as an example) Enter https://phone IP address (for example, https://1.2.3.4) in
the browser address bar and log in to the phone web user interface as an administrator.
2. Click Settings > Auto Provision.
3. Enter the URL of your auto-provisioning server in the Server URL field.
If your enterprise is in the EU region, enter https://eu-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
If your enterprise is in the US region, enter https://us-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
If your enterprise is in the AU region, enter https://au-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
If your enterprise is in the UAE region, enter https://uae-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
💡 NOTE
You can go to System (
20 / 82

View File

@@ -0,0 +1,519 @@
# Yealink Management Cloud Service(YMCS)_User Guide.pdf - Pages 21-40
**Source:** Yealink Management Cloud Service(YMCS)_User Guide.pdf
**Pages:** 21-40 of 953
**Chunk:** 2
---
Yealink Management Cloud Service(YMCS) U...
) > Enterprise Information to get the AutoP address.
21 / 82
Yealink Management Cloud Service(YMCS) U...
4. Click Auto Provision Now, and reconfirm in the pop-up window.
2. Add Devices to the Device List on YMCS
1. Sign in to YMCS.
2. Click Phone Device > Device > Device List > Add
3. Edit device-related information.
Paramet Introduction
er
Device Enter the device name.
Name
22 / 82
Yealink Management Cloud Service(YMCS) U...
MAC Enter the MAC address of the device. MAC must and can only
contain 12 letters or numbers; the length is limited to 12-17
characters; the characters that can be entered include 0-9, A-z,
"-", ":"; for example, 00-15-65-1a-2b-3c, 00:15:65:1a:2b:3c,
0015651a2B3c.
Machine Enter the Machine ID of the device. The Machine ID is the SN
ID corresponding to the body sticker or the Machine ID displayed in
the device status information.
Model Select the device model.
Site Select a site for the device.
Bind Bind an account for the device.
Account
Descript Enter a description.
ion
4. Click Save.
3. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the phone device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
23 / 82
Yealink Management Cloud Service(YMCS) U...
Connect Android-based Video
Conferencing Endpoints & Smart
Workspace Devices
💡 TIP
This connecting method is applicable to models: MeetingBar AX0/
MeetingBoard/DeskVision A24/RoomCast/RoomPanel/RoomPanel Plus/
CTP18/CTP25.
For CTP18/CTP25, independent connecting through this method is only
supported when paired with a host device. About pairing with a host
device, please refer to Pair with Devices.
Via Auto Provisioning Server (Batch Deployment)
Via DHCP Option (Batch Deployment)
Via the Device Web User Interface (for a Single Device)
Via Auto Provisioning Server (Batch
Deployment)
Introduction
24 / 82
Yealink Management Cloud Service(YMCS) U...
Prerequisites
If your device's firmware version is too low, do any of the following to upgrade it
to the latest version:
Visit Yealink Support to get the latest device firmware version.
Execute the following command to upgrade the device firmware
static.firmware.url=http://192.168.1.20/150.86.0.5.rom
💡 NOTE
Please change the above http://192.168.1.20/150.86.0.5.rom to the address of
your auto-provisioning server.
Procedure
1. Configure the Device CFG File
1. Perform the following configuration.
If your enterprise is located in the EU region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=eu-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the US region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=us-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the AU region, configure the following commands:
25 / 82
Yealink Management Cloud Service(YMCS) U...
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=au-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the UAE region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=uae-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
💡 NOTE
You can find this address in the DM Server address field by going to System (
26 / 82
Yealink Management Cloud Service(YMCS) U...
) > Enterprise Information on YMCS.
2. If you want to connect the device directly to a specific site in your enterprise, add the
following command to the CFG file:
static.dm.enterprise_id=Enterprise ID
static.dm.site_id=site ID
💡 NOTE
You can go to System (
27 / 82
Yealink Management Cloud Service(YMCS) U...
) > Enterprise Information to get the enterprise ID.
You can go to System (
28 / 82
Yealink Management Cloud Service(YMCS) U...
) > Settings > Device > Site to get the site ID from the site information.
3. The CFG file is configured.
2. Make Devices Access the CFG File
Make the devices access the CFG files stored in your auto-provisioning servers.
The operation of different auto-provisioning servers might vary, so we do not
elaborate on the process here.
3. Trigger the Device to Perform Auto Provisioning
There are various ways to trigger the device to complete the auto-provisioning,
e.g., turn the device on. Please select your desired method.
4. Import Devices to the Device List
29 / 82
Yealink Management Cloud Service(YMCS) U...
💡 NOTE
If you have set the enterprise ID and site ID in the Configure Device CFG
file operation, the device will be automatically added to the device list in
YMCS; if not, see Import Devices.
5. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the phone device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
Via DHCP Option (Batch Deployment)
Introduction
You can configure devices to obtain the provisioning server address via DHCP.
Procedure
1. Set Up the DHCP Server
If you do not have a DHCP server, you can see this section to set up one.
Following, we use DHCP Server for Windows to illustrate how to set up a DHCP
server on the Windows operating system.
1. Download DHCP Server.
2. Run dhcpsrv.
3. Click Next.
30 / 82
Yealink Management Cloud Service(YMCS) U...
4. Select your network interface card and click Next.
5. Configure your IP pool, click DHCP Options, and click Add.
31 / 82
Yealink Management Cloud Service(YMCS) U...
6. Do one of the following:
Enter the DHCP option and Yealink auto-provisioning server addresses, and click OK.
Paramet Introduction
er
Option Enter the option number.
number
Option Set the DHCP option value (the address of the auto-provisioning
string server) and enclose it with quotation marks.
You can use Yealink auto-provisioning server addresses.
Following are Yealink auto-provisioning addresses:
If your enterprise is in the EU region, enter https://eu-
32 / 82
Yealink Management Cloud Service(YMCS) U...
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
If your enterprise is in the US region, enter https://us-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
If your enterprise is in the AU region, enter https://au-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
If your enterprise is in the UAE region, enter https://uae-
resource.ymcs.yealink.com/hardware/autop/$MAC.boot
You can go to System (
) > Enterprise Information to get the Yealink auto-provisioning
address.
Enter the DHCP option and your own auto-provisioning server addresses, and click OK.
Param Introduction
eter
Option Enter the option number.
numbe
r
33 / 82
Yealink Management Cloud Service(YMCS) U...
Option Set the DHCP option value (the address of the auto-provisioning
string server) and enclose it with quotation marks.
You can use your own auto-provisioning server addresses.
Please follow the steps below to configure the Device CFG File:
1. Perform the following configuration.
If your enterprise is located in the EU region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=eu-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the US region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=us-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the AU region, configure the following commands:
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=au-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
If your enterprise is located in the UAE region, configure the following commands:
34 / 82
Yealink Management Cloud Service(YMCS) U...
#!version:1.0.0.1
##The header above must appear as-is in the first line
dm.enable=1
dm.server.address=uae-device-scheduler.ymcs.yealink.com
dm.server.port=443
dm.file_upload.http_post_mode=0
dm.file_upload.http_method=1
💡 NOTE
You can find this address in the DM Server address field by going to System (
) > Enterprise Information on YMCS.
2. If you want to connect the device directly to a specific site in your enterprise, add the
35 / 82
Yealink Management Cloud Service(YMCS) U...
following command to the CFG file:
static.dm.enterprise_id=Enterprise ID
static.dm.site_id=site ID
💡 NOTE
You can go to System (
) > Enterprise Information to get the enterprise ID.
You can go to System (
36 / 82
Yealink Management Cloud Service(YMCS) U...
) > Settings > Device > Site to get the site ID from the site information.
3. The CFG file is configured.
4. Click OK > Next.
37 / 82
Yealink Management Cloud Service(YMCS) U...
5. Select Overwrite existing file, click Write INI file, and click Next.
6. Click Start to run the server and click Finish to exit.
38 / 82
Yealink Management Cloud Service(YMCS) U...
2. Make Devices Obtain Server Address through DHCP
Yealink IP phones can obtain the provisioning server address by detecting DHCP
options during startup.
If you do not use the following default option value, you need to configure the
phone to obtain the provisioning server address via a custom DHCP option.
Netw Default Introduction
ork option
type value
IPv4 Option 66, Option 66 is used to identify the TFTP server.
Option 43 Option 43 is a vendor-specific option, which is used to
transfer the vendor-specific information.
IPv6 Option 59 Option 59 is used to specify a URL for the boot file to be
downloaded by the client.
39 / 82
Yealink Management Cloud Service(YMCS) U...
If you use the default option value:
1. Reconnect the network cable of your devices or restart the devices to make them obtain
the server address.
If you use a custom DHCP option:
1. Enter https://phone IP address (for example, https://1.2.3.4) in the browser address bar and
log in to the phone web user interface as an administrator.
2. Click System > Auto Provision.
3. Enable DHCP Active.
4. Do one of the following:
If you are using IPv4 network, enter the desired value in the IPv4 Custom Option field.
If you are using IPv6 network, enter the desired value in the IPv6 Custom Option field.
💡 NOTE
The IPv4 or IPv6 custom DHCP option must be in accordance with the one defined in the
DHCP server.
5. Click Confirm to accept the change.
6. Reconnect the network cable of your devices or restart the devices to make them obtain
the server address.
3. Add Devices to the Device List
💡 NOTE
If you have set the enterprise ID and site ID in Step 1, the device will be
automatically added to the device list in YMCS; if not, see Import Devices.
4. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the phone device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
Via the Device Web User Interface (for a
Single Device)
40 / 82

View File

@@ -0,0 +1,425 @@
# Yealink Management Cloud Service(YMCS)_User Guide.pdf - Pages 41-60
**Source:** Yealink Management Cloud Service(YMCS)_User Guide.pdf
**Pages:** 41-60 of 953
**Chunk:** 3
---
Yealink Management Cloud Service(YMCS) U...
Procedure
Supported devices:
Models MVC/ZVC Meeting Meeti MeetingDi Room Room
Board ngBar splay Panel Cast
Support Not Support Suppor Supported Suppo Suppo
Status Supporte ed ted rted rted
d
1. Configure on the device
💡 NOTE
1. Click System > Enterprise information in YMCS to obtain the Enterprise ID,
AutoP address, and DM Server address.
2. Click Workspace > Rooms in YMCS to obtain the Deployment code.
41 / 82
Yealink Management Cloud Service(YMCS) U...
Please choose from the following three configuration methods that your
device supports:
Method 1
1. Please enter the device, click Device Settings > Yealink Could Service.
2. Enable the Yealink Cloud Service .
3. Enable the Yealink Management Cloud Service.
4. Select the Yealink Management Cloud Service in the Device Manage Server.
5. Input the Deployment code or Enterprise ID.
6. Click Confirm
42 / 82
Yealink Management Cloud Service(YMCS) U...
💡 NOTE
You can go to System (
43 / 82
Yealink Management Cloud Service(YMCS) U...
) > Enterprise Information to get the Deployment code or Enterprise ID in the
YMCS (https://eu.ymcs.yealink.com).
Method 2
1. Enter https://device IP address (for example, https://1.2.3.4) in the browser address bar and
log in to the phone web user interface as an administrator.
2. Click System > Yealink Cloud Service (If you have this menu).
3. Enable the Yealink Cloud Service (If you have this configuration).
4. Enable the Yealink Management Cloud Service(If you have this configuration).
5. Select the Yealink Management Cloud Service in the Device Manage Platform.
6. Input the Deployment code or Enterprise ID (Lower versions do not support deployment
codes. Please use the enterprise id or leave it blank) .
7. Click Confirm
44 / 82
Yealink Management Cloud Service(YMCS) U...
💡 NOTE
You can go to System (
45 / 82
Yealink Management Cloud Service(YMCS) U...
) > Enterprise Information to get the Deployment code or Enterprise ID in the
YMCS (https://eu.ymcs.yealink.com).
Method 3
1. Enter https://device IP address (for example, https://1.2.3.4) in the browser address bar and
log in to the phone web user interface as an administrator.
2. Click System > Yealink Cloud Service(If you have this menu).
3. Enable the Yealink Cloud Service (If you have this configuration).
4. Enable the Yealink Management Cloud Service(If you have this configuration).
5. Click System > Device Manage.
6. Enter the URL of your AutoP address in the Server URL field.
If your enterprise is in the EU region, enter https://eu-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
46 / 82
Yealink Management Cloud Service(YMCS) U...
If your enterprise is in the US region, enter https://us-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
If your enterprise is in the AU region, enter https://au-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
If your enterprise is in the UAE region, enter https://uae-resource.ymcs.yealink.com/
hardware/autop/$MAC.boot
7. Click Auto Provision Now.
💡 NOTE
You can go to System (
) > Enterprise Information to get the AutoP address in the YMCS (https://eu.ymcs.yealink.com).
47 / 82
Yealink Management Cloud Service(YMCS) U...
2. Add Devices to the Device List on YMCS
💡 NOTE
If using method 1 & method 2 and entering the correct deployment code,
please skip this step.
1. Sign in to YMCS.
2. Click Device >Device > Room Device > Add.
3. Edit device-related information.
Parameter Introduction
Device Enter the device name.
48 / 82
Yealink Management Cloud Service(YMCS) U...
Name
MAC Enter the MAC address of the device. MAC must and can only
contain 12 letters or numbers; the length is limited to 12-17
characters; the characters that can be entered include 0-9, A-
z, "-", ":"; for example, 00-15-65-1a-2b-3c, 00:15:65:1a:2b:3c,
0015651a2B3c.
Machine ID Enter the Machine ID of the device. The Machine ID is the SN
corresponding to the body sticker or the Machine ID
displayed in the device status information.
Model Select the device model.
Site Select a site for the device.
Bind Bind an account for the device.
Account
Description Enter a description.
4. Click Save.
3. Devices Are Successfully Connected to YMCS
After the device successfully connects to YMCS, the device status changes from
"offline" to "online" in the room device list; the device status changes from
"unbound" to "bound" in the RPS device list, which means that the device is
successfully connected through the RPS server.
49 / 82
Yealink Management Cloud Service(YMCS) U...
Connect Windows-based Video
Conferencing Endpoints (MVC
and ZVC Series)
Batch Deployment via Yealink
RoomConnect
Prerequisites
The software version of YRC built into the devices should be 2.31.67.0 or higher.
Procedure
1. Import Devices
1. Sign in to YMCS.
2. Do one of the following:
On the DM platform, click Room Device > Device > Device List > Import.
On the WM platform, click Workspace > Rooms > Devices >Import.
3. Click Download Template. Edit the device information according to the guidance in the
template.
4. Save the file and upload it.
5. Click Upload.
50 / 82
Yealink Management Cloud Service(YMCS) U...
2. Power On Devices
After you power on the devices, the devices will connect to YMCS via YRC.
3. Devices Are Successfully Connected to YMCS
After the device is successfully connected to YMCS, the device status will change
from "offline" to "online" in the room device list.
Via Yealink RoomConnect Software
Procedure
1. Connect Room Devices to YMCS via YRC
Method 1: Classic Login
💡 NOTE
The software version of YRC built into the devices should be 2.33.0.0 or
51 / 82
Yealink Management Cloud Service(YMCS) U...
higher.
1. Open Yealink RoomConnect software and click
> DM Server Settings > Classic Login.
52 / 82
Yealink Management Cloud Service(YMCS) U...
2. Make the relevant settings and click Connect to platform.
Connect to platform: Choose Yealink Management Cloud Service.
Enterprise ID: Enter the enterprise ID.
💡 NOTE
You can obtain the enterprise ID in Enterprise Information.
Meeting Room: Enter the meeting room name.
Device Model: Select the device model.
53 / 82
Yealink Management Cloud Service(YMCS) U...
Authorize Remote Screenshot: Choose whether to grant remote screen capture
permission to administrators.
Authorize Remote Desktop: Choose whether to grant remote desktop permission to
administrators.
Method 2: Account Password Login
💡 NOTE
The software version of YRC built into the devices should be 2.31.67.0 or
higher.
1. Open Yealink RoomConnect software and click
54 / 82
Yealink Management Cloud Service(YMCS) U...
> DM Server Settings > Account Password Login.
55 / 82
Yealink Management Cloud Service(YMCS) U...
1. Enter your YMCS account credentials (the enterprise registration account) and click Login.
1. Confirm the information, such as meeting room, enterprise name and device name, then
click Confirm.
56 / 82
Yealink Management Cloud Service(YMCS) U...
2. Devices Are Successfully Connected to YMCS
After the device is successfully connected to YMCS, the device status will change
from "offline" to "online" in the room device list. You can view and modify the
relevant information in the setting interface of Yealink RoomConnect software.
57 / 82
Yealink Management Cloud Service(YMCS) U...
Connect USB Devices
Install the YUC Package in Bulk on User Computers
Introduction
There are various methods available for bulk-installing the YUC installation
package on user computers. You can choose the most suitable bulk installation
method based on your enterprise's specific environment. The following guide
explains how to use Intune for bulk installation. Installing through Intune is a
seamless process for your enterprise employees, and they won't even notice it.
Install the YUC Package in Bulk for Windows
09081035Mac intune视频1e87d19.autosave.mp4
About how to get the package, please refer to: How to get the package uploaded to
Intune when using Intune to install a batch of packages to Windows users?
Install the YUC Package in Bulk for macOS
How to Install YUC in Bulk for Users' ComputersWindows) with Intune.mp4
About how to set shell scripts, please refer to: How to set shell scripts when using
Intune to install a batch of packages to MacOS users?
Batch Deployment for Windows
💡 Warning
Before batch deployment, make sure you have the file installation and
access permissions of your enterprise so that the batch installation can
be carried out successfully.
Procedure
1. Download YUC Package
58 / 82
Yealink Management Cloud Service(YMCS) U...
1. Sign in to YMCS.
2. Click Personal Device > Device > Bulk Deployment.
3. Set the site settings.
If you select Do not specify a site, the USB devices will belong to the root site after they
are connected to YMCS. You can then adjust the device belonging site in the platform.
If you select Specify a site, select the desired belonging site. The USB devices will belong
to your chosen site.
💡 NOTE
You cannot directly change the belonging site for the device on the YMCS web portal. To
change the site, regenerate and redeploy the installation package.
4. Select Windows and click Download.
5. Click Download from the window that popped up in the bottom-right corner.
59 / 82
Yealink Management Cloud Service(YMCS) U...
7. You can also click Download from the Export Records to obtain the YUC package.
60 / 82

Some files were not shown because too many files have changed in this diff Show More