31 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
5 changed files with 680 additions and 7 deletions
+30 -2
View File
@@ -48,6 +48,33 @@ if [ "$IS_SPOKE" = false ] && [ "$IS_HUB" = false ]; then
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"
@@ -105,7 +132,7 @@ check_spoke() {
ok "autossh container running ($autossh_container)"
local logs
logs=$(docker logs "$autossh_container" 2>&1 | tail -20 || true)
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
@@ -190,7 +217,7 @@ check_hub() {
fail "rclone remote [${spoke_name}-remote] not found in rclone.conf"
fi
if crontab -u "$(basename "$HUB_HOME")" -l 2>/dev/null | grep -q "${spoke_name}-remote:"; then
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"
@@ -202,6 +229,7 @@ check_hub() {
}
check_common
check_disk
if [ "$IS_SPOKE" = true ]; then
check_spoke
+10 -3
View File
@@ -230,8 +230,15 @@ echo "${SPOKE_NAME} ${TUNNEL_PORT} ${KEY_PATH} ${MOUNT_POINT}" >> "$REGISTRY"
info "$SPOKE_NAME registered."
header "Setting Up Auto-Mount"
MOUNT_CMD="rclone mount ${SPOKE_NAME}-remote: ${MOUNT_POINT} --config ${HOME}/.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --daemon"
CRON_ENTRY="@reboot ${MOUNT_CMD}"
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."
@@ -245,7 +252,7 @@ else
fi
info "Starting mount now..."
mkdir -p "$MOUNT_POINT"
eval "$MOUNT_CMD" 2>/dev/null && info "Mounted ${SPOKE_NAME} at ${MOUNT_POINT}." || warn "Mount failed — will retry on next reboot."
/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}"
+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 ""
+136
View File
@@ -34,6 +34,7 @@ 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
@@ -71,6 +72,141 @@ case "$NET_OPT" in
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
;;
+141 -2
View File
@@ -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 ;;