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:
2026-06-24 17:37:35 -07:00
parent 9d68db953f
commit 2a1a275511
9 changed files with 930 additions and 17 deletions

View 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`.

View 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) | 12 | 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 | 13 | **[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` | 12 | **[W on set]** per-NIC IPv4 (static/DHCP/jumbo) | docs |
| SYNO.Core.Network.Bond | list/get/create/set/delete | members, mode | 12 | **[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` | 13 | **[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 | 12 | **[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[] | 12 | **[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)

View 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

View 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:]))