2026-04-16 09:05:41 -07:00
#!/usr/bin/env bash
set -euo pipefail
RCLONE_CONF = " ${ HOME } /.config/rclone/rclone.conf "
SSH_DIR = " ${ HOME } /.ssh "
2026-04-18 13:34:59 -07:00
REGISTRY = " ${ HOME } /.config/tinyboard/spokes "
2026-04-16 09:05:41 -07:00
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'
2026-04-16 13:17:12 -07:00
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 } " ; }
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
}
2026-04-16 09:30:47 -07:00
retry_or_abort( ) {
local test_cmd = " $1 "
local fail_msg = " $2 "
while true; do
if eval " $test_cmd " 2>/dev/null; then
return 0
fi
echo ""
warn " $fail_msg "
echo -e " ${ YELLOW } [R] ${ NC } Retry ${ RED } [A] ${ NC } Abort "
read -rp "Choice: " CHOICE
case " ${ CHOICE ,, } " in
r) info "Retrying..." ; ;
a) die "Aborted." ; ;
*) warn "Press R to retry or A to abort." ; ;
esac
done
}
2026-04-16 13:17:12 -07:00
if [ " $( id -u) " -eq 0 ] ; then
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
fi
mkdir -p " $SSH_DIR "
touch " $SSH_DIR /known_hosts "
chmod 700 " $SSH_DIR "
chmod 600 " $SSH_DIR /known_hosts "
2026-04-16 13:05:45 -07:00
2026-04-16 13:17:12 -07:00
check_deps ssh ssh-keygen ssh-keyscan ssh-copy-id rclone
2026-04-16 09:05:41 -07:00
header "TinyBoard Hub — Onboard New Spoke"
2026-04-16 13:17:12 -07:00
read -rp "Spoke local user [armbian]: " SPOKE_USER
SPOKE_USER = " ${ SPOKE_USER :- armbian } "
2026-04-16 09:05:41 -07:00
read -rp "Spoke name (e.g. rocky): " SPOKE_NAME
[ -n " $SPOKE_NAME " ] || die "Spoke name cannot be empty"
read -rp " Tunnel port for $SPOKE_NAME : " TUNNEL_PORT
[ [ " $TUNNEL_PORT " = ~ ^[ 0-9] +$ ] ] || die "Invalid port"
2026-04-16 13:17:12 -07:00
KEY_NAME = " ${ SPOKE_USER } - ${ SPOKE_NAME } - $( date +%Y%m) "
2026-04-16 09:05:41 -07:00
KEY_PATH = " $SSH_DIR / $KEY_NAME "
2026-04-16 09:08:07 -07:00
mkdir -p " $( dirname " $RCLONE_CONF " ) "
2026-04-16 09:05:41 -07:00
header "Checking Tunnel"
2026-04-19 14:38:10 -07:00
info " Verifying spoke SSH service is reachable on port $TUNNEL_PORT ... "
if ! timeout 5 bash -c " cat < /dev/null > /dev/tcp/localhost/ $TUNNEL_PORT " 2>/dev/null; then
die " Cannot connect to port $TUNNEL_PORT on localhost — is the tunnel up? "
fi
2026-04-16 09:08:07 -07:00
info "Scanning spoke host key..."
2026-04-16 09:18:40 -07:00
KEYSCAN = $( ssh-keyscan -p " $TUNNEL_PORT " -H localhost 2>/dev/null)
[ -n " $KEYSCAN " ] || die " Spoke not reachable on port $TUNNEL_PORT — is the tunnel up? "
2026-04-18 14:25:24 -07:00
while IFS = read -r KEYSCAN_LINE; do
2026-04-18 14:31:10 -07:00
KEYSCAN_KEY = $( echo " $KEYSCAN_LINE " | awk '{print $2, $3}' )
2026-04-18 14:25:24 -07:00
if ! grep -qF " $KEYSCAN_KEY " " $SSH_DIR /known_hosts " 2>/dev/null; then
echo " $KEYSCAN_LINE " >> " $SSH_DIR /known_hosts "
fi
done <<< " $KEYSCAN "
2026-04-16 09:08:07 -07:00
2026-04-19 14:38:10 -07:00
header "Generating Hub-to-Spoke Access Key"
2026-04-16 09:05:41 -07:00
if [ -f " $KEY_PATH " ] ; then
warn " Key $KEY_PATH already exists, skipping generation. "
else
2026-04-19 14:38:10 -07:00
ssh-keygen -t ed25519 -f " $KEY_PATH " -N "" -C " $KEY_NAME "
2026-04-16 09:05:41 -07:00
info " Key generated: $KEY_PATH "
fi
2026-04-18 14:12:05 -07:00
chmod 600 " $KEY_PATH "
info " Permissions set: $KEY_PATH is 600 "
2026-04-16 09:05:41 -07:00
2026-04-19 14:38:10 -07:00
header "Installing Hub-to-Spoke Access Key on Spoke"
2026-04-19 13:05:29 -07:00
info "Copying hub public key to spoke's authorized_keys so the hub can SSH in for rclone..."
2026-04-16 13:17:12 -07:00
info " (You will be prompted for the $SPOKE_USER password on the spoke) "
2026-04-19 14:38:10 -07:00
if ssh-copy-id -i " $KEY_PATH .pub " -p " $TUNNEL_PORT " " $SPOKE_USER " @localhost; then
2026-04-18 13:43:33 -07:00
info "Key copied."
else
2026-04-18 13:37:35 -07:00
warn "ssh-copy-id failed — password auth may be disabled on the spoke."
warn "Manually append the hub public key to the spoke's authorized_keys:"
echo ""
echo " cat $KEY_PATH .pub "
2026-04-18 14:07:02 -07:00
echo " Then on the spoke, append the output to:"
echo " /home/ $SPOKE_USER /.ssh/authorized_keys "
2026-04-18 13:37:35 -07:00
echo ""
read -rp "Press ENTER once the key has been added to the spoke..."
fi
2026-04-16 09:05:41 -07:00
2026-04-19 14:38:10 -07:00
header "Testing Hub-to-Spoke Key Auth"
2026-04-16 09:30:47 -07:00
retry_or_abort \
2026-04-16 13:17:12 -07:00
" ssh -i \" $KEY_PATH \" -o BatchMode=yes -o ConnectTimeout=10 -p \" $TUNNEL_PORT \" \" $SPOKE_USER \"@localhost exit " \
2026-04-16 09:30:47 -07:00
"Key auth failed. Check authorized_keys on the spoke."
info "Key auth to spoke successful."
2026-04-16 09:05:41 -07:00
header "Adding rclone Remote"
if grep -q " \[ ${ SPOKE_NAME } -remote\] " " $RCLONE_CONF " 2>/dev/null; then
warn " Remote [ ${ SPOKE_NAME } -remote] already exists in $RCLONE_CONF , skipping. "
else
2026-04-18 14:31:10 -07:00
[ -s " $RCLONE_CONF " ] && tail -c1 " $RCLONE_CONF " | grep -qv $'\n' && echo "" >> " $RCLONE_CONF "
2026-04-19 15:04:39 -07:00
python3 - " $RCLONE_CONF " " $SPOKE_NAME " " $TUNNEL_PORT " " $KEY_PATH " <<'PYEOF'
import sys
path, name, port, key = sys.argv[ 1] , sys.argv[ 2] , sys.argv[ 3] , sys.argv[ 4]
with open( path, 'a' ) as f:
f.write( f"\n[{name}-remote]\ntype = sftp\nhost = localhost\nport = {port}\nkey_file = {key}\nshell_type = unix\nmd5sum_command = md5sum\nsha1sum_command = sha1sum\n" )
PYEOF
2026-04-16 09:05:41 -07:00
info " Remote [ ${ SPOKE_NAME } -remote] added to $RCLONE_CONF . "
fi
2026-04-19 14:43:05 -07:00
header "Union Remote (optional)"
read -rp "Add this spoke to a union remote for redundancy? [y/N]: " ADD_UNION
ADD_UNION = " ${ ADD_UNION :- n } "
if [ [ " ${ ADD_UNION ,, } " = = "y" ] ] ; then
read -rp "Union remote name [shared-union]: " UNION_NAME
UNION_NAME = " ${ UNION_NAME :- shared -union } "
2026-04-19 15:04:39 -07:00
read -rp "Subfolder path on the spoke being onboarded (e.g. books, leave blank for root): " UNION_PATH
2026-04-19 14:43:05 -07:00
echo ""
echo "Upstream access mode for this spoke:"
echo " 0) None - full read/write (default)"
echo " 1) :ro - read only"
echo " 2) :nc - no create (read/write existing, no new files)"
echo " 3) :writeback - writeback cache"
echo ""
read -rp "Choose [0-3]: " UNION_MODE
UNION_MODE = " ${ UNION_MODE :- 0 } "
case " $UNION_MODE " in
0) UPSTREAM_TAG = "" ; ;
1) UPSTREAM_TAG = ":ro" ; ;
2) UPSTREAM_TAG = ":nc" ; ;
3) UPSTREAM_TAG = ":writeback" ; ;
*) warn "Invalid choice, defaulting to full read/write." ; UPSTREAM_TAG = "" ; ;
esac
if [ -n " $UNION_PATH " ] ; then
UPSTREAM = " ${ SPOKE_NAME } -remote: ${ UNION_PATH } ${ UPSTREAM_TAG } "
else
UPSTREAM = " ${ SPOKE_NAME } -remote: ${ UPSTREAM_TAG } "
fi
if grep -q " ^\[ ${ UNION_NAME } \] " " $RCLONE_CONF " 2>/dev/null; then
ALREADY = $( python3 - " $RCLONE_CONF " " $UNION_NAME " " ${ SPOKE_NAME } -remote: " <<'PYEOF2'
import sys
path, section, prefix = sys.argv[ 1] , sys.argv[ 2] , sys.argv[ 3]
with open( path) as f:
lines = f.readlines( )
in_section = False
for line in lines:
if line.strip( ) = = f"[{section}]" :
in_section = True
elif line.strip( ) .startswith( "[" ) :
in_section = False
if in_section and line.startswith( "upstreams =" ) and prefix in line:
print( "yes" )
sys.exit( 0)
print( "no" )
PYEOF2
)
if [ " $ALREADY " = "yes" ] ; then
warn " Upstream for ${ SPOKE_NAME } -remote already in union remote [ ${ UNION_NAME } ], skipping. "
else
python3 - " $RCLONE_CONF " " $UNION_NAME " " $UPSTREAM " <<'PYEOF2'
import sys
path, section, upstream = sys.argv[ 1] , sys.argv[ 2] , sys.argv[ 3]
with open( path) as f:
lines = f.readlines( )
out = [ ]
in_section = False
for line in lines:
if line.strip( ) = = f"[{section}]" :
in_section = True
elif line.strip( ) .startswith( "[" ) :
in_section = False
if in_section and line.startswith( "upstreams =" ) :
line = line.rstrip( ) + " " + upstream + "\n"
out.append( line)
with open( path, "w" ) as f:
f.writelines( out)
PYEOF2
info " Added ' $UPSTREAM ' to union remote [ ${ UNION_NAME } ]. "
fi
else
[ -s " $RCLONE_CONF " ] && tail -c1 " $RCLONE_CONF " | grep -qv $'\n' && echo "" >> " $RCLONE_CONF "
printf '\n[%s]\ntype = union\nupstreams = %s\n' " $UNION_NAME " " $UPSTREAM " >> " $RCLONE_CONF "
info " Union remote [ ${ UNION_NAME } ] created with upstream ' $UPSTREAM '. "
fi
fi
header "Testing rclone Connection"
if rclone lsd " ${ SPOKE_NAME } -remote: " --config " $RCLONE_CONF " 2>/dev/null; then
info " rclone connection to $SPOKE_NAME successful. "
else
warn " rclone test failed. Check the remote config in $RCLONE_CONF . "
fi
header "Registering Spoke"
mkdir -p " $( dirname " $REGISTRY " ) "
MOUNT_POINT = " ${ HOME } /mnt/ ${ SPOKE_NAME } "
mkdir -p " $MOUNT_POINT "
if grep -q " ^ ${ SPOKE_NAME } " " $REGISTRY " 2>/dev/null; then
warn " $SPOKE_NAME already in registry, updating. "
grep -v " ^ ${ SPOKE_NAME } " " $REGISTRY " > " ${ REGISTRY } .tmp " 2>/dev/null || true
mv " ${ REGISTRY } .tmp " " $REGISTRY "
fi
echo " ${ SPOKE_NAME } ${ TUNNEL_PORT } ${ KEY_PATH } ${ MOUNT_POINT } " >> " $REGISTRY "
info " $SPOKE_NAME registered. "
header "Setting Up Auto-Mount"
2026-04-20 22:27:10 -07:00
MOUNT_SCRIPT = " ${ HOME } /mount- ${ SPOKE_NAME } .sh "
cat > " $MOUNT_SCRIPT " << MOUNTEOF
#!/usr/bin/env bash
sleep 90
/usr/bin/rclone mount ${ SPOKE_NAME } -remote: ${ MOUNT_POINT } --config ${ HOME } /.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --allow-non-empty --daemon
MOUNTEOF
chmod +x " $MOUNT_SCRIPT "
CRON_ENTRY = " @reboot $MOUNT_SCRIPT >> ${ HOME } /rclone- ${ SPOKE_NAME } .log 2>&1 "
2026-04-19 14:43:05 -07:00
EXISTING = $( crontab -l 2>/dev/null || true )
if echo " $EXISTING " | grep -qF " ${ SPOKE_NAME } -remote: " ; then
warn " Crontab entry for ${ SPOKE_NAME } -remote already exists, skipping. "
else
CRONTAB_BACKUP = " ${ HOME } /.config/tinyboard/crontab. $( date +%Y%m%d%H%M%S) "
mkdir -p " $( dirname " $CRONTAB_BACKUP " ) "
echo " $EXISTING " > " $CRONTAB_BACKUP "
info " Crontab backed up to $CRONTAB_BACKUP "
{ echo " $EXISTING " ; echo " $CRON_ENTRY " ; } | crontab -
info " Auto-mount crontab entry added for ${ SPOKE_NAME } . "
fi
info "Starting mount now..."
mkdir -p " $MOUNT_POINT "
2026-04-20 22:27:10 -07:00
/usr/bin/rclone mount ${ SPOKE_NAME } -remote: ${ MOUNT_POINT } --config ${ HOME } /.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --allow-non-empty --daemon 2>/dev/null && info " Mounted ${ SPOKE_NAME } at ${ MOUNT_POINT } . " || warn "Mount failed — will retry on next reboot."
2026-04-19 14:43:05 -07:00
header "Onboarding Complete"
echo -e " Spoke: ${ GREEN } $SPOKE_NAME ${ NC } "
echo -e " Port: ${ GREEN } $TUNNEL_PORT ${ NC } "
echo -e " Hub key: ${ GREEN } $KEY_PATH ${ NC } "
echo -e " rclone: ${ GREEN } ${ SPOKE_NAME } -remote ${ NC } "
echo ""