With 70+ services sharing a handful of common default ports (emby and jellyfin both default to 8096, changedetection and frigate both default to 5000, arm and nextcloud both default to 8080...), nothing previously checked whether a service's default port was actually free on the host. Whichever service installed second would silently write a compose file claiming an already-held port, only failing at `docker compose up` time. Adds two shared helpers to lib/common.sh: - port_in_use PORT [PROTO] — true if something's already listening - find_free_port VARNAME START [PROTO] — scans upward, writes back the first free port Every service that publishes a fixed host port now scans before writing docker-compose.yml, on every install (not just when adding an explicit additional instance). On a normal single-install host this is a silent no-op; it only changes behavior when something else already holds the port. - The 19 services already given multi-instance support this session had their port scan moved out of the "add instance" branch to run unconditionally, since the same collision risk exists on a plain first install. - 20 more services with previously-hardcoded ports gained scanning for the first time: archivebox, arm, calibre-web, changedetection, drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf, uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so generated peer configs keep the right Endpoint), homeassistant (bridge-mode only — host mode can only warn), frigate and frigate-audio (multi-port stacks, moved together). - caddy.sh is the deliberate exception: 80/443 stay fixed and only warn on collision, since silently moving Caddy itself would leave nothing listening where any client actually looks. - authelia.sh needs no change — it has no published host port at all. - Every service's standalone bootstrap fallback (sudo bash services/x.sh with no sibling files) got the same two helpers duplicated into its stub block, matching how every other shared helper is already handled there. Documents the full pattern in CLAUDE.md's new "Port collision avoidance" section, including the quoted-heredoc/backtick-escaping gotcha and the network_mode:host limitation (can only scan ports the app takes as a configurable env var). Verified via bash -n on every changed file, plus functional runs seeding occupied ports for each collision shape used here (single, paired, multi-port stacks) and confirming the scan/shift and generated compose/README output are correct — including the emby/jellyfin, nextcloud/arm, and frigate/changedetection collision scenarios that originally motivated this.
782 lines
34 KiB
Bash
782 lines
34 KiB
Bash
#!/bin/bash
|
||
# services/koha.sh — Koha Integrated Library System (physical book catalog + loans).
|
||
# Part of the modular post-install system (sourced by setup.sh).
|
||
#
|
||
# Can also be run standalone on any machine:
|
||
# sudo bash koha.sh
|
||
# (Docker must already be installed when run standalone)
|
||
#
|
||
# Uses teogramm/koha (single bundled container: Apache, Plack, Zebra indexer,
|
||
# background jobs worker) + MariaDB + Memcached + RabbitMQ sidecars.
|
||
# OPAC (patron UI): port 8097 | Staff/admin: port 8098
|
||
# RAM: needs ~2.5 GB free.
|
||
#
|
||
# Database lives in ~/docker/koha/data/ (bind-mount, fully backed up by backup.sh).
|
||
|
||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||
|
||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||
|
||
if [[ -f "$_COMMON" ]]; then
|
||
source "$_COMMON"
|
||
else
|
||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||
|
||
require_docker() {
|
||
command -v docker &>/dev/null || {
|
||
log_error "Docker not found. Install it first:"
|
||
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||
return 1
|
||
}
|
||
docker compose version &>/dev/null || {
|
||
log_error "Docker Compose plugin missing:"
|
||
log_error " sudo apt-get install -y docker-compose-plugin"
|
||
return 1
|
||
}
|
||
}
|
||
|
||
ensure_docker_dir_ownership() {
|
||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||
}
|
||
|
||
port_in_use() {
|
||
local _port="$1" _proto="${2:-tcp}"
|
||
local _flag="-tlnH"
|
||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||
}
|
||
|
||
find_free_port() {
|
||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||
while port_in_use "$_port" "$_proto"; do
|
||
_port=$((_port + 1))
|
||
done
|
||
eval "$_varname='$_port'"
|
||
}
|
||
|
||
generate_password() {
|
||
local _len="${1:-32}"
|
||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||
echo
|
||
}
|
||
|
||
prompt_text() {
|
||
local _q="$1" _def="$2" _var="$3" _r
|
||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||
read -r -p " $_q " _r
|
||
eval "$_var='${_r:-$_def}'"
|
||
}
|
||
|
||
prompt_yn() {
|
||
local _q="$1" _def="$2" _var="$3" _r
|
||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||
read -r -p " $_q " _r
|
||
eval "$_var='${_r:-$_def}'"
|
||
}
|
||
|
||
configure_caddy_for_service() {
|
||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||
local _display_port="${_upstream##*:}"
|
||
|
||
local _mode="none"
|
||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||
[[ "$_mode" == "none" ]] && {
|
||
log_info "Access $_name directly on port $_display_port."
|
||
return 0
|
||
}
|
||
|
||
echo ""
|
||
local _do_caddy=""
|
||
if [[ "$_mode" == "remote" ]]; then
|
||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||
fi
|
||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||
return 0
|
||
}
|
||
|
||
local _default_domain=""
|
||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||
log_info "Default: $_default_domain"
|
||
fi
|
||
local _domain=""
|
||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||
_domain="${_domain:-$_default_domain}"
|
||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||
|
||
local _block_upstream="$_upstream"
|
||
if [[ "$_mode" == "remote" ]]; then
|
||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||
fi
|
||
|
||
local _site_block
|
||
_site_block="$(cat << CBLOCK
|
||
|
||
# $_name
|
||
${_domain} {
|
||
reverse_proxy ${_block_upstream}
|
||
|
||
header {
|
||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||
X-Content-Type-Options "nosniff"
|
||
X-Frame-Options "SAMEORIGIN"
|
||
Referrer-Policy "strict-origin-when-cross-origin"
|
||
}
|
||
|
||
log {
|
||
output file /var/log/caddy/${_domain}.log
|
||
format json
|
||
}
|
||
${_extra}
|
||
}
|
||
CBLOCK
|
||
)"
|
||
|
||
if [[ "$_mode" == "local" ]]; then
|
||
if [[ -f "$_caddyfile" ]]; then
|
||
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||
cp "$_caddyfile" "$_bk"
|
||
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||
else
|
||
touch "$_caddyfile"
|
||
fi
|
||
|
||
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||
log_warning "$_domain already in Caddyfile"
|
||
local _ow=""
|
||
read -r -p " Overwrite? [y/N]: " _ow
|
||
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||
fi
|
||
|
||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||
log_success "Added $_domain to Caddyfile"
|
||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||
log_success "$_name accessible at: https://$_domain"
|
||
else
|
||
log_warning "Reload failed — check: docker logs caddy"
|
||
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||
fi
|
||
else
|
||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||
mkdir -p "$_snippet_dir"
|
||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||
log_success "Snippet saved: $_snippet_file"
|
||
log_info "Copy to Caddy machine:"
|
||
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
|
||
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
|
||
fi
|
||
}
|
||
|
||
write_readme() {
|
||
local _dir="$1"; shift
|
||
mkdir -p "$_dir"
|
||
cat > "$_dir/README.md"
|
||
}
|
||
fi
|
||
|
||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||
DRY_RUN="${DRY_RUN:-false}"
|
||
UNATTENDED="${UNATTENDED:-false}"
|
||
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
|
||
|
||
register_service() { :; }
|
||
_RUN_STANDALONE=1
|
||
fi
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
register_service koha utilities "Physical book library — catalog, shelf locations, loans/checkout (Koha ILS)" 8097
|
||
|
||
install_koha() {
|
||
require_docker || return 1
|
||
|
||
local KOHA_DIR="$DOCKER_DIR/koha"
|
||
local OPAC_PORT="8097" STAFF_PORT="8098"
|
||
|
||
# Scan for free host ports, moving both together — a plain install
|
||
# shouldn't silently claim a port another already-running service holds.
|
||
# Run before any prompts so every message below (including DRY-RUN)
|
||
# reflects the real port. See CLAUDE.md's "Port collision avoidance"
|
||
# section.
|
||
while port_in_use "$OPAC_PORT" || port_in_use "$STAFF_PORT"; do
|
||
OPAC_PORT=$((OPAC_PORT + 1))
|
||
STAFF_PORT=$((STAFF_PORT + 1))
|
||
done
|
||
|
||
echo ""
|
||
echo "╔═══════════════════════════════════════════════════════════════════╗"
|
||
echo "║ Koha — Integrated Library System ║"
|
||
echo "╚═══════════════════════════════════════════════════════════════════╝"
|
||
echo ""
|
||
echo " • ISBN barcode scanning → auto-fetch cover art, author, summary"
|
||
echo " • Shelf / location tracking (define your own codes)"
|
||
echo " • Loan / checkout system with due dates and history"
|
||
echo " • OPAC (patron browsing UI) + staff admin interface"
|
||
echo " • Requires ~2.5 GB free RAM"
|
||
echo ""
|
||
echo " SETUP OVERVIEW:"
|
||
echo " 1) Answer the questions below (collects library details + credentials)"
|
||
echo " 2) Koha starts — takes 2–3 min on first boot"
|
||
echo " 3) Open http://localhost:${STAFF_PORT} and complete the brief web installer (~2 min)"
|
||
echo " 4) Run $KOHA_DIR/post-setup.sh to auto-configure library, items, locations"
|
||
echo ""
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would create $KOHA_DIR with docker-compose.yml + config-main.env"
|
||
echo "[DRY-RUN] Would deploy teogramm/koha + MariaDB (./data/) + Memcached + RabbitMQ"
|
||
echo "[DRY-RUN] Would generate post-setup.sh for REST API configuration"
|
||
echo "[DRY-RUN] OPAC (patron UI): port $OPAC_PORT"
|
||
echo "[DRY-RUN] Staff/admin: port $STAFF_PORT"
|
||
return 0
|
||
fi
|
||
|
||
# ── Collect library details ───────────────────────────────────────────────
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo " LIBRARY DETAILS"
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo ""
|
||
|
||
local LIB_NAME=""
|
||
prompt_text "Library name (shown in OPAC header) [My Home Library]:" "My Home Library" LIB_NAME
|
||
|
||
# Auto-derive a library code from the name
|
||
local _auto_code
|
||
_auto_code="$(echo "$LIB_NAME" | tr '[:lower:]' '[:upper:]' | tr -dc 'A-Z0-9' | head -c 5)"
|
||
[[ -z "$_auto_code" ]] && _auto_code="HOME"
|
||
local LIB_CODE=""
|
||
prompt_text "Library code (3-8 letters/numbers) [${_auto_code}]:" "$_auto_code" LIB_CODE
|
||
LIB_CODE="${LIB_CODE//[^A-Za-z0-9]/}"
|
||
LIB_CODE="${LIB_CODE^^}"
|
||
[[ ${#LIB_CODE} -lt 2 ]] && LIB_CODE="HOME"
|
||
|
||
echo ""
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo " ADMIN ACCOUNT"
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo ""
|
||
echo " You will enter this password during the web installer."
|
||
echo " It is stored in config-main.env (chmod 600) for the post-setup script."
|
||
echo ""
|
||
|
||
local KOHA_ADMIN_USER=""
|
||
prompt_text "Admin username [admin]:" "admin" KOHA_ADMIN_USER
|
||
[[ -z "$KOHA_ADMIN_USER" ]] && KOHA_ADMIN_USER="admin"
|
||
|
||
local KOHA_ADMIN_PASS=""
|
||
if [ "$UNATTENDED" = true ]; then
|
||
KOHA_ADMIN_PASS="$(generate_password 20)"
|
||
else
|
||
read -rsp " Admin password [Enter = auto-generate]: " KOHA_ADMIN_PASS; echo
|
||
[[ -z "$KOHA_ADMIN_PASS" ]] && KOHA_ADMIN_PASS="$(generate_password 20)"
|
||
fi
|
||
|
||
local KOHA_ADMIN_EMAIL=""
|
||
prompt_text "Admin email [blank to skip]:" "" KOHA_ADMIN_EMAIL
|
||
|
||
# ── Item types ────────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo " ITEM TYPES"
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo ""
|
||
echo " Default set: Book, DVD, Blu-ray, Magazine, Comic, Board Game"
|
||
echo " (You can add/edit more later in Staff UI → Administration → Item types)"
|
||
echo ""
|
||
|
||
# Store as "CODE:Description:loan_days" triplets
|
||
local -a ITEM_TYPES=(
|
||
"BK:Book:21"
|
||
"DVD:DVD:7"
|
||
"BLU:Blu-ray:7"
|
||
"MAG:Magazine:14"
|
||
"COM:Comic:14"
|
||
"BG:Board Game:14"
|
||
)
|
||
|
||
local _add_items=""
|
||
prompt_yn "Add custom item types now? (y/N):" "n" _add_items
|
||
if [[ "$_add_items" =~ ^[Yy]$ ]]; then
|
||
echo ""
|
||
echo " Enter each type (blank code to finish)."
|
||
echo " Example: CD : Compact Disc : 14"
|
||
echo ""
|
||
local _ic _id _il
|
||
while true; do
|
||
prompt_text " Code (3-5 chars, blank to finish):" "" _ic
|
||
[[ -z "$_ic" ]] && break
|
||
_ic="${_ic//[^A-Za-z0-9]/}"
|
||
_ic="${_ic^^}"
|
||
prompt_text " Description for '$_ic':" "$_ic" _id
|
||
prompt_text " Loan period in days [14]:" "14" _il
|
||
_il="${_il//[^0-9]/}"; [[ -z "$_il" ]] && _il="14"
|
||
ITEM_TYPES+=("${_ic}:${_id}:${_il}")
|
||
log_success " Added: $_ic — $_id (${_il}d)"
|
||
done
|
||
fi
|
||
|
||
# ── Shelf locations ───────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo " SHELF LOCATIONS"
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo ""
|
||
echo " Define where books live (e.g. LR1=Living Room Shelf 1, BR=Bedroom)."
|
||
echo " Used on item records so you can find them again."
|
||
echo " (You can add/edit more later in Staff UI → Administration → Authorized values → LOC)"
|
||
echo ""
|
||
|
||
local -a SHELF_LOCS=()
|
||
local _add_locs=""
|
||
prompt_yn "Add shelf locations now? (y/N):" "n" _add_locs
|
||
if [[ "$_add_locs" =~ ^[Yy]$ ]]; then
|
||
echo ""
|
||
local _lc _ld
|
||
while true; do
|
||
prompt_text " Location code (blank to finish):" "" _lc
|
||
[[ -z "$_lc" ]] && break
|
||
_lc="${_lc//[^A-Za-z0-9_]/_}"
|
||
prompt_text " Description for '$_lc':" "$_lc" _ld
|
||
SHELF_LOCS+=("${_lc}:${_ld}")
|
||
log_success " Added: $_lc — $_ld"
|
||
done
|
||
fi
|
||
|
||
# ── Passwords ─────────────────────────────────────────────────────────────
|
||
# Reused across reruns if already set — the MariaDB volume keeps
|
||
# DB_PASS/DB_ROOT_PASS from first init, and an already-created Koha admin
|
||
# account keeps its own KOHA_ADMIN_PASS; regenerating any of these on a
|
||
# rerun would lock the reinstall out of both the database and
|
||
# post-setup.sh's REST API login.
|
||
local DB_PASS DB_ROOT_PASS RABBIT_PASS
|
||
local _existing_koha_conf="$KOHA_DIR/config-main.env"
|
||
if [ -f "$_existing_koha_conf" ]; then
|
||
DB_PASS="$(grep '^DB_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||
DB_ROOT_PASS="$(grep '^DB_ROOT_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||
RABBIT_PASS="$(grep '^RABBIT_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||
local _existing_admin_pass
|
||
_existing_admin_pass="$(grep '^KOHA_ADMIN_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||
if [ -n "$_existing_admin_pass" ]; then
|
||
KOHA_ADMIN_PASS="$_existing_admin_pass"
|
||
log_info "Existing install detected — reusing its DB/admin passwords instead of what was just entered above, so this rebuild doesn't lock out the already-initialized database and admin account."
|
||
fi
|
||
fi
|
||
[ -n "${DB_PASS:-}" ] || DB_PASS="$(generate_password 24)"
|
||
[ -n "${DB_ROOT_PASS:-}" ] || DB_ROOT_PASS="$(generate_password 24)"
|
||
[ -n "${RABBIT_PASS:-}" ] || RABBIT_PASS="$(generate_password 24)"
|
||
|
||
# ── Create directories ────────────────────────────────────────────────────
|
||
mkdir -p "$KOHA_DIR/data"
|
||
ensure_docker_dir_ownership "$KOHA_DIR"
|
||
cd "$KOHA_DIR" || return 1
|
||
|
||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
||
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
|
||
# remote Caddy box can't resolve container names on this host's bridge
|
||
# network anyway; it reaches Koha via the host's published ports.
|
||
local _CADDY_MODE="${CADDY_MODE:-none}"
|
||
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
||
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
||
|
||
local _CADDY_NET_ENTRY=""
|
||
local _CADDY_NET_SECTION=""
|
||
if [ "$_CADDY_MODE" = "local" ]; then
|
||
_CADDY_NET_ENTRY=" - caddy_net
|
||
"
|
||
_CADDY_NET_SECTION=" caddy_net:
|
||
external: true
|
||
name: ${SITE_CADDY_NET:-caddy_net}
|
||
"
|
||
fi
|
||
|
||
# ── docker-compose.yml ────────────────────────────────────────────────────
|
||
cat > docker-compose.yml << KOHA_COMPOSE
|
||
name: koha
|
||
|
||
services:
|
||
koha:
|
||
image: teogramm/koha:24.11
|
||
container_name: koha
|
||
hostname: koha
|
||
restart: unless-stopped
|
||
cap_add:
|
||
- DAC_READ_SEARCH
|
||
- SYS_NICE
|
||
env_file:
|
||
- config-main.env
|
||
ports:
|
||
- "${OPAC_PORT}:8080"
|
||
- "${STAFF_PORT}:8081"
|
||
depends_on:
|
||
- koha-db
|
||
- koha-memcached
|
||
- koha-rabbitmq
|
||
networks:
|
||
- koha_internal
|
||
${_CADDY_NET_ENTRY}
|
||
koha-db:
|
||
image: mariadb:11
|
||
container_name: koha-db
|
||
hostname: koha-db
|
||
restart: unless-stopped
|
||
environment:
|
||
MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASS}
|
||
MYSQL_DATABASE: koha_default
|
||
MYSQL_USER: koha_default
|
||
MYSQL_PASSWORD: \${DB_PASS}
|
||
volumes:
|
||
- ./data:/var/lib/mysql
|
||
networks:
|
||
- koha_internal
|
||
|
||
koha-memcached:
|
||
image: memcached:latest
|
||
container_name: koha-memcached
|
||
hostname: koha-memcached
|
||
restart: unless-stopped
|
||
networks:
|
||
- koha_internal
|
||
|
||
koha-rabbitmq:
|
||
image: rabbitmq:3
|
||
container_name: koha-rabbitmq
|
||
hostname: koha-rabbitmq
|
||
restart: unless-stopped
|
||
environment:
|
||
RABBITMQ_DEFAULT_USER: koha
|
||
RABBITMQ_DEFAULT_PASS: \${RABBIT_PASS}
|
||
networks:
|
||
- koha_internal
|
||
|
||
networks:
|
||
koha_internal:
|
||
internal: true
|
||
${_CADDY_NET_SECTION}
|
||
KOHA_COMPOSE
|
||
|
||
# ── config-main.env ───────────────────────────────────────────────────────
|
||
cat > config-main.env << KOHA_ENV
|
||
# Koha ILS configuration — generated at install time
|
||
MYSQL_SERVER=koha-db
|
||
DB_NAME=koha_default
|
||
MYSQL_USER=koha_default
|
||
MYSQL_PASSWORD=$DB_PASS
|
||
DB_ROOT_PASS=$DB_ROOT_PASS
|
||
DB_PASS=$DB_PASS
|
||
MEMCACHED_SERVERS=koha-memcached:11211
|
||
MB_HOST=koha-rabbitmq
|
||
MB_PORT=61613
|
||
MB_USER=koha
|
||
MB_PASS=$RABBIT_PASS
|
||
RABBIT_PASS=$RABBIT_PASS
|
||
CADDY_NET=$SITE_CADDY_NET
|
||
KOHA_LANGS=en
|
||
ZEBRA_MARC_FORMAT=marc21
|
||
USE_Z3950=1
|
||
# Admin credentials (used by post-setup.sh after web installer)
|
||
KOHA_ADMIN_USER=$KOHA_ADMIN_USER
|
||
KOHA_ADMIN_PASS=$KOHA_ADMIN_PASS
|
||
KOHA_ENV
|
||
|
||
chmod 600 config-main.env
|
||
ensure_docker_dir_ownership "$KOHA_DIR"
|
||
|
||
# ── Generate post-setup.sh ────────────────────────────────────────────────
|
||
log_info "Writing post-setup.sh ..."
|
||
|
||
# Serialise item types and shelf locs into env-safe strings
|
||
local _items_str _locs_str
|
||
_items_str="$(IFS='|'; echo "${ITEM_TYPES[*]}")"
|
||
_locs_str="$(IFS='|'; echo "${SHELF_LOCS[*]}")"
|
||
|
||
cat > post-setup.sh << POSTSETUP
|
||
#!/bin/bash
|
||
# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:${STAFF_PORT}
|
||
# Configures library, item types, and shelf locations via the Koha REST API.
|
||
# Generated by ubuntu-post-install on $(date '+%F').
|
||
|
||
set -uo pipefail
|
||
|
||
KOHA_STAFF_URL="http://localhost:${STAFF_PORT}"
|
||
KOHA_DIR="${KOHA_DIR}"
|
||
CONF="\${KOHA_DIR}/config-main.env"
|
||
|
||
[ -f "\$CONF" ] || { echo "config-main.env not found at \$CONF"; exit 1; }
|
||
source "\$CONF"
|
||
|
||
ADMIN_USER="\${KOHA_ADMIN_USER:-admin}"
|
||
ADMIN_PASS="\${KOHA_ADMIN_PASS:-}"
|
||
LIB_NAME="${LIB_NAME}"
|
||
LIB_CODE="${LIB_CODE}"
|
||
ITEMS_RAW="${_items_str}"
|
||
LOCS_RAW="${_locs_str}"
|
||
|
||
log() { echo "[\$(date '+%T')] \$*"; }
|
||
ok() { echo " ✓ \$*"; }
|
||
warn() { echo " ⚠ \$*"; }
|
||
fail() { echo " ✗ \$*" >&2; }
|
||
|
||
echo ""
|
||
echo "╔═══════════════════════════════════════════════════════╗"
|
||
echo "║ Koha Post-Setup Configuration ║"
|
||
echo "╚═══════════════════════════════════════════════════════╝"
|
||
echo ""
|
||
echo " Library : \$LIB_NAME (\$LIB_CODE)"
|
||
echo " Admin : \$ADMIN_USER"
|
||
echo ""
|
||
|
||
# ── Wait for Koha API ────────────────────────────────────────────────────────
|
||
log "Waiting for Koha REST API..."
|
||
_tries=0
|
||
until curl -sf "\$KOHA_STAFF_URL/api/v1/auth/session" -o /dev/null 2>/dev/null; do
|
||
_tries=\$((_tries+1))
|
||
[ "\$_tries" -gt 60 ] && { fail "Koha API not available after 5 min — is the stack running?"; exit 1; }
|
||
printf "."
|
||
sleep 5
|
||
done
|
||
echo ""
|
||
ok "Koha API is up"
|
||
|
||
# ── Authenticate ─────────────────────────────────────────────────────────────
|
||
if [ -z "\$ADMIN_PASS" ]; then
|
||
read -rsp " Admin password (from web installer): " ADMIN_PASS; echo
|
||
fi
|
||
|
||
_COOKIE="\$(mktemp)"
|
||
trap 'rm -f "\$_COOKIE"' EXIT
|
||
|
||
_auth_resp=\$(curl -sf -c "\$_COOKIE" -X POST "\$KOHA_STAFF_URL/api/v1/auth/session" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d "{\"userid\":\"\$ADMIN_USER\",\"password\":\"\$ADMIN_PASS\"}" 2>&1) || true
|
||
if ! curl -sf -b "\$_COOKIE" "\$KOHA_STAFF_URL/api/v1/libraries" -o /dev/null 2>/dev/null; then
|
||
fail "Authentication failed — check admin username/password in config-main.env"
|
||
echo " You can update KOHA_ADMIN_PASS in \$CONF and re-run this script."
|
||
exit 1
|
||
fi
|
||
ok "Authenticated as \$ADMIN_USER"
|
||
|
||
# ── Helper: POST with JSON ────────────────────────────────────────────────────
|
||
koha_post() {
|
||
local _endpoint="\$1" _body="\$2"
|
||
curl -sf -b "\$_COOKIE" -X POST "\$KOHA_STAFF_URL/api/v1/\$_endpoint" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d "\$_body" -o /dev/null -w "%{http_code}"
|
||
}
|
||
|
||
koha_patch() {
|
||
local _endpoint="\$1" _body="\$2"
|
||
curl -sf -b "\$_COOKIE" -X PATCH "\$KOHA_STAFF_URL/api/v1/\$_endpoint" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d "\$_body" -o /dev/null -w "%{http_code}"
|
||
}
|
||
|
||
# ── Library branch ────────────────────────────────────────────────────────────
|
||
echo ""
|
||
log "Creating library branch '\$LIB_CODE' (\$LIB_NAME)..."
|
||
_code=\$(koha_post "libraries" "{\"library_id\":\"\$LIB_CODE\",\"name\":\"\$LIB_NAME\"}")
|
||
case "\$_code" in
|
||
201) ok "Library created" ;;
|
||
409) warn "Library '\$LIB_CODE' already exists — skipping" ;;
|
||
*) warn "Unexpected response \$_code — may need manual setup in Staff UI" ;;
|
||
esac
|
||
|
||
# ── System preferences ────────────────────────────────────────────────────────
|
||
log "Setting system preferences..."
|
||
docker exec koha bash -c "\\
|
||
mysql -u root -p\${DB_ROOT_PASS:-\$DB_ROOT_PASS} koha_default -e \\
|
||
\\\"UPDATE systempreferences SET value='\$LIB_NAME' WHERE variable='LibraryName';\\\"
|
||
" 2>/dev/null && ok "LibraryName → \$LIB_NAME" || warn "Could not set LibraryName (set manually in Staff UI → Admin → System preferences → OPAC)"
|
||
|
||
docker exec koha bash -c "\\
|
||
mysql -u root -p\${DB_ROOT_PASS:-\$DB_ROOT_PASS} koha_default -e \\
|
||
\\\"UPDATE systempreferences SET value='\$LIB_NAME' WHERE variable='OPACLibraryName';\\\"
|
||
" 2>/dev/null && ok "OPACLibraryName → \$LIB_NAME" || true
|
||
|
||
# ── Item types ────────────────────────────────────────────────────────────────
|
||
echo ""
|
||
log "Creating item types..."
|
||
IFS='|' read -ra _ITEMS <<< "\$ITEMS_RAW"
|
||
for _item in "\${_ITEMS[@]}"; do
|
||
IFS=':' read -r _ic _id _il <<< "\$_item"
|
||
[[ -z "\$_ic" ]] && continue
|
||
_body="{\"item_type_id\":\"\$_ic\",\"description\":\"\$_id\",\"loan_period\":\$_il,\"renewals_allowed\":99}"
|
||
_code=\$(koha_post "item_types" "\$_body")
|
||
case "\$_code" in
|
||
201) ok "\$_ic — \$_id (\${_il}d loan)" ;;
|
||
409) warn "\$_ic already exists — skipping" ;;
|
||
*) warn "\$_ic: unexpected response \$_code" ;;
|
||
esac
|
||
done
|
||
|
||
# ── Shelf locations (LOC authorized values) ───────────────────────────────────
|
||
if [ -n "\$LOCS_RAW" ]; then
|
||
echo ""
|
||
log "Creating shelf locations..."
|
||
IFS='|' read -ra _LOCS <<< "\$LOCS_RAW"
|
||
for _loc in "\${_LOCS[@]}"; do
|
||
IFS=':' read -r _lc _ld <<< "\$_loc"
|
||
[[ -z "\$_lc" ]] && continue
|
||
_body="{\"authorised_value\":\"\$_lc\",\"lib\":\"\$_ld\",\"lib_opac\":\"\$_ld\"}"
|
||
_code=\$(koha_post "authorised_value_categories/LOC/authorised_values" "\$_body")
|
||
case "\$_code" in
|
||
201) ok "\$_lc — \$_ld" ;;
|
||
409) warn "\$_lc already exists — skipping" ;;
|
||
*) warn "\$_lc: unexpected response \$_code" ;;
|
||
esac
|
||
done
|
||
fi
|
||
|
||
echo ""
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo " SETUP COMPLETE"
|
||
echo "══════════════════════════════════════════════════════"
|
||
echo ""
|
||
echo " Your Koha library is ready to use."
|
||
echo ""
|
||
echo " OPAC (patron browsing): http://localhost:${OPAC_PORT}"
|
||
echo " Staff / admin: http://localhost:${STAFF_PORT}"
|
||
echo ""
|
||
echo " Next steps in Staff UI:"
|
||
echo " 1. Administration → Patron categories → add patron types"
|
||
echo " (e.g. ADULT, CHILD, FAMILY)"
|
||
echo " 2. Administration → Circulation and fines rules → set loan rules"
|
||
echo " 3. Cataloguing → Z39.50/SRU — verify WorldCat/OpenLibrary targets work"
|
||
echo " (test by searching an ISBN)"
|
||
echo ""
|
||
echo " Adding books:"
|
||
echo " Cataloguing → Z39.50/SRU search → enter ISBN"
|
||
echo " Or use a USB barcode scanner — scan the ISBN barcode on any book"
|
||
echo ""
|
||
POSTSETUP
|
||
|
||
chmod +x post-setup.sh
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" post-setup.sh 2>/dev/null || true
|
||
log_success "post-setup.sh written"
|
||
|
||
log_success "Koha configured at $KOHA_DIR"
|
||
|
||
# Koha has its own staff login — no Authelia for staff interface
|
||
configure_caddy_for_service "Koha OPAC" "koha:8080" "library"
|
||
|
||
write_readme "$KOHA_DIR" << MD
|
||
# Koha ILS — Home Library
|
||
|
||
Full Integrated Library System for managing a physical book collection.
|
||
|
||
## Access
|
||
- **OPAC** (patron browsing): http://localhost:${OPAC_PORT}
|
||
- **Staff / admin**: http://localhost:${STAFF_PORT}
|
||
|
||
## Quick setup (4 steps)
|
||
|
||
### Step 1 — Start the stack
|
||
\`\`\`bash
|
||
cd $KOHA_DIR
|
||
docker compose up -d
|
||
\`\`\`
|
||
Takes 2–3 minutes on first boot while Koha initialises.
|
||
|
||
### Step 2 — Complete the web installer
|
||
1. Open **http://localhost:${STAFF_PORT}**
|
||
2. You may see a "Database connection" page first — wait 1–2 min and refresh
|
||
3. The installer wizard appears automatically:
|
||
- **Language**: click "Install for language English" → Continue
|
||
- **Koha database**: fields are pre-filled from env → Continue
|
||
- **Select MARC flavour**: choose **MARC21** → Continue
|
||
- **Install basic Koha data**: check all boxes → Continue
|
||
- **Set Koha administrator password**: enter **$KOHA_ADMIN_PASS** exactly
|
||
- **Finish**: click the login link
|
||
4. Log in with username **$KOHA_ADMIN_USER** and the password above
|
||
|
||
### Step 3 — Run post-setup
|
||
\`\`\`bash
|
||
sudo $KOHA_DIR/post-setup.sh
|
||
\`\`\`
|
||
Auto-creates your library branch, item types, and shelf locations via the REST API.
|
||
|
||
### Step 4 — Add patron categories and circulation rules
|
||
In Staff UI:
|
||
- Administration → Patron categories → New category (e.g. ADULT, CHILD)
|
||
- Administration → Circulation and fines rules → add a rule for your library
|
||
|
||
## Adding books
|
||
- **By ISBN** (recommended): Cataloguing → Z39.50/SRU search → enter ISBN
|
||
→ imports metadata, cover art, and summary from WorldCat / Open Library
|
||
- **Barcode scanner**: any USB scanner works; scan the ISBN barcode on the book cover
|
||
|
||
## Loans / checkout
|
||
- Add patrons: Patrons → New patron
|
||
- Checkout: Circulation → Check out → scan/enter patron card, scan book barcode
|
||
- Return: Circulation → Check in
|
||
|
||
## Data location
|
||
- **Database**: \`$KOHA_DIR/data/\` (MariaDB bind-mount, included in backup)
|
||
- **Config**: \`$KOHA_DIR/config-main.env\` (chmod 600)
|
||
|
||
## Manage
|
||
\`\`\`bash
|
||
cd $KOHA_DIR
|
||
docker compose up -d # start (3 min first boot)
|
||
docker compose down # stop
|
||
docker compose logs -f # logs
|
||
docker compose logs -f koha # Koha app logs only
|
||
docker compose pull && docker compose up -d # update
|
||
\`\`\`
|
||
MD
|
||
|
||
# ── Start ──────────────────────────────────────────────────────────────────
|
||
local START_KOHA=""
|
||
prompt_yn "Start Koha now? (y/n):" "y" START_KOHA
|
||
if [ "$START_KOHA" = "y" ] || [ "$START_KOHA" = "Y" ]; then
|
||
docker compose up -d \
|
||
&& log_success "Koha starting (takes 2–3 min on first run)" \
|
||
|| log_warning "Start failed — check: docker compose logs koha"
|
||
fi
|
||
|
||
echo ""
|
||
echo "╔═══════════════════════════════════════════════════════════════════╗"
|
||
echo "║ KOHA SETUP SUMMARY ║"
|
||
echo "╚═══════════════════════════════════════════════════════════════════╝"
|
||
echo ""
|
||
echo " Library name : $LIB_NAME ($LIB_CODE)"
|
||
echo " Admin user : $KOHA_ADMIN_USER"
|
||
echo " Admin pass : $KOHA_ADMIN_PASS"
|
||
echo ""
|
||
echo " ┌──────────────────────────────────────────────────────────────┐"
|
||
echo " │ IMPORTANT — Write down or save the admin password above. │"
|
||
echo " │ You will type it during the web installer in Step 3. │"
|
||
echo " │ Also stored in: $KOHA_DIR/config-main.env │"
|
||
echo " └──────────────────────────────────────────────────────────────┘"
|
||
echo ""
|
||
echo " NEXT STEPS:"
|
||
echo " 1. Wait ~3 min, then open: http://localhost:${STAFF_PORT}"
|
||
echo " 2. Complete the web installer (use password above when asked)"
|
||
echo " 3. Run: sudo $KOHA_DIR/post-setup.sh"
|
||
echo ""
|
||
echo " OPAC (patron UI): http://localhost:${OPAC_PORT}"
|
||
echo " Staff / admin: http://localhost:${STAFF_PORT}"
|
||
echo ""
|
||
}
|
||
|
||
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_koha
|