feat(calibre-server): Calibre Content Server as a boot-persistent Quadlet
lscr.io/linuxserver/calibre:9.15.0 (desktop build) running calibre-server as the s6 main service on internal 8090 -> host 8084, auth-gated with a plaintext userdb (mode 600) bootstrapped once by the deploy handler. The desktop GUI is kept on 8080/8182, basic-authed with the same credential pair via the machine-local env file.
This commit is contained in:
@@ -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
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user