#!/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(" Name: " + d["name"]) print(" 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(" " + str(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 = len(f.get("devices", [])) print(" Label: " + label) print(" ID: " + f["id"]) print(" Path: " + f["path"]) print(" Shared: " + str(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(" " + str(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(" " + str(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(" " + str(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(" " + str(i) + ") " + label + " (" + str(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 " 0) Show This Device's ID" echo " 1) Pending Devices" echo " 2) List Devices" echo " 3) Add Device" echo " 4) Remove Device" echo " 5) List Folders" echo " 6) Add Folder" echo " 7) Remove Folder" echo " 8) Share Folder with Device" echo " 9) Unshare Folder from Device" echo " q) Quit" echo "" read -rp "Choose: " OPT echo "" case "$OPT" in 0) show_own_device_id ;; 1) show_pending_devices ;; 2) list_devices ;; 3) add_device ;; 4) remove_device ;; 5) list_folders ;; 6) add_folder ;; 7) remove_folder ;; 8) share_folder ;; 9) unshare_folder ;; q|Q) echo "Bye."; exit 0 ;; *) warn "Invalid choice." ;; esac done