diff --git a/.claude/skills/synology/SKILL.md b/.claude/skills/synology/SKILL.md index 7a45a033..9daf8540 100644 --- a/.claude/skills/synology/SKILL.md +++ b/.claude/skills/synology/SKILL.md @@ -140,6 +140,16 @@ VPN-down connect error surfaced to the user, a method refused for lack of --conf hint on the FileStation 400/407 denial. **`--confirm`/`--vault` after the subcommand were rejected by argparse** (every documented gated-write example, e.g. `call X set k=v --confirm`, would have failed) — moved to a shared parent parser so both flags now work before AND after the subcommand. +- **SSH backend fully verified + fixed (2026-06-25):** the `priv` (sudo) recipes — `shares`, + `users`, `groups`, `acl` — were ALL broken: `sudo`'s `secure_path` drops `/usr/syno/{bin,sbin}`, + so `synoshare`/`synouser`/`synogroup`/`synoacltool` returned "command not found" (the non-sudo + `plain` recipes worked because the admin login PATH has those dirs). Fixed by injecting + `SYNO_PATH` and running `priv` via `sh -c` (so shell operators survive). Also: `synouser`/ + `synogroup` use `--enum local` (not `--list`); `acl` quotes the share path (handles "Sandra + Fish"); `services` repointed to the Web API (`synoservice` doesn't exist on DSM 7.2, and + `synosystemctl` has no list-all). Verified live: `info` `df` `shares` `users`(41) `groups`(4) + `packages` `acl Server`(real Windows ACEs) `acl Public`(Linux-mode) all OK. `acl` on a + Windows-ACL share is the SSH backend's unique value (the per-file ACE list the Web API can't give). - **Code-review hardening (2026-06-25, /code-review high):** `SynoError` now carries the DSM `code` + a `handled` flag; `call()` no longer logs eagerly — the top-level handler logs only genuine unhandled failures, so the handled FileStation denial (and VPN-down connect errors) no longer diff --git a/.claude/skills/synology/scripts/syno-ssh.sh b/.claude/skills/synology/scripts/syno-ssh.sh index 818ab84d..11a2895e 100644 --- a/.claude/skills/synology/scripts/syno-ssh.sh +++ b/.claude/skills/synology/scripts/syno-ssh.sh @@ -46,21 +46,28 @@ else 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'; } +# The syno* CLI lives in /usr/syno/{bin,sbin}. The admin's interactive login PATH has +# these (so `plain` worked), but `sudo`'s secure_path drops them -> `priv` got +# "command not found" for synoshare/synouser/synoacltool/... Inject the dirs explicitly. +SYNO_PATH='/usr/syno/bin:/usr/syno/sbin:/usr/local/bin:/bin:/usr/bin:/sbin:/usr/sbin' +# privileged remote command: feed the admin password to `sudo -S`, with the syno bin dirs on +# PATH. Run via `sh -c` so shell operators (|, ||, ;) in the recipe all execute as root+PATH. +priv() { run_ssh "$U@$H" "echo '$P' | sudo -S -p '' env PATH='$SYNO_PATH' sh -c '$1'" 2>&1 | grep -v '^Password:' ; } +plain() { run_ssh "$U@$H" "export PATH='$SYNO_PATH':\$PATH; $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';; + users) priv 'synouser --enum local';; + groups) priv 'synogroup --enum local';; packages) plain 'synopkg list 2>/dev/null || ls /var/packages';; - services) priv 'synoservice --list 2>/dev/null | head -80';; + services) # DSM 7.2 has no `synoservice`/synosystemctl list-all -> service enumeration is Web-API only. + echo "[INFO] service enumeration is Web-API only on DSM 7.2 (no synoservice CLI)." + echo " use: bash .claude/scripts/py.sh .claude/skills/synology/scripts/syno_client.py services";; acl) SHARE="${POS[1]:?acl needs a share name, e.g. acl Server}" - priv "synoacltool -get /volume1/$SHARE";; + 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';;