diff --git a/README.md b/README.md index 4bff1c3..1730656 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ 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` | `silent-send`, `sync-cc` | +| `gaming` | `drum-rhythm-game`, `js99er`, `minecraft`, `wolf`, `wolf-pair` | +| `extras` | `kdeconnect`, `silent-send`, `sync-cc` | | `backup` | `backup` — complete recovery: entire `~/docker//` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) | Run `./setup.sh --list` to see descriptions. 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/base.sh b/services/base.sh index a2875e6..7d351f2 100644 --- a/services/base.sh +++ b/services/base.sh @@ -2,7 +2,7 @@ # services/base.sh — essential CLI packages installed on every box. # Part of the modular post-install system (sourced by setup.sh). -register_service base base "Essential CLI packages (net-tools, git, htop, glow, …)" +register_service base base "Essential CLI packages (net-tools, git, htop, btop, glow, …)" install_base() { log_info "Installing essential packages..." @@ -10,7 +10,7 @@ install_base() { # Core utilities present on every install. run_cmd apt-get install -y \ - net-tools ncdu git curl wget htop tree zip unzip \ + net-tools ncdu git curl wget htop btop tree zip unzip \ ca-certificates gnupg jq rsync || log_warning "Some essential packages failed to install" # glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos, 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..78e553f --- /dev/null +++ b/services/changedetection.sh @@ -0,0 +1,288 @@ +#!/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 + ports: + - "5000:5000" + environment: + - BASE_URL=${BASE_URL} + - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 + volumes: + - ./data:/datastore + networks: + - caddy_net + depends_on: + - playwright-chrome + + 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 environment — edit before starting if needed +BASE_URL=https://changes.${SITE_DOMAIN} +CADDY_NET=${SITE_CADDY_NET} +CD_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CD_DIR" + chmod 600 "$CD_DIR/.env" + + 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 via email, Slack, +Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar +for JavaScript-heavy pages. + +## Access +- URL: http://localhost:5000 +- Optional password can be set in Settings → General within the UI. + +## Manage +```bash +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 +``` + +## Environment +Edit `.env` to change `BASE_URL` (used for notification links), +then restart: `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/drum-rhythm-game.sh b/services/drum-rhythm-game.sh new file mode 100644 index 0000000..32023bc --- /dev/null +++ b/services/drum-rhythm-game.sh @@ -0,0 +1,298 @@ +#!/bin/bash +# services/drum-rhythm-game.sh — Browser-based drum rhythm game (outis1one/drum-rhythm-game). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash drum-rhythm-game.sh +# (Docker must already be installed when run standalone) +# +# Serves a single self-contained index.html via nginx. No login — protect +# with Authelia via Caddy if you want access control. +# Source: https://github.com/outis1one/drum-rhythm-game + +# ── 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 drum-rhythm-game gaming "Browser-based drum rhythm game (outis1one/drum-rhythm-game)" 8096 + +install_drum-rhythm-game() { + require_docker || return 1 + + local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game" + local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] drum-rhythm-game would:" + echo " - Clone $REPO_URL to $DRUM_DIR/html" + echo " - Serve index.html via nginx on port 8096" + echo " - Offer Authelia SSO protection via Caddy (no built-in auth)" + return 0 + fi + + mkdir -p "$DRUM_DIR" + ensure_docker_dir_ownership "$DRUM_DIR" + cd "$DRUM_DIR" || return 1 + + # Clone or update the game source + if [ -d "$DRUM_DIR/html/.git" ]; then + log_info "Updating drum-rhythm-game source..." + git -C "$DRUM_DIR/html" pull --ff-only 2>/dev/null \ + && log_success "Updated to latest" \ + || log_warning "Could not pull latest — using existing version" + else + log_info "Cloning drum-rhythm-game..." + git clone --depth 1 "$REPO_URL" "$DRUM_DIR/html" \ + || { log_error "Clone failed — check network and git access"; return 1; } + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DRUM_DIR/html" + + cat > docker-compose.yml << 'DRUM_COMPOSE' +name: drum-rhythm-game + +services: + drum-rhythm-game: + image: nginx:alpine + container_name: drum-rhythm-game + hostname: drum-rhythm-game + restart: unless-stopped + volumes: + - ./html:/usr/share/nginx/html:ro + ports: + - "8096:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +DRUM_COMPOSE + + cat > .env << DRUM_ENV +CADDY_NET=${SITE_CADDY_NET} +DRUM_ENV + + ensure_docker_dir_ownership "$DRUM_DIR" + log_success "drum-rhythm-game configured at $DRUM_DIR" + + # No built-in auth — offer Authelia SSO protection + local DRUM_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect drum-rhythm-game with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && DRUM_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK" + + write_readme "$DRUM_DIR" << 'MD' +# Drum Rhythm Game + +Browser-based drum rhythm game — 124 synthesized orchestra pieces across +18 genres, 120 drum patterns. Supports keyboard and USB drum controllers. +No server required; all audio synthesized in-browser via Web Audio API. + +Source: https://github.com/outis1one/drum-rhythm-game + +## Access +- URL: http://localhost:8096 + +## Manage +```bash +cd ~/docker/drum-rhythm-game +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +``` + +## Update game +```bash +cd ~/docker/drum-rhythm-game +git -C html pull +docker compose restart +``` +MD + + local START_DRUM="" + prompt_yn "Start drum-rhythm-game now? (y/n):" "y" START_DRUM + if [ "$START_DRUM" = "y" ] || [ "$START_DRUM" = "Y" ]; then + docker compose up -d \ + && log_success "Drum Rhythm Game started — http://localhost:8096" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo "" + echo " URL: http://localhost:8096" + echo " Controls: keyboard or USB drum controller" + echo " Update: git -C $DRUM_DIR/html pull && docker compose -f $DRUM_DIR/docker-compose.yml restart" + echo "" +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_drum-rhythm-game diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 92a08f9..9a3bb77 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -278,6 +278,25 @@ HA_CONFIG configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" fi + write_readme "$HOMEASSISTANT_DIR" << MD +# Home Assistant + +Home automation hub. Built-in auth — no Authelia needed. + +## Access +- URL: http://localhost:8123 +- First run: create your admin account through the onboarding wizard + +## Manage +\`\`\`bash +cd $HOMEASSISTANT_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + local START_HA="" prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then 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/js99er.sh b/services/js99er.sh index 1f90845..3f380f1 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -455,7 +455,13 @@ COMPOSE log_success "js99er configured at $JS99ER_DIR" # ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ─────────── - configure_caddy_for_service "js99er" "js99er:80" "js99er" + local JS99ER_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect js99er with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && JS99ER_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "js99er" "js99er:80" "js99er" "$JS99ER_EXTRA_BLOCK" # ── 6. Build & start ───────────────────────────────────────────────────── local START_JS99ER="" @@ -477,7 +483,24 @@ COMPOSE fi fi - # ── 7. Access summary ──────────────────────────────────────────────────── + write_readme "$JS99ER_DIR" << MD +# js99er — TI-99/4A Emulator + +Browser-based TI-99/4A emulator. No built-in auth — protect with Authelia if exposing externally. + +## Access +- URL: http://localhost:${JS99ER_PORT} +- Online (no install): https://js99er.net + +## Manage +\`\`\`bash +cd $JS99ER_DIR +docker compose up -d --build # start (or rebuild) +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + echo "" echo " Access at: http://localhost:${JS99ER_PORT}" echo " If you set a domain above, it is also reachable via that domain (HTTPS)." diff --git a/services/kdeconnect.sh b/services/kdeconnect.sh new file mode 100644 index 0000000..4d0c7cb --- /dev/null +++ b/services/kdeconnect.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# services/kdeconnect.sh — Phone/desktop integration via KDE Connect. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash kdeconnect.sh +# +# KDE Connect is an APT package — NOT a Docker service. +# It enables Android/iPhone ↔ Linux integration: +# • Shared clipboard • File transfer +# • Phone notifications on desktop • Remote input (trackpad/keyboard) +# • SMS from desktop • Battery status +# +# NOTE: setup.sh is_installed() needs a case entry for this service: +# kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;; + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +# Detected when the script is executed directly rather than sourced by setup.sh. +# Sets up helpers and globals, then defers execution until after the function +# definition at the bottom of this file. +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 + # Full repo present — use the real helpers (picks up ~/docker/.config too) + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + # One-off copy — inline minimal stubs so the script works without the repo + 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; } + + # Match common.sh's eval-based pattern so local vars in install_* are set correctly + 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}'" + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) + 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}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service kdeconnect extras "Phone/desktop integration — notifications, clipboard, file transfer (KDE Connect)" + +install_kdeconnect() { + log_info "Installing KDE Connect phone/desktop integration..." + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ KDE CONNECT — Phone / Desktop Integration │" + echo "│ Shared clipboard, notifications, file transfer, remote input │" + echo "│ Works with Android (Play Store / F-Droid) and iPhone (App Store)│" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + # ── Already installed? ──────────────────────────────────────────────────── + if command -v kdeconnect &>/dev/null; then + log_info "KDE Connect is already installed — skipping." + return 0 + fi + + # ── DRY-RUN: describe the plan and bail before touching anything real ───── + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would install: kdeconnect" + if dpkg -l gnome-shell 2>/dev/null | grep -q ^ii; then + echo "[DRY-RUN] Would install: indicator-kdeconnect (GNOME/Ubuntu detected)" + fi + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + echo "[DRY-RUN] Would open UFW ports 1714:1764/tcp and 1714:1764/udp" + fi + return 0 + fi + + # ── 1. Install kdeconnect ───────────────────────────────────────────────── + log_info "Installing kdeconnect package..." + if apt-get install -y kdeconnect; then + log_success "kdeconnect installed" + else + log_error "Failed to install kdeconnect — check apt output above" + return 1 + fi + + # ── 2. GNOME indicator (Ubuntu/GNOME only) ──────────────────────────────── + if dpkg -l gnome-shell 2>/dev/null | grep -q ^ii; then + log_info "GNOME detected — installing indicator-kdeconnect for system tray support..." + if apt-get install -y indicator-kdeconnect; then + log_success "indicator-kdeconnect installed" + else + log_warning "Could not install indicator-kdeconnect — continuing without it" + fi + fi + + # ── 3. Open UFW firewall ports (KDE Connect port range) ─────────────────── + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports 1714:1764/tcp and 1714:1764/udp (KDE Connect)..." + ufw allow 1714:1764/tcp + ufw allow 1714:1764/udp + log_success "UFW ports 1714-1764 opened" + else + log_info "UFW not active — skipping firewall rules" + echo " If you enable UFW later, run:" + echo " sudo ufw allow 1714:1764/tcp" + echo " sudo ufw allow 1714:1764/udp" + fi + + # ── 4. Usage instructions ───────────────────────────────────────────────── + echo "" + echo " ┌─ Next steps ────────────────────────────────────────────────────┐" + echo " │ 1. Install KDE Connect on your phone: │" + echo " │ Android: Play Store or F-Droid → search 'KDE Connect' │" + echo " │ iPhone: App Store → search 'KDE Connect' │" + echo " │ │" + echo " │ 2. Ensure your phone and computer are on the same WiFi network. │" + echo " │ │" + echo " │ 3. Open KDE Connect on your phone — your computer should │" + echo " │ appear automatically. Tap it and accept the pairing request │" + echo " │ on both devices. │" + echo " │ │" + echo " │ Linux Mint Cinnamon: a system tray applet is available — │" + echo " │ right-click the desktop → Applets → search 'KDE Connect' │" + echo " │ and add it to your panel. │" + echo " └──────────────────────────────────────────────────────────────────┘" + echo "" + + log_success "KDE Connect installation complete" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_kdeconnect diff --git a/services/magicmirror.sh b/services/magicmirror.sh index 8614b76..ac82200 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -317,7 +317,15 @@ MM_COMPOSE log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)" # Offer Caddy only for first instance - [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" + if [ "$i" -eq 1 ]; then + local MM_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && MM_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" "$MM_EXTRA_BLOCK" + fi local START_MM="" prompt_yn "Start instance $i now? (y/n):" "y" START_MM diff --git a/services/mealie.sh b/services/mealie.sh index ef025f4..c54fc1d 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -205,6 +205,13 @@ install_mealie() { TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + # BASE_URL must match the public URL Mealie is served on (used for email links, + # OAuth redirects, and the web app manifest). Default to SITE_DOMAIN if set. + local MEALIE_BASE_URL="http://localhost:9925" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + MEALIE_BASE_URL="https://recipes.${SITE_DOMAIN}" + fi + cat > docker-compose.yml << MEALIE_COMPOSE name: mealie @@ -214,6 +221,7 @@ services: container_name: mealie hostname: mealie restart: unless-stopped + env_file: .env environment: - PUID=$UID_VAL - PGID=$GID_VAL @@ -221,7 +229,6 @@ services: - ALLOW_SIGNUP=true - MAX_WORKERS=1 - WEB_CONCURRENCY=1 - - BASE_URL=http://localhost:9925 volumes: - ./data:/app/data ports: @@ -235,6 +242,13 @@ networks: name: \${CADDY_NET:-caddy_net} MEALIE_COMPOSE + cat > .env << MEALIE_ENV +# Public URL Mealie is served on — used for email links and OAuth redirects. +# Update if you change your domain or switch from HTTP to HTTPS. +BASE_URL=$MEALIE_BASE_URL +CADDY_NET=$SITE_CADDY_NET +MEALIE_ENV + mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" log_success "Mealie configured at $MEALIE_DIR" diff --git a/services/minecraft.sh b/services/minecraft.sh index 0cc4ea1..2ab2f1f 100644 --- a/services/minecraft.sh +++ b/services/minecraft.sh @@ -2490,6 +2490,25 @@ NETEOF echo " Run: sudo ./setup.sh backup" echo "" + write_readme "$MC_DIR" << MD +# Minecraft — ${MC_NAME} + +## Manage +\`\`\`bash +cd $MC_DIR +docker compose up -d --build # start (builds image on first run) +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d --build # update +\`\`\` + +## Backups +World data lives in \`./data\` — covered by Kopia/Borg if installed. +\`\`\`bash +sudo ./setup.sh backup +\`\`\` +MD + # ── Optional: start server and run pre-gen now ────────────────────────────── local START_MC="" prompt_yn "Start the Minecraft server now? (first build takes a few minutes) (y/n) [y]:" "y" START_MC diff --git a/services/n8n.sh b/services/n8n.sh new file mode 100644 index 0000000..a986ea8 --- /dev/null +++ b/services/n8n.sh @@ -0,0 +1,283 @@ +#!/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 + ports: + - "5678:5678" + env_file: + - .env + environment: + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} + - N8N_HOST=${N8N_HOST:-n8n} + - WEBHOOK_URL=${WEBHOOK_URL} + 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 environment — edit before starting if needed +N8N_HOST=n8n +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" + chmod 600 "$N8N_DIR/.env" + + echo "" + log_success "n8n configured at $N8N_DIR" + + configure_caddy_for_service "n8n" "n8n:5678" "n8n" + + write_readme "$N8N_DIR" << 'MD' +# n8n + +Workflow automation platform — connect all your self-hosted services with +a visual editor. Create webhooks, scheduled jobs, and multi-step automations. + +## Access +- URL: http://localhost:5678 +- On first run, n8n prompts you to create an owner account. + +## Manage +```bash +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 +``` + +## Environment +Edit `.env` to change `WEBHOOK_URL` or `N8N_HOST` after deployment, +then restart: `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 your 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/ntfy.sh b/services/ntfy.sh index 3c38038..5a0f260 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -235,6 +235,28 @@ CADDY_NET=$SITE_CADDY_NET NTFY_ENV mkdir -p cache config + + # Write server.yml — ntfy needs base-url for correct push notification links + # Only on fresh install; never clobber existing config + if [ ! -f config/server.yml ]; then + local NTFY_BASE_URL="" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}" + fi + cat > config/server.yml << NTFY_CFG +# ntfy server configuration — https://docs.ntfy.sh/config/ +base-url: "${NTFY_BASE_URL:-https://ntfy.example.com}" # UPDATE to your actual domain +cache-file: /var/cache/ntfy/cache.db +cache-duration: 12h +auth-file: /var/cache/ntfy/auth.db +auth-default-access: deny-all +behind-proxy: true +NTFY_CFG + [[ -n "$NTFY_BASE_URL" ]] \ + && log_info "base-url set to $NTFY_BASE_URL — update if domain changes" \ + || log_warning "base-url set to placeholder — edit config/server.yml after install" + fi + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" echo "" 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 diff --git a/services/syncthing.sh b/services/syncthing.sh new file mode 100644 index 0000000..7c5138d --- /dev/null +++ b/services/syncthing.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# services/syncthing.sh — Continuous file sync between devices (Syncthing). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash syncthing.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 + # Full repo present — use the real helpers (picks up ~/docker/.config too) + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + # One-off copy — inline minimal stubs so the script works without the repo + 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 + } + + # Match common.sh's eval-based pattern so local vars in install_* are set correctly + 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" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + 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 + + # Remove existing block for this domain if present + 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 + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_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 + + 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 + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) + 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}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service syncthing utilities "Continuous file sync between devices (Syncthing)" 8384 + +install_syncthing() { + require_docker || return 1 + log_info "Installing Syncthing..." + + local DIR="$DOCKER_DIR/syncthing" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" + echo "[DRY-RUN] Would expose web UI on 8384, sync protocol on 22000 (tcp+udp), discovery on 21027/udp" + return 0 + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local PUID PGID + PUID="$(id -u "$ACTUAL_USER")" + PGID="$(id -g "$ACTUAL_USER")" + + cat > docker-compose.yml << EOF +name: syncthing + +services: + syncthing: + image: syncthing/syncthing:latest + container_name: syncthing + hostname: syncthing + restart: unless-stopped + environment: + - PUID=\${PUID:-$PUID} + - PGID=\${PGID:-$PGID} + - TZ=\${SITE_TZ:-UTC} + ports: + - "8384:8384" + - "22000:22000/tcp" + - "22000:22000/udp" + - "21027:21027/udp" + volumes: + - ./config:/var/syncthing/config + - ./data:/var/syncthing + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +EOF + + cat > .env << ENV +CADDY_NET=$SITE_CADDY_NET +PUID=$PUID +PGID=$PGID +SITE_TZ=$SITE_TZ +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + configure_caddy_for_service "Syncthing" "syncthing:8384" "sync" + + write_readme "$DIR" << MD +# Syncthing + +Continuous, decentralised file synchronisation between devices. + +## Access +- Web UI: http://localhost:8384 +- First run: go to **Settings → GUI** and set a username and password. + +## Firewall ports (for LAN sync) +Open these on the host firewall so other Syncthing devices can reach this node: +\`\`\` +sudo ufw allow 22000/tcp comment "Syncthing sync protocol" +sudo ufw allow 22000/udp comment "Syncthing sync protocol (QUIC)" +sudo ufw allow 21027/udp comment "Syncthing local discovery" +\`\`\` + +## Manage +\`\`\` +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START="" + prompt_yn "Start Syncthing now? (y/n):" "y" START + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "Syncthing started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo " Access at: http://localhost:8384" + echo " First run: set a username and password in Settings → GUI" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_syncthing diff --git a/services/unifi.sh b/services/unifi.sh index fe5d45d..cfd9977 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -202,25 +202,36 @@ UNIFI_ENV log_success "UniFi configured at $UNIFI_DIR" - # ── Optional Caddy reverse proxy (HTTPS backend requires special config) ── - if [ -d "$DOCKER_DIR/caddy" ]; then + # ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ── + local _caddy_mode="none" + [ -d "$DOCKER_DIR/caddy" ] && _caddy_mode="local" + [ -n "${CADDY_REMOTE_HOST:-}" ] && [ "$_caddy_mode" != "local" ] && _caddy_mode="remote" + + if [ "$_caddy_mode" != "none" ]; then echo "" echo " UniFi web UI is HTTPS-only (self-signed cert internally)." - echo " Caddy can proxy it, but requires tls_insecure_skip_verify." + echo " Caddy proxies it using tls_insecure_skip_verify." + if [ "$_caddy_mode" = "remote" ]; then + echo " Remote Caddy (${CADDY_REMOTE_HOST}) — a snippet file will be saved." + fi echo "" local CADDY_UNIFI="" prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then local UNIFI_DOMAIN="" - prompt_text "UniFi domain (e.g. unifi.example.com):" "unifi.${SITE_DOMAIN:-example.com}" UNIFI_DOMAIN + local _def_domain="unifi.${SITE_DOMAIN:-example.com}" + prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN if [ -n "$UNIFI_DOMAIN" ]; then - local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" - cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true - cat >> "$CADDYFILE" << CADDY_BLOCK + # UniFi uses HTTPS internally — upstream must use https:// + skip verify + local _upstream="https://unifi-app:8443" + [ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443" + + local _site_block + _site_block="$(cat << CBLOCK # UniFi Network Application -$UNIFI_DOMAIN { - reverse_proxy https://unifi-app:8443 { +${UNIFI_DOMAIN} { + reverse_proxy ${_upstream} { transport http { tls_insecure_skip_verify } @@ -234,15 +245,30 @@ $UNIFI_DOMAIN { } log { - output file /var/log/caddy/$UNIFI_DOMAIN.log + output file /var/log/caddy/${UNIFI_DOMAIN}.log format json } } -CADDY_BLOCK - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ - && log_success "Caddy configured for $UNIFI_DOMAIN" \ - || log_warning "Caddy reload failed — check: docker logs caddy" +CBLOCK +)" + if [ "$_caddy_mode" = "local" ]; then + local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" + cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + printf '%s\n' "$_site_block" >> "$CADDYFILE" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ + && log_success "Caddy configured for $UNIFI_DOMAIN" \ + || log_warning "Caddy reload failed — check: docker logs caddy" + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/unifi.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/" + fi fi fi fi diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index e160134..6a8d9e5 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -254,10 +254,8 @@ services: env_file: .env volumes: - ./vaultwarden_data:/data - expose: - - "80" ports: - - "3012:3012" # WebSocket (legacy — not needed for Vaultwarden v1.29+) + - "8888:80" networks: - caddy_net @@ -284,8 +282,6 @@ ADMIN_TOKEN=$ADMIN_TOKEN SIGNUPS_ALLOWED=false SIGNUPS_VERIFY=false -# WebSocket notifications (v1.29+: built into port 80, no separate port needed) -WEBSOCKET_ENABLED=true # ── SMTP (optional — for password-reset and invite emails) ──────────────────── SMTP_HOST=$SMTP_HOST diff --git a/services/wg-easy.sh b/services/wg-easy.sh index 9915801..177b7f8 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -215,13 +215,26 @@ install_wg-easy() { cd "$WGEASY_DIR" || return 1 # Auto-detect public IP as default for WG_HOST - local PUBLIC_IP WG_HOST WG_PASSWORD + local PUBLIC_IP WG_HOST WG_PASSWORD WG_PASSWORD_HASH PUBLIC_IP=$(curl -s --connect-timeout 5 ifconfig.me 2>/dev/null || echo "your-public-ip") WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) prompt_text "Public IP or hostname for VPN [$PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST - cat > docker-compose.yml << 'WGEASY_COMPOSE' + # wg-easy v14+ requires PASSWORD_HASH (bcrypt). Generate via docker. + log_info "Generating bcrypt password hash (requires Docker)..." + WG_PASSWORD_HASH=$(docker run --rm ghcr.io/wg-easy/wg-easy:latest wgpw "$WG_PASSWORD" 2>/dev/null \ + | grep -oP '\$2[ab]\$[^\s]+' | head -1) + if [[ -z "$WG_PASSWORD_HASH" ]]; then + log_warning "Could not generate bcrypt hash — falling back to plaintext PASSWORD env var." + log_warning "If wg-easy fails to start, run: docker run --rm ghcr.io/wg-easy/wg-easy wgpw 'yourpassword'" + log_warning "Then set PASSWORD_HASH in docker-compose.yml and remove PASSWORD." + fi + + # Escape $ in hash for docker-compose env (bcrypt hashes contain $$) + local WG_HASH_ESCAPED="${WG_PASSWORD_HASH//\$/\$\$}" + + cat > docker-compose.yml << WGEASY_COMPOSE name: wg-easy services: @@ -237,8 +250,8 @@ services: - net.ipv4.ip_forward=1 - net.ipv4.conf.all.src_valid_mark=1 environment: - - WG_HOST=${WG_HOST} - - PASSWORD=${WG_PASSWORD} + - WG_HOST=\${WG_HOST} + - PASSWORD_HASH=${WG_HASH_ESCAPED:-\${WG_PASSWORD}} - WG_DEFAULT_DNS=1.1.1.1 volumes: - ./config:/etc/wireguard @@ -251,11 +264,12 @@ services: networks: caddy_net: external: true - name: ${CADDY_NET:-caddy_net} + name: \${CADDY_NET:-caddy_net} WGEASY_COMPOSE cat > .env << WGEASY_ENV WG_HOST=$WG_HOST +# Plain-text password — used only if PASSWORD_HASH could not be generated above WG_PASSWORD=$WG_PASSWORD CADDY_NET=$SITE_CADDY_NET WGEASY_ENV @@ -304,6 +318,8 @@ MD echo "" echo " Web UI: http://localhost:51821" echo " Password: $WG_PASSWORD (saved in .env)" + [[ -n "$WG_PASSWORD_HASH" ]] && echo " Auth: bcrypt hash configured (v14+ compatible)" \ + || echo " Auth: WARNING — bcrypt hash generation failed; see README" echo " Router: forward UDP 51820 → this server for external VPN access" echo "" } diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh index 818fe38..19e3373 100644 --- a/services/wolf-pair.sh +++ b/services/wolf-pair.sh @@ -433,7 +433,13 @@ COMPOSE fi # ── 5. Caddy (optional) ─────────────────────────────────────────────────── - configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" + local WOLFPAIR_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect wolf-pair with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && WOLFPAIR_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" "$WOLFPAIR_EXTRA_BLOCK" # ── 6. README ───────────────────────────────────────────────────────────── write_readme "$WOLFPAIR_DIR" << 'MD' diff --git a/services/wolf.sh b/services/wolf.sh index 868675c..86e67f3 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -1046,7 +1046,7 @@ PYEOF echo " Set up automatic backups with the backup module:" echo " sudo ./setup.sh backup" echo "" - # ── Caddy reverse proxy (Wolf web UI — no built-in auth, offer Authelia) ──── + # Wolf's web UI (pair/manage) has no built-in auth — protect with Authelia if available local WOLF_EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then local _use_auth="" @@ -1060,32 +1060,54 @@ PYEOF Cloud gaming via Moonlight. Stream any Moonlight-compatible game or app from this server to any device on your network. +# Wolf — Cloud Gaming (Games-on-Whales) + +Stream games to any Moonlight client over your LAN or Tailscale VPN. + +## Pair a new client +1. Open Moonlight on the client device +2. Add host: this server's IP +3. Run the pin command on the server: +\`\`\`bash +cd $WOLF_DIR && ./manage.sh pin +\`\`\` ## Manage \`\`\`bash cd $WOLF_DIR -./manage.sh start # start Wolf -./manage.sh stop # stop Wolf -./manage.sh logs # stream logs -./manage.sh pin # show pairing PIN -./manage.sh update # pull latest and restart +./manage.sh start # start Wolf +./manage.sh stop # stop +./manage.sh restart # restart +./manage.sh logs # live logs +./manage.sh status # container status +./manage.sh update # pull latest image and restart +./manage.sh add-apps # add game launchers \`\`\` -## Pairing -1. Open Moonlight on your device, select this server -2. Run \`./manage.sh pin\` to get the pairing PIN -3. Enter the PIN in Moonlight +## Ports (open on firewall / router) +| Port(s) | Protocol | Use | +|---------|----------|-----| +| 47984–47990 | TCP | Moonlight control | +| 48010 | TCP | RTSP | +| 47998–48000 | UDP | RTP video/audio/control | ## Game storage: \`$GAME_STORAGE_DIR\` - \`roms/\` → /ROMs (EmulationStation) - \`steam/\` → Steam data - \`saves/\` → RetroArch saves + +## Backup +\`\`\`bash +sudo ./setup.sh backup # covers /etc/wolf saves and ES-DE settings +\`\`\` MD local START_WOLF="" prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF - if [ "$START_WOLF" = "y" ] || [ "$START_WOLF" = "Y" ]; then - docker compose up -d && log_success "Wolf started" || log_warning "Failed to start — check: docker compose logs" + if [[ "$START_WOLF" =~ ^[Yy]$ ]]; then + docker compose up -d \ + && log_success "Wolf started — pair Moonlight to this server's IP" \ + || log_warning "Start failed — check: docker compose logs" fi log_success "Done. Pair Moonlight and play." diff --git a/setup.sh b/setup.sh index dae66da..2fa7162 100755 --- a/setup.sh +++ b/setup.sh @@ -79,6 +79,7 @@ is_installed() { base) command -v ncdu >/dev/null 2>&1 ;; glow) command -v glow >/dev/null 2>&1 ;; crowdsec) command -v cscli >/dev/null 2>&1 ;; + kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;; silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;; sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;