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 = ""
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 '(?<=<apikey>)[^<]+' || 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:
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
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) :
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)
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" ]
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"
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" ]
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 } " )
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" , [ ] ) )
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
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