From 3ef968fd1029322b11b5f51783c41199aebdbccb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 03:45:26 +0000 Subject: [PATCH] library: HTTPS via Caddy (install.sh --domain [--lan-tls]) so the live camera works; real visitor address behind a proxy; camera permission header Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/.gitignore | 3 ++ library/README.md | 17 +++++++++++ library/docker-compose.yml | 19 ++++++++++++ library/install.sh | 62 ++++++++++++++++++++++++++++++++++++++ library/quickadd/app.py | 7 ++++- 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/library/.gitignore b/library/.gitignore index a0a4e8d..2d477a8 100644 --- a/library/.gitignore +++ b/library/.gitignore @@ -3,3 +3,6 @@ # Extra CA certificates dropped in for building behind a proxy koha/certs/*.crt quickadd/certs/*.crt +# Generated by install.sh --domain +Caddyfile +caddy-root.crt diff --git a/library/README.md b/library/README.md index 935ff98..cab6593 100644 --- a/library/README.md +++ b/library/README.md @@ -297,3 +297,20 @@ New installs of this stack use the same three ports whether you install it from **8098** is the one to use (the Room for Books home page, with Koha behind the menu), 8099 is Add books direct, and 8097 is the public catalogue, which stays closed. If one is taken, the next free port is used and the installer prints it. Existing installs keep the ports they already have. + +## HTTPS with Caddy (and the live camera scanner) + +Phones only allow a live camera on an HTTPS page. One command adds Caddy in front of the library: + +```bash +# a real domain that points at this machine (ports 80 and 443 reachable): the certificate is automatic +sudo ./install.sh --domain library.example.com + +# a name only your own network knows (library.home.lan): Caddy makes its own certificate authority +sudo ./install.sh --domain library.home.lan --lan-tls # then install caddy-root.crt on your phones +``` + +It writes `Caddyfile`, starts a `caddy` container (the compose `caddy` profile), sets `STAFF_URL=https://` and closes the plain-HTTP +ports to the network (`BIND_ADDR=127.0.0.1`; add `--keep-direct` to leave them open). Then use `https:///`: everything works +as before, and the **Live scan** buttons appear on Add books and Lend / return. Behind your own Caddy instead, point it at the library +port: `library.example.com { reverse_proxy :8098 }` and set `STAFF_URL=https://library.example.com` in `.env`. diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 3ab7a64..699ffe0 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -77,7 +77,26 @@ services: volumes: - quickadd-data:/data + # HTTPS with an automatic certificate (and so the live camera on phones): only started when + # `./install.sh --domain ` has set COMPOSE_PROFILES=caddy in .env. + caddy: + profiles: ["caddy"] + image: caddy:2 + container_name: library-caddy + restart: unless-stopped + depends_on: + - koha + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + volumes: db-data: koha-data: quickadd-data: + caddy-data: + caddy-config: diff --git a/library/install.sh b/library/install.sh index 67b4d6a..a9e8988 100755 --- a/library/install.sh +++ b/library/install.sh @@ -5,6 +5,9 @@ # sudo ./install.sh --status # show URLs and container health # sudo ./install.sh --update # rebuild with the latest Koha and restart # sudo ./install.sh --stop | --start +# sudo ./install.sh --domain library.example.com # HTTPS via Caddy (certificate is automatic) +# sudo ./install.sh --domain library.home.lan --lan-tls # HTTPS for a name only your network knows (own CA) +# add --keep-direct to also keep the plain-HTTP ports open on the network # # Optional environment variables: # LIBRARY_NAME="Smith Family Library" name shown on the catalogue @@ -79,6 +82,10 @@ show_status() { fi dc ps echo + if [ -n "${DOMAIN:-}" ]; then + echo " Your library (HTTPS): https://$DOMAIN <- use this one: the live camera scanner needs it" + echo " (Koha's own screens: https://$DOMAIN/cgi-bin/koha/mainpage.pl, or the menu inside)" + fi echo " Your library: http://$ip:$STAFF_PORT" echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people" echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" @@ -90,6 +97,52 @@ show_status() { echo } +# --- HTTPS (Caddy) options -------------------------------------------------------- +DOMAIN_ARG="" LAN_TLS=0 KEEP_DIRECT=0 ACTION="" +while [ $# -gt 0 ]; do + case "$1" in + --domain) DOMAIN_ARG="${2:-}"; [ -n "$DOMAIN_ARG" ] || { c_err "--domain needs a name"; exit 1; }; shift 2 ;; + --lan-tls) LAN_TLS=1; shift ;; + --keep-direct) KEEP_DIRECT=1; shift ;; + *) ACTION="${ACTION:+$ACTION }$1"; shift ;; + esac +done +set -- $ACTION +if [ -n "$DOMAIN_ARG" ] && ! printf '%s' "$DOMAIN_ARG" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$'; then + c_err "That does not look like a host name: $DOMAIN_ARG"; exit 1 +fi + +# Write (or replace) KEY=VALUE in .env +env_set() { if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi; } + +configure_caddy() { + local name="$DOMAIN_ARG" + # Caddy needs 80 and 443 (it is not moved to other ports: browsers expect these). + if ! dc ps --status running --services 2>/dev/null | grep -qx caddy; then + for p in 80 443; do + port_in_use "$p" && { c_err "Port $p is already in use on this machine - Caddy needs it. Stop what is using it, or use your own reverse proxy (see README)."; exit 1; } + done + fi + { + echo "# Generated by install.sh --domain - Caddy fetches and renews the certificate by itself." + echo "$name {" + [ "$LAN_TLS" = 1 ] && echo " tls internal" + echo " encode zstd gzip" + echo " reverse_proxy koha:8081" + echo "}" + } > Caddyfile + env_set DOMAIN "$name" + env_set COMPOSE_PROFILES caddy + env_set STAFF_URL "https://$name" + [ "$KEEP_DIRECT" = 1 ] || env_set BIND_ADDR 127.0.0.1 + c_ok "Caddy will serve https://$name" + if [ "$LAN_TLS" = 1 ]; then + c_info "Own-CA certificate: phones must trust Caddy's root, which is copied to $HERE/caddy-root.crt after start." + else + c_info "The name must point at this machine (DNS) and ports 80/443 must reach it, or the certificate cannot be issued." + fi +} + case "${1:-}" in --status) show_status; exit 0 ;; --stop) dc stop; exit 0 ;; @@ -152,6 +205,8 @@ else fi fi +[ -n "$DOMAIN_ARG" ] && configure_caddy + if [ -n "${EXTRA_CA:-}" ]; then cp "$EXTRA_CA" koha/certs/extra-ca.crt cp "$EXTRA_CA" quickadd/certs/extra-ca.crt @@ -180,5 +235,12 @@ done [ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; } c_ok "Your library is running." +if [ -n "${DOMAIN:-}" ] && grep -q '^ tls internal' Caddyfile 2>/dev/null; then + for i in $(seq 1 20); do + docker cp library-caddy:/data/caddy/pki/authorities/local/root.crt "$HERE/caddy-root.crt" >/dev/null 2>&1 && break + sleep 2 + done + [ -f "$HERE/caddy-root.crt" ] && c_info "Install this certificate on phones/computers that should trust https://$DOMAIN: $HERE/caddy-root.crt" +fi echo show_status diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 0571eca..12a4ecf 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -1720,6 +1720,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Permissions-Policy", "camera=(self), microphone=()") self.send_header("Referrer-Policy", "no-referrer") self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; " @@ -1754,7 +1755,11 @@ class Handler(BaseHTTPRequestHandler): # Otherwise: a patron signing in with their card number (or userid) and password. addr = self.client_address[0] if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()): - addr = self.headers.get("X-Forwarded-For", "").split(",")[-1].strip() or addr + # Behind Caddy the list reads ", " (Caddy adds the visitor, the staff site's web server + # adds Caddy), so the visitor is the one before the last. Used only to count failed sign-ins per + # visitor, never to vouch for anyone. + hops = [h.strip() for h in self.headers.get("X-Forwarded-For", "").split(",") if h.strip()] + addr = (hops[-2] if len(hops) >= 2 else hops[-1]) if hops else addr self.addr = addr pid = patron_login(u, p, addr) if pid: