#!/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 BOOKS_PATH="${BOOKS_PATH}" SERVER_NAME="${OPDS_SERVER}" if [ -d "\$BOOKS_PATH" ] && [ -z "\$(ls -A "\$BOOKS_PATH" 2>/dev/null)" ]; then docker restart "\$SERVER_NAME" 2>/dev/null || true fi EOF chmod +x "$WATCHDOG_SCRIPT" CRON_LINE="@reboot sleep 120 && $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 if mount is empty on boot." else info "Watchdog crontab entry already exists." 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 ""