From e6720804dc007bf1d3d3b80e7b04b78b0e7c0fb2 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Sat, 18 Apr 2026 18:36:01 -0700 Subject: [PATCH] syncthing.sh: new script for managing Syncthing devices and folders via REST API with interactive menu --- syncthing.sh | 434 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 syncthing.sh diff --git a/syncthing.sh b/syncthing.sh new file mode 100644 index 0000000..71e68de --- /dev/null +++ b/syncthing.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${GREEN}[+]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; } + +ST_URL="http://127.0.0.1:8384" +APIKEY="" + +get_apikey() { + local container + container=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true) + [ -n "$container" ] || die "No running Syncthing container found." + APIKEY=$(docker exec "$container" cat /var/syncthing/config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+' || true) + [ -n "$APIKEY" ] || die "Could not read Syncthing API key from container '$container'." +} + +st_get() { + curl -sf -H "X-API-Key: $APIKEY" "${ST_URL}${1}" +} + +st_post() { + curl -sf -X POST -H "X-API-Key: $APIKEY" -H "Content-Type: application/json" -d "$2" "${ST_URL}${1}" +} + +st_put() { + curl -sf -X PUT -H "X-API-Key: $APIKEY" -H "Content-Type: application/json" -d "$2" "${ST_URL}${1}" +} + +st_delete() { + curl -sf -X DELETE -H "X-API-Key: $APIKEY" "${ST_URL}${1}" +} + +check_deps() { + local missing=() + for cmd in "$@"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing+=("$cmd") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + die "Missing required dependencies: ${missing[*]}" + fi +} + +show_own_device_id() { + header "This Device's ID" + local id + id=$(st_get /rest/system/status | python3 -c 'import sys,json; print(json.load(sys.stdin)["myID"])') + echo -e " ${GREEN}${id}${NC}" + echo "" +} + +show_pending_devices() { + header "Pending Devices" + local pending + pending=$(st_get /rest/cluster/pending/devices) + local count + count=$(echo "$pending" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d))') + if [ "$count" -eq 0 ]; then + warn "No pending devices." + return + fi + echo "$pending" | python3 -c ' +import sys, json +devices = json.load(sys.stdin) +for device_id, info in devices.items(): + name = info.get("name", "(unknown)") + print(f" Name: {name}") + print(f" ID: {device_id}") + print() +' + read -rp "Add a pending device? [y/N]: " ADD_PENDING + if [[ "${ADD_PENDING,,}" == "y" ]]; then + add_device_by_pending "$pending" + fi +} + +add_device_by_pending() { + local pending="$1" + local ids + ids=$(echo "$pending" | python3 -c 'import sys,json; [print(k) for k in json.load(sys.stdin)]') + local id_list=() + while IFS= read -r line; do + id_list+=("$line") + done <<< "$ids" + + if [ ${#id_list[@]} -eq 1 ]; then + DEVICE_ID="${id_list[0]}" + local pending_name + pending_name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['${DEVICE_ID}'].get('name',''))") + else + echo "Pending devices:" + local i=1 + for id in "${id_list[@]}"; do + local name + name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['${id}'].get('name','(unknown)'))") + echo " $i) $name — $id" + i=$((i+1)) + done + read -rp "Choose [1-${#id_list[@]}]: " CHOICE + [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "${#id_list[@]}" ] || die "Invalid choice." + DEVICE_ID="${id_list[$((CHOICE-1))]}" + pending_name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['${DEVICE_ID}'].get('name',''))") + fi + + read -rp "Device name [${pending_name:-new-device}]: " DEVICE_NAME + DEVICE_NAME="${DEVICE_NAME:-${pending_name:-new-device}}" + _do_add_device "$DEVICE_ID" "$DEVICE_NAME" +} + +list_devices() { + header "Devices" + st_get /rest/config/devices | python3 -c ' +import sys, json +devices = json.load(sys.stdin) +if not devices: + print(" No devices configured.") +else: + for d in devices: + print(f" Name: {d[\"name\"]}") + print(f" ID: {d[\"deviceID\"]}") + print() +' +} + +add_device() { + header "Add Device" + read -rp "Device ID: " DEVICE_ID + [ -n "$DEVICE_ID" ] || die "Device ID cannot be empty." + read -rp "Device name: " DEVICE_NAME + [ -n "$DEVICE_NAME" ] || die "Device name cannot be empty." + _do_add_device "$DEVICE_ID" "$DEVICE_NAME" +} + +_do_add_device() { + local device_id="$1" + local device_name="$2" + local existing + existing=$(st_get /rest/config/devices) + local already + already=$(echo "$existing" | python3 -c "import sys,json; devs=json.load(sys.stdin); print(any(d['deviceID']=='${device_id}' for d in devs))") + if [ "$already" = "True" ]; then + warn "Device '${device_name}' is already configured." + return + fi + local payload + payload=$(python3 -c "import json; print(json.dumps({'deviceID': '${device_id}', 'name': '${device_name}', 'addresses': ['dynamic'], 'autoAcceptFolders': False}))") + st_post /rest/config/devices "$payload" >/dev/null + info "Device '${device_name}' added." +} + +remove_device() { + header "Remove Device" + local devices + devices=$(st_get /rest/config/devices) + local count + count=$(echo "$devices" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') + [ "$count" -gt 0 ] || { warn "No devices configured."; return; } + + echo "$devices" | python3 -c ' +import sys, json +for i, d in enumerate(json.load(sys.stdin), 1): + print(f" {i}) {d[\"name\"]} — {d[\"deviceID\"]}") +' + echo "" + read -rp "Choose device to remove [1-${count}]: " CHOICE + [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$count" ] || die "Invalid choice." + + local device_id device_name + device_id=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[$((CHOICE-1))]['deviceID'])") + device_name=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[$((CHOICE-1))]['name'])") + + local folders + folders=$(st_get /rest/config/folders) + local shared_folders + shared_folders=$(echo "$folders" | python3 -c " +import sys, json +folders = json.load(sys.stdin) +shared = [f['label'] or f['id'] for f in folders if any(dev['deviceID']=='${device_id}' for dev in f.get('devices',[]))] +print('\n'.join(shared)) +") + if [ -n "$shared_folders" ]; then + warn "Device '${device_name}' is still sharing these folders:" + echo "$shared_folders" | sed 's/^/ /' + warn "Removing the device will unshare these folders from it." + fi + + read -rp "Remove '${device_name}'? [y/N]: " CONFIRM + [[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; } + + st_delete "/rest/config/devices/${device_id}" >/dev/null + info "Device '${device_name}' removed." +} + +list_folders() { + header "Folders" + st_get /rest/config/folders | python3 -c ' +import sys, json +folders = json.load(sys.stdin) +if not folders: + print(" No folders configured.") +else: + for f in folders: + label = f["label"] or f["id"] + shared = [d["deviceID"][:8]+"..." for d in f.get("devices", [])] + print(f" Label: {label}") + print(f" ID: {f[\"id\"]}") + print(f" Path: {f[\"path\"]}") + print(f" Shared: {len(shared)} device(s)") + print() +' +} + +add_folder() { + header "Add Folder" + read -rp "Folder path on this device (e.g. /var/syncthing/docs): " FOLDER_PATH + [ -n "$FOLDER_PATH" ] || die "Path cannot be empty." + read -rp "Folder label (human-readable name): " FOLDER_LABEL + [ -n "$FOLDER_LABEL" ] || die "Label cannot be empty." + read -rp "Folder ID (leave blank to use label): " FOLDER_ID + FOLDER_ID="${FOLDER_ID:-$FOLDER_LABEL}" + + local existing + existing=$(st_get /rest/config/folders) + local already + already=$(echo "$existing" | python3 -c "import sys,json; folders=json.load(sys.stdin); print(any(f['id']=='${FOLDER_ID}' for f in folders))") + [ "$already" = "False" ] || die "Folder ID '${FOLDER_ID}' already exists." + + local payload + payload=$(python3 -c "import json; print(json.dumps({'id': '${FOLDER_ID}', 'label': '${FOLDER_LABEL}', 'path': '${FOLDER_PATH}', 'type': 'sendreceive', 'devices': []}))") + st_post /rest/config/folders "$payload" >/dev/null + info "Folder '${FOLDER_LABEL}' added." +} + +remove_folder() { + header "Remove Folder" + local folders + folders=$(st_get /rest/config/folders) + local count + count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') + [ "$count" -gt 0 ] || { warn "No folders configured."; return; } + + echo "$folders" | python3 -c ' +import sys, json +for i, f in enumerate(json.load(sys.stdin), 1): + label = f["label"] or f["id"] + print(f" {i}) {label} ({f[\"path\"]})") +' + echo "" + read -rp "Choose folder to remove [1-${count}]: " CHOICE + [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$count" ] || die "Invalid choice." + + local folder_id folder_label + folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((CHOICE-1))]; print(f['id'])") + folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((CHOICE-1))]; print(f['label'] or f['id'])") + + read -rp "Remove folder '${folder_label}'? [y/N]: " CONFIRM + [[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; } + + st_delete "/rest/config/folders/${folder_id}" >/dev/null + info "Folder '${folder_label}' removed." +} + +share_folder() { + header "Share Folder with Device" + local folders devices + folders=$(st_get /rest/config/folders) + devices=$(st_get /rest/config/devices) + + local f_count d_count + f_count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') + d_count=$(echo "$devices" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') + [ "$f_count" -gt 0 ] || { warn "No folders configured."; return; } + [ "$d_count" -gt 0 ] || { warn "No devices configured."; return; } + + echo "Folders:" + echo "$folders" | python3 -c ' +import sys, json +for i, f in enumerate(json.load(sys.stdin), 1): + label = f["label"] or f["id"] + print(f" {i}) {label}") +' + echo "" + read -rp "Choose folder [1-${f_count}]: " F_CHOICE + [[ "$F_CHOICE" =~ ^[0-9]+$ ]] && [ "$F_CHOICE" -ge 1 ] && [ "$F_CHOICE" -le "$f_count" ] || die "Invalid choice." + + echo "" + echo "Devices:" + echo "$devices" | python3 -c ' +import sys, json +for i, d in enumerate(json.load(sys.stdin), 1): + print(f" {i}) {d[\"name\"]}") +' + echo "" + read -rp "Choose device [1-${d_count}]: " D_CHOICE + [[ "$D_CHOICE" =~ ^[0-9]+$ ]] && [ "$D_CHOICE" -ge 1 ] && [ "$D_CHOICE" -le "$d_count" ] || die "Invalid choice." + + local folder_id device_id device_name folder_label + folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['id'])") + folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['label'] or f['id'])") + device_id=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin)[$((D_CHOICE-1))]; print(d['deviceID'])") + device_name=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin)[$((D_CHOICE-1))]; print(d['name'])") + + local folder_config already + folder_config=$(st_get "/rest/config/folders/${folder_id}") + already=$(echo "$folder_config" | python3 -c "import sys,json; f=json.load(sys.stdin); print(any(d['deviceID']=='${device_id}' for d in f.get('devices',[])))") + if [ "$already" = "True" ]; then + warn "Folder '${folder_label}' is already shared with '${device_name}'." + return + fi + + local updated + updated=$(echo "$folder_config" | python3 -c " +import sys, json +f = json.load(sys.stdin) +f['devices'].append({'deviceID': '${device_id}', 'introducedBy': ''}) +print(json.dumps(f)) +") + st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null + info "Folder '${folder_label}' shared with '${device_name}'." +} + +unshare_folder() { + header "Unshare Folder from Device" + local folders devices + folders=$(st_get /rest/config/folders) + devices=$(st_get /rest/config/devices) + + local f_count + f_count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') + [ "$f_count" -gt 0 ] || { warn "No folders configured."; return; } + + echo "Folders:" + echo "$folders" | python3 -c ' +import sys, json +for i, f in enumerate(json.load(sys.stdin), 1): + label = f["label"] or f["id"] + shared = len(f.get("devices", [])) + print(f" {i}) {label} ({shared} device(s))") +' + echo "" + read -rp "Choose folder [1-${f_count}]: " F_CHOICE + [[ "$F_CHOICE" =~ ^[0-9]+$ ]] && [ "$F_CHOICE" -ge 1 ] && [ "$F_CHOICE" -le "$f_count" ] || die "Invalid choice." + + local folder_id folder_label folder_config + folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['id'])") + folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['label'] or f['id'])") + folder_config=$(st_get "/rest/config/folders/${folder_id}") + + local shared_count + shared_count=$(echo "$folder_config" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("devices",[])))') + [ "$shared_count" -gt 0 ] || { warn "Folder '${folder_label}' is not shared with any devices."; return; } + + local shared_ids + shared_ids=$(echo "$folder_config" | python3 -c "import sys,json; [print(d['deviceID']) for d in json.load(sys.stdin).get('devices',[])]") + + echo "" + echo "Shared with:" + local i=1 + while IFS= read -r did; do + local dname + dname=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']=='${did}']; print(match[0] if match else '${did}')") + echo " $i) $dname — $did" + i=$((i+1)) + done <<< "$shared_ids" + local shared_count_actual=$((i-1)) + + echo "" + read -rp "Choose device to unshare [1-${shared_count_actual}]: " D_CHOICE + [[ "$D_CHOICE" =~ ^[0-9]+$ ]] && [ "$D_CHOICE" -ge 1 ] && [ "$D_CHOICE" -le "$shared_count_actual" ] || die "Invalid choice." + + local target_id + target_id=$(echo "$shared_ids" | sed -n "${D_CHOICE}p") + local target_name + target_name=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']=='${target_id}']; print(match[0] if match else '${target_id}')") + + read -rp "Unshare '${folder_label}' from '${target_name}'? [y/N]: " CONFIRM + [[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; } + + local updated + updated=$(echo "$folder_config" | python3 -c " +import sys, json +f = json.load(sys.stdin) +f['devices'] = [d for d in f['devices'] if d['deviceID'] != '${target_id}'] +print(json.dumps(f)) +") + st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null + info "Folder '${folder_label}' unshared from '${target_name}'." +} + +check_deps curl docker python3 + +get_apikey + +while true; do + header "Syncthing Manager" + echo " 1) Show This Device's ID" + echo " 2) Pending Devices" + echo " 3) List Devices" + echo " 4) Add Device" + echo " 5) Remove Device" + echo " 6) List Folders" + echo " 7) Add Folder" + echo " 8) Remove Folder" + echo " 9) Share Folder with Device" + echo " 10) Unshare Folder from Device" + echo " q) Quit" + echo "" + read -rp "Choose: " OPT + echo "" + case "$OPT" in + 1) show_own_device_id ;; + 2) show_pending_devices ;; + 3) list_devices ;; + 4) add_device ;; + 5) remove_device ;; + 6) list_folders ;; + 7) add_folder ;; + 8) remove_folder ;; + 9) share_folder ;; + 10) unshare_folder ;; + q|Q) echo "Bye."; exit 0 ;; + *) warn "Invalid choice." ;; + esac +done