Compare commits
192 Commits
85c8149495
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d2ae7a5b | |||
| 830a86ea95 | |||
| a0690793bc | |||
| 0fdeabde3d | |||
|
|
df46f56e59 | ||
|
|
299c053795 | ||
|
|
8350a4aeaf | ||
| 1041db8587 | |||
| ebeb0ec1f6 | |||
| ecc0c8cd82 | |||
|
|
07404759f3 | ||
|
|
3286a47090 | ||
| f95809cd9d | |||
| e78152a706 | |||
| 017dcdf04c | |||
| 2f35163866 | |||
| c34cdb3a39 | |||
| ec1cb40b62 | |||
| f9b44cfb68 | |||
| 1ca456b03f | |||
| 250eb8ecfe | |||
| 5641532429 | |||
| 1b0eeb5595 | |||
| b4972497a7 | |||
| a70ec28bdc | |||
| 7f8fb5d9c5 | |||
| d477970ab8 | |||
| 522594dedb | |||
| 06ed9ba562 | |||
| 25af453f61 | |||
| 736d43f482 | |||
| 033019df18 | |||
| bf95a10685 | |||
| 2fba62b7df | |||
| db10206aea | |||
| eb04287396 | |||
| ab987bf9e1 | |||
| d9f877661e | |||
| 5a463ae133 | |||
| 6942827391 | |||
|
|
07268cd741 | ||
| 57c23fc8d7 | |||
| 2e92a2d38d | |||
| 9926e5f2cc | |||
|
|
252d22048b | ||
| b3ea44a8ff | |||
| e765c9115d | |||
| cac5eadaee | |||
| 9c998a95d0 | |||
| 447b18d62b | |||
| a7b2fae468 | |||
| cc336243e3 | |||
| fa97bcc49b | |||
| d037d82f5e | |||
| c0d304485a | |||
| 25ca9306e1 | |||
| fcf43044d4 | |||
| 2a1ea6c9f7 | |||
| fc12c596f1 | |||
| eee469ce86 | |||
| 4e148b6dc5 | |||
| 19edfc45c0 | |||
| e548f63605 | |||
| 840b0ce9d6 | |||
| 09ab3de106 | |||
| 361478dd66 | |||
| 0a438929d0 | |||
|
|
f379a828e9 | ||
| a2a277ab67 | |||
| 9340cdec17 | |||
| 8def189a17 | |||
| 29725a3851 | |||
| 81398663ac | |||
| 2fd646218d | |||
|
|
30c20df2b9 | ||
| 5edab36df6 | |||
| 1952c36675 | |||
| 8b016edb9d | |||
| 5dfaaaa33b | |||
| 2259f4c172 | |||
| a507678254 | |||
|
|
1e1bae772d | ||
| 3d28f9ea5d | |||
|
|
a0a86742bc | ||
| 27595d6475 | |||
| 766c7fa54d | |||
| ff6546bee8 | |||
| 33b6140236 | |||
| efca405669 | |||
| f30413cffc | |||
| 5d53452c6d | |||
| 03d02128d7 | |||
| 2e262c619e | |||
| 8efc351e25 | |||
| ead30520e5 | |||
| 0daf06263f | |||
| 8854a584f3 | |||
| 5968ee9231 | |||
| da2aa60990 | |||
| b9635cafd9 | |||
| 45e1072dcb | |||
| 7264840da6 | |||
| 43101f4597 | |||
| e790d7c7e8 | |||
| 12c3d83f75 | |||
|
|
419de64af5 | ||
| ba2403b798 | |||
| 7dc519827c | |||
| 8f4984c5a6 | |||
| 0201e7422c | |||
| 0b11a400ed | |||
| 4b535a1a81 | |||
| c9ea7e8f01 | |||
| 27dc83f927 | |||
| b10f527cda | |||
| bd247191e6 | |||
| b20985d241 | |||
| 3cc9b22594 | |||
| 49d5a535a4 | |||
| 37a6598ef7 | |||
| a2a9356a02 | |||
| c3ad79d9fe | |||
| dfc237e6d4 | |||
| 14ac542a00 | |||
| c1706393b9 | |||
| e08f6c534e | |||
| f88c7d1739 | |||
| d5f7eadf82 | |||
| c289ba3370 | |||
| 60b307316c | |||
| 355c5f1e3f | |||
| d70cdd88e4 | |||
| faf796386a | |||
| 8b08ec64c2 | |||
| da6567ffe0 | |||
| 3d8aa2be59 | |||
| a8e76ba215 | |||
| 976bfe7957 | |||
| b0ebd44c9d | |||
| f274eed8fa | |||
| 5e682c9293 | |||
| bee140c020 | |||
| a737c4ab24 | |||
| fa4867580f | |||
| a5b8f6e7b1 | |||
| 6ad608c3bf | |||
| ea84b7bcd2 | |||
| b835f350d6 | |||
| 5e1ebf4789 | |||
| 656101a7b4 | |||
| 7c0f467cdf | |||
| 26b1cf400f | |||
| e359702837 | |||
| 67822d5ca8 | |||
| 346832b469 | |||
| a7174a894e | |||
| 3600df76cf | |||
| 9efa6eaf4f | |||
| 9542de2b0a | |||
| a0998fa255 | |||
| c8036d7e67 | |||
| 567fd8dd8c | |||
| 66211a5652 | |||
| 46705d48ff | |||
| e6dae3dba2 | |||
| 46d4900229 | |||
| 65b6043cde | |||
| 7a6fbcfc29 | |||
| 371fce1104 | |||
| 42d43a5bcc | |||
| a270f405a6 | |||
| a6a6a477d5 | |||
| b8bba3cd8f | |||
| 011ee7350d | |||
| 21b198f1ee | |||
| 6d36f7151c | |||
| edd4bd39e7 | |||
| ddd6bb06c2 | |||
| b97223f69e | |||
| f64418d5e1 | |||
| 2aed9d93c9 | |||
| cb99daa6cb | |||
| d7bc2b34ac | |||
| 734a7b201d | |||
| d4a1bbbc5b | |||
| a733db1029 | |||
| d5020a6415 | |||
| 0da31cbb65 | |||
| ff7025d912 | |||
| ad6a529ba4 | |||
| 60a0a5728f | |||
| 10b1ea7528 |
36
.analyze-glaztech.py
Normal file
36
.analyze-glaztech.py
Normal 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
10
.bk.ps1
Normal 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
7
.chk.ps1
Normal 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
8
.chk2.ps1
Normal 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)
|
||||
@@ -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`).
|
||||
|
||||
@@ -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")
|
||||
|
||||
46
.claude/commands/ask-forum.md
Normal file
46
.claude/commands/ask-forum.md
Normal 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.
|
||||
@@ -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 2–5 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 2–5 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`
|
||||
|
||||
@@ -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
|
||||
|
||||
55
.claude/commands/tailscale.md
Normal file
55
.claude/commands/tailscale.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
26
.claude/memory/feedback_background_task_no_ampersand.md
Normal file
26
.claude/memory/feedback_background_task_no_ampersand.md
Normal 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]].
|
||||
20
.claude/memory/feedback_backup_check_only_billed.md
Normal file
20
.claude/memory/feedback_backup_check_only_billed.md
Normal 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]].
|
||||
21
.claude/memory/feedback_backup_targets_never_guessed.md
Normal file
21
.claude/memory/feedback_backup_targets_never_guessed.md
Normal 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).
|
||||
22
.claude/memory/feedback_claude_tui_fullscreen_crash.md
Normal file
22
.claude/memory/feedback_claude_tui_fullscreen_crash.md
Normal 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]].
|
||||
26
.claude/memory/feedback_discord_read_replies.md
Normal file
26
.claude/memory/feedback_discord_read_replies.md
Normal 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`).
|
||||
29
.claude/memory/feedback_fire_dev_alerts_for_client_work.md
Normal file
29
.claude/memory/feedback_fire_dev_alerts_for_client_work.md
Normal 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.
|
||||
22
.claude/memory/feedback_gitbash_tz_ignored.md
Normal file
22
.claude/memory/feedback_gitbash_tz_ignored.md
Normal 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.
|
||||
29
.claude/memory/feedback_scheduled_task_no_console_flash.md
Normal file
29
.claude/memory/feedback_scheduled_task_no_console_flash.md
Normal 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]].
|
||||
20
.claude/memory/feedback_simplest_solution_first.md
Normal file
20
.claude/memory/feedback_simplest_solution_first.md
Normal 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]].
|
||||
@@ -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`.
|
||||
|
||||
28
.claude/memory/project_edr_rmm_autoisolation_fp.md
Normal file
28
.claude/memory/project_edr_rmm_autoisolation_fp.md
Normal 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).
|
||||
27
.claude/memory/project_gururmm_legacy_disabled.md
Normal file
27
.claude/memory/project_gururmm_legacy_disabled.md
Normal 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).
|
||||
31
.claude/memory/project_gururmm_software_removal_pr58.md
Normal file
31
.claude/memory/project_gururmm_software_removal_pr58.md
Normal 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.
|
||||
32
.claude/memory/reference_client_wifi_inventory.md
Normal file
32
.claude/memory/reference_client_wifi_inventory.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
30
.claude/memory/reference_msp360_backup_monitoring.md
Normal file
30
.claude/memory/reference_msp360_backup_monitoring.md
Normal 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.
|
||||
54
.claude/memory/reference_windows_edition_upgrade_rmm.md
Normal file
54
.claude/memory/reference_windows_edition_upgrade_rmm.md
Normal 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.
|
||||
247
.claude/scripts/ask-forum.sh
Normal file
247
.claude/scripts/ask-forum.sh
Normal 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
|
||||
268
.claude/scripts/backup_common.py
Normal file
268
.claude/scripts/backup_common.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
6
.claude/scripts/edr-isolation-watch-hidden.vbs
Normal file
6
.claude/scripts/edr-isolation-watch-hidden.vbs
Normal 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
|
||||
71
.claude/scripts/edr-isolation-watch.sh
Normal file
71
.claude/scripts/edr-isolation-watch.sh
Normal 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"
|
||||
@@ -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")
|
||||
|
||||
45
.claude/scripts/get-identity.sh
Normal file
45
.claude/scripts/get-identity.sh
Normal 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
|
||||
36
.claude/scripts/gps-rmm-autoenroll.sh
Normal file
36
.claude/scripts/gps-rmm-autoenroll.sh
Normal 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
|
||||
5
.claude/scripts/gps-rmm-progress-hidden.vbs
Normal file
5
.claude/scripts/gps-rmm-progress-hidden.vbs
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
51
.claude/scripts/register-edr-watcher.ps1
Normal file
51
.claude/scripts/register-edr-watcher.ps1
Normal 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
|
||||
@@ -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 `
|
||||
|
||||
82
.claude/scripts/syncro-env.sh
Normal file
82
.claude/scripts/syncro-env.sh
Normal 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
|
||||
128
.claude/skills/ask-forum/SKILL.md
Normal file
128
.claude/skills/ask-forum/SKILL.md
Normal 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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
3
.claude/skills/datto-workplace/.gitignore
vendored
Normal file
3
.claude/skills/datto-workplace/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# No live secrets are cached (Basic auth is per-request), but keep scratch out.
|
||||
.cache/
|
||||
__pycache__/
|
||||
74
.claude/skills/datto-workplace/SKILL.md
Normal file
74
.claude/skills/datto-workplace/SKILL.md
Normal 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.
|
||||
164
.claude/skills/datto-workplace/scripts/datto_client.py
Normal file
164
.claude/skills/datto-workplace/scripts/datto_client.py
Normal 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())
|
||||
165
.claude/skills/datto-workplace/scripts/datto_workplace.py
Normal file
165
.claude/skills/datto-workplace/scripts/datto_workplace.py
Normal 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())
|
||||
@@ -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
3
.claude/skills/msp360/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Skill scratch/cache - never commit. Auth token is per-run (not cached to disk).
|
||||
.cache/
|
||||
__pycache__/
|
||||
103
.claude/skills/msp360/SKILL.md
Normal file
103
.claude/skills/msp360/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: msp360
|
||||
description: "Manage ACG's MSP360 Managed Backup Service (MBS) tenant via the provider REST API: the authoritative fleet backup-status source (who is backing up, last run, failures, stale plans) plus user/company/license provisioning. Reads run freely; writes (create/delete user or company, grant/release/revoke license) are gated --confirm. Triggers: msp360, mspbackups, managed backup, is X backing up, backup status, backup failures, create backup user, add backup company, grant backup license."
|
||||
---
|
||||
|
||||
# MSP360 Managed Backup Service Skill
|
||||
|
||||
Live client for ACG's **MSP360 Managed Backup Service (MBS)** provider tenant. This is the
|
||||
**authoritative answer to "is client X actually backing up"** — the monitoring feed reports
|
||||
every backup plan's last run, status, and data across the whole fleet. B2/Seafile/ownCloud
|
||||
are just where the data lands; MSP360 is where you see whether a backup *ran*.
|
||||
|
||||
Built as a sibling to the `b2` / `seafile` / `owncloud` / `datto-workplace` backup skills;
|
||||
shares `.claude/scripts/backup_common.py` for repo-root/vault/HTTP plumbing.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
MSP=".claude/skills/msp360/scripts/msp360.py"
|
||||
|
||||
# --- reads (free) ---
|
||||
py "$MSP" status # reachable + counts + plans needing attention
|
||||
py "$MSP" monitoring # every plan: company/computer/status/last-run/data
|
||||
py "$MSP" monitoring --failed # only plans NOT in a clean state (the triage view)
|
||||
py "$MSP" monitoring --company Dataforth # scope to one client
|
||||
py "$MSP" monitoring --stale 30 # plans whose last run is >=30 days old (or never)
|
||||
py "$MSP" companies # 37 companies with ids + storage limits
|
||||
py "$MSP" users --company "Business Services" # backup users (+ space used) in a company
|
||||
py "$MSP" licenses --available # free vs taken licenses
|
||||
py "$MSP" billing --json # billing feed
|
||||
py "$MSP" raw GET /api/Users/{id} # generic passthrough for any GET endpoint
|
||||
|
||||
# --- writes (require --confirm; without it you get a dry-run preview) ---
|
||||
py "$MSP" create-company --name "New Client LLC" --confirm
|
||||
py "$MSP" create-user --email backup@client.com --company "New Client LLC" \
|
||||
--first Jane --last Doe --notify admin@acg.com --confirm
|
||||
py "$MSP" grant-license --user-id <UUID> --license-id <UUID> --confirm
|
||||
py "$MSP" release-license --user-id <UUID> --confirm
|
||||
py "$MSP" delete-user --id <UUID> [--keep-data] --confirm
|
||||
py "$MSP" delete-company --id <UUID> --confirm
|
||||
py "$MSP" raw POST /api/Accounts/CreateDestination --data '{...}' --confirm
|
||||
```
|
||||
|
||||
Add `--json` to any read for machine-readable output. `--base` overrides the API URL.
|
||||
|
||||
## Credentials
|
||||
|
||||
Loaded at runtime from the SOPS vault (never hardcoded):
|
||||
**`msp-tools/msp360-api.sops.yaml`** -> `credentials.login` / `credentials.password`.
|
||||
These are the **API-specific** creds
|
||||
generated in the MSP360 console (Settings > General) by the main admin account — NOT the
|
||||
console login. Auth flow: `POST /api/Provider/Login {UserName, Password}` -> Bearer
|
||||
`access_token` (per-run, never cached to disk) -> `Authorization: Bearer <token>` on every
|
||||
request. Base: `https://api.mspbackups.com`.
|
||||
|
||||
## What "backing up?" looks like
|
||||
|
||||
`monitoring` returns one row per backup plan with `Status` (0/1=Success, 2=Warning,
|
||||
3=Failed, 4=Running, 6=Interrupted, 7=Completed-with-warnings), `LastStart`, `DataCopied`,
|
||||
`CompanyName`, `ComputerName`, `PlanName`. Key reads:
|
||||
|
||||
- **A company with 0 rows in `monitoring` is not backing up** even if it exists (this is how
|
||||
the GPS audit found Arizona Medical Transit + T&C Sorensen billed-but-unprotected).
|
||||
- `--failed` surfaces failing/warning plans; `--stale N` surfaces plans that stopped running.
|
||||
- A B2 bucket with files does NOT prove a recent backup — always confirm with `monitoring`.
|
||||
|
||||
## Writes & safety
|
||||
|
||||
- **Every write is gated `--confirm`.** Without it the command prints a `[DRY-RUN]` line (and,
|
||||
for create-*, the exact JSON payload) and changes nothing.
|
||||
- `delete-user` defaults to removing metadata **and all backup data**; pass `--keep-data` to
|
||||
drop only the metadata (`DELETE /api/Users/{id}/Account`) and retain the backups.
|
||||
- `delete-company` / `delete-user` print a `[WARNING]` describing the blast radius first.
|
||||
- create-user / grant-license payloads are built from the live API schema (User fields:
|
||||
Email/FirstName/LastName/Company/Enabled/NotificationEmails; grant: UserID + LicenseID).
|
||||
On first real provisioning, eyeball the API's response — it returns the created object or a
|
||||
validation error, which the command prints verbatim.
|
||||
|
||||
## Full endpoint surface (via `raw` when a typed command doesn't exist)
|
||||
|
||||
The MBS API groups (all reachable through `raw <METHOD> <path>`): **Provider** (login),
|
||||
**Users** (GET/POST/PUT, DELETE `/{id}` or `/{id}/Account`), **Companies** (GET/POST/PUT/DELETE),
|
||||
**Monitoring** (GET), **Licenses** (GET, POST Grant/Release/Revoke), **Destinations**
|
||||
(GET/POST/PUT/DELETE), **Accounts** (GET/POST/PUT + Add/Create/Edit/RemoveDestination),
|
||||
**Packages** (GET/POST/PUT/DELETE), **Billing** (GET/PUT), **Builds** (GET, POST
|
||||
RequestCustomBuilds), **Administrators** (GET/POST/PUT/DELETE). Reference:
|
||||
`https://help.mspbackups.com/mbs-api-specification/methods`.
|
||||
|
||||
## Notes / gotchas
|
||||
|
||||
- **`/api/Computers` returns HTTP 400 "Remote Management API methods are not enabled"** —
|
||||
that feature is off on this account. `computers` prints a clean [INFO] and points you at
|
||||
`users` + `monitoring`, which cover endpoint/backup data without it.
|
||||
- **Git-Bash path mangling:** a leading-slash `raw` path like `/api/Users` gets rewritten to
|
||||
`C:/Program Files/Git/api/Users` by MSYS. The `raw` command auto-recovers the `/api/...`
|
||||
tail, so pass the path normally.
|
||||
- MSP360 CompanyName is the join key to B2 buckets (e.g. `ACG-PST`=Peaceful Spirit,
|
||||
`ACG-TCA`=Tucson Coin and Autograph) — useful when reconciling destinations.
|
||||
- Backup *targets* are a per-client decision: report mismatches, don't provision jobs off a
|
||||
billing count. See memory `feedback_backup_targets_never_guessed` +
|
||||
`reference_msp360_backup_monitoring`.
|
||||
- Failures are logged to the fleet `errorlog.md` via the canonical helper; expected
|
||||
conditions (feature-gated endpoint, no matching rows) are not.
|
||||
430
.claude/skills/msp360/scripts/msp360.py
Normal file
430
.claude/skills/msp360/scripts/msp360.py
Normal file
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MSP360 Managed Backup Service (MBS) CLI.
|
||||
|
||||
Read the fleet backup state and manage users / companies / licenses via the MBS
|
||||
provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is
|
||||
printed without it). See SKILL.md for the trigger list and examples.
|
||||
|
||||
Reads: status | monitoring | companies | users | computers | licenses | billing
|
||||
Writes: create-company | delete-company | create-user | delete-user
|
||||
grant-license | release-license | revoke-license | raw
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS # noqa: E402
|
||||
import backup_common as bc # noqa: E402 (msp360_client put .claude/scripts on sys.path)
|
||||
|
||||
|
||||
# --- helpers ------------------------------------------------------------------
|
||||
def _root() -> Path:
|
||||
# honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing.
|
||||
return bc.resolve_root()
|
||||
|
||||
|
||||
def _as_list(data, what: str) -> list:
|
||||
"""A read endpoint promised a JSON array; a non-list 2xx body is anomalous."""
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}")
|
||||
|
||||
|
||||
def log_error(brief: str, context: str = "") -> None:
|
||||
"""Best-effort fleet error log; never breaks the caller (per CLAUDE.md)."""
|
||||
try:
|
||||
script = _root() / ".claude" / "scripts" / "log-skill-error.sh"
|
||||
if not script.exists():
|
||||
return
|
||||
cmd = ["bash", str(script), "msp360", brief]
|
||||
if context:
|
||||
cmd += ["--context", context]
|
||||
subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def out(obj, as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(json.dumps(obj, indent=2, default=str))
|
||||
|
||||
|
||||
def human(n) -> str:
|
||||
try:
|
||||
n = float(n)
|
||||
except (TypeError, ValueError):
|
||||
return str(n)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(n) < 1024:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f}PB"
|
||||
|
||||
|
||||
def _days_since(ts: str):
|
||||
"""Whole days since an ISO timestamp. None if empty OR unparseable (callers must
|
||||
distinguish 'never ran' from 'could not parse' via the raw value themselves)."""
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_dt.timezone.utc)
|
||||
return (_dt.datetime.now(_dt.timezone.utc) - dt).days
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def require_confirm(args, action: str) -> bool:
|
||||
"""Return True to proceed; if --confirm absent, print the dry-run and return False."""
|
||||
if getattr(args, "confirm", False):
|
||||
return True
|
||||
print(f"[DRY-RUN] Would: {action}")
|
||||
print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.")
|
||||
return False
|
||||
|
||||
|
||||
# --- read commands ------------------------------------------------------------
|
||||
def cmd_status(c: Msp360Client, args) -> int:
|
||||
comps = c.companies()
|
||||
users = c.users()
|
||||
mon = c.monitoring()
|
||||
if args.json:
|
||||
out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True)
|
||||
return 0
|
||||
bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS)
|
||||
print(f"[OK] MSP360 MBS reachable at {c.base}")
|
||||
print(f"[INFO] companies={len(comps)} users={len(users)} backup plans={len(mon)} "
|
||||
f"plans needing attention={bad}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_monitoring(c: Msp360Client, args) -> int:
|
||||
mon = _as_list(c.monitoring(), "monitoring")
|
||||
rows = []
|
||||
for m in mon:
|
||||
if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower():
|
||||
continue
|
||||
status = m.get("Status")
|
||||
last_raw = m.get("LastStart") or ""
|
||||
never = not last_raw
|
||||
age = _days_since(last_raw)
|
||||
# --failed = triage: anything that is NOT a clean completed run and is not
|
||||
# in-progress. Includes never-run, Unknown(5) and null status (dead plans).
|
||||
if args.failed:
|
||||
clean_success = status in (0, 1) and not never
|
||||
if clean_success or status == 4: # 4 = Running
|
||||
continue
|
||||
# --stale = last run older than N days, OR never ran. An unparseable-but-present
|
||||
# timestamp is NOT claimed stale (avoid a false "not backing up").
|
||||
if args.stale is not None:
|
||||
if never:
|
||||
pass
|
||||
elif age is not None and age >= args.stale:
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
rows.append((m, age))
|
||||
if args.json:
|
||||
out([r[0] for r in rows], True)
|
||||
return 0
|
||||
if not rows:
|
||||
print("[INFO] no plans match the filter.")
|
||||
return 0
|
||||
print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA")
|
||||
for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(),
|
||||
r[0].get('ComputerName') or '')):
|
||||
st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status")))
|
||||
last = m.get("LastStart") or "never"
|
||||
agestr = f" ({age}d)" if age is not None and age >= 30 else ""
|
||||
print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} "
|
||||
f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} "
|
||||
f"{human(m.get('DataCopied', 0))}")
|
||||
print(f"\n[INFO] {len(rows)} plan(s). Filters: "
|
||||
f"company={args.company or '-'} failed={args.failed} stale>={args.stale}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_companies(c: Msp360Client, args) -> int:
|
||||
comps = _as_list(c.companies(), "companies")
|
||||
if args.json:
|
||||
out(comps, True)
|
||||
return 0
|
||||
for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()):
|
||||
lim = co.get("StorageLimit")
|
||||
limstr = "unlimited" if lim in (-1, None) else human(lim)
|
||||
print(f" {(co.get('Name') or ''):<34} id={co.get('Id')} storage_limit={limstr}")
|
||||
print(f"\n[INFO] {len(comps)} companies")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_users(c: Msp360Client, args) -> int:
|
||||
users = _as_list(c.users(), "users")
|
||||
if args.company:
|
||||
users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()]
|
||||
if args.json:
|
||||
out(users, True)
|
||||
return 0
|
||||
for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")):
|
||||
en = "enabled" if u.get("Enabled") else "DISABLED"
|
||||
print(f" {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} "
|
||||
f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}")
|
||||
print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else ""))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_computers(c: Msp360Client, args) -> int:
|
||||
try:
|
||||
comps = _as_list(c.computers(), "computers")
|
||||
except Msp360Error as exc:
|
||||
msg = str(exc).lower()
|
||||
if "remote management" in msg or "not enabled" in msg or "not available" in msg:
|
||||
print("[INFO] /api/Computers is gated behind the Remote Management API feature, "
|
||||
"which is not enabled on this MSP360 account. Use `users` + `monitoring` "
|
||||
"for endpoint/backup data instead.")
|
||||
return 0
|
||||
raise
|
||||
if args.json:
|
||||
out(comps, True)
|
||||
return 0
|
||||
for m in comps:
|
||||
print(f" {(m.get('Name') or m.get('ComputerName') or ''):<24} "
|
||||
f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}")
|
||||
print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_licenses(c: Msp360Client, args) -> int:
|
||||
lic = _as_list(c.licenses(available=True if args.available else None), "licenses")
|
||||
if args.json:
|
||||
out(lic, True)
|
||||
return 0
|
||||
taken = sum(1 for x in lic if x.get("IsTaken"))
|
||||
for x in lic:
|
||||
state = "TAKEN" if x.get("IsTaken") else "free"
|
||||
num = x.get("Number")
|
||||
num = "" if num is None else num
|
||||
print(f" #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} "
|
||||
f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}")
|
||||
print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_billing(c: Msp360Client, args) -> int:
|
||||
out(c.billing(), True) # billing has no table form; always JSON
|
||||
return 0
|
||||
|
||||
|
||||
# --- write commands -----------------------------------------------------------
|
||||
def cmd_create_company(c: Msp360Client, args) -> int:
|
||||
if not require_confirm(args, f"create company '{args.name}'"):
|
||||
return 0
|
||||
res = c.create_company(args.name)
|
||||
print(f"[OK] created company '{args.name}'")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete_company(c: Msp360Client, args) -> int:
|
||||
print(f"[WARNING] Deleting company id={args.id} removes the company record.")
|
||||
if not require_confirm(args, f"DELETE company id={args.id}"):
|
||||
return 0
|
||||
res = c.delete_company(args.id)
|
||||
print(f"[OK] deleted company id={args.id}")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create_user(c: Msp360Client, args) -> int:
|
||||
payload = {
|
||||
"Email": args.email,
|
||||
"FirstName": args.first or "",
|
||||
"LastName": args.last or "",
|
||||
"Company": args.company,
|
||||
"Enabled": True,
|
||||
"NotificationEmails": [args.notify] if args.notify else [],
|
||||
}
|
||||
if args.password:
|
||||
payload["Password"] = args.password
|
||||
if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"):
|
||||
preview = dict(payload)
|
||||
if "Password" in preview:
|
||||
preview["Password"] = "***redacted***" # never echo a secret to the transcript
|
||||
out(preview, True)
|
||||
return 0
|
||||
res = c.create_user(payload)
|
||||
print(f"[OK] created user '{args.email}'")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete_user(c: Msp360Client, args) -> int:
|
||||
scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data"
|
||||
print(f"[WARNING] Deleting user id={args.id}: {scope}.")
|
||||
if not require_confirm(args, f"DELETE user id={args.id} ({scope})"):
|
||||
return 0
|
||||
res = c.delete_user(args.id, keep_data=args.keep_data)
|
||||
print(f"[OK] deleted user id={args.id}")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_grant_license(c: Msp360Client, args) -> int:
|
||||
payload = {"UserID": args.user_id}
|
||||
if args.license_id:
|
||||
payload["LicenseID"] = args.license_id
|
||||
if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"):
|
||||
out(payload, True)
|
||||
return 0
|
||||
res = c.grant_license(payload)
|
||||
print("[OK] license granted")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_release_license(c: Msp360Client, args) -> int:
|
||||
payload = {"UserID": args.user_id}
|
||||
if args.license_id:
|
||||
payload["LicenseID"] = args.license_id
|
||||
if not require_confirm(args, f"release license from user {args.user_id}"):
|
||||
return 0
|
||||
res = c.release_license(payload)
|
||||
print("[OK] license released")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_revoke_license(c: Msp360Client, args) -> int:
|
||||
payload = {"UserID": args.user_id}
|
||||
if args.license_id:
|
||||
payload["LicenseID"] = args.license_id
|
||||
if not require_confirm(args, f"revoke license from user {args.user_id}"):
|
||||
return 0
|
||||
res = c.revoke_license(payload)
|
||||
print("[OK] license revoked")
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_raw(c: Msp360Client, args) -> int:
|
||||
method = args.method.upper()
|
||||
body = json.loads(args.data) if args.data else None
|
||||
# Git-Bash rewrites a leading-slash arg into a Windows path (e.g.
|
||||
# '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail.
|
||||
path = args.path
|
||||
if "/api/" in path and not path.startswith("/api"):
|
||||
path = path[path.index("/api/"):]
|
||||
elif not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"):
|
||||
return 0
|
||||
res = c.request(method, path, body=body)
|
||||
out(res, True)
|
||||
return 0
|
||||
|
||||
|
||||
# --- argparse -----------------------------------------------------------------
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI")
|
||||
p.add_argument("--base", help="override API base URL")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
def add_json(sp):
|
||||
sp.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
||||
return sp
|
||||
|
||||
add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status)
|
||||
|
||||
sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)")
|
||||
sp.add_argument("--company", help="filter by company name substring")
|
||||
sp.add_argument("--failed", action="store_true", help="only plans not in a clean state")
|
||||
sp.add_argument("--stale", type=int, metavar="DAYS",
|
||||
help="only plans whose last run is >= DAYS old (or never)")
|
||||
add_json(sp)
|
||||
sp.set_defaults(func=cmd_monitoring)
|
||||
|
||||
add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies)
|
||||
|
||||
sp = sub.add_parser("users", help="list backup users")
|
||||
sp.add_argument("--company", help="filter by company name substring")
|
||||
add_json(sp)
|
||||
sp.set_defaults(func=cmd_users)
|
||||
|
||||
add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers)
|
||||
|
||||
sp = sub.add_parser("licenses", help="list licenses")
|
||||
sp.add_argument("--available", action="store_true", help="only free/available licenses")
|
||||
add_json(sp)
|
||||
sp.set_defaults(func=cmd_licenses)
|
||||
|
||||
sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing)
|
||||
|
||||
# writes
|
||||
sp = sub.add_parser("create-company", help="create a company [--confirm]")
|
||||
sp.add_argument("--name", required=True)
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(func=cmd_create_company)
|
||||
|
||||
sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]")
|
||||
sp.add_argument("--id", required=True)
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(func=cmd_delete_company)
|
||||
|
||||
sp = sub.add_parser("create-user", help="create a backup user [--confirm]")
|
||||
sp.add_argument("--email", required=True)
|
||||
sp.add_argument("--company", required=True)
|
||||
sp.add_argument("--first")
|
||||
sp.add_argument("--last")
|
||||
sp.add_argument("--password")
|
||||
sp.add_argument("--notify", help="notification email")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(func=cmd_create_user)
|
||||
|
||||
sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]")
|
||||
sp.add_argument("--id", required=True)
|
||||
sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(func=cmd_delete_user)
|
||||
|
||||
for name, fn, verb in (("grant-license", cmd_grant_license, "grant"),
|
||||
("release-license", cmd_release_license, "release"),
|
||||
("revoke-license", cmd_revoke_license, "revoke")):
|
||||
sp = sub.add_parser(name, help=f"{verb} a license [--confirm]")
|
||||
sp.add_argument("--user-id", required=True)
|
||||
sp.add_argument("--license-id")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(func=fn)
|
||||
|
||||
sp = sub.add_parser("raw", help="raw API call (writes need --confirm)")
|
||||
sp.add_argument("method")
|
||||
sp.add_argument("path", help="e.g. /api/Users/{id}")
|
||||
sp.add_argument("--data", help="JSON request body")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(func=cmd_raw)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
client = Msp360Client(base=args.base)
|
||||
try:
|
||||
return args.func(client, args)
|
||||
except bc.BackupError as exc: # includes Msp360Error + vault/HTTP transport errors
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
log_error(str(exc)[:200], context=f"command={args.command}")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[ERROR] unexpected: {exc}", file=sys.stderr)
|
||||
log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MSP360 Managed Backup Service (MBS) API client.
|
||||
|
||||
Full CRUD over the provider REST API at https://api.mspbackups.com. Auth is a Bearer
|
||||
token from POST /api/Provider/Login (login+password generated in MSP360 console
|
||||
Settings > General, vaulted at msp-tools/msp360-api.sops.yaml).
|
||||
|
||||
Reuses `.claude/scripts/backup_common.py` for repo-root resolution, the canonical SOPS
|
||||
vault read, and stdlib JSON HTTP -- same plumbing as the seafile/owncloud/datto-workplace
|
||||
sibling skills. Standalone: stdlib-only transport.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# backup_common lives at <root>/.claude/scripts/ ; this file is
|
||||
# <root>/.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == <root>/.claude
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts"))
|
||||
import backup_common as bc # noqa: E402
|
||||
|
||||
VAULT_ENTRY = "msp-tools/msp360-api.sops.yaml"
|
||||
DEFAULT_BASE = "https://api.mspbackups.com"
|
||||
|
||||
# Monitoring plan Status codes (observed + MBS convention).
|
||||
STATUS_TEXT = {
|
||||
0: "Success", 1: "Success", 2: "Warning", 3: "Failed",
|
||||
4: "Running", 5: "Unknown", 6: "Interrupted", 7: "Completed/warnings",
|
||||
}
|
||||
# Statuses that mean "this run did NOT produce a clean backup".
|
||||
BAD_STATUS = {2, 3, 6, 7}
|
||||
|
||||
|
||||
class Msp360Error(bc.BackupError):
|
||||
"""MSP360 transport / auth / API error (carries optional HTTP status)."""
|
||||
|
||||
|
||||
class Msp360Client:
|
||||
"""Thin MSP360 MBS API client: login once, then typed helpers over the endpoints."""
|
||||
|
||||
def __init__(self, base: Optional[str] = None):
|
||||
self.base = (base or DEFAULT_BASE).rstrip("/")
|
||||
self._token: Optional[str] = None
|
||||
|
||||
# --- auth -----------------------------------------------------------------
|
||||
def login(self) -> None:
|
||||
login = bc.vault_get_field(VAULT_ENTRY, "credentials.login")
|
||||
pw = bc.vault_get_field(VAULT_ENTRY, "credentials.password")
|
||||
st, body = bc.http_json(
|
||||
"POST", f"{self.base}/api/Provider/Login",
|
||||
body={"UserName": login, "Password": pw})
|
||||
if st != 200 or not isinstance(body, dict) or not body.get("access_token"):
|
||||
raise Msp360Error(f"MSP360 login failed (HTTP {st}): {body}", status=st)
|
||||
self._token = body["access_token"]
|
||||
|
||||
def _auth(self) -> dict:
|
||||
if not self._token:
|
||||
self.login()
|
||||
return {"Authorization": "Bearer " + str(self._token)}
|
||||
|
||||
def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
||||
"""One API call. Raises Msp360Error on any non-2xx (with the API's error body)."""
|
||||
st, data = bc.http_json(method, f"{self.base}{path}", headers=self._auth(), body=body)
|
||||
if st < 200 or st >= 300:
|
||||
raise Msp360Error(f"{method} {path} -> HTTP {st}: {data}", status=st)
|
||||
return data
|
||||
|
||||
# --- reads ----------------------------------------------------------------
|
||||
def monitoring(self, user_id: Optional[str] = None) -> list:
|
||||
return self.request("GET", f"/api/Monitoring/{user_id}" if user_id else "/api/Monitoring")
|
||||
|
||||
def companies(self) -> list:
|
||||
return self.request("GET", "/api/Companies")
|
||||
|
||||
def users(self) -> list:
|
||||
return self.request("GET", "/api/Users")
|
||||
|
||||
def computers(self, offset: int = 0, count: int = 1000) -> list:
|
||||
return self.request("GET", f"/api/Computers/{offset}/{count}")
|
||||
|
||||
def licenses(self, available: Optional[bool] = None) -> list:
|
||||
path = "/api/Licenses"
|
||||
if available is not None:
|
||||
path += f"?isAvailable={'true' if available else 'false'}"
|
||||
return self.request("GET", path)
|
||||
|
||||
def billing(self) -> Any:
|
||||
return self.request("GET", "/api/Billing")
|
||||
|
||||
# --- writes (companies) ---------------------------------------------------
|
||||
def create_company(self, name: str, **extra: Any) -> Any:
|
||||
payload = {"Name": name, "StorageLimit": -1}
|
||||
payload.update(extra)
|
||||
return self.request("POST", "/api/Companies", body=payload)
|
||||
|
||||
def delete_company(self, company_id: str) -> Any:
|
||||
return self.request("DELETE", f"/api/Companies/{company_id}")
|
||||
|
||||
# --- writes (users) -------------------------------------------------------
|
||||
# Property edits (PUT /api/Users, /api/Companies) are reachable via the CLI's
|
||||
# `raw PUT ...` passthrough; not wrapped here until a typed edit command exists.
|
||||
def create_user(self, payload: dict) -> Any:
|
||||
return self.request("POST", "/api/Users", body=payload)
|
||||
|
||||
def delete_user(self, user_id: str, keep_data: bool = False) -> Any:
|
||||
# /{id}/Account = drop metadata but KEEP backup data; /{id} = drop everything.
|
||||
suffix = f"/api/Users/{user_id}/Account" if keep_data else f"/api/Users/{user_id}"
|
||||
return self.request("DELETE", suffix)
|
||||
|
||||
# --- writes (licenses) ----------------------------------------------------
|
||||
def grant_license(self, payload: dict) -> Any:
|
||||
return self.request("POST", "/api/Licenses/Grant", body=payload)
|
||||
|
||||
def release_license(self, payload: dict) -> Any:
|
||||
return self.request("POST", "/api/Licenses/Release", body=payload)
|
||||
|
||||
def revoke_license(self, payload: dict) -> Any:
|
||||
return self.request("POST", "/api/Licenses/Revoke", body=payload)
|
||||
3
.claude/skills/owncloud/.gitignore
vendored
Normal file
3
.claude/skills/owncloud/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Skill scratch/cache - never commit.
|
||||
.cache/
|
||||
__pycache__/
|
||||
175
.claude/skills/owncloud/SKILL.md
Normal file
175
.claude/skills/owncloud/SKILL.md
Normal 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).
|
||||
76
.claude/skills/owncloud/scripts/dry_test.sh
Normal file
76
.claude/skills/owncloud/scripts/dry_test.sh
Normal 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 ]
|
||||
626
.claude/skills/owncloud/scripts/owncloud.py
Normal file
626
.claude/skills/owncloud/scripts/owncloud.py
Normal 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())
|
||||
510
.claude/skills/owncloud/scripts/owncloud_client.py
Normal file
510
.claude/skills/owncloud/scripts/owncloud_client.py
Normal 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())
|
||||
464
.claude/skills/packetdial/docs/VOIP_ONBOARDING_WORKFLOW.md
Normal file
464
.claude/skills/packetdial/docs/VOIP_ONBOARDING_WORKFLOW.md
Normal 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/)
|
||||
@@ -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)
|
||||
50
.claude/skills/packetdial/scripts/provision-vwp-devices.sh
Normal file
50
.claude/skills/packetdial/scripts/provision-vwp-devices.sh
Normal 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
|
||||
@@ -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"
|
||||
64
.claude/skills/packetdial/scripts/provision-vwp-users.sh
Normal file
64
.claude/skills/packetdial/scripts/provision-vwp-users.sh
Normal 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"
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
3
.claude/skills/seafile/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Holds a live Seafile API token — never commit.
|
||||
.cache/
|
||||
__pycache__/
|
||||
118
.claude/skills/seafile/SKILL.md
Normal file
118
.claude/skills/seafile/SKILL.md
Normal 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).
|
||||
435
.claude/skills/seafile/scripts/seafile.py
Normal file
435
.claude/skills/seafile/scripts/seafile.py
Normal 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())
|
||||
284
.claude/skills/seafile/scripts/seafile_client.py
Normal file
284
.claude/skills/seafile/scripts/seafile_client.py
Normal 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())
|
||||
171
.claude/skills/tailscale/SKILL.md
Normal file
171
.claude/skills/tailscale/SKILL.md
Normal 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
|
||||
293
.claude/skills/tailscale/scripts/tailscale-api.sh
Normal file
293
.claude/skills/tailscale/scripts/tailscale-api.sh
Normal 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
|
||||
116
.claude/skills/yealink-ymcs/docs/SIPSERVER_SCHEMA.md
Normal file
116
.claude/skills/yealink-ymcs/docs/SIPSERVER_SCHEMA.md
Normal 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.
|
||||
104
.claude/skills/yealink-ymcs/docs/chunks/README.md
Normal file
104
.claude/skills/yealink-ymcs/docs/chunks/README.md
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 Name,maximum 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 Description,maximum 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 you’re 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
|
||||
@@ -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.
|
||||
|
||||
💡 Note:You 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.
|
||||
|
||||
💡 Note:If 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.
|
||||
|
||||
💡 Note:Editing 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.
|
||||
|
||||
💡 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.
|
||||
|
||||
|
||||
|
||||
|
||||
39 / 43
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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' Computers(Windows) 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
Reference in New Issue
Block a user