forked from finn/tinyboard
Compare commits
67 Commits
600f6044ce
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cbdbce7a41 | |||
| bbf1d8b79a | |||
| 436906582c | |||
| a01f4aa11c | |||
| d61f7e4512 | |||
| 337d238d7f | |||
| ff5cb104f8 | |||
| 5d9e0f579c | |||
| 2bd8711db3 | |||
| 2928285143 | |||
| f9d0717b71 | |||
| 518f54394c | |||
| defb4fdaa3 | |||
| c7c5d2bf8d | |||
| 85def22fca | |||
| 80d5f1d1fd | |||
| 56e0fc38c0 | |||
| 1c6e12e2d6 | |||
| 40d24158b6 | |||
| 5bc33b28f4 | |||
| 21a1c7e922 | |||
| 4586a0f598 | |||
| 2999c464fa | |||
| dfa3c1ce6d | |||
| 9dc2b221d3 | |||
| 89e84c41c1 | |||
| 2d2b19b2db | |||
| 78d4373c0d | |||
| 132d15357c | |||
| 8acfc3269a | |||
| ad15498bb9 | |||
| 8a7fe7b4de | |||
| e3a12c0f6e | |||
| a8b10a1814 | |||
| 92a2af8c5c | |||
| a5cf3d1f8b | |||
| ebb366e4bc | |||
| 84b3b7ce1d | |||
| 86688c43c7 | |||
| 972dbef11c | |||
| e55ab898ef | |||
| 81d0bebd5e | |||
| 6fe164a6ae | |||
| b76e890857 | |||
| 4e2f17266a | |||
| 0af3c30f79 | |||
| b0e63a2e01 | |||
| 22eced7607 | |||
| 58c641d603 | |||
| 1cc50f8ff0 | |||
| 97aff6a741 | |||
| eaff38477c | |||
| e2ed499e58 | |||
| 48ba67e351 | |||
| 5a9e55b673 | |||
| e5bdf95dcf | |||
| 0553420d04 | |||
| 4cdddd649d | |||
| 0fd7d94d58 | |||
| f3c9cf2344 | |||
| f486795154 | |||
| fe3f2c5b77 | |||
| 4e1e9282ac | |||
| 07f4601bad | |||
| 9bdd12ebbd | |||
| d3a6d406d8 | |||
| e74c9b45d5 |
@@ -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:
|
||||
|
||||
Executable
+242
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
Executable
+363
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ;;
|
||||
|
||||
Reference in New Issue
Block a user