67 Commits

Author SHA1 Message Date
Justin Oros cbdbce7a41 refactor(setup-network): let netplan own the AP switch — update config and apply before wpa_cli 2026-04-23 12:16:40 -07:00
Justin Oros bbf1d8b79a fix(setup-network): use remove_network instead of disable_network and drop interface bounce to prevent AP fallback 2026-04-23 12:11:12 -07:00
Justin Oros 436906582c fix(setup-network): move netplan file selection before Proceed prompt so it isn't missed on SSH drop 2026-04-23 11:57:04 -07:00
Justin Oros a01f4aa11c fix(setup-network): prompt user to choose netplan file when multiple configs exist 2026-04-23 11:54:03 -07:00
Justin Oros d61f7e4512 fix(setup-network): update all netplan files containing access-points, not just the first one 2026-04-23 11:49:01 -07:00
Justin Oros 337d238d7f fix(setup-network): bounce interface and force DHCP renew after AP switch to restore connectivity 2026-04-23 11:43:39 -07:00
Justin Oros ff5cb104f8 fix(setup-network): collect all user input before switching AP to prevent SSH session drop mid-prompt 2026-04-23 11:39:10 -07:00
Justin Oros 5d9e0f579c setup-network.sh: add Change Wireless Network option using iw/wpa_cli/wpa_passphrase for systemd-networkd on Armbian 2026-04-23 11:32:47 -07:00
Justin Oros 2bd8711db3 health-check.sh: show human-readable used/total disk usage instead of percentage 2026-04-22 09:50:44 -07:00
Justin Oros 2928285143 health-check.sh: fix total disk usage label to show root filesystem instead of empty mount point 2026-04-22 09:47:25 -07:00
Justin Oros f9d0717b71 health-check.sh: add total disk usage summary line to disk space section 2026-04-22 09:45:30 -07:00
Justin Oros 518f54394c health-check.sh: only check last 60 seconds of autossh logs for tunnel failures to avoid false positives from old log entries 2026-04-22 08:52:15 -07:00
Justin Oros defb4fdaa3 onboard-spoke.sh: use script-based rclone mount with sleep 90 for reliable boot ordering, add per-spoke mount log 2026-04-20 22:27:10 -07:00
Justin Oros c7c5d2bf8d setup-opds.sh: replace fixed sleep watchdog with retry loop that restarts dir2opds until books are visible inside container 2026-04-20 22:26:28 -07:00
Justin Oros 85def22fca setup-opds.sh: add bind-propagation=shared to dir2opds mount for FUSE mount visibility inside container 2026-04-20 21:41:46 -07:00
Justin Oros 80d5f1d1fd setup-opds.sh: use --mount type=bind instead of -v for FUSE mount propagation into dir2opds container 2026-04-20 21:22:27 -07:00
Justin Oros 56e0fc38c0 onboard-spoke.sh: use full path to rclone in crontab mount command to ensure it works at boot 2026-04-20 20:56:45 -07:00
Justin Oros 1c6e12e2d6 setup-opds.sh: add boot watchdog cron to restart dir2opds if books mount is empty after reboot 2026-04-20 20:45:54 -07:00
Justin Oros 40d24158b6 onboard-spoke.sh: add --allow-non-empty to rclone mount command to prevent mount failure after reboot 2026-04-20 20:43:00 -07:00
Justin Oros 5bc33b28f4 syncthing.sh: show folder label alongside folder ID in pending folders display and selection menu 2026-04-20 19:04:12 -07:00
Justin Oros 21a1c7e922 setup-opds.sh: replace hardcoded books path with dynamic spoke mount listing 2026-04-20 16:08:31 -07:00
Justin Oros 4586a0f598 setup-opds.sh: add -hide-dot-files flag to dir2opds to filter macOS metadata files and Syncthing folders 2026-04-20 15:01:41 -07:00
Justin Oros 2999c464fa setup-opds.sh: add firewall port warning before starting Caddy 2026-04-20 14:53:44 -07:00
Justin Oros dfa3c1ce6d setup-opds.sh: add reconfigure option to update existing OPDS setup, improve Caddyfile generation for auto and manual SSL 2026-04-20 14:45:08 -07:00
Justin Oros 9dc2b221d3 setup-opds.sh: prompt user to install Docker if not found, using same approach as setup-spoke.sh 2026-04-20 14:40:32 -07:00
Justin Oros 89e84c41c1 hub/setup-opds.sh: add OPDS server setup script with dir2opds and Caddy running in Docker on shared network, with SSL and auth options 2026-04-20 14:37:10 -07:00
Justin Oros 2d2b19b2db syncthing.sh: add ignored folder display and un-ignore option to pending folders menu 2026-04-20 13:19:37 -07:00
Justin Oros 78d4373c0d syncthing.sh: add pending folders support to option 1, allowing acceptance of incoming folder offers 2026-04-20 13:08:29 -07:00
Justin Oros 132d15357c health-check.sh: filter Docker overlay mounts from disk space check 2026-04-20 09:54:47 -07:00
Justin Oros 8acfc3269a health-check.sh: filter Docker overlay mounts from disk space check 2026-04-20 09:37:42 -07:00
Justin Oros ad15498bb9 health-check.sh: add disk space check with OK/WARN/FAIL thresholds at 80% and 90% 2026-04-20 09:33:36 -07:00
Justin Oros 8a7fe7b4de setup-network.sh: back up and remove conflicting netplan files before writing static IP config 2026-04-19 22:45:24 -07:00
Justin Oros e3a12c0f6e health-check.sh: remove union remote check 2026-04-19 22:39:04 -07:00
Justin Oros a8b10a1814 health-check.sh: check hub user's crontab instead of root's when running as root 2026-04-19 22:36:46 -07:00
Justin Oros 92a2af8c5c health-check.sh: auto-detect hub user home directory when running as root 2026-04-19 22:35:42 -07:00
Justin Oros a5cf3d1f8b health-check.sh: only show docker and Syncthing checks on spokes, not hubs 2026-04-19 22:34:21 -07:00
Justin Oros ebb366e4bc chmod +x health-check.sh 2026-04-19 22:31:10 -07:00
Justin Oros 84b3b7ce1d health-check.sh: fix spoke detection to check for running autossh container instead of compose.yaml presence 2026-04-19 22:29:02 -07:00
Justin Oros 86688c43c7 setup-hub.sh: use fuse3 for apt systems 2026-04-19 22:26:11 -07:00
Justin Oros 972dbef11c setup-hub.sh: change AllowTcpForwarding from local to yes to allow reverse tunnels from spokes 2026-04-19 22:11:02 -07:00
Justin Oros e55ab898ef setup-spoke.sh: show current hostname as default in hostname prompt 2026-04-19 22:01:53 -07:00
Justin Oros 81d0bebd5e setup-spoke.sh: write hub host key to root's known_hosts during keyscan to prevent host key prompt during tunnel test 2026-04-19 21:58:29 -07:00
Justin Oros 6fe164a6ae setup-network.sh: add IPv4/IPv6 preference options to network setup menu 2026-04-19 21:49:13 -07:00
Justin Oros b76e890857 setup-spoke.sh: print exact authorized_keys command with public key when displaying hub key instructions 2026-04-19 21:32:23 -07:00
Justin Oros 4e2f17266a compose.yaml: bind Syncthing web UI to all interfaces instead of localhost only 2026-04-19 21:12:40 -07:00
Justin Oros 0af3c30f79 setup-hub.sh: change password auth disable default to N 2026-04-19 20:58:50 -07:00
Justin Oros b0e63a2e01 syncthing.sh: replace python3 XML parsing with grep/sed for API key extraction since Syncthing container has no python3 2026-04-19 15:50:41 -07:00
Justin Oros 22eced7607 health-check.sh: fix duplicate autossh_container variable declaration in check_spoke 2026-04-19 15:44:21 -07:00
Justin Oros 58c641d603 health-check.sh: auto-detects hub/spoke role and reports status of Docker, SSH, Syncthing, autossh tunnel, rclone mounts, and crontab entries 2026-04-19 15:41:50 -07:00
Justin Oros 1cc50f8ff0 compose.yaml, setup-spoke.sh: replace named Docker volume with host directory for syncthing config, reset compose.yaml to generic placeholders, remove volume permission fix step 2026-04-19 15:35:13 -07:00
Justin Oros 97aff6a741 onboard-spoke.sh: replace printf with python3 to correctly write rclone remote config with real newlines 2026-04-19 15:04:39 -07:00
Justin Oros eaff38477c onboard-spoke.sh: restore union remote, rclone test, registry, auto-mount, and completion sections lost during rewrite 2026-04-19 14:43:05 -07:00
Justin Oros e2ed499e58 onboard-spoke.sh: adopt Finn's cleaner tunnel verification flow, remove key selection prompt, add TCP pre-check before keyscan 2026-04-19 14:38:10 -07:00
Justin Oros 48ba67e351 setup-spoke.sh: fix syncthing-config volume ownership before starting containers 2026-04-19 14:22:49 -07:00
Justin Oros 5a9e55b673 setup-spoke.sh: replace manual hub instructions with onboard-spoke.sh next step prompt 2026-04-19 14:09:30 -07:00
Justin Oros e5bdf95dcf setup-spoke.sh: replace ~ with full paths and clarify hub user in completion message 2026-04-19 14:07:17 -07:00
Justin Oros 0553420d04 setup-spoke.sh: add docker-cli to apt install list 2026-04-19 14:00:25 -07:00
Justin Oros 4cdddd649d setup-spoke.sh: add option to choose existing key from ~/.ssh/ in SSH key setup menu 2026-04-19 13:52:29 -07:00
Justin Oros 0fd7d94d58 setup-spoke.sh: redirect find_free_port warn output to stderr to prevent contamination of TUNNEL_PORT variable 2026-04-19 13:46:07 -07:00
Justin Oros f3c9cf2344 setup-spoke.sh: change password auth disable default to N and add warning to wait until after onboard-spoke.sh 2026-04-19 13:14:52 -07:00
Justin Oros f486795154 onboard-spoke.sh: add key selection prompt for tunnel auth, use explicit -i flag for all SSH calls, clarify hub key installation header 2026-04-19 13:05:29 -07:00
Justin Oros fe3f2c5b77 Update readme 2026-04-19 12:52:40 -07:00
Justin Oros 4e1e9282ac setup-spoke.sh: use docker.io and docker-compose instead of docker-compose-plugin for apt installs 2026-04-19 12:37:19 -07:00
Justin Oros 07f4601bad setup-spoke.sh: replace docker.io apt install with Docker official install script to fix docker-compose-plugin availability 2026-04-19 11:45:07 -07:00
Justin Oros 9bdd12ebbd onboard-spoke.sh: add rclone auto-mount via crontab @reboot entry and immediate mount on onboarding 2026-04-19 11:40:47 -07:00
Justin Oros d3a6d406d8 setup-hub.sh: change AllowTcpForwarding from yes to local to restrict forwarding to local connections only 2026-04-19 11:26:15 -07:00
Justin Oros e74c9b45d5 etup-hub.sh: change GatewayPorts from yes to no for improved security 2026-04-19 11:21:52 -07:00
9 changed files with 1044 additions and 77 deletions
+18
View File
@@ -35,6 +35,24 @@ cd tinyboard
./setup.sh # option 1 (configure new spoke)
```
### Adding the Spoke's Public Key to the Hub
During `setup-spoke.sh`, a key pair is generated on the spoke for the autossh tunnel. The script will display the public key and pause. Before pressing ENTER, the hub owner must add the public key to the hub user's `authorized_keys`. Run this on the hub as the hub user (e.g. `armbian`):
```bash
echo "<paste public key here>" >> ~/.ssh/authorized_keys
```
Or as root:
```bash
echo "<paste public key here>" >> /home/armbian/.ssh/authorized_keys
```
Once the key is added, press ENTER on the spoke to continue. The script will test the SSH connection and if successful, bring up the tunnel.
The private key never leaves the spoke — only the public key is shared.
### Onboarding a Spoke from the Hub
Once the spoke tunnel is up, run on the hub:
+242
View File
@@ -0,0 +1,242 @@
#!/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'
ok() { echo -e " ${GREEN}[OK]${NC} $*"; }
fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HUB_USER="${SUDO_USER:-${USER}}"
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ]; then
HUB_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
else
HUB_HOME="$HOME"
fi
if [ "$(id -u)" -eq 0 ] && [ "$HUB_HOME" = "/root" ]; then
for u in armbian; do
CANDIDATE=$(getent passwd "$u" 2>/dev/null | cut -d: -f6)
if [ -f "${CANDIDATE}/.config/tinyboard/spokes" ]; then
HUB_HOME="$CANDIDATE"
break
fi
done
fi
REGISTRY="${HUB_HOME}/.config/tinyboard/spokes"
COMPOSE="$SCRIPT_DIR/spoke/compose.yaml"
RCLONE_CONF="${HUB_HOME}/.config/rclone/rclone.conf"
IS_SPOKE=false
IS_HUB=false
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qi autossh; then
IS_SPOKE=true
fi
if [ -f "$REGISTRY" ]; then
IS_HUB=true
fi
if [ "$IS_SPOKE" = false ] && [ "$IS_HUB" = false ]; then
echo -e "${YELLOW}Could not detect hub or spoke configuration. Is tinyboard set up?${NC}"
exit 1
fi
check_disk() {
header "Disk Space"
while IFS= read -r line; do
local pct mount
pct=$(echo "$line" | awk '{print $5}' | tr -d '%')
mount=$(echo "$line" | awk '{print $6}')
if [ "$pct" -ge 90 ]; then
fail "Disk usage at ${pct}% on $mount — critically low"
elif [ "$pct" -ge 80 ]; then
warn "Disk usage at ${pct}% on $mount — getting full"
else
ok "Disk usage at ${pct}% on $mount"
fi
done < <(df -h | awk 'NR>1' | grep -v ' /dev' | grep -v ' /sys' | grep -v ' /proc' | grep -v ' /run' | grep -v ' /snap' | grep -v 'overlay2' | grep -v 'docker')
local used_human total_human total_pct
used_human=$(df -h / | awk 'NR==2 {print $3}')
total_human=$(df -h / | awk 'NR==2 {print $2}')
total_pct=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$total_pct" -ge 90 ]; then
fail "Total disk usage ${used_human} of ${total_human} — critically low"
elif [ "$total_pct" -ge 80 ]; then
warn "Total disk usage ${used_human} of ${total_human}"
else
ok "Total disk usage ${used_human} of ${total_human}"
fi
}
check_common() {
header "System"
local ssh_svc=""
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
ssh_svc="ssh"
elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then
ssh_svc="sshd"
fi
if [ -n "$ssh_svc" ]; then
if systemctl is-active "$ssh_svc" >/dev/null 2>&1; then
ok "SSH server running ($ssh_svc)"
else
fail "SSH server not running ($ssh_svc)"
fi
else
warn "Could not detect SSH service"
fi
if [ "$IS_SPOKE" = true ]; then
if command -v docker >/dev/null 2>&1; then
ok "docker installed"
else
fail "docker not found"
fi
if docker info >/dev/null 2>&1; then
ok "docker daemon running"
else
fail "docker daemon not running"
fi
local st_container
st_container=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i syncthing | head -1 || true)
if [ -n "$st_container" ]; then
ok "Syncthing container running ($st_container)"
if curl -sf http://127.0.0.1:8384 >/dev/null 2>&1; then
ok "Syncthing API reachable"
else
warn "Syncthing container running but API not reachable on :8384"
fi
else
warn "No Syncthing container running"
fi
fi
}
check_spoke() {
header "Spoke"
local autossh_container
autossh_container=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i autossh | head -1 || true)
if [ -n "$autossh_container" ]; then
ok "autossh container running ($autossh_container)"
local logs
logs=$(docker logs "$autossh_container" --since 60s 2>&1 || true)
if echo "$logs" | grep -q "remote port forwarding failed"; then
fail "Tunnel reports port forwarding failed — check hub authorized_keys"
else
ok "No tunnel errors in recent logs"
fi
else
fail "No autossh container running"
fi
if [ -n "$autossh_container" ]; then
local tunnel_port hub_host
tunnel_port=$(docker inspect "$autossh_container" 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
cmd = ' '.join(data[0].get('Config', {}).get('Cmd', []))
import re
m = re.search(r'-R (\d+):localhost', cmd)
print(m.group(1) if m else '')
" 2>/dev/null || true)
hub_host=$(docker inspect "$autossh_container" 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
cmd = ' '.join(data[0].get('Config', {}).get('Cmd', []))
import re
m = re.search(r'[a-zA-Z0-9._-]+@([a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)', cmd)
print(m.group(1) if m else '')
" 2>/dev/null || true)
if [ -n "$tunnel_port" ] && [ -n "$hub_host" ]; then
ok "Tunnel configured: port $tunnel_port$hub_host"
else
warn "Could not parse tunnel config from running container"
fi
fi
local st_data="/home/armbian/st/data"
if [ -d "$st_data" ]; then
ok "Syncthing data directory exists ($st_data)"
else
warn "Syncthing data directory not found ($st_data)"
fi
}
check_hub() {
header "Hub"
local spoke_count
spoke_count=$(wc -l < "$REGISTRY" 2>/dev/null || echo 0)
ok "$spoke_count spoke(s) in registry"
while IFS= read -r line; do
[ -n "$line" ] || continue
local spoke_name tunnel_port key_path mount_point
spoke_name=$(echo "$line" | awk '{print $1}')
tunnel_port=$(echo "$line" | awk '{print $2}')
key_path=$(echo "$line" | awk '{print $3}')
mount_point=$(echo "$line" | awk '{print $4}')
echo ""
echo -e " ${CYAN}Spoke: $spoke_name${NC}"
if ss -tlnp 2>/dev/null | grep -q ":${tunnel_port}"; then
ok "Tunnel port $tunnel_port is listening"
else
fail "Tunnel port $tunnel_port not listening — is the spoke connected?"
fi
if [ -f "$key_path" ]; then
ok "Hub key exists ($key_path)"
else
fail "Hub key missing ($key_path)"
fi
if mountpoint -q "$mount_point" 2>/dev/null; then
ok "Mounted at $mount_point"
else
fail "Not mounted at $mount_point"
fi
if grep -q "\[${spoke_name}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
ok "rclone remote [${spoke_name}-remote] configured"
else
fail "rclone remote [${spoke_name}-remote] not found in rclone.conf"
fi
if crontab -u "$(basename "$HUB_HOME")" -l 2>/dev/null | grep -qE "${spoke_name}-remote:|mount-${spoke_name}"; then
ok "Auto-mount crontab entry present"
else
warn "No auto-mount crontab entry for $spoke_name"
fi
done < "$REGISTRY"
echo ""
}
check_common
check_disk
if [ "$IS_SPOKE" = true ]; then
check_spoke
fi
if [ "$IS_HUB" = true ]; then
check_hub
fi
echo ""
+45 -29
View File
@@ -74,6 +74,10 @@ KEY_PATH="$SSH_DIR/$KEY_NAME"
mkdir -p "$(dirname "$RCLONE_CONF")"
header "Checking Tunnel"
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
info "Scanning spoke host key..."
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?"
@@ -84,23 +88,18 @@ while IFS= read -r KEYSCAN_LINE; do
fi
done <<< "$KEYSCAN"
info "Verifying spoke is reachable on port $TUNNEL_PORT..."
retry_or_abort \
"ssh -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Spoke not reachable on port $TUNNEL_PORT. Make sure the tunnel is up."
header "Generating Hub SSH Key"
header "Generating Hub-to-Spoke Access Key"
if [ -f "$KEY_PATH" ]; then
warn "Key $KEY_PATH already exists, skipping generation."
else
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "$KEY_NAME"
info "Key generated: $KEY_PATH"
fi
chmod 600 "$KEY_PATH"
info "Permissions set: $KEY_PATH is 600"
header "Copying Hub Key to Spoke"
info "Running ssh-copy-id to $SPOKE_USER@localhost:$TUNNEL_PORT..."
header "Installing Hub-to-Spoke Access Key on Spoke"
info "Copying hub public key to spoke's authorized_keys so the hub can SSH in for rclone..."
info "(You will be prompted for the $SPOKE_USER password on the spoke)"
if ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost; then
info "Key copied."
@@ -115,7 +114,7 @@ else
read -rp "Press ENTER once the key has been added to the spoke..."
fi
header "Testing Hub -> Spoke Key Auth"
header "Testing Hub-to-Spoke Key Auth"
retry_or_abort \
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Key auth failed. Check authorized_keys on the spoke."
@@ -126,17 +125,12 @@ if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
else
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >> "$RCLONE_CONF"
cat >> "$RCLONE_CONF" <<EOF
[${SPOKE_NAME}-remote]
type = sftp
host = localhost
port = $TUNNEL_PORT
key_file = $KEY_PATH
shell_type = unix
md5sum_command = md5sum
sha1sum_command = sha1sum
EOF
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
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
fi
@@ -146,7 +140,7 @@ 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}"
read -rp "Subfolder path on this spoke (e.g. books, leave blank for root): " UNION_PATH
read -rp "Subfolder path on the spoke being onboarded (e.g. books, leave blank for root): " UNION_PATH
echo ""
echo "Upstream access mode for this spoke:"
echo " 0) None - full read/write (default)"
@@ -169,7 +163,7 @@ if [[ "${ADD_UNION,,}" == "y" ]]; then
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:" <<'PYEOF'
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:
@@ -184,12 +178,12 @@ for line in lines:
print("yes")
sys.exit(0)
print("no")
PYEOF
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" <<'PYEOF'
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:
@@ -206,7 +200,7 @@ for line in lines:
out.append(line)
with open(path, "w") as f:
f.writelines(out)
PYEOF
PYEOF2
info "Added '$UPSTREAM' to union remote [${UNION_NAME}]."
fi
else
@@ -235,12 +229,34 @@ fi
echo "${SPOKE_NAME} ${TUNNEL_PORT} ${KEY_PATH} ${MOUNT_POINT}" >> "$REGISTRY"
info "$SPOKE_NAME registered."
header "Setting Up Auto-Mount"
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"
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"
/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."
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 ""
echo -e "${YELLOW}To mount this spoke:${NC}"
echo " RCLONE_REMOTE=${SPOKE_NAME}-remote hubspoke-helper.sh hub start"
echo ""
+5 -5
View File
@@ -63,7 +63,7 @@ if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
PKG_INSTALL="apt-get install -y -q"
OPENSSH_PKG="openssh-server"
FUSE_PKG="fuse"
FUSE_PKG="fuse3"
info "Detected: apt (Debian/Ubuntu)"
apt-get update -q
elif command -v dnf >/dev/null 2>&1; then
@@ -136,7 +136,7 @@ header "SSH Server Configuration"
SSHD_CONF="/etc/ssh/sshd_config"
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
for DIRECTIVE in "GatewayPorts yes" "AllowTcpForwarding yes" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
for DIRECTIVE in "GatewayPorts no" "AllowTcpForwarding yes" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
KEY="${DIRECTIVE%% *}"
if grep -q "^$KEY" "$SSHD_CONF"; then
sed -i "s|^$KEY.*|$DIRECTIVE|" "$SSHD_CONF"
@@ -161,8 +161,8 @@ else
fi
header "Password Authentication"
read -rp "Disable password auth for $HUB_USER and use keys only? [Y/n]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-y}"
read -rp "Disable password auth for $HUB_USER and use keys only? [y/N]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-n}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
@@ -251,7 +251,7 @@ info "Mount point created at $MOUNT_POINT."
header "Hub Setup Complete"
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
echo -e " SSH config: ${GREEN}GatewayPorts yes, AllowTcpForwarding yes, ClientAliveInterval 60${NC}"
echo -e " SSH config: ${GREEN}GatewayPorts no, AllowTcpForwarding yes, ClientAliveInterval 60${NC}"
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
+363
View File
@@ -0,0 +1,363 @@
#!/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}"; }
[ "$(id -u)" -eq 0 ] || die "Run as root"
if ! command -v docker >/dev/null 2>&1; then
warn "Docker is not installed."
read -rp "Install Docker now? [Y/n]: " INSTALL_DOCKER
INSTALL_DOCKER="${INSTALL_DOCKER:-y}"
if [[ "${INSTALL_DOCKER,,}" == "y" ]]; then
header "Installing Docker"
if command -v apt-get >/dev/null 2>&1; then
apt-get update -q
apt-get install -y -q docker.io docker-cli docker-compose
else
curl -fsSL https://get.docker.com | bash
fi
command -v docker >/dev/null 2>&1 || die "Docker installation failed."
info "Docker installed."
else
die "Docker is required. Aborting."
fi
fi
header "TinyBoard OPDS Setup"
echo ""
EXISTING_SERVER=""
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^(dir2opds|stump)$'; then
EXISTING_SERVER=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^(dir2opds|stump)$' | head -1)
fi
if [ -n "$EXISTING_SERVER" ]; then
echo "An existing OPDS server was found: ${EXISTING_SERVER}"
echo ""
echo " 1) Reconfigure — update books path, domain, SSL, or auth"
echo " 2) Fresh install — remove existing and start over"
echo " q) Quit"
echo ""
read -rp "Choose [1/2/q]: " RECONF_CHOICE
case "$RECONF_CHOICE" in
1) info "Reconfiguring existing setup..." ;;
2)
docker rm -f dir2opds stump caddy-opds 2>/dev/null || true
info "Existing containers removed."
;;
q|Q) exit 0 ;;
*) die "Invalid choice." ;;
esac
echo ""
fi
echo "Choose an OPDS server:"
echo " 1) dir2opds — lightweight, no database, serves files directly from a folder"
echo " 2) Stump — full-featured book/comic server with web UI and OPDS support"
echo ""
read -rp "Choose [1/2]: " OPDS_CHOICE
case "$OPDS_CHOICE" in
1)
OPDS_SERVER="dir2opds"
OPDS_IMAGE="ghcr.io/dubyte/dir2opds:latest"
OPDS_INTERNAL_PORT="8080"
;;
2)
OPDS_SERVER="stump"
OPDS_IMAGE="aaronleopold/stump"
OPDS_INTERNAL_PORT="10801"
;;
*)
die "Invalid choice."
;;
esac
info "Selected: $OPDS_SERVER"
echo ""
read -rp "OPDS domain (e.g. opds.yourdomain.com): " OPDS_DOMAIN
[ -n "$OPDS_DOMAIN" ] || die "Domain cannot be empty"
read -rp "Hub user [armbian]: " HUB_USER
HUB_USER="${HUB_USER:-armbian}"
HUB_HOME="/home/$HUB_USER"
MOUNT_BASE="${HUB_HOME}/mnt"
AVAILABLE_MOUNTS=()
if [ -d "$MOUNT_BASE" ]; then
while IFS= read -r mountdir; do
AVAILABLE_MOUNTS+=("$mountdir")
done < <(find "$MOUNT_BASE" -mindepth 1 -maxdepth 1 -type d | sort)
fi
if [ ${#AVAILABLE_MOUNTS[@]} -gt 0 ]; then
echo "Available spoke mounts:"
for i in "${!AVAILABLE_MOUNTS[@]}"; do
echo " $i) ${AVAILABLE_MOUNTS[$i]}"
done
echo " m) Enter path manually"
echo ""
read -rp "Choose a mount [0]: " MOUNT_CHOICE
MOUNT_CHOICE="${MOUNT_CHOICE:-0}"
if [[ "$MOUNT_CHOICE" == "m" ]]; then
read -rp "Path to books directory: " BOOKS_PATH
elif [[ "$MOUNT_CHOICE" =~ ^[0-9]+$ ]] && [ "$MOUNT_CHOICE" -lt "${#AVAILABLE_MOUNTS[@]}" ]; then
BASE="${AVAILABLE_MOUNTS[$MOUNT_CHOICE]}"
read -rp "Subdirectory within ${BASE} (leave blank for root): " SUBDIR
if [ -n "$SUBDIR" ]; then
BOOKS_PATH="${BASE}/${SUBDIR}"
else
BOOKS_PATH="$BASE"
fi
else
die "Invalid choice."
fi
else
warn "No spoke mounts found in ${MOUNT_BASE}."
read -rp "Path to books directory: " BOOKS_PATH
fi
[ -n "$BOOKS_PATH" ] || die "Books path cannot be empty."
[ -d "$BOOKS_PATH" ] || die "Books directory not found: $BOOKS_PATH"
echo ""
echo "SSL certificate management:"
echo " 1) Automatic — Caddy obtains and renews certs from Let's Encrypt (recommended)"
echo " 2) Manual — provide paths to existing certificate files"
echo ""
read -rp "Choose [1/2]: " SSL_CHOICE
AUTO_HTTPS=true
CERT_PATH=""
KEY_PATH=""
case "$SSL_CHOICE" in
1)
AUTO_HTTPS=true
info "Automatic HTTPS selected — DNS must point ${OPDS_DOMAIN} to this server."
;;
2)
AUTO_HTTPS=false
read -rp "Path to fullchain.pem: " CERT_PATH
[ -f "$CERT_PATH" ] || die "Certificate file not found: $CERT_PATH"
read -rp "Path to privkey.pem: " KEY_PATH
[ -f "$KEY_PATH" ] || die "Key file not found: $KEY_PATH"
;;
*)
die "Invalid choice."
;;
esac
echo ""
read -rp "Protect with a password? [y/N]: " USE_AUTH
USE_AUTH="${USE_AUTH:-n}"
OPDS_USER=""
OPDS_PASS=""
HASHED_PASS=""
if [[ "${USE_AUTH,,}" == "y" ]]; then
read -rp "Username [opds]: " OPDS_USER
OPDS_USER="${OPDS_USER:-opds}"
read -rsp "Password: " OPDS_PASS
echo ""
[ -n "$OPDS_PASS" ] || die "Password cannot be empty"
fi
OPDS_DIR="${HUB_HOME}/opds"
mkdir -p "$OPDS_DIR"
chown "$HUB_USER":"$HUB_USER" "$OPDS_DIR"
header "Creating Docker Network"
docker network create opds-net 2>/dev/null || info "Network opds-net already exists."
header "Starting $OPDS_SERVER"
docker rm -f "$OPDS_SERVER" 2>/dev/null || true
case "$OPDS_SERVER" in
dir2opds)
docker run -d \
--name dir2opds \
--restart unless-stopped \
--network opds-net \
--mount "type=bind,source=${BOOKS_PATH},target=/books,readonly,bind-propagation=shared" \
"$OPDS_IMAGE" \
/dir2opds -dir /books -hide-dot-files -calibre
;;
stump)
mkdir -p "${OPDS_DIR}/stump-config"
chown -R "$HUB_USER":"$HUB_USER" "${OPDS_DIR}"
docker run -d \
--name stump \
--restart unless-stopped \
--network opds-net \
-v "${BOOKS_PATH}:/books:ro" \
-v "${OPDS_DIR}/stump-config:/config" \
-e PUID=1000 \
-e PGID=1000 \
"$OPDS_IMAGE"
;;
esac
info "$OPDS_SERVER started on internal network opds-net."
header "Writing Caddyfile"
CADDY_DIR="${OPDS_DIR}/caddy"
mkdir -p "${CADDY_DIR}/data" "${CADDY_DIR}/config"
if [ "$AUTO_HTTPS" = false ]; then
TLS_BLOCK=" tls ${CERT_PATH} ${KEY_PATH}"
else
TLS_BLOCK=""
fi
if [[ "${USE_AUTH,,}" == "y" ]]; then
docker run --rm caddy:alpine caddy hash-password --plaintext "$OPDS_PASS" > /tmp/opds_hash.txt 2>/dev/null
HASHED_PASS=$(cat /tmp/opds_hash.txt)
rm -f /tmp/opds_hash.txt
AUTH_BLOCK=" basicauth {
${OPDS_USER} ${HASHED_PASS}
}"
else
AUTH_BLOCK=""
fi
if [ "$AUTO_HTTPS" = false ]; then
cat > "${CADDY_DIR}/Caddyfile" << EOF
{
auto_https off
}
:80 {
redir https://{host}{uri} permanent
}
${OPDS_DOMAIN} {
encode gzip
${TLS_BLOCK}
${AUTH_BLOCK}
reverse_proxy http://${OPDS_SERVER}:${OPDS_INTERNAL_PORT} {
header_up Host {host}
header_up X-Real-IP {remote}
}
}
EOF
else
cat > "${CADDY_DIR}/Caddyfile" << EOF
${OPDS_DOMAIN} {
encode gzip
${AUTH_BLOCK}
reverse_proxy http://${OPDS_SERVER}:${OPDS_INTERNAL_PORT} {
header_up Host {host}
header_up X-Real-IP {remote}
}
}
EOF
fi
info "Caddyfile written to ${CADDY_DIR}/Caddyfile"
header "Firewall Check"
warn "Caddy requires ports 80 and 443 to be open on this server."
warn "If using a cloud firewall (e.g. Linode), ensure inbound TCP rules allow:"
warn " Port 80 — required for Let's Encrypt HTTP challenge and HTTP→HTTPS redirect"
warn " Port 443 — required for HTTPS"
echo ""
read -rp "Press ENTER to continue once ports are open..."
header "Starting Caddy"
docker rm -f caddy-opds 2>/dev/null || true
if [ "$AUTO_HTTPS" = true ]; then
docker run -d \
--name caddy-opds \
--restart unless-stopped \
--network opds-net \
-p 80:80 \
-p 443:443 \
-v "${CADDY_DIR}/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "${CADDY_DIR}/data:/data" \
-v "${CADDY_DIR}/config:/config" \
caddy:alpine
else
CERT_DIR=$(dirname "$CERT_PATH")
docker run -d \
--name caddy-opds \
--restart unless-stopped \
--network opds-net \
-p 80:80 \
-p 443:443 \
-v "${CADDY_DIR}/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "${CADDY_DIR}/data:/data" \
-v "${CADDY_DIR}/config:/config" \
-v "${CERT_DIR}:/certs:ro" \
caddy:alpine
fi
sleep 3
docker logs caddy-opds --tail 5 2>/dev/null || true
header "Setting Up Mount Watchdog"
WATCHDOG_SCRIPT="/usr/local/bin/opds-watchdog.sh"
cat > "$WATCHDOG_SCRIPT" << EOF
#!/usr/bin/env bash
SERVER_NAME="${OPDS_SERVER}"
MAX_WAIT=300
ELAPSED=0
while [ \$ELAPSED -lt \$MAX_WAIT ]; do
COUNT=\$(docker exec "\$SERVER_NAME" ls /books 2>/dev/null | wc -l)
if [ "\$COUNT" -gt 0 ]; then
exit 0
fi
docker restart "\$SERVER_NAME" 2>/dev/null || true
sleep 15
ELAPSED=\$((ELAPSED + 15))
done
EOF
chmod +x "$WATCHDOG_SCRIPT"
MOUNT_SCRIPT="/home/${HUB_USER}/mount-$(basename ${MOUNT_POINT:-grace}).sh"
cat > "$MOUNT_SCRIPT" << EOF
#!/usr/bin/env bash
sleep 90
/usr/bin/rclone mount ${OPDS_SERVER}-remote: ${MOUNT_POINT:-/home/${HUB_USER}/mnt/grace} --config /home/${HUB_USER}/.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --allow-non-empty --daemon
EOF
chmod +x "$MOUNT_SCRIPT"
chown "$HUB_USER":"$HUB_USER" "$MOUNT_SCRIPT"
CRON_LINE="@reboot $WATCHDOG_SCRIPT"
EXISTING_ROOT_CRON=$(crontab -l 2>/dev/null || true)
if ! echo "$EXISTING_ROOT_CRON" | grep -qF "opds-watchdog"; then
{ echo "$EXISTING_ROOT_CRON"; echo "$CRON_LINE"; } | crontab -
info "Watchdog crontab entry added — restarts $OPDS_SERVER until books are visible."
else
crontab -l 2>/dev/null | grep -v "opds-watchdog" | { cat; echo "$CRON_LINE"; } | crontab -
info "Watchdog crontab entry updated."
fi
header "OPDS Setup Complete"
echo ""
echo -e " OPDS URL: ${GREEN}https://${OPDS_DOMAIN}${NC}"
if [[ "${USE_AUTH,,}" == "y" ]]; then
echo -e " Username: ${GREEN}${OPDS_USER}${NC}"
echo -e " Password: ${GREEN}(as entered)${NC}"
else
echo -e " Auth: ${YELLOW}None — publicly accessible${NC}"
fi
if [ "$AUTO_HTTPS" = true ]; then
echo -e " SSL: ${GREEN}Automatic (Let's Encrypt)${NC}"
warn "DNS must point ${OPDS_DOMAIN} to this server's IP for HTTPS to work."
else
echo -e " SSL: ${GREEN}Manual certificates${NC}"
fi
echo ""
+7 -10
View File
@@ -11,25 +11,22 @@ services:
-o "ServerAliveInterval=60"
-o "ServerAliveCountMax=3"
-R 11111:localhost:22
-i /home/armbian/.ssh/oilykey2026
armbian@oily.dad
-i /home/armbian/.ssh/hubkey
armbian@hub.example.com
volumes:
- /home/armbian/.ssh/oilykey2026:/home/armbian/.ssh/oilykey2026:ro
- /home/armbian/.ssh/hubkey:/home/armbian/.ssh/hubkey:ro
- /home/armbian/.ssh/known_hosts:/home/armbian/.ssh/known_hosts:ro
syncthing:
image: syncthing/syncthing
container_name: spoke-syncthing
hostname: spoke-syncthing
restart: unless-stopped
user: "1000:1000"
environment:
- PUID=1000
- PGID=1000
- HOME=/var/syncthing
ports:
- "127.0.0.1:8384:8384"
- "8384:8384"
- "22000:22000"
volumes:
- syncthing-config:/var/syncthing/config
- /home/armbian/st/config:/var/syncthing/config
- /home/armbian/st/data:/var/syncthing/data
volumes:
syncthing-config:
+165 -2
View File
@@ -32,6 +32,9 @@ header "TinyBoard Network Setup"
echo ""
echo " 0) Change hostname"
echo " 1) Configure static IP"
echo " 2) Prefer IPv4 over IPv6"
echo " 3) Prefer IPv6 over IPv4"
echo " 4) Change Wireless Network"
echo " q) Quit"
echo ""
read -rp "Choose: " NET_OPT
@@ -53,6 +56,157 @@ case "$NET_OPT" in
;;
1)
;;
2)
header "Prefer IPv4 over IPv6"
if grep -q "precedence ::ffff:0:0/96" /etc/gai.conf 2>/dev/null; then
warn "IPv4 preference already set."
else
echo "precedence ::ffff:0:0/96 100" >> /etc/gai.conf
info "IPv4 preference set. Outgoing connections will prefer IPv4."
fi
exit 0
;;
3)
header "Prefer IPv6 over IPv4"
sed -i '/precedence ::ffff:0:0\/96/d' /etc/gai.conf 2>/dev/null || true
info "IPv4 preference removed. System will use default IPv6-first behavior."
exit 0
;;
4)
header "Change Wireless Network"
check_deps iw netplan
WIFI_IFACE=$(iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1)
[ -n "$WIFI_IFACE" ] || die "No wireless interface found."
CURRENT_SSID=$(iw dev "$WIFI_IFACE" link 2>/dev/null | awk '/SSID:/{print $2}')
info "Scanning for networks on ${WIFI_IFACE}..."
ip link set "$WIFI_IFACE" up 2>/dev/null || true
iw dev "$WIFI_IFACE" scan >/dev/null 2>&1 || true
sleep 2
mapfile -t SCAN_LINES < <(iw dev "$WIFI_IFACE" scan 2>/dev/null \
| awk '
/^BSS / { signal=""; ssid="" }
/signal:/ { signal=$2 }
/SSID:/ { ssid=substr($0, index($0,$2)); gsub(/^[[:space:]]+|[[:space:]]+$/, "", ssid) }
ssid!="" && signal!="" { print signal "\t" ssid; signal=""; ssid="" }
' \
| sort -rn \
| awk -F'\t' '!seen[$2]++ && $2!="" {print $2}')
[ ${#SCAN_LINES[@]} -gt 0 ] || die "No wireless networks found. Ensure the interface is up and try again."
echo ""
for i in "${!SCAN_LINES[@]}"; do
SSID="${SCAN_LINES[$i]}"
if [ "$SSID" = "$CURRENT_SSID" ]; then
echo -e " $((i+1))) ${GREEN}${SSID}${NC} ${CYAN}(connected)${NC}"
else
echo -e " $((i+1))) ${SSID}"
fi
done
echo ""
read -rp "Enter network number to join: " WIFI_CHOICE
[[ "$WIFI_CHOICE" =~ ^[0-9]+$ ]] || die "Invalid selection."
WIFI_IDX=$((WIFI_CHOICE - 1))
[ "$WIFI_IDX" -ge 0 ] && [ "$WIFI_IDX" -lt "${#SCAN_LINES[@]}" ] || die "Selection out of range."
NEW_SSID="${SCAN_LINES[$WIFI_IDX]}"
echo ""
read -rsp "Password for '${NEW_SSID}': " NEW_PASS
echo ""
[ -n "$NEW_PASS" ] || die "Password cannot be empty."
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
mkdir -p "$NETPLAN_BACKUP_DIR"
mapfile -t NETPLAN_FILES < <(find /etc/netplan -maxdepth 1 -name '*.yaml' 2>/dev/null | sort)
if [ ${#NETPLAN_FILES[@]} -eq 0 ]; then
warn "No netplan config files found — WiFi credentials will not persist across reboots."
NETPLAN_FILE=""
elif [ ${#NETPLAN_FILES[@]} -eq 1 ]; then
NETPLAN_FILE="${NETPLAN_FILES[0]}"
else
echo ""
warn "Multiple netplan config files found:"
for i in "${!NETPLAN_FILES[@]}"; do
echo -e " $((i+1))) ${NETPLAN_FILES[$i]}"
done
echo ""
read -rp "Which file should be updated with the new WiFi credentials? [1]: " NP_CHOICE
NP_CHOICE="${NP_CHOICE:-1}"
[[ "$NP_CHOICE" =~ ^[0-9]+$ ]] || die "Invalid selection."
NP_IDX=$((NP_CHOICE - 1))
[ "$NP_IDX" -ge 0 ] && [ "$NP_IDX" -lt "${#NETPLAN_FILES[@]}" ] || die "Selection out of range."
NETPLAN_FILE="${NETPLAN_FILES[$NP_IDX]}"
fi
echo ""
info "Currently connected to: ${CURRENT_SSID:-none}"
info "Switching to: ${NEW_SSID}"
warn "Your SSH session will drop. Reconnect once the device joins '${NEW_SSID}'."
echo ""
read -rp "Proceed? [Y/n]: " CONFIRM
CONFIRM="${CONFIRM:-y}"
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; exit 0; }
if [ -n "$NETPLAN_FILE" ] && grep -q "access-points" "$NETPLAN_FILE" 2>/dev/null; then
BACKUP_FILE="$NETPLAN_BACKUP_DIR/$(basename "${NETPLAN_FILE}").$(date +%Y%m%d%H%M%S)"
cp "$NETPLAN_FILE" "$BACKUP_FILE"
info "Backed up: $NETPLAN_FILE$BACKUP_FILE"
python3 - "$NETPLAN_FILE" "$NEW_SSID" "$NEW_PASS" <<'PYEOF'
import sys, re
path, ssid, pw = sys.argv[1], sys.argv[2], sys.argv[3]
txt = open(path).read()
txt = re.sub(
r'(access-points:\s*\n\s+)["\']?[^"\':\n]+["\']?:\s*\n(\s+password:)[^\n]*',
lambda m: f'{m.group(1)}"{ssid}":\n{m.group(2)} "{pw}"',
txt
)
open(path, "w").write(txt)
PYEOF
info "Netplan config updated."
elif [ -n "$NETPLAN_FILE" ]; then
warn "$NETPLAN_FILE has no access-points section — skipping."
fi
info "Applying netplan — device will switch to '${NEW_SSID}' now..."
netplan apply
info "Waiting for association..."
ASSOCIATED=false
for i in $(seq 1 15); do
sleep 2
CONN_SSID=$(wpa_cli -i "$WIFI_IFACE" status 2>/dev/null | awk -F= '/^ssid=/{print $2}')
STATUS=$(wpa_cli -i "$WIFI_IFACE" status 2>/dev/null | awk -F= '/^wpa_state=/{print $2}')
if [ "$STATUS" = "COMPLETED" ] && [ "$CONN_SSID" = "$NEW_SSID" ]; then
ASSOCIATED=true
break
fi
warn "Attempt $i/15 — state: ${STATUS:-unknown}, ssid: ${CONN_SSID:-none}"
done
if [ "$ASSOCIATED" = "false" ]; then
die "Failed to associate with '${NEW_SSID}'. Check the password and try again. Backup at: $BACKUP_FILE"
fi
sleep 3
NEW_IP=$(ip -o -4 addr show "$WIFI_IFACE" 2>/dev/null | awk '{print $4}' | head -1)
if [ -n "$NEW_IP" ]; then
info "IP address: ${NEW_IP}"
else
warn "No IP assigned yet — DHCP may still be in progress."
fi
info "Connected to '${NEW_SSID}' successfully."
exit 0
;;
q|Q)
exit 0
;;
@@ -159,10 +313,19 @@ PYEOF
fi
header "Writing Netplan Config"
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
mkdir -p "$NETPLAN_BACKUP_DIR"
BACKUP_FILE=""
for OTHER_FILE in /etc/netplan/*.yaml; do
[ "$OTHER_FILE" = "$NETPLAN_FILE" ] && continue
BACKUP_OTHER="$NETPLAN_BACKUP_DIR/$(basename "${OTHER_FILE}").$(date +%Y%m%d%H%M%S)"
cp "$OTHER_FILE" "$BACKUP_OTHER"
rm "$OTHER_FILE"
warn "Removed conflicting netplan file: $OTHER_FILE (backed up to $BACKUP_OTHER)"
done
if [ -f "$NETPLAN_FILE" ]; then
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
mkdir -p "$NETPLAN_BACKUP_DIR"
BACKUP_FILE="$NETPLAN_BACKUP_DIR/$(basename "${NETPLAN_FILE}").$(date +%Y%m%d%H%M%S)"
cp "$NETPLAN_FILE" "$BACKUP_FILE"
info "Netplan config backed up to $BACKUP_FILE"
+57 -28
View File
@@ -136,7 +136,7 @@ $PKG_INSTALL vim "$AUTOSSH_PKG" "$OPENSSH_PKG" git
info "Installing Docker..."
if ! command -v docker >/dev/null 2>&1; then
if [ "$PKG_MANAGER" = "apt" ]; then
$PKG_INSTALL docker.io docker-compose-plugin
$PKG_INSTALL docker.io docker-cli docker-compose
else
curl -fsSL https://get.docker.com | bash
fi
@@ -146,7 +146,7 @@ fi
if ! docker compose version >/dev/null 2>&1; then
if [ "$PKG_MANAGER" = "apt" ]; then
$PKG_INSTALL docker-compose-plugin
$PKG_INSTALL docker-compose
else
warn "docker compose not available — Docker install script should have included it."
fi
@@ -174,7 +174,7 @@ SSHD_CONF="/etc/ssh/sshd_config"
header "Hostname Setup"
CURRENT_HOSTNAME=$(hostname)
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
read -rp "Enter a hostname for this spoke [${CURRENT_HOSTNAME}]: " SPOKE_NAME
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
[[ "$SPOKE_NAME" =~ ^[a-zA-Z0-9._-]+$ ]] || die "Spoke name '$SPOKE_NAME' contains invalid characters. Use only letters, numbers, dots, underscores, hyphens."
hostnamectl set-hostname "$SPOKE_NAME"
@@ -184,9 +184,10 @@ info "Hostname set to: $SPOKE_NAME"
header "SSH Key Setup"
echo "How would you like to handle the SSH key for the tunnel to $HUB_HOST?"
echo " 1) Generate a new key automatically"
echo " 2) Use an existing key (paste the private key)"
echo " 2) Choose an existing key from $SSH_DIR"
echo " 3) Paste a private key manually"
echo ""
read -rp "Choose [1/2]: " KEY_CHOICE
read -rp "Choose [1/2/3]: " KEY_CHOICE
case "$KEY_CHOICE" in
1)
@@ -214,9 +215,41 @@ case "$KEY_CHOICE" in
cat "$KEY_PATH.pub"
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW} On the hub, run as ${HUB_USER}:${NC}"
echo ""
echo " echo "$(cat "$KEY_PATH.pub")" >> /home/${HUB_USER}/.ssh/authorized_keys"
echo ""
read -rp "Press ENTER once the key has been added to ${HUB_HOST}..."
;;
2)
mkdir -p "$SSH_DIR"
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR"
chmod 700 "$SSH_DIR"
AVAILABLE_KEYS=()
while IFS= read -r keyfile; do
AVAILABLE_KEYS+=("$keyfile")
done < <(find "$SSH_DIR" -maxdepth 1 -type f ! -name "*.pub" ! -name "known_hosts" ! -name "authorized_keys" ! -name "config" | sort)
if [ ${#AVAILABLE_KEYS[@]} -eq 0 ]; then
die "No private keys found in $SSH_DIR."
fi
echo "Available keys:"
for i in "${!AVAILABLE_KEYS[@]}"; do
echo " $i) ${AVAILABLE_KEYS[$i]}"
done
echo ""
read -rp "Choose key [0]: " KEY_IDX
KEY_IDX="${KEY_IDX:-0}"
[[ "$KEY_IDX" =~ ^[0-9]+$ ]] && [ "$KEY_IDX" -lt "${#AVAILABLE_KEYS[@]}" ] || die "Invalid choice."
KEY_PATH="${AVAILABLE_KEYS[$KEY_IDX]}"
KEY_NAME="$(basename "$KEY_PATH")"
info "Using existing key: $KEY_PATH"
echo ""
read -rp "Press ENTER once the public key has been added to ${HUB_HOST} authorized_keys..."
;;
3)
read -rp "Enter a name for the key file [hubkey]: " KEY_NAME
KEY_NAME="${KEY_NAME:-hubkey}"
KEY_PATH="$SSH_DIR/$KEY_NAME"
@@ -237,8 +270,11 @@ case "$KEY_CHOICE" in
esac
header "Password Authentication"
read -rp "Disable password auth for $SPOKE_USER and use keys only? [Y/n]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-y}"
warn "Do not disable password auth yet — the hub still needs password access to install its key via ssh-copy-id."
warn "Only disable this after running onboard-spoke.sh on the hub."
echo ""
read -rp "Disable password auth for $SPOKE_USER and use keys only? [y/N]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-n}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -f "$KEY_PATH" ]; then
warn "No key found at $KEY_PATH — skipping password auth disable to avoid lockout."
@@ -283,6 +319,12 @@ if [ -n "$HUB_KEYSCAN" ]; then
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
echo "$KEYSCAN_LINE" >> "$SSH_DIR/known_hosts"
fi
mkdir -p /root/.ssh
touch /root/.ssh/known_hosts
chmod 600 /root/.ssh/known_hosts
if ! grep -qF "$KEYSCAN_KEY" /root/.ssh/known_hosts 2>/dev/null; then
echo "$KEYSCAN_LINE" >> /root/.ssh/known_hosts
fi
done <<< "$HUB_KEYSCAN"
fi
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
@@ -305,7 +347,7 @@ find_free_port() {
echo "$port"
return 0
fi
warn "Port $port is in use, trying next..."
echo -e "${YELLOW}[!]${NC} Port $port is in use, trying next..." >&2
done
return 1
}
@@ -317,8 +359,9 @@ header "Configuring compose.yaml"
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
SYNCTHING_MOUNT="$ARMBIAN_HOME/st/data"
mkdir -p "$SYNCTHING_MOUNT"
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT"
SYNCTHING_CONFIG="$ARMBIAN_HOME/st/config"
mkdir -p "$SYNCTHING_MOUNT" "$SYNCTHING_CONFIG"
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT" "$SYNCTHING_CONFIG"
SPOKE_UID=$(id -u "$SPOKE_USER")
SPOKE_GID=$(id -g "$SPOKE_USER")
@@ -329,8 +372,8 @@ sed -i "/known_hosts/!s|/home/[^/]*/\.ssh/[^:]*:/home/[^/]*/\.ssh/[^:]*:ro|${SSH
sed -i "s|/home/[^/]*/\.ssh/known_hosts|${SSH_DIR}/known_hosts|g" "$COMPOSE"
sed -i "s| [a-zA-Z0-9._-]*@[a-zA-Z0-9._-]*\.[a-zA-Z0-9._-]*[[:space:]]*\$| ${HUB_USER}@${HUB_HOST}|" "$COMPOSE"
sed -i "s|/home/[^/]*/st/data:|${SYNCTHING_MOUNT}:|g" "$COMPOSE"
sed -i "s|PUID=[0-9]*|PUID=${SPOKE_UID}|g" "$COMPOSE"
sed -i "s|PGID=[0-9]*|PGID=${SPOKE_GID}|g" "$COMPOSE"
sed -i "s|/home/[^/]*/st/config:|${SYNCTHING_CONFIG}:|g" "$COMPOSE"
sed -i "s|user: \"[0-9]*:[0-9]*\"|user: \"${SPOKE_UID}:${SPOKE_GID}\"|" "$COMPOSE"
sed -i "s|container_name: spoke-autossh|container_name: ${SPOKE_NAME}-autossh|g" "$COMPOSE"
sed -i "s|container_name: spoke-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
@@ -373,21 +416,7 @@ echo -e " Spoke name: ${GREEN}$SPOKE_NAME${NC}"
echo -e " Tunnel port: ${GREEN}$TUNNEL_PORT${NC} on $HUB_HOST"
echo -e " SSH key: ${GREEN}$KEY_PATH${NC}"
echo ""
echo -e "${YELLOW}The hub owner needs to do the following on ${HUB_HOST}:${NC}"
echo -e "${YELLOW}Next step — on the hub, run as ${HUB_USER}:${NC}"
echo ""
echo " 1. Generate a hub->spoke key:"
echo " ssh-keygen -t ed25519 -f ~/.ssh/${HUB_USER}-${SPOKE_NAME}-$(date +%Y%m)"
echo ""
echo " 2. Copy it to this spoke through the tunnel:"
echo " ssh-copy-id -i ~/.ssh/${HUB_USER}-${SPOKE_NAME}-$(date +%Y%m).pub -p $TUNNEL_PORT ${HUB_USER}@localhost"
echo ""
echo " 3. Add an rclone remote in ~/.config/rclone/rclone.conf:"
echo " [${SPOKE_NAME}-remote]"
echo " type = sftp"
echo " host = localhost"
echo " port = $TUNNEL_PORT"
echo " key_file = /home/$HUB_USER/.ssh/${HUB_USER}-${SPOKE_NAME}-$(date +%Y%m)"
echo " shell_type = unix"
echo " md5sum_command = md5sum"
echo " sha1sum_command = sha1sum"
echo " cd tinyboard && ./setup.sh # choose option 2 (onboard spoke)"
echo ""
+142 -3
View File
@@ -19,7 +19,7 @@ ST_CONTAINER=""
get_apikey() {
ST_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true)
[ -n "$ST_CONTAINER" ] || die "No running Syncthing container found."
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)
APIKEY=$(docker exec "$ST_CONTAINER" cat /var/syncthing/config/config.xml 2>/dev/null | grep -o '<apikey>[^<]*</apikey>' | sed 's/<[^>]*>//g' || true)
[ -n "$APIKEY" ] || die "Could not read Syncthing API key from container '$ST_CONTAINER'."
}
@@ -84,6 +84,145 @@ for device_id, info in devices.items():
fi
}
show_ignored_folders() {
local config
config=$(st_get /rest/config)
local ignored
ignored=$(echo "$config" | python3 -c '
import sys,json
d=json.load(sys.stdin)
folders = d.get("ignoredFolders", [])
for f in folders:
print(f.get("id","?") + " — " + f.get("label","(no label)"))
' 2>/dev/null || true)
if [ -z "$ignored" ]; then
info "No ignored folders."
return
fi
echo ""
warn "Ignored folders (click Ignore in WUI causes this):"
echo "$ignored" | sed 's/^/ /'
echo ""
read -rp "Un-ignore a folder? [y/N]: " UNIGNORE
UNIGNORE="${UNIGNORE:-n}"
if [[ "${UNIGNORE,,}" != "y" ]]; then return; fi
local folder_ids
folder_ids=$(echo "$config" | python3 -c '
import sys,json
d=json.load(sys.stdin)
for f in d.get("ignoredFolders",[]):
print(f.get("id",""))
' 2>/dev/null || true)
echo "Ignored folder IDs:"
local i=1
while IFS= read -r fid; do
echo " $i) $fid"
i=$((i+1))
done <<< "$folder_ids"
read -rp "Choose folder number to un-ignore: " CHOICE
local TARGET_ID
TARGET_ID=$(echo "$folder_ids" | sed -n "${CHOICE}p")
[ -n "$TARGET_ID" ] || { warn "Invalid choice."; return; }
local new_config
new_config=$(echo "$config" | python3 -c "
import sys,json
d=json.load(sys.stdin)
d['ignoredFolders'] = [f for f in d.get('ignoredFolders',[]) if f.get('id') != sys.argv[1]]
print(json.dumps(d))
" "$TARGET_ID")
st_put /rest/config "$new_config"
info "Folder $TARGET_ID removed from ignored list. It should now appear as pending."
}
show_pending_folders() {
header "Pending Folders"
local pending
pending=$(st_get /rest/cluster/pending/folders)
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 folders."
echo ""
read -rp "Check ignored folders? [y/N]: " CHECK_IGNORED
if [[ "${CHECK_IGNORED,,}" == "y" ]]; then
show_ignored_folders
fi
return
fi
echo "$pending" | python3 -c '
import sys,json
d=json.load(sys.stdin)
for fid,info in d.items():
label = info.get("label", "") or info.get("offeredBy", {})
label = list(info.get("offeredBy",{}).values())[0].get("receiveEncrypted","") if info.get("offeredBy") else ""
offered_by = list(info.get("offeredBy",{}).keys())
folder_label = ""
for dev_info in info.get("offeredBy",{}).values():
if dev_info.get("folderLabel"):
folder_label = dev_info["folderLabel"]
break
if folder_label:
print(f" Folder Name: {folder_label}")
print(f" Folder ID: {fid}")
print(f" Offered by: {offered_by}")
print("")
'
read -rp "Accept a pending folder? [y/N/R (retry/check ignored)]: " ACCEPT
ACCEPT="${ACCEPT:-n}"
if [[ "${ACCEPT,,}" == "r" ]]; then
show_ignored_folders
return
fi
if [[ "${ACCEPT,,}" != "y" ]]; then return; fi
local folder_ids folder_labels
folder_ids=$(echo "$pending" | python3 -c 'import sys,json; [print(k) for k in json.load(sys.stdin)]')
folder_labels=$(echo "$pending" | python3 -c '
import sys,json
d=json.load(sys.stdin)
for fid,info in d.items():
label=""
for dev_info in info.get("offeredBy",{}).values():
if dev_info.get("folderLabel"):
label=dev_info["folderLabel"]
break
print(label if label else fid)
')
local FOLDER_ID
if [ "$(echo "$folder_ids" | wc -l)" -eq 1 ]; then
FOLDER_ID="$folder_ids"
else
echo "Available pending folders:"
local i=1
while IFS= read -r fid && IFS= read -r flabel <&3; do
echo " $i) $flabel ($fid)"
i=$((i+1))
done <<< "$folder_ids" 3<<< "$folder_labels"
read -rp "Choose folder number: " FOLDER_NUM
FOLDER_ID=$(echo "$folder_ids" | sed -n "${FOLDER_NUM}p")
fi
[ -n "$FOLDER_ID" ] || { warn "No folder selected."; return; }
echo ""
info "Available directories in /var/syncthing/data/:"
ls /var/syncthing/data/ 2>/dev/null | sed 's/^/ /' || true
echo ""
read -rp "Folder path on this device: " FOLDER_PATH
[ -n "$FOLDER_PATH" ] || { warn "Path cannot be empty."; return; }
read -rp "Folder label [$FOLDER_ID]: " FOLDER_LABEL
FOLDER_LABEL="${FOLDER_LABEL:-$FOLDER_ID}"
st_post /rest/config/folders "$(python3 -c "
import json
print(json.dumps({'id': '$FOLDER_ID', 'label': '$FOLDER_LABEL', 'path': '$FOLDER_PATH'}))
")"
info "Folder '$FOLDER_LABEL' added at $FOLDER_PATH."
}
add_device_by_pending() {
local pending="$1"
local ids
@@ -436,7 +575,7 @@ get_apikey
while true; do
header "Syncthing Manager"
echo " 0) Show This Device's ID"
echo " 1) Pending Devices"
echo " 1) Pending Devices & Folders"
echo " 2) List Devices"
echo " 3) Add Device"
echo " 4) Remove Device"
@@ -451,7 +590,7 @@ while true; do
echo ""
case "$OPT" in
0) show_own_device_id ;;
1) show_pending_devices ;;
1) show_pending_devices; show_pending_folders ;;
2) list_devices ;;
3) add_device ;;
4) remove_device ;;