diff --git a/dankserver/calibre-server/quadlet/calibre-server.container b/dankserver/calibre-server/quadlet/calibre-server.container new file mode 100644 index 0000000..0fde088 --- /dev/null +++ b/dankserver/calibre-server/quadlet/calibre-server.container @@ -0,0 +1,59 @@ +# Quadlet unit: Calibre Content Server (rootless, boot-persistent). +# +# Install path on dankserver: +# ~/.config/containers/systemd/calibre-server.container +# Unit name: calibre-server.service (podman 5.x drops the container- prefix) +# +# The image is the CURRENT linuxserver/calibre (9.15.0), which is a desktop +# image (Selkies web-streamed GUI on 8080/8181) with the headless +# calibre-server available inside. The compose-era snippet in +# serverAppConfigs-docs/Dankserver/Calibre.md predates the desktop revamp; +# the dedicated headless images (ta264/docker-calibre, agentgreen) are +# dormant (calibre 6.10 / 2017), so this unit runs calibre-server as the +# image's s6-overlay main service via Exec= and keeps the desktop as a +# bonus (browser GUI on 8080, basic-authed via the env file). +# +# Port map (Network=pasta, so container ports bind on the host directly): +# 8090 -> host 8084 calibre-server (the content server; 8080 is taken by +# the image's own nginx, 8083 by calibre-web) +# 8080 -> host 8080 desktop GUI (nginx; CUSTOM_HTTPS_PORT moved to 8182 +# because Tautulli holds host 8181) +# +# Auth: calibre-server 9.x keeps its users in a SQLite userdb (the pw column +# is PLAINTEXT, so the file is mode 600). The single admin account is +# bootstrapped once by the deploy handler (deploy_calibre_server) via +# `calibre-server --manage-users -- add`, password piped on stdin with +# printf (a trailing newline from echo lands in the stored password and +# breaks every login). The desktop's basic auth (CUSTOM_USER/CUSTOM_PASSWORD) +# reuses the same credential pair. Both live in the machine-local env file; +# this unit holds no secrets. +# +# --auth-mode=basic is required: on a non-SSL port the server defaults to +# Digest auth, which rejects plain Basic clients with 400 "Unsupported +# authentication method". + +[Unit] +Description=Calibre content server container +After=network.target + +[Container] +ContainerName=calibre-server +Image=lscr.io/linuxserver/calibre:9.15.0 +Network=pasta +EnvironmentFile=/home/dank/Containers/calibre-server/env +Environment=PUID=1000 +Environment=PGID=1000 +Environment=TZ=America/Vancouver +Environment=CUSTOM_HTTPS_PORT=8182 +Volume=/home/dank/Containers/calibre-web/books:/books +Volume=/home/dank/Containers/calibre-server/userdb.sqlite:/userdb.sqlite +Exec=calibre-server --port 8090 --enable-auth --auth-mode=basic --userdb /userdb.sqlite /books +PublishPort=8084:8090 + +[Service] +TimeoutStartSec=300 +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 1d2cda2..d3a3ff2 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -52,6 +52,7 @@ SERVICE_ORDER = ( "copyparty", "jellyfin", "calibre-web", + "calibre-server", "plex", "tautulli", "prowlarr", @@ -103,6 +104,7 @@ DANKSERVER_SERVICES = { "copyparty", "jellyfin", "calibre-web", + "calibre-server", "plex", "tautulli", "prowlarr", @@ -353,6 +355,7 @@ def services_for_path(path: str, target: str) -> set[str]: "romm": {"romm", "romm-mariadb", "romm-redis"}, "jellyfin": {"jellyfin"}, "calibre-web": {"calibre-web"}, + "calibre-server": {"calibre-server"}, "copyparty": {"copyparty"}, "plex": {"plex"}, "tautulli": {"tautulli"}, diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 115045e..f9c3774 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -92,7 +92,7 @@ service_is_allowed() { dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\ dankclaw:frank-large|dankclaw:frank-small|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ - dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:plex|dankserver:tautulli|\ + dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\ dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\ dankserver:frank-medium|dankserver:node-exporter|\ @@ -850,6 +850,50 @@ deploy_calibre_web() { systemctl --user restart calibre-web.service } +# The content server's single admin account and the desktop's basic-auth +# credential (same pair) are bootstrapped exactly once: the env file is the +# plaintext store (mode 600), the userdb's pw column is plaintext too, so +# its file is mode 600 as well. The password is piped on stdin with printf - +# a trailing newline from echo lands in the stored password and breaks every +# login (verified 2026-09-21). +deploy_calibre_server() { + local data_root=/home/dank/Containers/calibre-server + mkdir -p "$data_root" + if [[ ! -f $data_root/env ]]; then + local pw + pw=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20) + printf 'CUSTOM_USER=calibre\nCUSTOM_PASSWORD=%s\n' "$pw" > "$data_root/env" + chmod 600 "$data_root/env" + echo "calibre-server: generated credential (shown once): calibre / $pw" + fi + [[ -f $data_root/env ]] || { + echo "FAIL: $data_root/env does not exist; seed it before the first calibre-server deploy" >&2 + return 1 + } + if [[ ! -f $data_root/userdb.sqlite ]]; then + local pw + pw=$(grep '^CUSTOM_PASSWORD=' "$data_root/env" | cut -d= -f2) + touch "$data_root/userdb.sqlite" + printf '%s' "$pw" | podman run --rm -i --entrypoint /usr/bin/calibre-server \ + -v "$data_root/userdb.sqlite:/userdb.sqlite" \ + lscr.io/linuxserver/calibre:9.15.0 \ + --userdb /userdb.sqlite --manage-users -- add calibre + chown 1000:1000 "$data_root/userdb.sqlite" + chmod 600 "$data_root/userdb.sqlite" + fi + [[ -f $data_root/userdb.sqlite && -s $data_root/userdb.sqlite ]] || { + echo "FAIL: $data_root/userdb.sqlite missing or empty; the --manage-users bootstrap failed" >&2 + return 1 + } + cp "dankserver/calibre-server/quadlet/calibre-server.container" "$quadlet_unit_dir/" + systemctl --user daemon-reload + if ! systemctl --user is-active -q calibre-server.service; then + podman stop calibre-server 2>/dev/null || true + podman rm calibre-server 2>/dev/null || true + fi + systemctl --user restart calibre-server.service +} + deploy_copyparty() { # The admin account is created from ${COPAY_ACCOUNT}:${COPAY_PASSWORD} expanded # at ExecStart time; a missing env file must fail the unit, not boot copyparty @@ -1476,6 +1520,7 @@ deploy_service() { romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;; jellyfin) deploy_jellyfin ;; calibre-web) deploy_calibre_web ;; + calibre-server) deploy_calibre_server ;; copyparty) deploy_copyparty ;; plex) deploy_plex ;; tautulli) deploy_tautulli ;; diff --git a/serverAppConfigs-docs/Dankserver/Calibre.md b/serverAppConfigs-docs/Dankserver/Calibre.md index 143791f..c3cfb6b 100644 --- a/serverAppConfigs-docs/Dankserver/Calibre.md +++ b/serverAppConfigs-docs/Dankserver/Calibre.md @@ -39,33 +39,49 @@ gone; the quadlet unit mounts the Containers data root.) - Read/write access to the library directory. Calibre-Web edits metadata through the database file. - Ownership matching `PUID=1000` / `PGID=1000` (the `dank` user on Dankserver). -## Optional: Calibre Content Server (not running) +## Calibre Content Server (running) -Calibre includes a built-in HTTP content server (`calibre-server`) that can serve the library via OPDS feeds and a mobile-friendly web interface. This enables: -- OPDS feeds for mobile reading apps (FBReader, Moon+ Reader) -- Mobile-optimized UI via the `--mobile` flag -- Direct Kindle push +Calibre includes a built-in HTTP content server (`calibre-server`) that serves +the library via OPDS feeds and a web interface: +- OPDS feeds for mobile reading apps (FBReader, Moon+ Reader, KyBook) +- Web UI for browsing, metadata editing, and uploads +- Direct device push (from the GUI, not the content server) -To run as a container: +**Live shape (deployed 2026-09-21):** a rootless Quadlet unit +(`dankserver/calibre-server/quadlet/calibre-server.container`), boot-persistent +and self-healing, on `lscr.io/linuxserver/calibre:9.15.0`. -```yaml -services: - calibre-server: - image: lscr.io/linuxserver/calibre:latest - container_name: calibre-server - restart: unless-stopped - environment: - PUID: "1000" - PGID: "1000" - TZ: Etc/UTC - command: calibre-server --daemonize --port 8080 --enable-auth --url-prefix /calibre /books - volumes: - - /opt/calibre-web/books:/books - ports: - - "8084:8080" -``` +The image is the current **desktop** build (Selkies web-streamed GUI), not the +headless image the old compose snippet assumed — the dedicated headless images +(ta264/docker-calibre, agentgreen/calibre-server) are dormant (calibre 6.10 / +2017, both below the 9.9.0 content-server fix). The unit therefore runs +`calibre-server` as the image's s6-overlay main service via `Exec=` and keeps +the desktop as a bonus: -Note: The content server runs headless in `--daemonize` mode. It requires the full Calibre package, not a minimal server. +| Surface | Host port | Auth | +|---------|-----------|------| +| Content server (OPDS + web UI) | **8084** (internal 8090) | HTTP Basic, single admin `calibre` in the SQLite userdb | +| Desktop GUI (bonus) | 8080 (HTTPS on 8182) | HTTP Basic, same credential | + +Port notes: 8080 inside the container is the image's own nginx (the desktop), +so the content server runs on internal 8090; `CUSTOM_HTTPS_PORT=8182` avoids +Tautulli's host 8181. + +**Auth model (calibre 9.x):** users live in a SQLite userdb whose `pw` column +is **plaintext** — the file is mode 600. The single admin account is +bootstrapped once by the deploy handler +(`deploy_calibre_server` in `scripts/deploy-remote.sh`) via +`calibre-server --manage-users -- add`, the password piped on stdin with +`printf` (a trailing newline from `echo` lands in the stored password and +breaks every login). The desktop's basic auth (`CUSTOM_USER`/`CUSTOM_PASSWORD`) +reuses the same pair. Both live in the machine-local env file +`/home/dank/Containers/calibre-server/env` (mode 600); the repo holds no +secrets. `--auth-mode=basic` is pinned: on a non-SSL port the server defaults +to Digest auth, which rejects plain Basic clients with +`400 Unsupported authentication method`. + +LAN-only (no vhost, no public DNS). The library is mounted read-write — the +content server writes metadata, the same library Calibre-Web uses. ## See Also diff --git a/tests/dankserver_quadlet.bats b/tests/dankserver_quadlet.bats index 6e4c26c..dd87e0a 100755 --- a/tests/dankserver_quadlet.bats +++ b/tests/dankserver_quadlet.bats @@ -80,3 +80,29 @@ load helpers grep -q '^Environment=PGID=0$' "$unit" || return 1 ! grep -qE '^(UserNS|UsernsMode)=' "$unit" || return 1 } + +@test "calibre-server unit exists and is boot-persistent" { + unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container" + [ -f "$unit" ] || return 1 + grep -q '^WantedBy=default.target$' "$unit" || return 1 + grep -q '^Restart=always$' "$unit" || return 1 +} + +@test "calibre-server serves the content server auth-gated on the published port" { + # The security contract: the content server runs with --enable-auth and + # --auth-mode=basic (Digest is the non-SSL default and rejects Basic + # clients with 400), on the internal port the unit publishes as 8084. + unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container" + grep -q '^Exec=calibre-server --port 8090 --enable-auth --auth-mode=basic --userdb /userdb.sqlite /books$' "$unit" || return 1 + grep -q '^PublishPort=8084:8090$' "$unit" || return 1 +} + +@test "calibre-server consumes host-local credential stores, never in-repo secrets" { + unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container" + grep -q '^EnvironmentFile=/home/dank/Containers/calibre-server/env$' "$unit" || return 1 + grep -q '^Volume=/home/dank/Containers/calibre-server/userdb.sqlite:/userdb.sqlite$' "$unit" || return 1 + # The userdb's pw column is plaintext: no literal password may appear in + # the tracked unit, and the desktop basic-auth credential comes from the + # env file, not an Environment= line. + ! grep -qE '^Environment=CUSTOM_(USER|PASSWORD)=' "$unit" || return 1 +}