forked from finn/tinyboard
Compare commits
41 Commits
onboard-sp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
231
health-check.sh
Executable file
231
health-check.sh
Executable file
@@ -0,0 +1,231 @@
|
|||||||
|
#!/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')
|
||||||
|
}
|
||||||
|
|
||||||
|
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" 2>&1 | tail -20 || 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 -q "${spoke_name}-remote:"; 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 ""
|
||||||
@@ -11,51 +11,44 @@ YELLOW='\033[1;33m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
die() {
|
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||||
echo -e "${RED}[ERROR]${NC} $*" >&2
|
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
header() {
|
|
||||||
echo -e "\n${CYAN}══════════════════════════════════════════${NC}"
|
|
||||||
echo -e "${CYAN} $*${NC}"
|
|
||||||
echo -e "${CYAN}══════════════════════════════════════════${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_deps() {
|
check_deps() {
|
||||||
local missing=()
|
local missing=()
|
||||||
for cmd in "$@"; do
|
for cmd in "$@"; do
|
||||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
missing+=("$cmd")
|
missing+=("$cmd")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
|
die "Missing required dependencies: ${missing[*]}"
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
if [ ${#missing[@]} -gt 0 ]; then
|
|
||||||
die "Missing required dependencies: ${missing[*]}"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
retry_or_abort() {
|
retry_or_abort() {
|
||||||
local test_cmd="$1"
|
local test_cmd="$1"
|
||||||
local fail_msg="$2"
|
local fail_msg="$2"
|
||||||
while true; do
|
while true; do
|
||||||
if eval "$test_cmd" 2>/dev/null; then
|
if eval "$test_cmd" 2>/dev/null; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
warn "$fail_msg"
|
warn "$fail_msg"
|
||||||
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
|
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
|
||||||
read -rp "Choice: " CHOICE
|
read -rp "Choice: " CHOICE
|
||||||
case "${CHOICE,,}" in
|
case "${CHOICE,,}" in
|
||||||
r) info "Retrying..." ;;
|
r) info "Retrying..." ;;
|
||||||
a) die "Aborted." ;;
|
a) die "Aborted." ;;
|
||||||
*) warn "Press R to retry or A to abort." ;;
|
*) warn "Press R to retry or A to abort." ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
|
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
|
||||||
fi
|
fi
|
||||||
mkdir -p "$SSH_DIR"
|
mkdir -p "$SSH_DIR"
|
||||||
touch "$SSH_DIR/known_hosts"
|
touch "$SSH_DIR/known_hosts"
|
||||||
@@ -82,105 +75,95 @@ mkdir -p "$(dirname "$RCLONE_CONF")"
|
|||||||
|
|
||||||
header "Checking Tunnel"
|
header "Checking Tunnel"
|
||||||
info "Verifying spoke SSH service is reachable on port $TUNNEL_PORT..."
|
info "Verifying spoke SSH service is reachable on port $TUNNEL_PORT..."
|
||||||
# Test TCP connectivity first
|
|
||||||
if ! timeout 5 bash -c "cat < /dev/null > /dev/tcp/localhost/$TUNNEL_PORT" 2>/dev/null; then
|
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?"
|
die "Cannot connect to port $TUNNEL_PORT on localhost — is the tunnel up?"
|
||||||
fi
|
fi
|
||||||
info "Scanning spoke host key..."
|
info "Scanning spoke host key..."
|
||||||
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
|
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?"
|
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
|
||||||
while IFS= read -r KEYSCAN_LINE; do
|
while IFS= read -r KEYSCAN_LINE; do
|
||||||
KEYSCAN_KEY=$(echo "$KEYSCAN_LINE" | awk '{print $2, $3}')
|
KEYSCAN_KEY=$(echo "$KEYSCAN_LINE" | awk '{print $2, $3}')
|
||||||
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
||||||
echo "$KEYSCAN_LINE" >>"$SSH_DIR/known_hosts"
|
echo "$KEYSCAN_LINE" >> "$SSH_DIR/known_hosts"
|
||||||
fi
|
fi
|
||||||
done <<<"$KEYSCAN"
|
done <<< "$KEYSCAN"
|
||||||
|
|
||||||
header "Generating Hub SSH Key"
|
header "Generating Hub-to-Spoke Access Key"
|
||||||
if [ -f "$KEY_PATH" ]; then
|
if [ -f "$KEY_PATH" ]; then
|
||||||
warn "Key $KEY_PATH already exists, skipping generation."
|
warn "Key $KEY_PATH already exists, skipping generation."
|
||||||
else
|
else
|
||||||
ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "$KEY_NAME"
|
ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "$KEY_NAME"
|
||||||
info "Key generated: $KEY_PATH"
|
info "Key generated: $KEY_PATH"
|
||||||
fi
|
fi
|
||||||
chmod 600 "$KEY_PATH"
|
chmod 600 "$KEY_PATH"
|
||||||
info "Permissions set: $KEY_PATH is 600"
|
info "Permissions set: $KEY_PATH is 600"
|
||||||
|
|
||||||
header "Installing Hub Access Key on Spoke"
|
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 "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)"
|
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
|
if ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost; then
|
||||||
info "Key copied."
|
info "Key copied."
|
||||||
else
|
else
|
||||||
warn "ssh-copy-id failed — password auth may be disabled on the spoke."
|
warn "ssh-copy-id failed — password auth may be disabled on the spoke."
|
||||||
warn "Manually append the hub public key to the spoke's authorized_keys:"
|
warn "Manually append the hub public key to the spoke's authorized_keys:"
|
||||||
echo ""
|
echo ""
|
||||||
echo " cat $KEY_PATH.pub"
|
echo " cat $KEY_PATH.pub"
|
||||||
echo " Then on the spoke, append the output to:"
|
echo " Then on the spoke, append the output to:"
|
||||||
echo " /home/$SPOKE_USER/.ssh/authorized_keys"
|
echo " /home/$SPOKE_USER/.ssh/authorized_keys"
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Press ENTER once the key has been added to the spoke..."
|
read -rp "Press ENTER once the key has been added to the spoke..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Testing Hub -> Spoke Key Auth"
|
header "Testing Hub-to-Spoke Key Auth"
|
||||||
retry_or_abort \
|
retry_or_abort \
|
||||||
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
|
"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."
|
"Key auth failed. Check authorized_keys on the spoke."
|
||||||
info "Key auth to spoke successful."
|
info "Key auth to spoke successful."
|
||||||
|
|
||||||
header "Adding rclone Remote"
|
header "Adding rclone Remote"
|
||||||
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||||
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
||||||
else
|
else
|
||||||
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >>"$RCLONE_CONF"
|
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >> "$RCLONE_CONF"
|
||||||
cat >>"$RCLONE_CONF" <<EOF
|
python3 - "$RCLONE_CONF" "$SPOKE_NAME" "$TUNNEL_PORT" "$KEY_PATH" <<'PYEOF'
|
||||||
|
import sys
|
||||||
[${SPOKE_NAME}-remote]
|
path, name, port, key = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||||
type = sftp
|
with open(path, 'a') as f:
|
||||||
host = localhost
|
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")
|
||||||
port = $TUNNEL_PORT
|
PYEOF
|
||||||
key_file = $KEY_PATH
|
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
|
||||||
shell_type = unix
|
|
||||||
md5sum_command = md5sum
|
|
||||||
sha1sum_command = sha1sum
|
|
||||||
EOF
|
|
||||||
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Union Remote (optional)"
|
header "Union Remote (optional)"
|
||||||
read -rp "Add this spoke to a union remote for redundancy? [y/N]: " ADD_UNION
|
read -rp "Add this spoke to a union remote for redundancy? [y/N]: " ADD_UNION
|
||||||
ADD_UNION="${ADD_UNION:-n}"
|
ADD_UNION="${ADD_UNION:-n}"
|
||||||
if [[ "${ADD_UNION,,}" == "y" ]]; then
|
if [[ "${ADD_UNION,,}" == "y" ]]; then
|
||||||
read -rp "Union remote name [shared-union]: " UNION_NAME
|
read -rp "Union remote name [shared-union]: " UNION_NAME
|
||||||
UNION_NAME="${UNION_NAME:-shared-union}"
|
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 ""
|
||||||
echo "Upstream access mode for this spoke:"
|
echo "Upstream access mode for this spoke:"
|
||||||
echo " 0) None - full read/write (default)"
|
echo " 0) None - full read/write (default)"
|
||||||
echo " 1) :ro - read only"
|
echo " 1) :ro - read only"
|
||||||
echo " 2) :nc - no create (read/write existing, no new files)"
|
echo " 2) :nc - no create (read/write existing, no new files)"
|
||||||
echo " 3) :writeback - writeback cache"
|
echo " 3) :writeback - writeback cache"
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Choose [0-3]: " UNION_MODE
|
read -rp "Choose [0-3]: " UNION_MODE
|
||||||
UNION_MODE="${UNION_MODE:-0}"
|
UNION_MODE="${UNION_MODE:-0}"
|
||||||
case "$UNION_MODE" in
|
case "$UNION_MODE" in
|
||||||
0) UPSTREAM_TAG="" ;;
|
0) UPSTREAM_TAG="" ;;
|
||||||
1) UPSTREAM_TAG=":ro" ;;
|
1) UPSTREAM_TAG=":ro" ;;
|
||||||
2) UPSTREAM_TAG=":nc" ;;
|
2) UPSTREAM_TAG=":nc" ;;
|
||||||
3) UPSTREAM_TAG=":writeback" ;;
|
3) UPSTREAM_TAG=":writeback" ;;
|
||||||
*)
|
*) warn "Invalid choice, defaulting to full read/write."; UPSTREAM_TAG="" ;;
|
||||||
warn "Invalid choice, defaulting to full read/write."
|
esac
|
||||||
UPSTREAM_TAG=""
|
if [ -n "$UNION_PATH" ]; then
|
||||||
;;
|
UPSTREAM="${SPOKE_NAME}-remote:${UNION_PATH}${UPSTREAM_TAG}"
|
||||||
esac
|
else
|
||||||
if [ -n "$UNION_PATH" ]; then
|
UPSTREAM="${SPOKE_NAME}-remote:${UPSTREAM_TAG}"
|
||||||
UPSTREAM="${SPOKE_NAME}-remote:${UNION_PATH}${UPSTREAM_TAG}"
|
fi
|
||||||
else
|
if grep -q "^\[${UNION_NAME}\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||||
UPSTREAM="${SPOKE_NAME}-remote:${UPSTREAM_TAG}"
|
ALREADY=$(python3 - "$RCLONE_CONF" "$UNION_NAME" "${SPOKE_NAME}-remote:" <<'PYEOF2'
|
||||||
fi
|
|
||||||
if grep -q "^\[${UNION_NAME}\]" "$RCLONE_CONF" 2>/dev/null; then
|
|
||||||
ALREADY=$(
|
|
||||||
python3 - "$RCLONE_CONF" "$UNION_NAME" "${SPOKE_NAME}-remote:" <<'PYEOF'
|
|
||||||
import sys
|
import sys
|
||||||
path, section, prefix = sys.argv[1], sys.argv[2], sys.argv[3]
|
path, section, prefix = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -195,12 +178,12 @@ for line in lines:
|
|||||||
print("yes")
|
print("yes")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
print("no")
|
print("no")
|
||||||
PYEOF
|
PYEOF2
|
||||||
)
|
)
|
||||||
if [ "$ALREADY" = "yes" ]; then
|
if [ "$ALREADY" = "yes" ]; then
|
||||||
warn "Upstream for ${SPOKE_NAME}-remote already in union remote [${UNION_NAME}], skipping."
|
warn "Upstream for ${SPOKE_NAME}-remote already in union remote [${UNION_NAME}], skipping."
|
||||||
else
|
else
|
||||||
python3 - "$RCLONE_CONF" "$UNION_NAME" "$UPSTREAM" <<'PYEOF'
|
python3 - "$RCLONE_CONF" "$UNION_NAME" "$UPSTREAM" <<'PYEOF2'
|
||||||
import sys
|
import sys
|
||||||
path, section, upstream = sys.argv[1], sys.argv[2], sys.argv[3]
|
path, section, upstream = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -217,21 +200,21 @@ for line in lines:
|
|||||||
out.append(line)
|
out.append(line)
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
f.writelines(out)
|
f.writelines(out)
|
||||||
PYEOF
|
PYEOF2
|
||||||
info "Added '$UPSTREAM' to union remote [${UNION_NAME}]."
|
info "Added '$UPSTREAM' to union remote [${UNION_NAME}]."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >> "$RCLONE_CONF"
|
||||||
|
printf '\n[%s]\ntype = union\nupstreams = %s\n' "$UNION_NAME" "$UPSTREAM" >> "$RCLONE_CONF"
|
||||||
|
info "Union remote [${UNION_NAME}] created with upstream '$UPSTREAM'."
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >>"$RCLONE_CONF"
|
|
||||||
printf '\n[%s]\ntype = union\nupstreams = %s\n' "$UNION_NAME" "$UPSTREAM" >>"$RCLONE_CONF"
|
|
||||||
info "Union remote [${UNION_NAME}] created with upstream '$UPSTREAM'."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Testing rclone Connection"
|
header "Testing rclone Connection"
|
||||||
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
|
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
|
||||||
info "rclone connection to $SPOKE_NAME successful."
|
info "rclone connection to $SPOKE_NAME successful."
|
||||||
else
|
else
|
||||||
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
|
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Registering Spoke"
|
header "Registering Spoke"
|
||||||
@@ -239,29 +222,26 @@ mkdir -p "$(dirname "$REGISTRY")"
|
|||||||
MOUNT_POINT="${HOME}/mnt/${SPOKE_NAME}"
|
MOUNT_POINT="${HOME}/mnt/${SPOKE_NAME}"
|
||||||
mkdir -p "$MOUNT_POINT"
|
mkdir -p "$MOUNT_POINT"
|
||||||
if grep -q "^${SPOKE_NAME} " "$REGISTRY" 2>/dev/null; then
|
if grep -q "^${SPOKE_NAME} " "$REGISTRY" 2>/dev/null; then
|
||||||
warn "$SPOKE_NAME already in registry, updating."
|
warn "$SPOKE_NAME already in registry, updating."
|
||||||
grep -v "^${SPOKE_NAME} " "$REGISTRY" >"${REGISTRY}.tmp" 2>/dev/null || true
|
grep -v "^${SPOKE_NAME} " "$REGISTRY" > "${REGISTRY}.tmp" 2>/dev/null || true
|
||||||
mv "${REGISTRY}.tmp" "$REGISTRY"
|
mv "${REGISTRY}.tmp" "$REGISTRY"
|
||||||
fi
|
fi
|
||||||
echo "${SPOKE_NAME} ${TUNNEL_PORT} ${KEY_PATH} ${MOUNT_POINT}" >>"$REGISTRY"
|
echo "${SPOKE_NAME} ${TUNNEL_PORT} ${KEY_PATH} ${MOUNT_POINT}" >> "$REGISTRY"
|
||||||
info "$SPOKE_NAME registered."
|
info "$SPOKE_NAME registered."
|
||||||
|
|
||||||
header "Setting Up Auto-Mount"
|
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"
|
MOUNT_CMD="/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"
|
||||||
CRON_ENTRY="@reboot ${MOUNT_CMD}"
|
CRON_ENTRY="@reboot ${MOUNT_CMD}"
|
||||||
EXISTING=$(crontab -l 2>/dev/null || true)
|
EXISTING=$(crontab -l 2>/dev/null || true)
|
||||||
if echo "$EXISTING" | grep -qF "${SPOKE_NAME}-remote:"; then
|
if echo "$EXISTING" | grep -qF "${SPOKE_NAME}-remote:"; then
|
||||||
warn "Crontab entry for ${SPOKE_NAME}-remote already exists, skipping."
|
warn "Crontab entry for ${SPOKE_NAME}-remote already exists, skipping."
|
||||||
else
|
else
|
||||||
CRONTAB_BACKUP="${HOME}/.config/tinyboard/crontab.$(date +%Y%m%d%H%M%S)"
|
CRONTAB_BACKUP="${HOME}/.config/tinyboard/crontab.$(date +%Y%m%d%H%M%S)"
|
||||||
mkdir -p "$(dirname "$CRONTAB_BACKUP")"
|
mkdir -p "$(dirname "$CRONTAB_BACKUP")"
|
||||||
echo "$EXISTING" >"$CRONTAB_BACKUP"
|
echo "$EXISTING" > "$CRONTAB_BACKUP"
|
||||||
info "Crontab backed up to $CRONTAB_BACKUP"
|
info "Crontab backed up to $CRONTAB_BACKUP"
|
||||||
{
|
{ echo "$EXISTING"; echo "$CRON_ENTRY"; } | crontab -
|
||||||
echo "$EXISTING"
|
info "Auto-mount crontab entry added for ${SPOKE_NAME}."
|
||||||
echo "$CRON_ENTRY"
|
|
||||||
} | crontab -
|
|
||||||
info "Auto-mount crontab entry added for ${SPOKE_NAME}."
|
|
||||||
fi
|
fi
|
||||||
info "Starting mount now..."
|
info "Starting mount now..."
|
||||||
mkdir -p "$MOUNT_POINT"
|
mkdir -p "$MOUNT_POINT"
|
||||||
@@ -273,6 +253,3 @@ echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
|
|||||||
echo -e " Hub key: ${GREEN}$KEY_PATH${NC}"
|
echo -e " Hub key: ${GREEN}$KEY_PATH${NC}"
|
||||||
echo -e " rclone: ${GREEN}${SPOKE_NAME}-remote${NC}"
|
echo -e " rclone: ${GREEN}${SPOKE_NAME}-remote${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}To mount this spoke:${NC}"
|
|
||||||
echo " RCLONE_REMOTE=${SPOKE_NAME}-remote hubspoke-helper.sh hub start"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ if command -v apt-get >/dev/null 2>&1; then
|
|||||||
PKG_MANAGER="apt"
|
PKG_MANAGER="apt"
|
||||||
PKG_INSTALL="apt-get install -y -q"
|
PKG_INSTALL="apt-get install -y -q"
|
||||||
OPENSSH_PKG="openssh-server"
|
OPENSSH_PKG="openssh-server"
|
||||||
FUSE_PKG="fuse"
|
FUSE_PKG="fuse3"
|
||||||
info "Detected: apt (Debian/Ubuntu)"
|
info "Detected: apt (Debian/Ubuntu)"
|
||||||
apt-get update -q
|
apt-get update -q
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
@@ -136,7 +136,7 @@ header "SSH Server Configuration"
|
|||||||
SSHD_CONF="/etc/ssh/sshd_config"
|
SSHD_CONF="/etc/ssh/sshd_config"
|
||||||
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
|
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
|
||||||
|
|
||||||
for DIRECTIVE in "GatewayPorts no" "AllowTcpForwarding local" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
|
for DIRECTIVE in "GatewayPorts no" "AllowTcpForwarding yes" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
|
||||||
KEY="${DIRECTIVE%% *}"
|
KEY="${DIRECTIVE%% *}"
|
||||||
if grep -q "^$KEY" "$SSHD_CONF"; then
|
if grep -q "^$KEY" "$SSHD_CONF"; then
|
||||||
sed -i "s|^$KEY.*|$DIRECTIVE|" "$SSHD_CONF"
|
sed -i "s|^$KEY.*|$DIRECTIVE|" "$SSHD_CONF"
|
||||||
@@ -161,8 +161,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
header "Password Authentication"
|
header "Password Authentication"
|
||||||
read -rp "Disable password auth for $HUB_USER and use keys only? [Y/n]: " DISABLE_PASS
|
read -rp "Disable password auth for $HUB_USER and use keys only? [y/N]: " DISABLE_PASS
|
||||||
DISABLE_PASS="${DISABLE_PASS:-y}"
|
DISABLE_PASS="${DISABLE_PASS:-n}"
|
||||||
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
||||||
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
|
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
|
||||||
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
|
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"
|
header "Hub Setup Complete"
|
||||||
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
|
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
|
||||||
echo -e " SSH config: ${GREEN}GatewayPorts no, AllowTcpForwarding local, 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 " FUSE: ${GREEN}user_allow_other enabled${NC}"
|
||||||
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
|
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
|
||||||
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
|
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
|
||||||
|
|||||||
363
hub/setup-opds.sh
Executable file
363
hub/setup-opds.sh
Executable 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 ""
|
||||||
@@ -11,25 +11,22 @@ services:
|
|||||||
-o "ServerAliveInterval=60"
|
-o "ServerAliveInterval=60"
|
||||||
-o "ServerAliveCountMax=3"
|
-o "ServerAliveCountMax=3"
|
||||||
-R 11111:localhost:22
|
-R 11111:localhost:22
|
||||||
-i /home/armbian/.ssh/oilykey2026
|
-i /home/armbian/.ssh/hubkey
|
||||||
armbian@oily.dad
|
armbian@hub.example.com
|
||||||
volumes:
|
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
|
- /home/armbian/.ssh/known_hosts:/home/armbian/.ssh/known_hosts:ro
|
||||||
syncthing:
|
syncthing:
|
||||||
image: syncthing/syncthing
|
image: syncthing/syncthing
|
||||||
container_name: spoke-syncthing
|
container_name: spoke-syncthing
|
||||||
hostname: spoke-syncthing
|
hostname: spoke-syncthing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
environment:
|
environment:
|
||||||
- PUID=1000
|
- HOME=/var/syncthing
|
||||||
- PGID=1000
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8384:8384"
|
- "8384:8384"
|
||||||
- "22000:22000"
|
- "22000:22000"
|
||||||
volumes:
|
volumes:
|
||||||
- syncthing-config:/var/syncthing/config
|
- /home/armbian/st/config:/var/syncthing/config
|
||||||
- /home/armbian/st/data:/var/syncthing/data
|
- /home/armbian/st/data:/var/syncthing/data
|
||||||
|
|
||||||
volumes:
|
|
||||||
syncthing-config:
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ header "TinyBoard Network Setup"
|
|||||||
echo ""
|
echo ""
|
||||||
echo " 0) Change hostname"
|
echo " 0) Change hostname"
|
||||||
echo " 1) Configure static IP"
|
echo " 1) Configure static IP"
|
||||||
|
echo " 2) Prefer IPv4 over IPv6"
|
||||||
|
echo " 3) Prefer IPv6 over IPv4"
|
||||||
echo " q) Quit"
|
echo " q) Quit"
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Choose: " NET_OPT
|
read -rp "Choose: " NET_OPT
|
||||||
@@ -53,6 +55,22 @@ case "$NET_OPT" in
|
|||||||
;;
|
;;
|
||||||
1)
|
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
|
||||||
|
;;
|
||||||
q|Q)
|
q|Q)
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -159,10 +177,19 @@ PYEOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
header "Writing Netplan Config"
|
header "Writing Netplan Config"
|
||||||
|
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
|
||||||
|
mkdir -p "$NETPLAN_BACKUP_DIR"
|
||||||
BACKUP_FILE=""
|
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
|
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)"
|
BACKUP_FILE="$NETPLAN_BACKUP_DIR/$(basename "${NETPLAN_FILE}").$(date +%Y%m%d%H%M%S)"
|
||||||
cp "$NETPLAN_FILE" "$BACKUP_FILE"
|
cp "$NETPLAN_FILE" "$BACKUP_FILE"
|
||||||
info "Netplan config backed up to $BACKUP_FILE"
|
info "Netplan config backed up to $BACKUP_FILE"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ SSHD_CONF="/etc/ssh/sshd_config"
|
|||||||
header "Hostname Setup"
|
header "Hostname Setup"
|
||||||
CURRENT_HOSTNAME=$(hostname)
|
CURRENT_HOSTNAME=$(hostname)
|
||||||
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
|
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="${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."
|
[[ "$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"
|
hostnamectl set-hostname "$SPOKE_NAME"
|
||||||
@@ -215,6 +215,10 @@ case "$KEY_CHOICE" in
|
|||||||
cat "$KEY_PATH.pub"
|
cat "$KEY_PATH.pub"
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
echo ""
|
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}..."
|
read -rp "Press ENTER once the key has been added to ${HUB_HOST}..."
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
@@ -315,6 +319,12 @@ if [ -n "$HUB_KEYSCAN" ]; then
|
|||||||
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
||||||
echo "$KEYSCAN_LINE" >> "$SSH_DIR/known_hosts"
|
echo "$KEYSCAN_LINE" >> "$SSH_DIR/known_hosts"
|
||||||
fi
|
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"
|
done <<< "$HUB_KEYSCAN"
|
||||||
fi
|
fi
|
||||||
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
|
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
|
||||||
@@ -349,8 +359,9 @@ header "Configuring compose.yaml"
|
|||||||
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
|
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
|
||||||
|
|
||||||
SYNCTHING_MOUNT="$ARMBIAN_HOME/st/data"
|
SYNCTHING_MOUNT="$ARMBIAN_HOME/st/data"
|
||||||
mkdir -p "$SYNCTHING_MOUNT"
|
SYNCTHING_CONFIG="$ARMBIAN_HOME/st/config"
|
||||||
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT"
|
mkdir -p "$SYNCTHING_MOUNT" "$SYNCTHING_CONFIG"
|
||||||
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT" "$SYNCTHING_CONFIG"
|
||||||
|
|
||||||
SPOKE_UID=$(id -u "$SPOKE_USER")
|
SPOKE_UID=$(id -u "$SPOKE_USER")
|
||||||
SPOKE_GID=$(id -g "$SPOKE_USER")
|
SPOKE_GID=$(id -g "$SPOKE_USER")
|
||||||
@@ -361,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|/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| [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|/home/[^/]*/st/data:|${SYNCTHING_MOUNT}:|g" "$COMPOSE"
|
||||||
sed -i "s|PUID=[0-9]*|PUID=${SPOKE_UID}|g" "$COMPOSE"
|
sed -i "s|/home/[^/]*/st/config:|${SYNCTHING_CONFIG}:|g" "$COMPOSE"
|
||||||
sed -i "s|PGID=[0-9]*|PGID=${SPOKE_GID}|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-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|container_name: spoke-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
||||||
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
||||||
|
|||||||
145
syncthing.sh
145
syncthing.sh
@@ -19,7 +19,7 @@ ST_CONTAINER=""
|
|||||||
get_apikey() {
|
get_apikey() {
|
||||||
ST_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true)
|
ST_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true)
|
||||||
[ -n "$ST_CONTAINER" ] || die "No running Syncthing container found."
|
[ -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'."
|
[ -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
|
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() {
|
add_device_by_pending() {
|
||||||
local pending="$1"
|
local pending="$1"
|
||||||
local ids
|
local ids
|
||||||
@@ -436,7 +575,7 @@ get_apikey
|
|||||||
while true; do
|
while true; do
|
||||||
header "Syncthing Manager"
|
header "Syncthing Manager"
|
||||||
echo " 0) Show This Device's ID"
|
echo " 0) Show This Device's ID"
|
||||||
echo " 1) Pending Devices"
|
echo " 1) Pending Devices & Folders"
|
||||||
echo " 2) List Devices"
|
echo " 2) List Devices"
|
||||||
echo " 3) Add Device"
|
echo " 3) Add Device"
|
||||||
echo " 4) Remove Device"
|
echo " 4) Remove Device"
|
||||||
@@ -451,7 +590,7 @@ while true; do
|
|||||||
echo ""
|
echo ""
|
||||||
case "$OPT" in
|
case "$OPT" in
|
||||||
0) show_own_device_id ;;
|
0) show_own_device_id ;;
|
||||||
1) show_pending_devices ;;
|
1) show_pending_devices; show_pending_folders ;;
|
||||||
2) list_devices ;;
|
2) list_devices ;;
|
||||||
3) add_device ;;
|
3) add_device ;;
|
||||||
4) remove_device ;;
|
4) remove_device ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user