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
This commit is contained in:
123
.claude/skills/synology/SKILL.md
Normal file
123
.claude/skills/synology/SKILL.md
Normal file
@@ -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 <API> <method>` 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/<x>/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 <API> <method> [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 <id> --confirm | pkg-stop <id> --confirm
|
||||
bash "$PY" "$S" reboot --confirm | shutdown --confirm
|
||||
bash "$PY" "$S" call <API> <set-method> 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 <share> # synoacltool -get /volume1/<share> (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 "<command>" --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 <code>` 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`.
|
||||
205
.claude/skills/synology/references/dsm-api.md
Normal file
205
.claude/skills/synology/references/dsm-api.md
Normal file
@@ -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=<API> method=<m> version=<v> 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=<pkgid>` (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 <pkgid>` · `synopkg
|
||||
start|stop <pkgid>` (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 <path>`** — per-file NTFS ACE list (the data Web API lacks). ACE format:
|
||||
`[idx] <type>:<name>:<allow|deny>:<perm-bits rwxpdDaARWcCo>:<inherit fdin> (level: N)`; level 0 =
|
||||
set on this path, ≥1 = inherited. `-add`/`-del <idx>`/`-enforce-inherit` to write.
|
||||
- **`synoshare --enum ALL|ENC|DEC`** (list, incl. encrypted/plain split) · `--get <share>` ·
|
||||
`--list_acl <share>` · `--enc_mount/--enc_unmount <share> <pw>` (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 <pkgid>`** — package control fallback.
|
||||
- **`synonet --get_all` / `--hostname` / `--gateway` / `--dns` / `--restart <iface>`** — network.
|
||||
- **`synoservice --enable|--disable|--restart sshd|snmpd|rsyncd|crond`** — service toggles.
|
||||
- **`synoschedtask --get | --run --id=<N>`** — scheduled tasks.
|
||||
- **`synogetkeyvalue / synosetkeyvalue /etc/synoinfo.conf <key> [val]`** — raw config.
|
||||
- **`synowebapi --exec api=<API> method=<m> version=<v> 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)
|
||||
79
.claude/skills/synology/scripts/syno-ssh.sh
Normal file
79
.claude/skills/synology/scripts/syno-ssh.sh
Normal file
@@ -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 "<cmd>"` 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 <share> | df | packages | services
|
||||
# reboot | shutdown [--confirm] power via synoshutdown -r|-s (Web-API 103 fallback)
|
||||
# run "<cmd>" [--confirm] arbitrary command (gated; privileged -> prepend `sudo -S`)
|
||||
#
|
||||
# Usage: bash .claude/skills/synology/scripts/syno-ssh.sh <recipe> [args] [--confirm]
|
||||
# [--vault clients/<x>/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 <info|shares|users|groups|acl <share>|df|packages|services|reboot|shutdown|run \"<cmd>\"> [--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
|
||||
378
.claude/skills/synology/scripts/syno_client.py
Normal file
378
.claude/skills/synology/scripts/syno_client.py
Normal file
@@ -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 <path> (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 <code> / 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:]))
|
||||
Reference in New Issue
Block a user