From 2a1a27551173de7cfb3373b9099ef8c4c0e57073 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Wed, 24 Jun 2026 17:37:35 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-06-24 17:37:00 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-24 17:37:00 --- .claude/skills/synology/SKILL.md | 123 ++++++ .claude/skills/synology/references/dsm-api.md | 205 ++++++++++ .claude/skills/synology/scripts/syno-ssh.sh | 79 ++++ .../skills/synology/scripts/syno_client.py | 378 ++++++++++++++++++ .../csc-ent-client-inventory-2026-06-24.md | 32 +- .../network/csc-ent-device-island-plan.md | 39 +- .../proposals/carf-technology-plan-intake.md | 36 ++ ...icket-review-and-cascades-consolidation.md | 49 +++ errorlog.md | 6 + 9 files changed, 930 insertions(+), 17 deletions(-) create mode 100644 .claude/skills/synology/SKILL.md create mode 100644 .claude/skills/synology/references/dsm-api.md create mode 100644 .claude/skills/synology/scripts/syno-ssh.sh create mode 100644 .claude/skills/synology/scripts/syno_client.py diff --git a/.claude/skills/synology/SKILL.md b/.claude/skills/synology/SKILL.md new file mode 100644 index 00000000..38397859 --- /dev/null +++ b/.claude/skills/synology/SKILL.md @@ -0,0 +1,123 @@ +--- +name: synology +description: > + Control a Synology NAS end-to-end via two surfaces: the DSM Web API (auth + full + API discovery + any API method — system, storage, shares, users/groups, packages, + services, FileStation, connections) and an SSH backend for the syno* CLI the Web + API doesn't expose (filesystem ACLs, low-level share/user/group internals). Reads + run freely; mutating calls (set/create/delete/start/stop/reboot/shutdown/...) are + gated behind --confirm. Default device is the Cascades NAS (cascadesDS, 192.168.0.120, + admin); point at another client's NAS with --vault. Triggers: synology, diskstation, + DSM, cascadesDS, NAS, control the synology, synology drive, hyper backup, active backup, + synology package/service/share/user, reboot the nas, synology acl. +--- + +# synology — Synology DSM control (Web API + SSH) + +Full control of a Synology NAS. Two complementary backends: + +1. **DSM Web API** (`scripts/syno_client.py`) — the structured surface. Auth → discover the + device's *own* API map → call any API method. This is how you "control all functions": + `apis` shows everything the device exposes, `call ` reaches any of it. +2. **SSH `syno*` CLI** (`scripts/syno-ssh.sh`) — the gaps the Web API doesn't cover: filesystem + ACLs (`synoacltool`), share/user/group CLI internals, package CLI. We already use this for + the Cascades share-permission inventory (`docs/migration/synology-permission-inventory.md`). + +**Default target = Cascades `cascadesDS`** (`192.168.0.120`, DSM on :5000, admin) — credential +vaulted at `clients/cascades-tucson/synology-cascadesds.sops.yaml` (`host`/`port`/ +`credentials.username`/`credentials.password`). ext4 filesystem (NOT Btrfs → no Active Backup +for Business). Shares: homes, Public, SalesDept, Server, Management. **On a private LAN — every +command needs the Cascades site VPN up** (a connect failure almost always = VPN down). Point at +any other NAS with `--vault clients//synology-...sops.yaml` (same field layout). + +## Web API commands +```bash +PY="$CLAUDETOOLS_ROOT/.claude/scripts/py.sh"; S=".claude/skills/synology/scripts/syno_client.py" +# verify + discover +bash "$PY" "$S" test # login, print model/serial/firmware/RAM/uptime +bash "$PY" "$S" apis [filter] # the device's full API map (e.g. `apis backup`, `apis drive`) +# reads +bash "$PY" "$S" sysinfo # SYNO.Core.System — model/serial/RAM/temp/uptime +bash "$PY" "$S" util # live CPU/mem/disk/net +bash "$PY" "$S" storage # volumes/disks/RAID/usage (SYNO.Storage.CGI.Storage) +bash "$PY" "$S" shares | users | groups | packages | services | connections +bash "$PY" "$S" ls [folder_path] # FileStation: no path = shares; else list a folder +# generic power tool — ANY API method the device exposes +bash "$PY" "$S" call [k=v ...] [k:=json] [--version N] [--post] +bash "$PY" "$S" call SYNO.Core.System.Status get +bash "$PY" "$S" call SYNO.Backup.Task list # Hyper Backup tasks (if installed) +# writes — ALL gated --confirm +bash "$PY" "$S" pkg-start --confirm | pkg-stop --confirm +bash "$PY" "$S" reboot --confirm | shutdown --confirm +bash "$PY" "$S" call k=v --confirm # any mutating method needs --confirm +``` +`call` params: `k=v` for strings, `k:=json` for typed/array values (e.g. `additional:='["size","owner"]'`). +A method whose name starts with a mutating verb (set/create/delete/start/stop/reboot/…) is auto-detected +and **refuses to run without `--confirm`**. + +## SSH commands (the syno* CLI surface) +```bash +X=".claude/skills/synology/scripts/syno-ssh.sh" +bash "$X" info | df | shares | users | groups | packages | services # reads +bash "$X" acl # synoacltool -get /volume1/ (e.g. acl Server) +bash "$X" reboot --confirm # synoshutdown -r (use when the Web-API reboot 103s) +bash "$X" shutdown --confirm # synoshutdown -s +bash "$X" run "" --confirm # arbitrary command (gated); privileged auto-sudo -S +``` +Requires SSH enabled on the NAS (DSM → Terminal & SNMP). Privileged recipes feed the vaulted +admin password to `sudo -S`. Auth: sshpass if present, else OpenSSH SSH_ASKPASS (on Windows the +fallback needs MSYS/Git-bash `ssh` on PATH — system OpenSSH can't exec a shell askpass). + +## Auth model (DSM Web API) +`SYNO.API.Auth` login (`session=DSM`, `enable_syno_token=yes`) → `_sid` (session cookie) + a +`synotoken` (CSRF). Both are attached to every subsequent call (the synotoken as `X-SYNO-TOKEN` +header + `SynoToken` param) so writes pass CSRF. 2FA: pass `--otp ` or `SYNO_OTP`. The client +always logs out at the end. Env overrides for ad-hoc use: `SYNO_HOST/SYNO_PORT/SYNO_USER/SYNO_PASS`, +`SYNO_HTTPS=1` (→ :5001, cert verify off). + +## Full API reference +**`references/dsm-api.md`** is the consolidated, sourced map (auth, system/power, storage/shares, +users/groups/security, packages, Log Center, Drive, backup, network/services + the SSH `syno*` +surface) with per-row confidence and the 5 "golden rules". **Read it before any write** — the key +ones: discover versions via `apis` (don't hardcode), CSRF is mandatory for writes (handled), +**setters are read-modify-write** (`get` → mutate → push back; never construct from scratch), and +when a setter's param keys are uncertain run it on-box via `synowebapi --exec` (validates params). + +## Why both surfaces (don't reach for SSH first) +The Web API is the default and covers system/storage/shares/users/packages/services/FileStation/ +backup/connections. Use SSH only for what the API genuinely lacks — chiefly **filesystem ACLs** +(`synoacltool`): the Web API tells you a share is in Windows-ACL mode but NOT the per-file ACE list. +That ACE data is exactly what the Cascades cutover needs (`docs/migration/ +synology-permission-analysis-2026-04-22.md`). + +## Open Cascades work this skill serves (verified call recipes) +- **Confirm model/RAM/DSM version** (wiki TODO) — `test` answers it in one call. +- **Log Center syslog collector** (`docs/network/network-logging-plan.md`) — the NAS-as-syslog-SERVER + is `SYNO.LogCenter.*` (needs the Log Center package): `apis logcenter` to confirm it's installed, + then `call SYNO.LogCenter.RecvRule list` (receive rules), `call SYNO.LogCenter.Log list` (received + logs). NOTE `SYNO.Core.SyslogClient.*` is the *forward-own-logs-out* side, not the collector. + Schedule the snapshotter as a root task via `SYNO.Core.TaskScheduler.Root create`. +- **Synology Drive Team Folder migration** (`PROJECT_STATE` pending) — `apis synologydrive`, then + `call SYNO.SynologyDrive.TeamFolders list` / `... Tasks list` / `... Connection list` to inspect + before the CS-SERVER cutover. (`SYNO.C2FS.*` is C2 cloud — not this.) +- **Hyper Backup offsite** (phase4 §6.4) — `call SYNO.Backup.Task list` / `... Repository list` once + configured. (NOTE: Active Backup **is** installed + running on this ext4 box — the Btrfs requirement + is only for ABB dedup/self-healing features, not the package. `call SYNO.ActiveBackup.Task list`.) + +## Error logging (mandatory) +On a GENUINE functional failure (DSM login fail, API error, SSH connect/auth rc=255) both scripts +log via `.claude/scripts/log-skill-error.sh` before surfacing. Handled conditions (missing cred, +VPN-down connect error surfaced to the user, a method refused for lack of --confirm) are NOT logged. + +## Status / verification +- **[PLUMBED]** Built from a 5-agent scan of the DSM 7 help tree + the authoritative API sources + (kwent device-extracted `_full.json`, N4S4/synology-api, Synology's Web API guides) — see + `references/dsm-api.md`. Auth (sid + synotoken CSRF), API discovery, version-by-maxVersion routing, + session-expiry re-login (codes 106/119), the mutating-verb gate, and the generic-error map are all + coded to spec but **not yet live-exercised** against the device (needs the Cascades VPN up). First + live `test` confirms endpoint shapes + fills the model/DSM-version wiki TODO. Mark verified then. +- **DSM 7.2.x reboot/shutdown 103:** the Web-API `reboot`/`shutdown` can return error 103 on some + builds. Fallback is the SSH `reboot|shutdown --confirm` recipe (`synoshutdown -r|-s`). +- **Param-schema caveat:** method names + versions in the reference are device-authoritative, but many + *setter param keys* are community/inferred. For a production write where keys are uncertain, capture + the matching `get` and round-trip it, or run on-box via `synowebapi --exec`. diff --git a/.claude/skills/synology/references/dsm-api.md b/.claude/skills/synology/references/dsm-api.md new file mode 100644 index 00000000..da9e9471 --- /dev/null +++ b/.claude/skills/synology/references/dsm-api.md @@ -0,0 +1,205 @@ +# DSM 7 Web API reference (for the `synology` skill) + +Consolidated from primary sources — kwent's device-extracted DSM 7.x `_full.json` +(method/version dump from a real device = authoritative for **names + versions**), +N4S4/`synology-api` source (param detail), and Synology's official *DSM Login Web API* +and *FileStation API* guides. Confidence per row: **[docs]** authoritative · **[comm]** +community/library · **[inf]** inferred. **[W]** = mutating → gate behind `--confirm`. +**[PKG]** = the API only appears in the device's `SYNO.API.Info` map if that package is +installed. + +> **Golden rules (why the client is shaped the way it is)** +> 1. **Discover, don't hardcode.** Call `SYNO.API.Info query=all` after login and route +> every call to the returned `path` (DSM 7 funnels most `SYNO.Core.*` through +> `entry.cgi`; DSM 6 used per-API `.cgi`) at the advertised `maxVersion` (FileStation +> `Upload` is the exception — use `minVersion`). +> 2. **CSRF is mandatory for writes.** Login with `enable_syno_token=yes`, then send the +> `synotoken` as both the `X-SYNO-TOKEN` header and a `SynoToken` param on every call. +> Without it, DSM 7 silently fails mutating calls. (The client does this.) +> 3. **Setters are read-modify-write.** Many `set` methods take a JSON blob (`conf`, +> `profile`, `rules`, `shareinfo`, firewall/DSM `**kwargs`). Always `get` first, mutate, +> push back — constructing from scratch wipes sibling settings. +> 4. **Param schemas are the weak spot.** Method names + versions below are +> device-authoritative; many *setter param keys* are community/inferred. Before a +> production write, capture the matching `get` and round-trip it, or run the call on-box +> via `synowebapi --exec api= method= version= k=v` (the on-box binary +> validates params, sidestepping the gaps). +> 5. `format=sid` over `cookie` on DSM 7 (avoids cookie-jar/HTTPS issues). + +## Auth & discovery — always present +| API | method | key params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.API.Info | query | `query=all` or CSV of API names | 1 | Discover APIs → `path`/`minVersion`/`maxVersion`. **Call first.** | docs | +| SYNO.API.Auth | login | `account`,`passwd`,`session`,`format=sid`,`enable_syno_token=yes`,`otp_code`,`enable_device_token=yes`,`device_id`,`device_name` | 7 (6/3 older) | Returns `data.sid` + `data.synotoken`. `device_id`/`enable_device_token` = trusted-device skip-OTP. | docs | +| SYNO.API.Auth | logout | `session` | 7 | End session. | docs | + +**Auth error codes** (in `error.code` on login): 400 bad account/password · 401 disabled/guest · +402 permission denied · **403 OTP code required** · **404 OTP code wrong** · 406 enforce-2FA (must +enable) · **407 IP auto-blocked** · 408 password expired (uncchangeable) · 409 password expired · +410 password must change. Branch on 403/404/407 — the rest are hard failures. +**Generic codes** (any API): 100 unknown · 101 bad param · 102 API n/a · 103 method n/a · 104 version +unsupported · 105 insufficient permission · 106 session timeout · 107 dup-login interrupt · **119 +invalid/missing SID or synotoken** → re-login. (Client re-logs in once on 106/119.) + +## System / power / utilization — `SYNO.Core.System*` +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.Core.System | info | (none) | 1–2 | model, serial, firmware_ver/version_string, ram_size(MB), cpu_*, temperature, up_time, time/time_zone, ntp_server, sata_dev/usb_dev | comm | +| SYNO.Core.System | reboot | optional `force` | 1 | **[W]** reboot. *See 103 note.* | comm | +| SYNO.Core.System | shutdown | optional `force` | 1 | **[W]** power off. *See 103 note.* | comm | +| SYNO.Core.System.Utilization | get | — | 1 | live `cpu{*_load,1/5/15min}`, `memory{total_real,avail_real,real_usage,cached,...}`, `disk{total{read/write_byte},disk[]}`, `network[{device,tx,rx}]` | comm | +| SYNO.Core.System.Status | get | — | 1 | `is_system_crashed`,`is_disk_wcache_crashed`,`upgrade_ready` | comm | +| SYNO.Core.System.SystemHealth | get | — | 1 | aggregate health summary | comm | +| SYNO.Core.CurrentConnection | get *(some builds `list`)* | `offset`,`limit` | 1 | active user/file-service sessions | comm | +| SYNO.Core.System.Process / ProcessGroup | list | — | 1 | Resource Monitor processes | comm | + +> **reboot/shutdown 103 regression:** confirmed API is `SYNO.Core.System` `reboot`/`shutdown` +> (NOT `Hardware.PowerSchedule` = *scheduled* on/off, NOT `Core.Upgrade`). Some DSM 7.2.x builds +> return **103 "method does not exist"** via the Web API. Mitigation: route via `entry.cgi` at +> `maxVersion` with the synotoken; on persistent 103 fall back to **SSH `synoshutdown -r|-s`** +> (the `syno-ssh.sh reboot|shutdown --confirm` recipes). + +## Storage / shares / file services +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.Storage.CGI.Storage | load_info | — | 1 | one snapshot: `volumes[]`(id,fs_type,size.total/used,raid_type,vol_path), `disks[]`, `storagePools[]`, `ssdCaches[]`, `env`, `ha_info`. Parse defensively (sizes are byte-strings). | docs/comm | +| SYNO.Core.Share | list | `shareType`(all/dec/enc), `additional:=[…]`, `offset`,`limit` | 1 | shares + flags | docs | +| SYNO.Core.Share | get | `name`, `additional:=[…]` | 1 | one share | docs | +| SYNO.Core.Share | create / set | `name`, `shareinfo:={…}`(name,vol_path,desc,enable_recycle_bin,encryption,hidden,enable_share_quota,share_quota) | 1 | **[W]** create/modify share | comm | +| SYNO.Core.Share | delete | `name:=["s1",…]` | 1 | **[W]** delete | docs | +| SYNO.Core.Share.Permission | list | `name`,`action`(enum/find),`user_group_type`,`offset`,`limit` | 1 | per-user/group RW/RO/NA on a share | comm | +| SYNO.Core.Share.Permission | set / set_by_user_group | `name`,`user_group_type`,`permissions:=[…]` | 1 | **[W]** set share perms | comm | +| SYNO.Core.Share.Permission | list_by_group | `name`,`user_group_type`,`share_type` | 1 | shares a group can reach | comm | +| SYNO.Core.Share.Crypto | encrypt/decrypt/mount/unmount | `name`,`password` | 1 | **[W]** encryption (API partly gated → prefer CLI) | inf | +| SYNO.Core.FileServ.SMB / AFP / NFS / FTP | get / set | service config | 1–3 | **[W on set]** file-service protocol config | docs | + +`Share.list` `additional` values (where perm/ACL/encryption/quota state is read): +`hidden, encryption, is_aclmode, is_support_acl, unite_permission, recyclebin, share_quota, +share_quota_used, is_sync_share, is_force_readonly, support_snapshot`. **Windows-ACL mode** is read +via `is_aclmode`+`is_support_acl`+`unite_permission` — but this only says a share *is* in ACL mode; +**the per-file ACE list is NOT in the Web API** (filesystem-only → use `synoacltool` over SSH). + +## Users / groups / security / directory / certificates +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.Core.User | list / get | `additional:=["email","description","expired","cannot_chg_passwd","passwd_never_expire"]` | 1 | local users | docs | +| SYNO.Core.User | create / set / delete | `name`,`password`,`description`,`email`,`expired`,`cannot_chg_passwd` | 1 | **[W]** user CRUD (password set via `set`, no separate method) | docs | +| SYNO.Core.User.Group | join | `name`,`join_group:=[…]`,`leave_group:=[…]` | 1 | **[W]** add/remove user↔groups (async → `join_status task_id`) | docs | +| SYNO.Core.User.PasswordPolicy / PasswordExpiry | get / set | policy blob | 1 | **[W]** password strength/aging | docs | +| SYNO.Core.Group | list | `offset`,`limit`,`type` | 1 | groups | docs | +| SYNO.Core.Group | create / set / delete | `name`,`new_name`,`description` | 1 | **[W]** group CRUD | docs | +| SYNO.Core.Group.Member | list | `group`,`ingroup`(bool) | 1 | members | docs | +| SYNO.Core.Group.Member | change | `group`,`add_member:=[…]`,`remove_member:=[…]` | 1 | **[W]** add+remove in one call (**list-replace semantics — read first**) | docs | +| SYNO.Core.Quota | get / set | `name`,`group_quota` | 1 | **[W]** per-share group quota | docs | +| SYNO.Core.Security.Firewall | get / set | settings blob | max | **[W]** global firewall | docs | +| SYNO.Core.Security.Firewall.Rules | list / get / set / delete | `rules`(full JSON list) | max | **[W]** firewall rules (read-modify-write the whole set) | docs | +| SYNO.Core.Security.AutoBlock.Rules | get / list / set / delete | `rules` | max | **[W]** brute-force auto-block list + thresholds | docs | +| SYNO.Core.Security.DoS / Security.DSM | get / set | **kwargs | max | **[W]** DoS protect; DSM hardening (account-protection, HSTS, banner) | docs | +| SYNO.Core.OTP.EnforcePolicy / SmartBlock(.Trusted/.Untrusted/.User/.Device) | get/set | **kwargs | — | **[W]** force-2FA / Adaptive-MFA (account protection) | comm | +| SYNO.Core.Directory.Domain.Conf | get / set | `conf`(JSON: server,DNS,admin creds,NetBIOS) | max | **[W]** AD join (creds inside `conf` — capture a joined NAS's `get` first) | comm | +| SYNO.Core.Directory.LDAP.* | get / set | profile/base_dn | max | **[W]** LDAP join | comm | +| SYNO.Core.Certificate.CRT | list / set / delete | `cert_id`,`as_default`,`ids` | 1 | **[W on set/del]** list/select/delete certs | docs | +| SYNO.Core.Certificate | import / export | `serv_key`,`ser_cert`,`ca_cert`,`id`,`as_default` (multipart) | min | **[W]** upload/export cert | docs | +| SYNO.Core.Certificate.Service | set | `cert_id`,`service`,`old_id` | min | **[W]** bind cert to a service | docs | + +## Packages — `SYNO.Core.Package*` (core) +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.Core.Package | list | `additional:=["status","installed_info"]` | 2 | installed packages + run status | comm | +| SYNO.Core.Package.Control | start / stop | `id=` (POST) | 1 | **[W]** start/stop a package. `id` = the package-ID **string** (`SynologyDrive`,`HyperBackup`,`LogCenter`,…) | docs | +| SYNO.Core.Package.Server | list | `blforcereload` | 2 | available catalog packages | comm | +| SYNO.Core.Package.Installation / Uninstallation | install / upload / uninstall | `url`/`name`/`id` | 1 | **[W]** install/remove | comm | + +CLI fallback (most reliable): `synopkg list --name` · `synopkg status ` · `synopkg +start|stop ` (sudo). + +## Log Center — syslog collector project [PKG: Log Center] +The **NAS-as-server** (receive pfSense/UniFi logs) is `SYNO.LogCenter.*` (needs the package). The +**NAS-forwarding-its-own-logs** is `SYNO.Core.SyslogClient.*` (core). For the on-site collector you +want the server side. +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.LogCenter.RecvRule | list | — | max | log-receiving rules (name, UDP/TCP/TLS, port 514, BSD/IETF format) = the "syslog server" objects | comm | +| SYNO.LogCenter.RecvRule | create / set / delete | `name`,`port`,`protocol`,`format`,`enable` | max | **[W]** create/enable a receive rule = turn the NAS into a syslog server. *Method names inferred — verify live via `apis SYNO.LogCenter.RecvRule`.* | inf | +| SYNO.LogCenter.Log | list | filter/paging | max | read received remote logs | comm | +| SYNO.LogCenter.Log | get_remotearch_subfolder | — | max | list remote-log archive subfolders | comm | +| SYNO.LogCenter.Setting.Storage | get / set | volume,retention,archive | max | **[W on set]** where received logs are stored + rotation | comm | +| SYNO.Core.SyslogClient.Status | cnt_get / eps_get | — | 1 | received-count / events-per-sec metrics | comm | +| SYNO.Core.SyslogClient.Setting(.Profile) | get / set | remote host,port,protocol,TLS,severity | 1 | **[W on set]** forward the NAS's OWN logs out | inf | + +Log Center wraps **syslog-ng**; custom listeners can also be dropped in +`/etc/syslog-ng/patterndb.d/`. Prefer the RecvRule API/GUI for standup. + +## Synology Drive — team-folder migration project [PKG: Synology Drive Server] +All read-only `list` (confirmed). `SYNO.C2FS.*` is C2 **cloud**, not on-prem — don't use it here. +| API | method | ver | what | conf | +|---|---|---|---|---| +| SYNO.SynologyDrive.Tasks | list | max | server-side sync tasks | comm | +| SYNO.SynologyDrive.TeamFolders | list | max | **Team Folders** (migration target) | comm | +| SYNO.SynologyDrive.Connection | list | max | connected Drive clients/devices | comm | +| SYNO.SynologyDrive.Profiles | list | max | per-user sync profiles (`user`,`start`,`limit`) | comm | +| SYNO.SynologyDrive.Settings | list | max | Drive Admin Console settings | comm | +| SYNO.SynologyDriveShareSync.Connection | list | max | server-to-server ShareSync connections | comm | + +## Backup +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.Backup.Task | list / get / status | `task_id` | min | **[PKG: Hyper Backup]** backup tasks + run state | comm | +| SYNO.Backup.Task | backup | `task_id` | min | **[W]** run a backup task now | comm | +| SYNO.Backup.Repository | list / get | `task_id` | min | destinations/repositories (B2/Wasabi appear as target fields) | comm | +| SYNO.ActiveBackup.Task | list | `load_status`,`load_result`,`load_devices` | 1 | **[PKG: ABB — needs Btrfs]** task inventory | comm | +| SYNO.ActiveBackup.Task | backup / cancel / remove | `task_ids` | 1 | **[W]** run/cancel/delete | comm | +| SYNO.ActiveBackup.Log | list_log / list_result | `filter`,`offset`,`limit` | 1 | task logs + run history | comm | + +> **ABB on ext4 — CORRECTED by live test 2026-06-24.** Active Backup for Business **is installed and +> running** on the Cascades DS718+ (ext4), and all 40 `SYNO.ActiveBackup.*` APIs are present. The +> Btrfs requirement applies only to certain ABB *features* (block-level dedup on the backup +> destination, file self-healing), NOT to installing/running the package. Don't assume absence — +> check `apis activebackup` on the device. Offsite copy still = **Hyper Backup → B2** (phase4 §6.4). + +## Network / external access / services +| API | method | params | ver | what | conf | +|---|---|---|---|---|---| +| SYNO.Core.Network | get / set | hostname, dns(primary/secondary), gateway | 1 | **[W on set]** global net: hostname/DNS/default-gateway (single object — NOT per-NIC) | docs | +| SYNO.Core.Network.Interface | list | — | 1 | enumerate NICs + addrs (read-only) | docs | +| SYNO.Core.Network.Ethernet | get / set | `ifname`,`ip`,`mask`,`gateway`,`use_dhcp`,`mtu` | 1–2 | **[W on set]** per-NIC IPv4 (static/DHCP/jumbo) | docs | +| SYNO.Core.Network.Bond | list/get/create/set/delete | members, mode | 1–2 | **[W]** link-aggregation | docs | +| SYNO.Core.Network.Router.Static.Route | get / tablesget / set | route list[] | 1 | **[W on set]** static routes (there is NO `SYNO.Core.Network.Route`) | docs | +| SYNO.Core.Terminal | get / set | `enable_ssh`,`enable_telnet`,`ssh_port` | 3 | **[W on set]** enable SSH/Telnet, SSH port | docs | +| SYNO.Core.SNMP | get / set | `enable_snmp`,`community`,`snmp_port`,v3 user/auth/priv,trap | 1 | **[W on set]** SNMP | docs | +| SYNO.Core.QuickConnect | get / status / set | `enabled`,`quickconnect_id` | 1–3 | **[W on set]** QuickConnect | docs | +| SYNO.Core.DDNS.Record | list / create / set / delete / update_ip_address | provider,hostname,user,passwd | 1 | **[W on writes]** DDNS records | docs | +| SYNO.Core.PortForwarding.Rules | load / save | rules[] | 1 | **[W on save]** UPnP router port-forwards | docs | +| SYNO.Core.Notification.Mail.Conf / .Mail.Auth | get / set | mailserver,smtp_port,mailaddr,user,passwd,ssl | 1 | **[W on set]** SMTP for alerts | docs | +| SYNO.Core.Notification.Mail | send_test | — | 1 | **[W]** (actually emails) test mail | docs | +| SYNO.Core.Notification.Push.Conf | get / set / status | `enable_mobile`,`enable_mail`,`enable_webhook` | 1 | **[W on set]** push toggles | docs | +| SYNO.Core.Notification.Advance.FilterSettings | get / set / list | per-event rules | 1 | **[W on set]** alert rules (NOT `Notification.Rule`) | docs | +| SYNO.Core.TaskScheduler | list / get / run / set_enable / create / set / delete | id, real_owner, task object | 1–2 | **[W on writes]** scheduled tasks | docs | +| SYNO.Core.TaskScheduler.Root | create / set | `task_name`,`owner=root`,`script`,`run_days`,`start_time_h/m`,`same_day_repeat_*` | 1 | **[W]** root user-script task — **the way to schedule a syslog snapshotter as root** | comm | +| SYNO.Core.BandwidthControl | list/get/set | rules[] | 1–2 | **[W on set]** bandwidth rules | docs | +| SYNO.Backup.Service.NetworkBackup *(or SYNO.Core.FileServ.Rsync)* | get / set | `enable_network_backup` | 1 | **[W on set]** rsync server enable (probe which the device returns) | comm | + +## SSH `syno*` CLI surface (what the Web API can't fully express) +- **`synoacltool -get `** — per-file NTFS ACE list (the data Web API lacks). ACE format: + `[idx] :::: (level: N)`; level 0 = + set on this path, ≥1 = inherited. `-add`/`-del `/`-enforce-inherit` to write. +- **`synoshare --enum ALL|ENC|DEC`** (list, incl. encrypted/plain split) · `--get ` · + `--list_acl ` · `--enc_mount/--enc_unmount ` (the dependable encryption path). +- **`synouser`** / **`synogroup`** — local account CLI (line-oriented `key=value`, not JSON); + `--add` is **list-replace** for group members; run `--rebuild all` after manual edits. +- **`synopkg start|stop|status `** — package control fallback. +- **`synonet --get_all` / `--hostname` / `--gateway` / `--dns` / `--restart `** — network. +- **`synoservice --enable|--disable|--restart sshd|snmpd|rsyncd|crond`** — service toggles. +- **`synoschedtask --get | --run --id=`** — scheduled tasks. +- **`synogetkeyvalue / synosetkeyvalue /etc/synoinfo.conf [val]`** — raw config. +- **`synowebapi --exec api= method= version= k=v`** — call ANY Web API on-box; the + binary validates params, so this is the most robust scripted path for setters whose param schema + isn't publicly documented. **Use this for production writes when unsure of param keys.** +- **`synoshutdown -r` (reboot) / `-s` (shutdown)** — power fallback when the Web API 103s. + +## Sources +- kwent/syno `definitions/7.x/_full.json` (device-extracted method+version dump — authority for names/versions) +- N4S4/synology-api source + https://n4s4.github.io/synology-api/docs/apis (param detail) +- Synology *DSM Login Web API Guide* + *File Station API Guide* (PDFs) +- Synology KB: Log Center server, Terminal & SNMP, Task Scheduler, Key Manager (DSM 7 help tree) +- zub2/synoacl (synoacltool ACE format); ordinoscope.net (synoshare/synoacltool CLI) diff --git a/.claude/skills/synology/scripts/syno-ssh.sh b/.claude/skills/synology/scripts/syno-ssh.sh new file mode 100644 index 00000000..818ab84d --- /dev/null +++ b/.claude/skills/synology/scripts/syno-ssh.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# syno-ssh.sh — SSH backend for a Synology NAS: the `syno*` CLI surface the DSM Web +# API does NOT expose (filesystem ACLs, low-level share/user/group internals, package +# CLI). Pairs with scripts/syno_client.py (the Web API surface). Read recipes run +# freely; arbitrary `run ""` is gated behind --confirm. +# +# REQUIRES: SSH enabled on the NAS (DSM > Terminal & SNMP) + L3 reach. Cascades NAS is +# 192.168.0.120 — bring up the site VPN first. Privileged recipes use `sudo -S` with the +# vaulted admin password. +# AUTH (password): sshpass if installed, else OpenSSH SSH_ASKPASS fallback. On Windows the +# askpass fallback needs MSYS/Git-bash ssh on PATH (system OpenSSH can't exec a shell askpass). +# +# Recipes (read): info | shares | users | groups | acl | df | packages | services +# reboot | shutdown [--confirm] power via synoshutdown -r|-s (Web-API 103 fallback) +# run "" [--confirm] arbitrary command (gated; privileged -> prepend `sudo -S`) +# +# Usage: bash .claude/skills/synology/scripts/syno-ssh.sh [args] [--confirm] +# [--vault clients//synology-...sops.yaml] +set -uo pipefail +REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +VAULT="$REPO/.claude/scripts/vault.sh" +logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "synology/ssh" "$1" --context "${2:-}" >/dev/null 2>&1 || true; } + +VP="clients/cascades-tucson/synology-cascadesds.sops.yaml"; CONFIRM=0; POS=() +while [ $# -gt 0 ]; do + case "$1" in + --vault) VP="${2:?--vault needs a path}"; shift 2;; + --confirm) CONFIRM=1; shift;; + *) POS+=("$1"); shift;; + esac +done +RECIPE="${POS[0]:-}"; [ -n "$RECIPE" ] || { echo "usage: syno-ssh.sh |df|packages|services|reboot|shutdown|run \"\"> [--confirm]"; exit 2; } + +H="$(bash "$VAULT" get-field "$VP" host 2>/dev/null)" +U="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)" +P="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)" +[ -n "$H" ] && [ -n "$U" ] && [ -n "$P" ] || { echo "[BLOCKED] no Synology cred at vault:$VP (need host/credentials.username/credentials.password)"; exit 2; } + +SSH_OPTS=(-o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ + -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1) +if command -v sshpass >/dev/null 2>&1; then + run_ssh() { local rc; SSHPASS="$P" sshpass -e ssh "${SSH_OPTS[@]}" "$@" || { rc=$?; [ "$rc" = 255 ] && logerr "syno SSH connect/auth failed (rc=255)" "host=$H vp=$VP"; return $rc; }; } +else + ASKPASS="$(mktemp)"; printf '#!/bin/sh\nprintf "%%s\\n" "$SYNO_SSH_PW"\n' > "$ASKPASS"; chmod +x "$ASKPASS" + trap 'rm -f "$ASKPASS"' EXIT + run_ssh() { local rc; SYNO_SSH_PW="$P" SSH_ASKPASS="$ASKPASS" SSH_ASKPASS_REQUIRE=force DISPLAY="${DISPLAY:-:0}" ssh "${SSH_OPTS[@]}" "$@" || { rc=$?; [ "$rc" = 255 ] && logerr "syno SSH connect/auth failed (rc=255)" "host=$H vp=$VP"; return $rc; }; } +fi + +# privileged remote command: feed the admin password to `sudo -S` +priv() { run_ssh "$U@$H" "echo '$P' | sudo -S -p '' $1" 2>&1 | grep -v '^Password:' ; } +plain() { run_ssh "$U@$H" "$1" 2>&1 | grep -viE 'Permanently added'; } + +case "$RECIPE" in + info) plain 'uname -a; echo; cat /etc/synoinfo.conf 2>/dev/null | grep -iE "^(productversion|buildnumber|unique|upnpmodelname)" ; echo; cat /proc/meminfo | head -1';; + df) plain 'df -h | grep -E "Filesystem|/volume"';; + shares) priv 'synoshare --enum ALL';; + users) priv 'synouser --list local || synouser --enum local';; + groups) priv 'synogroup --list || synogroup --enum local';; + packages) plain 'synopkg list 2>/dev/null || ls /var/packages';; + services) priv 'synoservice --list 2>/dev/null | head -80';; + acl) + SHARE="${POS[1]:?acl needs a share name, e.g. acl Server}" + priv "synoacltool -get /volume1/$SHARE";; + reboot) + [ "$CONFIRM" = "1" ] || { echo "[BLOCKED] reboot the NAS — re-run with --confirm"; exit 2; } + echo "[INFO] rebooting $H via synoshutdown -r (Web-API 103 fallback)"; priv 'synoshutdown -r';; + shutdown) + [ "$CONFIRM" = "1" ] || { echo "[BLOCKED] shut down the NAS — re-run with --confirm"; exit 2; } + echo "[INFO] shutting down $H via synoshutdown -s"; priv 'synoshutdown -s';; + run) + CMD="${POS[1]:?run needs a quoted command}" + [ "$CONFIRM" = "1" ] || { echo "[BLOCKED] 'run' executes an arbitrary command on the NAS — re-run with --confirm"; echo " would run: $CMD"; exit 2; } + echo "[INFO] running on $U@$H: $CMD" + plain "$CMD";; + *) echo "[ERROR] unknown recipe: $RECIPE"; exit 2;; +esac +rc=$? +[ "$rc" -ne 0 ] && logerr "syno-ssh recipe '$RECIPE' failed (rc=$rc)" "host=$H" +exit $rc diff --git a/.claude/skills/synology/scripts/syno_client.py b/.claude/skills/synology/scripts/syno_client.py new file mode 100644 index 00000000..b3e6970a --- /dev/null +++ b/.claude/skills/synology/scripts/syno_client.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""syno_client.py — Synology DSM Web API client (DSM 7.x). + +The structured control surface for a Synology NAS: auth -> discover the device's +own API map (SYNO.API.Info) -> call any API method. Read methods run freely; +mutating methods (set/create/delete/start/stop/reboot/...) require --confirm at +the CLI layer. + +Credentials come from a SOPS vault entry (default +`clients/cascades-tucson/synology-cascadesds.sops.yaml`) — fields host, port, +credentials.username, credentials.password — or env overrides +SYNO_HOST / SYNO_PORT / SYNO_USER / SYNO_PASS (+ SYNO_OTP for 2FA, SYNO_HTTPS=1). +Override the vault entry with --vault (e.g. another client's NAS). + +The device is on a private LAN (Cascades 192.168.0.120) — reachable only with the +site VPN up. Connection failures almost always mean the VPN is down. +""" +import sys, os, json, ssl, subprocess, argparse +import urllib.request, urllib.error, urllib.parse + +DEFAULT_VAULT = "clients/cascades-tucson/synology-cascadesds.sops.yaml" + +# Method-name prefixes that MUTATE the device -> gated behind --confirm at the CLI. +# (Expanded per the DSM API survey: backup/run/save/wake/send_test etc. all change state.) +MUTATING = ("set", "create", "delete", "del", "add", "remove", "edit", "update", + "start", "stop", "restart", "reboot", "shutdown", "enable", "disable", + "install", "uninstall", "apply", "clear", "rename", "move", "copy", + "upload", "write", "clean", "format", "mount", "unmount", "join", + "leave", "reset", "upgrade", "downgrade", "import", "backup", "run", + "save", "wake", "send", "eject", "connect", "disconnect", "register", + "encrypt", "decrypt", "cancel", "change") + +# DSM generic error codes (any API) -> friendly text. 106/119 trigger a re-login retry. +GENERIC_ERRORS = { + 100: "unknown error", 101: "invalid parameter", 102: "API does not exist", + 103: "method does not exist (DSM-version mismatch -- try `apis` to find the right version, " + "or the SSH fallback)", 104: "API version not supported", 105: "insufficient permission", + 106: "session timeout", 107: "session interrupted by duplicate login", + 119: "invalid/expired SID or synotoken", +} + + +def _repo_root(): + return os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + + +def _log_skill_error(msg, context=""): + try: + h = os.path.join(_repo_root(), ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, "synology", msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + except Exception: + pass + + +def _vault_field(vault, field): + vs = os.path.join(_repo_root(), ".claude", "scripts", "vault.sh") + if not os.path.exists(vs): + return None + try: + r = subprocess.run(["bash", vs, "get-field", vault, field], + capture_output=True, text=True, timeout=30) + v = (r.stdout or "").strip() + return v or None + except Exception: + return None + + +# DSM auth error codes -> human strings (the common ones). +AUTH_ERRORS = { + 400: "no such account or incorrect password", + 401: "account disabled", + 402: "permission denied", + 403: "2-factor auth required (pass --otp / SYNO_OTP)", + 404: "failed to authenticate 2-factor code", + 406: "enforce 2FA but user has none configured", + 407: "blocked by IP auto-block", + 408: "password expired (cannot change)", + 409: "password expired", + 410: "password must be changed", +} + + +class SynoError(Exception): + pass + + +class SynoClient: + def __init__(self, vault=DEFAULT_VAULT): + self.host = os.environ.get("SYNO_HOST") or _vault_field(vault, "host") + self.port = os.environ.get("SYNO_PORT") or _vault_field(vault, "port") + self.user = os.environ.get("SYNO_USER") or _vault_field(vault, "credentials.username") + self.passwd = os.environ.get("SYNO_PASS") or _vault_field(vault, "credentials.password") + self.otp = os.environ.get("SYNO_OTP") + self.https = os.environ.get("SYNO_HTTPS", "") not in ("", "0", "false") + if not (self.host and self.user and self.passwd): + raise SynoError( + "No Synology credentials. Expected vault " + vault + + " (host / credentials.username / credentials.password) or env " + "SYNO_HOST / SYNO_USER / SYNO_PASS.") + if self.https and (not self.port or self.port == "5000"): + self.port = "5001" + self.port = self.port or ("5001" if self.https else "5000") + scheme = "https" if self.https else "http" + self.base = f"{scheme}://{self.host}:{self.port}/webapi" + self._ctx = None + if self.https: + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + self._sid = None + self._syno_token = None + self._apimap = None # name -> {path, minVersion, maxVersion} + + # ---- low-level ---- + def _get(self, path, params, post=False): + url = f"{self.base}/{path}" + q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + headers = {} + if self._syno_token: + headers["X-SYNO-TOKEN"] = self._syno_token + if post: + req = urllib.request.Request(url, data=q.encode(), headers=headers, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + else: + req = urllib.request.Request(url + "?" + q, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=30, context=self._ctx) as r: + raw = r.read().decode("utf-8", "replace") + return json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8", "replace") + try: + return json.loads(raw) + except Exception: + raise SynoError(f"HTTP {e.code}: {raw[:300]}") + except Exception as e: + raise SynoError(f"connect failed ({e}) -- is the site VPN up? host={self.host}:{self.port}") + + # ---- API discovery ---- + def apimap(self): + if self._apimap is None: + r = self._get("query.cgi", { + "api": "SYNO.API.Info", "version": "1", "method": "query", "query": "all"}) + if not r.get("success"): + raise SynoError(f"SYNO.API.Info query failed: {r.get('error')}") + self._apimap = r.get("data", {}) + return self._apimap + + def _resolve(self, api): + """Return (cgi_path, max_version) for an API name.""" + m = self.apimap() + if api == "SYNO.API.Auth": + info = m.get(api, {"path": "auth.cgi", "maxVersion": 7}) + else: + info = m.get(api) + if not info: + raise SynoError(f"API '{api}' not available on this device (see `apis`)") + return info.get("path", "entry.cgi"), info.get("maxVersion", 1) + + # ---- auth ---- + def login(self): + if self._sid: + return self._sid + path, ver = self._resolve("SYNO.API.Auth") + p = {"api": "SYNO.API.Auth", "version": min(ver, 7), "method": "login", + "account": self.user, "passwd": self.passwd, "session": "DSM", + "format": "sid", "enable_syno_token": "yes"} + if self.otp: + p["otp_code"] = self.otp + r = self._get(path, p, post=True) + if not r.get("success"): + code = (r.get("error") or {}).get("code") + msg = AUTH_ERRORS.get(code, f"login failed (code {code})") + _log_skill_error(f"DSM login failed: {msg}", context=f"host={self.host} code={code}") + raise SynoError(f"DSM login failed: {msg}") + data = r.get("data", {}) + self._sid = data.get("sid") + self._syno_token = data.get("synotoken") + return self._sid + + def logout(self): + if not self._sid: + return + try: + path, ver = self._resolve("SYNO.API.Auth") + self._get(path, {"api": "SYNO.API.Auth", "version": min(ver, 7), + "method": "logout", "session": "DSM", "_sid": self._sid}) + except Exception: + pass + self._sid = None + + # ---- generic call ---- + def call(self, api, method, version=None, post=False, _retry=True, **params): + self.login() + path, maxver = self._resolve(api) + ver = version or maxver + + def _do(): + p = {"api": api, "version": ver, "method": method, "_sid": self._sid} + if self._syno_token: + p["SynoToken"] = self._syno_token + p.update(params) + return self._get(path, p, post=post) + + r = _do() + if not r.get("success"): + err = r.get("error") or {} + code = err.get("code") + # session expired / token invalid -> re-login once and retry transparently + if code in (106, 119) and _retry: + self._sid = None + self._syno_token = None + self.login() + r = _do() + if not r.get("success"): + err = r.get("error") or {} + code = err.get("code") + hint = GENERIC_ERRORS.get(code, "") + _log_skill_error(f"{api}.{method} failed (code {code})", + context=f"err={json.dumps(err)[:120]}") + raise SynoError(f"{api}.{method} -> error {code}" + + (f" ({hint})" if hint else "") + f" {json.dumps(err)[:200]}") + return r.get("data", r) + + +def is_mutating(method): + m = method.lower() + return any(m == v or m.startswith(v + "_") or m.startswith(v) for v in MUTATING) \ + and not m.startswith(("getinfo", "list", "get", "info", "query", "load", "enum", "status")) + + +# ============================ CLI ============================ +def _print(obj): + print(json.dumps(obj, indent=2, ensure_ascii=False, default=str)) + + +def _kv(pairs): + """Parse k=v (string) and k:=json pairs into a params dict.""" + out = {} + for item in pairs or []: + if ":=" in item: + k, v = item.split(":=", 1) + out[k] = json.dumps(json.loads(v)) # normalize JSON + elif "=" in item: + k, v = item.split("=", 1) + out[k] = v + else: + raise SynoError(f"bad param '{item}' (want k=v or k:=json)") + return out + + +def main(argv): + ap = argparse.ArgumentParser(prog="syno", description="Synology DSM Web API client") + ap.add_argument("--vault", default=DEFAULT_VAULT, help="SOPS vault entry for the NAS") + ap.add_argument("--confirm", action="store_true", help="authorize a mutating call") + sub = ap.add_subparsers(dest="cmd", required=True) + + sub.add_parser("test", help="login and report DSM identity") + pa = sub.add_parser("apis", help="list the device's API map (what you can control)") + pa.add_argument("filter", nargs="?", help="case-insensitive substring filter") + sub.add_parser("sysinfo", help="model/serial/RAM/temp/uptime (SYNO.Core.System)") + sub.add_parser("util", help="live CPU/mem/disk/net (SYNO.Core.System.Utilization)") + sub.add_parser("storage", help="volumes/disks/RAID/usage (SYNO.Storage.CGI.Storage)") + sub.add_parser("shares", help="shared folders (SYNO.Core.Share)") + sub.add_parser("users", help="local users (SYNO.Core.User)") + sub.add_parser("groups", help="local groups (SYNO.Core.Group)") + sub.add_parser("packages", help="installed packages (SYNO.Core.Package)") + sub.add_parser("services", help="services (SYNO.Core.Service)") + sub.add_parser("connections", help="current connections (SYNO.Core.CurrentConnection)") + pl = sub.add_parser("ls", help="FileStation list (no path = shares)") + pl.add_argument("path", nargs="?") + + pc = sub.add_parser("call", help="generic: call ANY API method (the power tool)") + pc.add_argument("api"); pc.add_argument("method") + pc.add_argument("--version", type=int) + pc.add_argument("--post", action="store_true") + pc.add_argument("params", nargs="*", help="k=v or k:=json") + + # gated convenience writes + for name, helptxt in (("pkg-start", "start a package by id"), + ("pkg-stop", "stop a package by id")): + pp = sub.add_parser(name, help=helptxt + " (needs --confirm)") + pp.add_argument("id") + sub.add_parser("reboot", help="reboot the NAS (needs --confirm)") + sub.add_parser("shutdown", help="shut down the NAS (needs --confirm)") + + args = ap.parse_args(argv) + + def guard(method): + if not args.confirm: + raise SynoError(f"'{method}' mutates the device -- re-run with --confirm") + + try: + c = SynoClient(vault=args.vault) + if args.cmd == "test": + c.login() + d = c.call("SYNO.Core.System", "info") + _print({"login": "ok", "host": f"{c.host}:{c.port}", + "model": d.get("model"), "serial": d.get("serial"), + "firmware": d.get("firmware_ver") or d.get("version_string"), + "ram_mb": d.get("ram_size") or d.get("ram"), + "uptime_s": d.get("up_time")}) + elif args.cmd == "apis": + m = c.apimap() + keys = sorted(m) + if args.filter: + f = args.filter.lower() + keys = [k for k in keys if f in k.lower()] + _print({k: {"path": m[k].get("path"), "maxVersion": m[k].get("maxVersion")} + for k in keys}) + elif args.cmd == "sysinfo": + _print(c.call("SYNO.Core.System", "info")) + elif args.cmd == "util": + _print(c.call("SYNO.Core.System.Utilization", "get")) + elif args.cmd == "storage": + _print(c.call("SYNO.Storage.CGI.Storage", "load_info")) + elif args.cmd == "shares": + _print(c.call("SYNO.Core.Share", "list", + additional='["hidden","encryption","share_quota_used","is_aclmode",' + '"is_support_acl","unite_permission","is_force_readonly",' + '"recyclebin"]')) + elif args.cmd == "users": + _print(c.call("SYNO.Core.User", "list", additional='["email","description","expired"]')) + elif args.cmd == "groups": + _print(c.call("SYNO.Core.Group", "list")) + elif args.cmd == "packages": + _print(c.call("SYNO.Core.Package", "list", additional='["status","installed_info"]')) + elif args.cmd == "services": + _print(c.call("SYNO.Core.Service", "list")) + elif args.cmd == "connections": + _print(c.call("SYNO.Core.CurrentConnection", "list")) + elif args.cmd == "ls": + if args.path: + _print(c.call("SYNO.FileStation.List", "list", folder_path=args.path, + additional='["size","owner","time","perm"]')) + else: + _print(c.call("SYNO.FileStation.List", "list_share", + additional='["size","owner","perm"]')) + elif args.cmd == "call": + params = _kv(args.params) + if is_mutating(args.method): + guard(f"{args.api}.{args.method}") + _print(c.call(args.api, args.method, version=args.version, + post=args.post or is_mutating(args.method), **params)) + elif args.cmd == "pkg-start": + guard("pkg-start") + _print(c.call("SYNO.Core.Package.Control", "start", post=True, id=args.id)) + elif args.cmd == "pkg-stop": + guard("pkg-stop") + _print(c.call("SYNO.Core.Package.Control", "stop", post=True, id=args.id)) + elif args.cmd == "reboot": + guard("reboot") + _print(c.call("SYNO.Core.System", "reboot", post=True)) + elif args.cmd == "shutdown": + guard("shutdown") + _print(c.call("SYNO.Core.System", "shutdown", post=True)) + else: + ap.print_help() + return 2 + except SynoError as e: + print(f"[ERROR] {e}", file=sys.stderr) + return 1 + finally: + try: + c.logout() + except NameError: + pass # construction failed before c was bound + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/clients/cascades-tucson/docs/network/csc-ent-client-inventory-2026-06-24.md b/clients/cascades-tucson/docs/network/csc-ent-client-inventory-2026-06-24.md index bf624b54..2f4a5650 100644 --- a/clients/cascades-tucson/docs/network/csc-ent-client-inventory-2026-06-24.md +++ b/clients/cascades-tucson/docs/network/csc-ent-client-inventory-2026-06-24.md @@ -112,13 +112,31 @@ Notable: three `98:17:3c:*` devices clustered on one AP at strong signal (-39/-4 | Laptop3 | c0:35:32:66:46:af | 192.168.2.156 | caregiver | | Laptop4 | 70:08:94:90:26:85 | 169.254.1.9 | caregiver (APIPA — DHCP issue, check) | -### Printers (11) — we reconfigure to the staff/internal network -Canon: `canona93684` (9c:50:d1, .2.67), `canoncbdf73-2` (10:98:c3, .3.232), `canonfb04b5` -(80:a5:89, .3.227), `Canonf46423` (20:0b:74, .3.52). -Brother: `brwc8a3e8dc60fd` (.3.10, 5 GHz), `BRW2C9C5828EC9E` (.3.44), `BRWC8A3E8A2DD9E` (.2.53), -`brw283a4d1ad571` (.2.75), `brw5cea1d4e96af` (.2.145), `brw90324b15f558` (.3.88). -Epson: `EPSON822B7A` (dc:cd:2f, .2.147). -(10 of 11 are on 2.4 GHz — these drop on a 5 GHz-only flip; relocate first.) +### Printers (11) — relocate to CSCNet (keeps 2.4 GHz). 2.4-only band assessment + +CSC ENT is going 5 GHz-only, so every printer here moves to **CSCNet** (which retains 2.4+5) — the +2.4-only ones *require* it. **Operationally the action is identical for all 11** (all -> CSCNet); +the model lookup below only labels which physically cannot do 5 GHz. + +| Hostname | MAC | IP | Brand | Now on | Band capability | Model | +|---|---|---|---|---|---|---| +| brwc8a3e8dc60fd | c8:a3:e8:dc:60:fd | 192.168.3.10 | Brother | **5 GHz** | **DUAL-BAND (confirmed — it's on 5 GHz)** | TBD | +| BRW2C9C5828EC9E | 2c:9c:58:28:ec:9e | 192.168.3.44 | Brother | 2.4 | likely 2.4-only (SOHO) | TBD | +| BRWC8A3E8A2DD9E | c8:a3:e8:a2:dd:9e | 192.168.2.53 | Brother | 2.4 | likely 2.4-only (SOHO) | TBD | +| brw283a4d1ad571 | 28:3a:4d:1a:d5:71 | 192.168.2.75 | Brother | 2.4 | likely 2.4-only (SOHO) | TBD | +| brw5cea1d4e96af | 5c:ea:1d:4e:96:af | 192.168.2.145 | Brother | 2.4 | likely 2.4-only (SOHO) | TBD | +| brw90324b15f558 | 90:32:4b:15:f5:58 | 192.168.3.88 | Brother | 2.4 | likely 2.4-only (SOHO) | TBD | +| canona93684 | 9c:50:d1:aa:f8:9a | 192.168.2.67 | Canon | 2.4 | likely 2.4-only (PIXMA-class) | TBD | +| canoncbdf73-2 | 10:98:c3:da:33:80 | 192.168.3.232 | Canon | 2.4 | likely 2.4-only (PIXMA-class) | TBD | +| canonfb04b5 | 80:a5:89:f6:71:9b | 192.168.3.227 | Canon | 2.4 | likely 2.4-only (PIXMA-class) | TBD | +| Canonf46423 | 20:0b:74:b2:29:08 | 192.168.3.52 | Canon | 2.4 | likely 2.4-only (PIXMA-class) | TBD | +| EPSON822B7A | dc:cd:2f:82:2b:7a | 192.168.2.147 | Epson | 2.4 | likely 2.4-only (WorkForce-class) | TBD | + +**Status:** 1 confirmed dual-band (it's literally on 5 GHz); 10 on 2.4, brand patterns suggest +2.4-only, but **models not yet confirmed** — the authoritative probe (CS-SERVER `Get-Printer` +DriverName + per-IP HTTP/SNMP) was **blocked 2026-06-24** by loss of the Howard-Home -> 172.16.3.x +network path (RMM/UOS/coord all unreachable). Re-run when connectivity returns to fill `Model` + +confirm 2.4-only. Bottom line unaffected: all 11 -> CSCNet. --- diff --git a/clients/cascades-tucson/docs/network/csc-ent-device-island-plan.md b/clients/cascades-tucson/docs/network/csc-ent-device-island-plan.md index c53df692..ef3d9351 100644 --- a/clients/cascades-tucson/docs/network/csc-ent-device-island-plan.md +++ b/clients/cascades-tucson/docs/network/csc-ent-device-island-plan.md @@ -40,10 +40,18 @@ WiFi5 and is the correct network for them to use."*). This plan formalizes and e - **Helpany is WPA2-only** — explicitly **NOT** WPA3 or hybrid WPA2/WPA3 (*"we don't support hybrid, only WPA2"*). The device SSID must stay WPA2-PSK. -- **5 GHz has shorter range** than 2.4 GHz. Both vendors warn: a device with weak 5 GHz signal - will fall back to 2.4 GHz or be orphaned. **Per-room 5 GHz coverage must be verified before - transitioning** (Cascades is 6 floors with steel hallway walls). Leave any weak-signal device - on 2.4 rather than force it. +- **Neither vendor can pin a device to 5 GHz from their side** (confirmed: Poly/Vertical AND + Helpany support, 2026-06-24). The handsets/Pauls choose the band themselves, and band steering + doesn't hold them. **Therefore a 5 GHz-only SSID (2.4 disabled) is the ONLY mechanism** — you + remove 2.4 as an option so the device has nowhere else to associate. This is the whole basis of + the plan. +- **Consequence — 5 GHz coverage is now a HARD GATE, with no safety net.** On a 5 GHz-only SSID + there is **no 2.4 fallback**: a Paul/phone in a weak-5 GHz spot will simply **fail to connect** + (not drop to 2.4). 5 GHz has shorter range and Cascades has steel walls. So per-room 5 GHz + coverage must be **verified and remediated** (AP placement/power/channel) BEFORE cutover — you + cannot "leave a weak device on 2.4," because 2.4 won't exist on this SSID. The 42 Pauls already + holding 5 GHz prove coverage in those spots; the **26 Pauls currently on 2.4** (+ any 2.4 phones) + are the risk set to survey first. - **Reprogramming is painful on Helpany's side** — they can't reach offline devices, and key rotations need **72 h notice + the new key**. The SSID/password must be right and stable. - **Helpany bandwidth is negligible:** < 0.04 Mbps per Paul device; whole fleet ~0.38 Mbps low / @@ -107,17 +115,28 @@ are the visible-impact set — they need a relocation/reconnection plan before t ## Execution sequence -0. **Evacuate the ~79 non-Helpany clients off CSC ENT** to their correct networks (staff -> CSCNet/ - INTERNAL via domain migration; printers -> internal; resident TVs/IoT/phones -> CSCNet resident - PPSK or a dedicated resident SSID). Complete the registry with `stat/alluser` first so offline - resident TVs aren't missed. **This is the gating sub-project** — see the inventory doc. +0. **Remove the ~79 non-Helpany clients from CSC ENT onto EXISTING networks — we do NOT build new + VLANs for them** (scope decision, Howard 2026-06-24): staff PCs -> CSCNet/INTERNAL (domain + migration); resident TVs/IoT/phones -> CSCNet (resident PPSK / per-room). Only the **phones and + Helpany** get dedicated VLANs (30 / 40); internal + resident devices are simply relocated, not + segmented. + - **2.4-only devices must land on a 2.4-capable SSID (CSCNet), because CSC ENT is losing 2.4.** + ~10 of the 11 wireless printers are on 2.4 today and several are likely 2.4-only hardware + (SOHO Brother/Canon) — move those to CSCNet (which keeps 2.4+5). Verify model if unsure; + default 2.4 printers to CSCNet. + - Complete the registry with `stat/alluser` first so offline resident TVs aren't missed. **This + is the gating sub-project** — see the inventory doc. 1. **Build VLAN 40** on pfSense (igc1.40, DHCP scope, DNS) + firewall egress rules above; mirror VLAN 30 isolation. 2. **Enable PPSK on CSC ENT**; add keys: `Ftfd85710#` -> VLAN 40, new voice key -> VLAN 30. 3. **[ONSITE GATE] Verify 5 GHz coverage** in the rooms where Pauls + phones live (per-floor, account for steel walls). Use `unifi-wifi` skill (`live-stats.sh --clients`, `watch-ap.sh`). -4. **Flip CSC ENT to 5 GHz-only** (`apply-wlan.sh bands 5g --wlan `), coordinated - with both vendors during a change window. +4. **Disable 2.4 GHz on CSC ENT (-> 5 GHz-only)** (`apply-wlan.sh bands 5g --wlan `), + coordinated with both vendors during a change window. **ORDER MATTERS:** 26 of the 68 Pauls (and + any 2.4 phones) are on 2.4 today; once 2.4 is off CSC ENT there is **no 2.4 fallback** — a Paul + with weak 5 GHz signal goes OFFLINE. So Helpany must verify 5 GHz coverage + move those 26 to + 5 GHz FIRST; only then disable 2.4. Likewise confirm no 2.4-only device (printer/IoT) is still on + CSC ENT before flipping. 5. **Vendors transition their devices:** - **Helpany** remotely moves the Pauls to 5 GHz (we hand them: SSID `CSC ENT`, key `Ftfd85710#` — unchanged; they confirm strong 2.4 signal per-device first). diff --git a/clients/cascades-tucson/docs/proposals/carf-technology-plan-intake.md b/clients/cascades-tucson/docs/proposals/carf-technology-plan-intake.md index efab6aba..38a2a392 100644 --- a/clients/cascades-tucson/docs/proposals/carf-technology-plan-intake.md +++ b/clients/cascades-tucson/docs/proposals/carf-technology-plan-intake.md @@ -118,6 +118,42 @@ For each area, fill the four input fields: **Responsible person**, **Estimated/a --- +## Part 6 — Cost estimates (verified via live web lookup 2026-06-24) + +> Per ACG policy these are verified against current vendor/retail pricing, not estimated from +> memory. Sources cited below the table. "ACG labor" draws the prepaid block (48.25 hrs @ $175/hr) +> unless quoted as a separate project. + +| Item | Area | Qty | Cost (verified) | Notes | +|---|---|---|---|---| +| R610 redundant power supply (refurb, RN442 717W) | Hardware / DR | 1 | **~$99 one-time** | Restores lost PSU redundancy; cheap, do soon | +| Enterprise SSD 480 GB (Samsung PM893) | Hardware | 2 | **~$320–350 (already purchased)** | Sunk cost; planned install on a maintenance window | +| **M365 Business Premium relicense (31 users)** | Software | 31 | **likely $0 new spend** | Our records show 31 Premium seats already owned + free; reassign the 31 suspended-Standard users to them and drop Standard. If those seats are NOT a paid subscription: $22/user/mo = **$682/mo (~$8,184/yr)**. **Verify subscription status.** | +| Windows Home → Pro upgrade | Software | 5 | **~$495** (~$99/device; ACG to source via CSP, may be lower) | Howard handling keys | +| Replacement workstations (OptiPlex i5 / 16 GB / 512 NVMe, Win 11 Pro) | Hardware | 2 | **~$1,400–1,900** (~$700–950 ea) | Lupe Sanchez EOL + spare for new hire (#32194) | +| Break-glass FIDO2 YubiKeys (5-series) | Confidentiality | 2 | **~$110** (already ordered per records) | Approximate | +| Azure audit-log retention (Log Analytics 90 d + 6 yr archive) | Security | — | **~$50–120/mo** consumption (log-volume dependent) + one-time ACG build | Firm up after measuring actual audit-log volume | +| Managed antivirus, all devices incl. server | Virus protection | — | **Included in existing ACG Bitdefender managed security** + ACG labor to enroll server / remove legacy Datto agents | **Client (Mike) is deploying AV** | +| DR written plan + system-image confirm + restore test | DR | — | **ACG labor (prepaid block)** | Restore test **deferred** per client (revisit after AV + basic items) | +| Security risk assessment (dated package) + file-share audit logging | Security | — | **ACG labor (prepaid block); no license cost** | | +| **Long-term server replacement (PowerEdge T360-class)** | Hardware / DR | 1 | **~$4,000–7,000 configured (formal quote required)** | Depends on spec + Windows Server licensing + CALs; separate project | + +**One-time hardware/licensing subtotal (excludes the optional server replacement):** +~$2,300–2,950, of which ~$320–350 (the SSDs) is already spent. Plus ~$50–120/mo Azure. The +server replacement is a separate ~$4–7k project to quote when you're ready. + +**Pricing sources (2026-06-24):** +[M365 Business Premium $22/user/mo](https://www.microsoft.com/en-us/microsoft-365/business/microsoft-365-plans-and-pricing) · +[M365 July 2026 price changes (Premium unchanged)](https://www.stmicro.net/blog/microsoft-365-price-increase-2026/) · +[Samsung PM893 480 GB ~$160–175](https://www.marigoldsystems.com/products/b-samsung-pm893-480gb-enterprise-sata-ssd-1dwpd-b) · +[Windows 11 Home→Pro upgrade ~$99](https://learn.microsoft.com/en-us/answers/questions/3923910/how-much-does-it-cost-to-upgrade-to-windows-11-pro) · +[Azure Log Analytics $2.30/GB ingest, ~$0.10/GB/mo retention, ~$0.02/GB/mo archive](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs) · +[Dell R610 717W redundant PSU refurb ~$99](https://store.flagshiptech.com/dell-poweredge-r610-redundant-power-supply-717w-rn442/) · +[Dell PowerEdge T360 tower (from ~$1,900 base)](https://www.dell.com/en-us/shop/servers-storage-and-networking/poweredge-t360/spd/poweredge-t360/pe_t360_tm_vi_vp_sb) · +[Dell OptiPlex business desktop i5/16 GB](https://www.dell.com/en-us/shop/desktop-computers/optiplex-tower/spd/optiplex-7020t-desktop) + +--- + ## What we do once you return this 1. Build the final **CARF Technology and System Plan** (Cascades-branded, ACG as preparer) in CARF action-document format, complete with your owners/costs/dates. diff --git a/clients/cascades-tucson/session-logs/2026-06/2026-06-24-howard-ticket-review-and-cascades-consolidation.md b/clients/cascades-tucson/session-logs/2026-06/2026-06-24-howard-ticket-review-and-cascades-consolidation.md index fb2aa260..64402ee3 100644 --- a/clients/cascades-tucson/session-logs/2026-06/2026-06-24-howard-ticket-review-and-cascades-consolidation.md +++ b/clients/cascades-tucson/session-logs/2026-06/2026-06-24-howard-ticket-review-and-cascades-consolidation.md @@ -295,3 +295,52 @@ before the 5×$99 Cascades invoice. - Vault: `infrastructure/windows-pro-mak` (credentials.product_key), `clients/cascades-tucson/meredith-kuhn`. - Generic Pro key VK7JG-NPHTM-C97JM-9MPGT-3V66T (edition flip); MAK in vault (activation). - Cron job ad0a56a9 @ 18:00 2026-06-24. + +--- + +## Update: 17:36 PT — M365 relicense assessment (Workstream 4): seat shortfall + cleanup opportunity + +### Session Summary (continued) + +Started the plan's Workstream 4 (M365 relicense) remotely. Pulled the LIVE license state via the +remediation-tool (investigator/Graph token, tenant 207fa277-e9d8-4eb7-ada1-1064d2221498) before +touching anything. The plan's "relicense 31 Standard->Premium" is **blocked by a 3-seat shortfall** +and surfaced a licensing-cleanup opportunity. + +**Live SKU state:** SPB (Business Premium) Enabled 34 seats, 6 consumed -> **28 free**. +O365_BUSINESS_PREMIUM (the legacy-named "Business Standard") **SUSPENDED**, **31 users still +assigned**. Also EXCHANGE_S_ESSENTIALS SUSPENDED with 5 users (separate cleanup). AAD_PREMIUM_P2 +suspended (1). + +**Per-user overlap (decisive):** all **31** Standard users, and **0 of them already hold SPB** -> all +31 need a NEW SPB seat. 31 needed vs 28 free = **3 short** for a straight 1:1 migration. + +**Cleanup opportunity:** ~8 of the 31 are shared/role accounts (accounting@, accountingassistant@, +frontdesk@, hr@, security@, memcarereceptionist@, boadmin@, Training@, dax.howard@?) that likely +should be UNLICENSED shared mailboxes, not $22/mo Premium users. The 22 clearly-real people fit in +the 28 free seats with room to spare -> converting the true shared mailboxes to unlicensed both +removes the shortfall AND drops ~8 paid licenses. Caveat: any "shared" account that is actually an +interactive login (e.g. frontdesk@ / memcarereceptionist@ signing into shared reception PCs) must +keep a license (shared mailboxes can't sign in). Presented both paths to Howard; **awaiting his +decision** on which flagged accounts are shared mailboxes vs login accounts (path 1, recommended) vs +buy 3 more SPB seats (path 2). Nothing changed — assessment only. + +### Key Decisions (continued) +- Did NOT bulk-assign SPB — live data showed a 3-seat shortfall the wiki/plan didn't capture; a blind + "assign 28, strand 3" would be wrong. Surfaced the shared-mailbox cleanup as the better fix. + +### Configuration Changes (continued) +- No changes this segment (read-only M365 license assessment). + +### Pending / Incomplete Tasks (continued) +- **M365 relicense (Workstream 4) — BLOCKED on Howard's decision:** path 1 (unlicense the true shared + mailboxes among accounting@/accountingassistant@/frontdesk@/hr@/security@/memcarereceptionist@/ + boadmin@/Training@/dax.howard@, then assign SPB to the 22 real people — fits 28 free) vs path 2 + (buy 3 more SPB, migrate all 31 as-is). Then execute via user-manager tier. +- **5 users on suspended EXCHANGE_S_ESSENTIALS** — assess/clean up next. +- 6PM cron ad0a56a9 (Home->Pro) still pending its fire. + +### Reference Information (continued) +- SKU IDs: SPB cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46; O365_BUSINESS_PREMIUM (Standard, suspended) f245ecc8-75af-4f8e-b61f-27d8114de5f3; EXCHANGE_S_ESSENTIALS e8f81a67-bd96-4074-b108-cf193eb9433b. +- 22 real people on Standard needing SPB: Allison Reibschied, Shelby Trozzi, Alyssa Brooks, Ashley Jensen, Christina DuPras, Christine Nyanzunda, Crystal Rodriguez, JD Martin, Jodi Ramstack, John Trozzi, Karen Rossini, Lauren Hasselman, Lois Lane, Lupe Sanchez, Matthew Brooks, Megan Hiatt, Meredith Kuhn, Ramon Castaneda, Sharon Edwards, Susan Hicks, Tamra Matthews, Veronica Feller. +- Tenant 207fa277-e9d8-4eb7-ada1-1064d2221498 (cascadestucson.com). diff --git a/errorlog.md b/errorlog.md index 2475c82d..61c3cfab 100644 --- a/errorlog.md +++ b/errorlog.md @@ -17,6 +17,12 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +2026-06-25 | Howard-Home | bash/reachability-probe | [friction] /dev/tcp TCP probe falsely reported host DOWN on Windows MSYS while device was UP; wasted a cycle. Fix: don't trust /dev/tcp on Git-bash Windows for reachability — use the actual client (python urllib) or curl --max-time. + +2026-06-25 | Howard-Home | synology | SYNO.Core.QuickConnect.get failed (code 103) [ctx: err={"code": 103}] + +2026-06-25 | Howard-Home | bash/background | [friction] run_in_background shell does not inherit $TMPDIR -> empty path, exit 127; use absolute paths in detached scripts [ctx: ref=feedback_tmp_path_windows] + 2026-06-24 | Howard-Home | unifi-wifi/live-stats | [friction] rapid successive controller logins -> HTTP 403 lockout; reuse one session/save JSON instead of re-auth per query [ctx: host=172.16.3.29:11443 site=va6iba3v] 2026-06-24 | Howard-Home | rmm/cascades-cs-server | [correction] led with a 9-day-stale wiki '[CRITICAL] degraded RAID / failing drive' flag and recommended drive replacement (SSDs were purchased, tech went onsite to hot-swap); a LIVE Dell OMSA omreport query then showed the OS mirror had self-recovered and is healthy (all 5 disks Online, all LEDs green), and the '5th unused drive' was actually the global hot spare. Always pull live OMSA/iDRAC before acting on a stale hardware flag; Windows Get-PhysicalDisk cannot see RAID member health. [ctx: ref=feedback_verify_live_before_acting host=CS-SERVER tag=9MQFTK1]