From f21c96c1a704732cce1a244daf0445a8ed50fe67 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 00:26:52 +0000 Subject: [PATCH] Add 7 new services: Joplin, Stirling PDF, n8n, Changedetection, ArchiveBox, Calibre-Web, Homebox - joplin: self-hosted sync server (PostgreSQL sidecar, APP_BASE_URL from SITE_DOMAIN) - stirling-pdf: PDF toolkit with optional Authelia SSO (no built-in auth) - n8n: workflow automation connecting self-hosted services (WEBHOOK_URL from SITE_DOMAIN) - changedetection: web page change monitoring with playwright-chrome renderer - archivebox: personal Wayback Machine, initializes data dir at install time - calibre-web: ebook library UI with Calibre conversion support (linuxserver image) - homebox: home inventory and asset management All services follow the standalone bootstrap pattern and support local/remote Caddy. README.md updated with new services in appropriate group rows. --- README.md | 4 +- services/archivebox.sh | 285 ++++++++++++++++++++++++++++++++ services/calibre-web.sh | 287 ++++++++++++++++++++++++++++++++ services/changedetection.sh | 281 ++++++++++++++++++++++++++++++++ services/homebox.sh | 277 +++++++++++++++++++++++++++++++ services/joplin.sh | 316 ++++++++++++++++++++++++++++++++++++ services/n8n.sh | 273 +++++++++++++++++++++++++++++++ services/stirling-pdf.sh | 305 ++++++++++++++++++++++++++++++++++ 8 files changed, 2026 insertions(+), 2 deletions(-) create mode 100644 services/archivebox.sh create mode 100644 services/calibre-web.sh create mode 100644 services/changedetection.sh create mode 100644 services/homebox.sh create mode 100644 services/joplin.sh create mode 100644 services/n8n.sh create mode 100644 services/stirling-pdf.sh diff --git a/README.md b/README.md index 5b7615d..fe76c82 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ Update them any time with `sudo ./setup.sh configure`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | -| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | +| `utilities` | `actualbudget`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `joplin`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | | `extras` | `kdeconnect`, `silent-send`, `sync-cc` | diff --git a/services/archivebox.sh b/services/archivebox.sh new file mode 100644 index 0000000..f8c2917 --- /dev/null +++ b/services/archivebox.sh @@ -0,0 +1,285 @@ +#!/bin/bash +# services/archivebox.sh — Self-hosted web archiving (ArchiveBox). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash archivebox.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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 archivebox utilities "Self-hosted web archiving — save pages like Wayback Machine (ArchiveBox)" 8000 + +install_archivebox() { + require_docker || return 1 + + local AB_DIR="$DOCKER_DIR/archivebox" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] archivebox would:" + echo " - Create $AB_DIR with docker-compose.yml" + echo " - Initialize ArchiveBox data directory" + echo " - Expose port 8000 (web UI)" + return 0 + fi + + mkdir -p "$AB_DIR" + ensure_docker_dir_ownership "$AB_DIR" + cd "$AB_DIR" || return 1 + + cat > docker-compose.yml << 'ABCOMPOSE' +name: archivebox + +services: + archivebox: + image: archivebox/archivebox:latest + container_name: archivebox + restart: unless-stopped + user: "1000:1000" + environment: + - ALLOWED_HOSTS=* + - MEDIA_MAX_SIZE=750m + - PUBLIC_INDEX=True + - PUBLIC_SNAPSHOTS=True + - PUBLIC_ADD_VIEW=False + volumes: + - ./data:/data + ports: + - "8000:8000" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +ABCOMPOSE + + cat > .env << ABENV +CADDY_NET=$SITE_CADDY_NET +ABENV + + mkdir -p data + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" + + log_info "Initializing ArchiveBox data directory..." + docker compose run --rm archivebox init --setup \ + && log_success "ArchiveBox initialized" \ + || log_warning "Init failed — will retry on first start" + + configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive" + + write_readme "$AB_DIR" << 'MD' +# ArchiveBox + +Self-hosted web archiving — saves full snapshots of web pages (HTML, screenshots, +PDFs, WARC) like a personal Wayback Machine. + +- Web UI: http://localhost:8000 + +## Manage +```bash +cd ~/docker/archivebox +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +``` + +## Add URLs to archive +```bash +# Via web UI — visit http://localhost:8000 and use the Add page +# Via CLI: +echo "https://example.com" | docker compose run --rm archivebox add +docker compose run --rm archivebox add --depth=1 https://example.com +``` + +## Create admin user +```bash +docker compose run --rm archivebox manage createsuperuser +``` +MD + + local START_AB="" + prompt_yn "Start ArchiveBox now? (y/n):" "y" START_AB + if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then + docker compose up -d \ + && log_success "ArchiveBox started — http://localhost:8000" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo "" + echo " Web UI: http://localhost:8000" + echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add" + echo "" +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_archivebox diff --git a/services/calibre-web.sh b/services/calibre-web.sh new file mode 100644 index 0000000..5218998 --- /dev/null +++ b/services/calibre-web.sh @@ -0,0 +1,287 @@ +#!/bin/bash +# services/calibre-web.sh — Ebook library web UI with metadata editing (Calibre-Web). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash calibre-web.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + 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 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + 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; } + + # Build upstream — remote Caddy uses host IP:port, not container name + 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 calibre-web media "Ebook library web UI with metadata editing (Calibre-Web)" 8083 + +install_calibre_web() { + require_docker || return 1 + log_info "Installing Calibre-Web..." + + local CW_DIR="$DOCKER_DIR/calibre-web" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $CW_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$CW_DIR/config" "$CW_DIR/books" + ensure_docker_dir_ownership "$CW_DIR" + cd "$CW_DIR" || return 1 + + cat > docker-compose.yml << CW_COMPOSE +name: calibre-web + +services: + calibre-web: + image: lscr.io/linuxserver/calibre-web:latest + container_name: calibre-web + hostname: calibre-web + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=${SITE_TZ:-UTC} + - DOCKER_MODS=linuxserver/mods:universal-calibre + volumes: + - ./config:/config + - ./books:/books + ports: + - "8083:8083" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +CW_COMPOSE + + cat > .env << CW_ENV +CADDY_NET=$SITE_CADDY_NET +CW_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CW_DIR" + + echo "" + log_success "Calibre-Web configured at $CW_DIR" + + configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books" + + write_readme "$CW_DIR" << 'MD' +# Calibre-Web + +Web-based ebook library with metadata editing, reading, and format conversion +powered by Calibre. + +## First-run setup +1. Open the UI (http://localhost:8083) and log in with admin / admin123 +2. When prompted for the database location, enter: `/books` + (point this at your existing Calibre library or an empty directory) +3. Change the default password immediately under Admin → Edit User + +## Ebook conversion +The `DOCKER_MODS=linuxserver/mods:universal-calibre` environment variable +installs the full Calibre binary inside the container, enabling on-the-fly +ebook conversion (e.g. EPUB → MOBI/AZW3). + +## Books directory +Place your Calibre library (or individual books) in: + ~/docker/calibre-web/books/ + +If you already have a Calibre library elsewhere, mount that path instead by +editing the `./books:/books` volume line in docker-compose.yml. + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_CW="" + prompt_yn "Start Calibre-Web now? (y/n):" "y" START_CW + if [ "$START_CW" = "y" ] || [ "$START_CW" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Calibre-Web started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:8083" + echo " Default login: admin / admin123 (change immediately!)" + echo " Point the database to /books on first run." + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_calibre_web diff --git a/services/changedetection.sh b/services/changedetection.sh new file mode 100644 index 0000000..7403eaf --- /dev/null +++ b/services/changedetection.sh @@ -0,0 +1,281 @@ +#!/bin/bash +# services/changedetection.sh — Web page change detection and notification (Changedetection.io). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash changedetection.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + 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 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + 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; } + + # Build upstream — remote Caddy uses host IP:port, not container name + 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 changedetection utilities "Web page change detection and notification (Changedetection.io)" 5000 + +install_changedetection() { + require_docker || return 1 + log_info "Installing Changedetection.io..." + + local CD_DIR="$DOCKER_DIR/changedetection" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $CD_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$CD_DIR/data" + ensure_docker_dir_ownership "$CD_DIR" + cd "$CD_DIR" || return 1 + + cat > docker-compose.yml << 'CD_COMPOSE' +name: changedetection + +services: + changedetection: + image: ghcr.io/dgtlmoon/changedetection.io:latest + container_name: changedetection + hostname: changedetection + restart: unless-stopped + env_file: .env + ports: + - "5000:5000" + volumes: + - ./data:/datastore + depends_on: + - playwright-chrome + networks: + - caddy_net + + playwright-chrome: + image: browserless/chrome:latest + container_name: playwright-chrome + hostname: playwright-chrome + restart: unless-stopped + environment: + - DEFAULT_LAUNCH_ARGS=--no-sandbox --disable-dev-shm-usage + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +CD_COMPOSE + + cat > .env << CD_ENV +# Changedetection.io configuration +BASE_URL=https://changes.${SITE_DOMAIN} +PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 +CADDY_NET=${SITE_CADDY_NET} +CD_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CD_DIR" + + echo "" + log_success "Changedetection.io configured at $CD_DIR" + + configure_caddy_for_service "Changedetection" "changedetection:5000" "changes" + + write_readme "$CD_DIR" << 'MD' +# Changedetection.io + +Monitor web pages for changes and receive notifications. Includes a +Playwright/Chrome sidecar for JavaScript-rendered pages. + +## Access +- URL: http://localhost:5000 +- Optional password can be set via Settings in the UI + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_CD="" + prompt_yn "Start Changedetection.io now? (y/n):" "y" START_CD + if [ "$START_CD" = "y" ] || [ "$START_CD" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Changedetection.io started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:5000" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_changedetection diff --git a/services/homebox.sh b/services/homebox.sh new file mode 100644 index 0000000..9eeed7d --- /dev/null +++ b/services/homebox.sh @@ -0,0 +1,277 @@ +#!/bin/bash +# services/homebox.sh — Home inventory and asset management (Homebox). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash homebox.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + 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 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + 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; } + + # Build upstream — remote Caddy uses host IP:port, not container name + 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 homebox utilities "Home inventory and asset management (Homebox)" 7745 + +install_homebox() { + require_docker || return 1 + log_info "Installing Homebox..." + + local HB_DIR="$DOCKER_DIR/homebox" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $HB_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$HB_DIR/data" + ensure_docker_dir_ownership "$HB_DIR" + cd "$HB_DIR" || return 1 + + cat > docker-compose.yml << HB_COMPOSE +name: homebox + +services: + homebox: + image: ghcr.io/sysadminsmedia/homebox:latest + container_name: homebox + hostname: homebox + restart: unless-stopped + environment: + - HBOX_LOG_LEVEL=info + - HBOX_WEB_MAX_UPLOAD_SIZE=10 + volumes: + - ./data:/data + ports: + - "7745:7745" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +HB_COMPOSE + + cat > .env << HB_ENV +CADDY_NET=$SITE_CADDY_NET +HB_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR" + + echo "" + log_success "Homebox configured at $HB_DIR" + + configure_caddy_for_service "Homebox" "homebox:7745" "homebox" + + write_readme "$HB_DIR" << 'MD' +# Homebox + +Home inventory and asset management. Track items, locations, labels, +warranties, and attachments across your household. + +## Access +- URL: http://localhost:7745 +- Register your account on first visit — the first user becomes the admin. + +## Data +- All inventory data and attachments are stored in: ./data/ + +## Configuration +Key environment variables (edit docker-compose.yml to change): +- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error) +- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10) + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_HB="" + prompt_yn "Start Homebox now? (y/n):" "y" START_HB + if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Homebox started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:7745" + echo " Register your account on first visit." + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_homebox diff --git a/services/joplin.sh b/services/joplin.sh new file mode 100644 index 0000000..8f0d9c1 --- /dev/null +++ b/services/joplin.sh @@ -0,0 +1,316 @@ +#!/bin/bash +# services/joplin.sh — Self-hosted Joplin sync server for notes. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash joplin.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + 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 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + 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; } + + # Build upstream — remote Caddy uses host IP:port, not container name + 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 joplin utilities "Self-hosted Joplin sync server for notes" 22300 + +install_joplin() { + require_docker || return 1 + log_info "Installing Joplin Server..." + + local JOPLIN_DIR="$DOCKER_DIR/joplin" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $JOPLIN_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$JOPLIN_DIR" + ensure_docker_dir_ownership "$JOPLIN_DIR" + cd "$JOPLIN_DIR" || return 1 + + local DB_PASS + DB_PASS="$(generate_password 32)" + local BASE_URL="https://joplin.${SITE_DOMAIN}" + + cat > docker-compose.yml << 'JOPLIN_COMPOSE' +name: joplin + +services: + joplin: + image: joplin/server:latest + container_name: joplin + hostname: joplin + restart: unless-stopped + depends_on: + - joplin-db + env_file: .env + ports: + - "22300:22300" + networks: + - caddy_net + + joplin-db: + image: postgres:15-alpine + container_name: joplin-db + hostname: joplin-db + restart: unless-stopped + env_file: .env + volumes: + - ./db-data:/var/lib/postgresql/data + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +JOPLIN_COMPOSE + + cat > .env << JOPLIN_ENV +# Joplin Server configuration +APP_PORT=22300 +# Must match the public URL — update if your domain changes +APP_BASE_URL=${BASE_URL} + +# Database connection (Joplin Server) +DB_CLIENT=pg +POSTGRES_HOST=joplin-db +POSTGRES_DATABASE=joplin +POSTGRES_USER=joplin +POSTGRES_PASSWORD=${DB_PASS} + +# PostgreSQL sidecar +POSTGRES_DB=joplin + +# Caddy network +CADDY_NET=${SITE_CADDY_NET} +JOPLIN_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR" + + echo "" + log_success "Joplin Server configured at $JOPLIN_DIR" + log_info "APP_BASE_URL set to: $BASE_URL" + log_warning "APP_BASE_URL in .env must match the public URL used by Joplin clients." + + configure_caddy_for_service "Joplin" "joplin:22300" "joplin" + + write_readme "$JOPLIN_DIR" << MD +# Joplin Server + +Self-hosted sync server for the Joplin note-taking app. + +## Access +- URL: http://localhost:22300 +- Default admin: admin@localhost / admin (change immediately after first login!) + +## Important +\`APP_BASE_URL\` in \`.env\` must exactly match the public URL your Joplin +clients connect to (e.g. https://joplin.example.com). If this URL changes, +update .env and restart the stack. + +## Client setup +In the Joplin desktop or mobile app: + Tools → Options → Synchronisation → Synchronisation target: Joplin Server + Enter your server URL, email, and password. + +## Manage +\`\`\`bash +cd $JOPLIN_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose down && docker compose up -d # update +\`\`\` + +## Files +- docker-compose.yml — stack definition +- .env — secrets and config (chmod 600) +- db-data/ — PostgreSQL data volume +MD + + local START_JOPLIN="" + prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN + if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Joplin Server started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:22300" + echo " Default login: admin@localhost / admin (change immediately!)" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_joplin diff --git a/services/n8n.sh b/services/n8n.sh new file mode 100644 index 0000000..c3cb7d8 --- /dev/null +++ b/services/n8n.sh @@ -0,0 +1,273 @@ +#!/bin/bash +# services/n8n.sh — Workflow automation — connect all your self-hosted services (n8n). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash n8n.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + 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 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + 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; } + + # Build upstream — remote Caddy uses host IP:port, not container name + 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 n8n utilities "Workflow automation — connect all your self-hosted services (n8n)" 5678 + +install_n8n() { + require_docker || return 1 + log_info "Installing n8n..." + + local N8N_DIR="$DOCKER_DIR/n8n" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $N8N_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$N8N_DIR/data" + ensure_docker_dir_ownership "$N8N_DIR" + cd "$N8N_DIR" || return 1 + + cat > docker-compose.yml << 'N8N_COMPOSE' +name: n8n + +services: + n8n: + image: n8nio/n8n:latest + container_name: n8n + hostname: n8n + restart: unless-stopped + env_file: .env + ports: + - "5678:5678" + volumes: + - ./data:/home/node/.n8n + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +N8N_COMPOSE + + cat > .env << N8N_ENV +# n8n configuration +N8N_HOST=n8n.${SITE_DOMAIN} +N8N_PORT=5678 +N8N_PROTOCOL=https +WEBHOOK_URL=https://n8n.${SITE_DOMAIN} +GENERIC_TIMEZONE=${SITE_TZ} +CADDY_NET=${SITE_CADDY_NET} +N8N_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$N8N_DIR" + + echo "" + log_success "n8n configured at $N8N_DIR" + + configure_caddy_for_service "n8n" "n8n:5678" "n8n" + + write_readme "$N8N_DIR" << 'MD' +# n8n + +Workflow automation — connect all your self-hosted services with a visual +node-based editor. An owner account is created on first login. + +## Access +- URL: http://localhost:5678 +- Create an owner account on first visit + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_N8N="" + prompt_yn "Start n8n now? (y/n):" "y" START_N8N + if [ "$START_N8N" = "y" ] || [ "$START_N8N" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "n8n started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:5678" + echo " Create an owner account on first visit." + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_n8n diff --git a/services/stirling-pdf.sh b/services/stirling-pdf.sh new file mode 100644 index 0000000..3c707e4 --- /dev/null +++ b/services/stirling-pdf.sh @@ -0,0 +1,305 @@ +#!/bin/bash +# services/stirling-pdf.sh — PDF toolkit — merge, split, compress, OCR (Stirling PDF). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash stirling-pdf.sh +# (Docker must already be installed when run standalone) + +# ── 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 + } + + 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##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + 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 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + 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; } + + # Build upstream — remote Caddy uses host IP:port, not container name + 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 stirling-pdf utilities "PDF toolkit — merge, split, compress, OCR (Stirling PDF)" 8070 + +install_stirling_pdf() { + require_docker || return 1 + log_info "Installing Stirling PDF..." + + local PDF_DIR="$DOCKER_DIR/stirling-pdf" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $PDF_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$PDF_DIR" + ensure_docker_dir_ownership "$PDF_DIR" + cd "$PDF_DIR" || return 1 + + cat > docker-compose.yml << 'PDF_COMPOSE' +name: stirling-pdf + +services: + stirling-pdf: + image: frooodle/s-pdf:latest + container_name: stirling-pdf + hostname: stirling-pdf + restart: unless-stopped + env_file: .env + ports: + - "8070:8080" + volumes: + - ./training-data:/usr/share/tessdata + - ./extraConfigs:/configs + - ./logs:/logs + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +PDF_COMPOSE + + cat > .env << PDF_ENV +# Stirling PDF configuration + +# Set to true to enable login/user management (requires restart) +DOCKER_ENABLE_SECURITY=false + +# Set to true to install LibreOffice for advanced HTML/book conversion ops +INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false + +# Caddy network +CADDY_NET=${SITE_CADDY_NET} +PDF_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PDF_DIR" + + echo "" + log_success "Stirling PDF configured at $PDF_DIR" + log_info "Security/login is disabled by default (DOCKER_ENABLE_SECURITY=false)." + log_info "To enable built-in auth, set DOCKER_ENABLE_SECURITY=true in .env and restart." + + # No built-in auth by default — offer Authelia SSO protection + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Stirling PDF with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia" + fi + + configure_caddy_for_service "Stirling PDF" "stirling-pdf:8080" "pdf" "$EXTRA_BLOCK" + + write_readme "$PDF_DIR" << MD +# Stirling PDF + +Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more. + +## Access +- URL: http://localhost:8070 +- No login required by default (security disabled) + +## Enabling built-in auth +Set \`DOCKER_ENABLE_SECURITY=true\` in \`.env\`, then restart: +\`\`\`bash +cd $PDF_DIR +docker compose down && docker compose up -d +\`\`\` + +## OCR / Tesseract +Additional Tesseract language packs can be placed in \`./training-data/\`. +See: https://github.com/Frooodle/Stirling-PDF#ocr + +## Manage +\`\`\`bash +cd $PDF_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose down && docker compose up -d # update +\`\`\` + +## Files +- docker-compose.yml — stack definition +- .env — configuration flags +- training-data/ — Tesseract OCR language data +- extraConfigs/ — optional extra config files +- logs/ — application logs +MD + + local START_PDF="" + prompt_yn "Start Stirling PDF now? (y/n):" "y" START_PDF + if [ "$START_PDF" = "y" ] || [ "$START_PDF" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Stirling PDF started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:8070" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_stirling_pdf