#!/usr/bin/env bash # syno-ssh.sh — SSH backend for a Synology NAS: the `syno*` CLI surface the DSM Web # API does NOT expose (filesystem ACLs, low-level share/user/group internals, package # CLI). Pairs with scripts/syno_client.py (the Web API surface). Read recipes run # freely; arbitrary `run ""` is gated behind --confirm. # # REQUIRES: SSH enabled on the NAS (DSM > Terminal & SNMP) + L3 reach. Cascades NAS is # 192.168.0.120 — bring up the site VPN first. Privileged recipes use `sudo -S` with the # vaulted admin password. # AUTH (password): sshpass if installed, else OpenSSH SSH_ASKPASS fallback. On Windows the # askpass fallback needs MSYS/Git-bash ssh on PATH (system OpenSSH can't exec a shell askpass). # # Recipes (read): info | shares | users | groups | acl | df | packages | services # reboot | shutdown [--confirm] power via synoshutdown -r|-s (Web-API 103 fallback) # run "" [--confirm] arbitrary command (gated; privileged -> prepend `sudo -S`) # # Usage: bash .claude/skills/synology/scripts/syno-ssh.sh [args] [--confirm] # [--vault clients//synology-...sops.yaml] set -uo pipefail REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" VAULT="$REPO/.claude/scripts/vault.sh" logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "synology/ssh" "$1" --context "${2:-}" >/dev/null 2>&1 || true; } VP="clients/cascades-tucson/synology-cascadesds.sops.yaml"; CONFIRM=0; POS=() while [ $# -gt 0 ]; do case "$1" in --vault) VP="${2:?--vault needs a path}"; shift 2;; --confirm) CONFIRM=1; shift;; *) POS+=("$1"); shift;; esac done RECIPE="${POS[0]:-}"; [ -n "$RECIPE" ] || { echo "usage: syno-ssh.sh |df|packages|services|reboot|shutdown|run \"\"> [--confirm]"; exit 2; } H="$(bash "$VAULT" get-field "$VP" host 2>/dev/null)" U="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)" P="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)" [ -n "$H" ] && [ -n "$U" ] && [ -n "$P" ] || { echo "[BLOCKED] no Synology cred at vault:$VP (need host/credentials.username/credentials.password)"; exit 2; } SSH_OPTS=(-o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1) if command -v sshpass >/dev/null 2>&1; then run_ssh() { local rc; SSHPASS="$P" sshpass -e ssh "${SSH_OPTS[@]}" "$@" || { rc=$?; [ "$rc" = 255 ] && logerr "syno SSH connect/auth failed (rc=255)" "host=$H vp=$VP"; return $rc; }; } else ASKPASS="$(mktemp)"; printf '#!/bin/sh\nprintf "%%s\\n" "$SYNO_SSH_PW"\n' > "$ASKPASS"; chmod +x "$ASKPASS" trap 'rm -f "$ASKPASS"' EXIT run_ssh() { local rc; SYNO_SSH_PW="$P" SSH_ASKPASS="$ASKPASS" SSH_ASKPASS_REQUIRE=force DISPLAY="${DISPLAY:-:0}" ssh "${SSH_OPTS[@]}" "$@" || { rc=$?; [ "$rc" = 255 ] && logerr "syno SSH connect/auth failed (rc=255)" "host=$H vp=$VP"; return $rc; }; } fi # 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 --enum local';; groups) priv 'synogroup --enum local';; packages) plain 'synopkg list 2>/dev/null || ls /var/packages';; 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\"";; 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