2026-04-18 18:36:01 -07:00
#!/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 = ""
2026-04-18 21:13:34 -07:00
ST_CONTAINER = ""
2026-04-18 18:36:01 -07:00
get_apikey( ) {
2026-04-18 21:13:34 -07:00
ST_CONTAINER = $( docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true )
[ -n " $ST_CONTAINER " ] || die "No running Syncthing container found."
2026-04-18 21:15:46 -07:00
APIKEY = $( docker exec " $ST_CONTAINER " python3 -c "import xml.etree.ElementTree as ET; print(ET.parse('/var/syncthing/config/config.xml').find('.//apikey').text)" 2>/dev/null || true )
2026-04-18 21:13:34 -07:00
[ -n " $APIKEY " ] || die " Could not read Syncthing API key from container ' $ST_CONTAINER '. "
2026-04-18 18:36:01 -07:00
}
st_get( ) {
2026-04-18 21:15:46 -07:00
curl -sf -H " X-API-Key: $APIKEY " " ${ ST_URL } ${ 1 } " || die " API call failed: GET ${ 1 } "
2026-04-18 18:36:01 -07:00
}
st_post( ) {
2026-04-18 21:15:46 -07:00
curl -sf -X POST -H " X-API-Key: $APIKEY " -H "Content-Type: application/json" -d " $2 " " ${ ST_URL } ${ 1 } " || die " API call failed: POST ${ 1 } "
2026-04-18 18:36:01 -07:00
}
st_put( ) {
2026-04-18 21:15:46 -07:00
curl -sf -X PUT -H " X-API-Key: $APIKEY " -H "Content-Type: application/json" -d " $2 " " ${ ST_URL } ${ 1 } " || die " API call failed: PUT ${ 1 } "
2026-04-18 18:36:01 -07:00
}
st_delete( ) {
2026-04-18 21:15:46 -07:00
curl -sf -X DELETE -H " X-API-Key: $APIKEY " " ${ ST_URL } ${ 1 } " || die " API call failed: DELETE ${ 1 } "
2026-04-18 18:36:01 -07:00
}
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
2026-04-18 21:18:04 -07:00
pending_name = $( echo " $pending " | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name',''))" " $DEVICE_ID " )
2026-04-18 18:36:01 -07:00
else
echo "Pending devices:"
local i = 1
for id in " ${ id_list [@] } " ; do
local name
2026-04-18 21:18:04 -07:00
name = $( echo " $pending " | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name','(unknown)'))" " $id " )
2026-04-18 18:36:01 -07:00
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)) ] } "
2026-04-18 21:18:04 -07:00
pending_name = $( echo " $pending " | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name',''))" " $DEVICE_ID " )
2026-04-18 18:36:01 -07:00
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:
2026-04-18 18:42:00 -07:00
print( " Name: " + d[ "name" ] )
print( " ID: " + d[ "deviceID" ] )
2026-04-18 18:36:01 -07:00
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
2026-04-18 21:15:46 -07:00
already = $( echo " $existing " | python3 -c "import sys,json; devs=json.load(sys.stdin); print(any(d['deviceID']==sys.argv[1] for d in devs))" " $device_id " )
2026-04-18 18:36:01 -07:00
if [ " $already " = "True" ] ; then
warn " Device ' ${ device_name } ' is already configured. "
return
fi
local payload
2026-04-18 21:15:46 -07:00
payload = $( python3 -c "import sys,json; print(json.dumps({'deviceID':sys.argv[1],'name':sys.argv[2],'addresses':['dynamic'],'autoAcceptFolders':False}))" " $device_id " " $device_name " )
2026-04-18 18:36:01 -07:00
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) :
2026-04-18 18:42:00 -07:00
print( " " + str( i) + ") " + d[ "name" ] + " — " + d[ "deviceID" ] )
2026-04-18 18:36:01 -07:00
'
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)
2026-04-18 21:18:04 -07:00
shared = [ f[ 'label' ] or f[ 'id' ] for f in folders if any( dev[ 'deviceID' ] = = sys.argv[ 1] for dev in f.get( 'devices' ,[ ] ) ) ]
2026-04-18 18:36:01 -07:00
print( '\n' .join( shared) )
2026-04-18 21:18:04 -07:00
" " $device_id " )
2026-04-18 18:36:01 -07:00
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" ]
2026-04-18 18:42:00 -07:00
shared = len( f.get( "devices" , [ ] ) )
print( " Label: " + label)
print( " ID: " + f[ "id" ] )
print( " Path: " + f[ "path" ] )
print( " Shared: " + str( shared) + " device(s)" )
2026-04-18 18:36:01 -07:00
print( )
'
}
add_folder( ) {
header "Add Folder"
2026-04-18 21:13:34 -07:00
if [ -n " $ST_CONTAINER " ] ; then
2026-04-18 21:12:47 -07:00
echo "Available folders in /var/syncthing/data/:"
2026-04-18 21:13:34 -07:00
docker exec " $ST_CONTAINER " ls /var/syncthing/data/ 2>/dev/null | sed 's/^/ /' || warn "Could not list data directory."
2026-04-18 21:12:47 -07:00
echo ""
fi
2026-04-18 21:08:22 -07:00
read -rp "Folder path on this device (e.g. /var/syncthing/data/books): " FOLDER_PATH
2026-04-18 18:36:01 -07:00
[ -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
2026-04-18 21:15:46 -07:00
already = $( echo " $existing " | python3 -c "import sys,json; folders=json.load(sys.stdin); print(any(f['id']==sys.argv[1] for f in folders))" " $FOLDER_ID " )
2026-04-18 18:36:01 -07:00
[ " $already " = "False" ] || die " Folder ID ' ${ FOLDER_ID } ' already exists. "
local payload
2026-04-18 21:15:46 -07:00
payload = $( python3 -c "import sys,json; print(json.dumps({'id':sys.argv[1],'label':sys.argv[2],'path':sys.argv[3],'type':'sendreceive','devices':[]}))" " $FOLDER_ID " " $FOLDER_LABEL " " $FOLDER_PATH " )
2026-04-18 18:36:01 -07:00
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" ]
2026-04-18 18:44:59 -07:00
print( " " + str( i) + ") " + label + " (" + f[ "path" ] + ")" )
2026-04-18 18:36:01 -07:00
'
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']) " )
2026-04-18 21:21:24 -07:00
local folder_config shared_devices
folder_config = $( st_get " /rest/config/folders/ ${ folder_id } " )
shared_devices = $( echo " $folder_config " | python3 -c "import sys,json; [print(d['deviceID']) for d in json.load(sys.stdin).get('devices',[])]" )
if [ -n " $shared_devices " ] ; then
local devices
devices = $( st_get /rest/config/devices)
warn " Folder ' ${ folder_label } ' is currently shared with: "
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']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" " $did " )
echo " - $dname "
done <<< " $shared_devices "
warn "It will be unshared from all devices before removal."
read -rp "Proceed? [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' ] = [ ]
print( json.dumps( f) )
" )
st_put " /rest/config/folders/ ${ folder_id } " " $updated " >/dev/null
info " Folder ' ${ folder_label } ' unshared from all devices. "
else
read -rp " Remove folder ' ${ folder_label } '? [y/N]: " CONFIRM
[ [ " ${ CONFIRM ,, } " = = "y" ] ] || { info "Aborted." ; return ; }
fi
2026-04-18 18:36:01 -07:00
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" ]
2026-04-18 18:42:00 -07:00
print( " " + str( i) + ") " + label)
2026-04-18 18:36:01 -07:00
'
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) :
2026-04-18 18:42:00 -07:00
print( " " + str( i) + ") " + d[ "name" ] )
2026-04-18 18:36:01 -07:00
'
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 } " )
2026-04-18 21:15:46 -07:00
already = $( echo " $folder_config " | python3 -c "import sys,json; f=json.load(sys.stdin); print(any(d['deviceID']==sys.argv[1] for d in f.get('devices',[])))" " $device_id " )
2026-04-18 18:36:01 -07:00
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)
2026-04-18 21:15:46 -07:00
f[ 'devices' ] .append( { 'deviceID' : sys.argv[ 1] , 'introducedBy' : '' } )
2026-04-18 18:36:01 -07:00
print( json.dumps( f) )
2026-04-18 21:15:46 -07:00
" " $device_id " )
2026-04-18 18:36:01 -07:00
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" , [ ] ) )
2026-04-18 18:42:00 -07:00
print( " " + str( i) + ") " + label + " (" + str( shared) + " device(s))" )
2026-04-18 18:36:01 -07:00
'
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
2026-04-18 21:18:04 -07:00
dname = $( echo " $devices " | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" " $did " )
2026-04-18 18:36:01 -07:00
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
2026-04-18 21:18:04 -07:00
target_name = $( echo " $devices " | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" " $target_id " )
2026-04-18 18:36:01 -07:00
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)
2026-04-18 21:15:46 -07:00
f[ 'devices' ] = [ d for d in f[ 'devices' ] if d[ 'deviceID' ] != sys.argv[ 1] ]
2026-04-18 18:36:01 -07:00
print( json.dumps( f) )
2026-04-18 21:15:46 -07:00
" " $target_id " )
2026-04-18 18:36:01 -07:00
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"
2026-04-18 18:57:36 -07:00
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"
2026-04-18 18:36:01 -07:00
echo " q) Quit"
echo ""
read -rp "Choose: " OPT
echo ""
case " $OPT " in
2026-04-18 18:57:36 -07:00
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 ; ;
2026-04-18 18:36:01 -07:00
q| Q) echo "Bye." ; exit 0 ; ;
*) warn "Invalid choice." ; ;
esac
done