From b860a8b17440f4eeb09c094cbfadb3fad9ba4288 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:53:40 +0000 Subject: [PATCH 1/2] Add multi-instance support to 13 more services Retrofits the standard multi-instance pattern (documented in CLAUDE.md) onto actualbudget, filebrowser, fmd, homebox, immich, jellyfin, joplin, lyrion, meshcentral, ntfy, rustdesk, unifi, and vaultwarden. First instance of each keeps its original name/paths/ports unchanged; adding a second instance prompts for a short name and auto-scans for free ports. Service-specific handling beyond the base pattern: - joplin, immich, unifi: dedicated Postgres/Mongo container per instance (not shared), matching the backup-isolation reasoning in CLAUDE.md. - meshcentral, unifi: multiple fixed ports scanned/shifted together so they stay paired per instance. - rustdesk: 6-port block shifted by a fixed offset per instance, since the image hardcodes its internal ports with no per-port env override. - jellyfin: DLNA/discovery UDP ports only published for the first instance to avoid a host-wide fixed-port conflict. - lyrion: first instance keeps network_mode: host (required for Chromecast/Squeezebox broadcast discovery); additional instances fall back to bridge networking with auto-scanned ports, trading away zero-config discovery since a second container can't also bind host networking's fixed ports. - magicmirror.sh already had its own working multi-instance pattern (upfront instance count, numbered subdirs) and was left as-is. Verified via bash -n on every changed file, plus scripted functional runs (fake docker/ss) exercising first + second instance installs for every port-scanning shape used here (single, dual-paired, quad-paired, block-offset) and confirming dedicated per-instance DB naming and the lyrion host->bridge compose output. --- services/actualbudget.sh | 68 +++++++++++++++++---- services/filebrowser.sh | 69 +++++++++++++++++---- services/fmd.sh | 72 +++++++++++++++++----- services/homebox.sh | 80 ++++++++++++++++++------ services/immich.sh | 93 ++++++++++++++++++++++------ services/jellyfin.sh | 95 +++++++++++++++++++++++----- services/joplin.sh | 82 ++++++++++++++++++++----- services/lyrion.sh | 129 +++++++++++++++++++++++++++++++++------ services/meshcentral.sh | 81 ++++++++++++++++++------ services/ntfy.sh | 81 ++++++++++++++++++------ services/rustdesk.sh | 110 +++++++++++++++++++++++++-------- services/unifi.sh | 117 +++++++++++++++++++++++++++-------- services/vaultwarden.sh | 70 +++++++++++++++++---- 13 files changed, 923 insertions(+), 224 deletions(-) diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 606ed25..694ecf9 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -186,16 +186,57 @@ register_service actualbudget utilities "Open-source personal finance & budgetin install_actualbudget() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "actualbudget" name/paths/port exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local AB_DIR="$DOCKER_DIR/actualbudget" + local INSTANCE_SUFFIX="" CONTAINER="actualbudget" + local WEB_PORT="5006" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Actual Budget would:" - echo " - Create $AB_DIR with docker-compose.yml (data/)" - echo " - Expose port 5006" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/actualbudget(-) with docker-compose.yml (data/)" + echo " - Auto-scan for a free host port if this is an additional instance" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$AB_DIR" ]; then + echo "" + echo " Actual Budget is already installed at $AB_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Actual Budget instance alongside it (its own" + echo " server, budget file, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/actualbudget-$_suffix" ]; then + log_warning "actualbudget-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + AB_DIR="$DOCKER_DIR/actualbudget-$_suffix" + CONTAINER="actualbudget-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $AB_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$AB_DIR/data" ensure_docker_dir_ownership "$AB_DIR" cd "$AB_DIR" || return 1 @@ -226,15 +267,15 @@ networks: fi cat > docker-compose.yml << AB_COMPOSE -name: actualbudget +name: $CONTAINER services: actualbudget: image: actualbudget/actual-server:latest - container_name: actualbudget + container_name: $CONTAINER restart: unless-stopped ports: - - "5006:5006" + - "${WEB_PORT}:5006" volumes: - ./data:/data env_file: @@ -248,17 +289,20 @@ CADDY_NET=$SITE_CADDY_NET AB_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" - log_success "Actual Budget configured at $AB_DIR" + log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AB_DIR (port $WEB_PORT)" - configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget" + configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$AB_DIR" << MD -# Actual Budget +# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Open-source personal finance and budgeting tool. Supports bank sync via SimpleFIN (requires a SimpleFIN account at simplefin.org). +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own budget file, +own port) — not shared data with another Actual Budget instance.") -- Web UI: http://localhost:5006 +- Web UI: http://localhost:${WEB_PORT} - App data: \`data/\` ## Manage @@ -276,13 +320,13 @@ docker compose pull && docker compose up -d # update MD local START_AB="" - prompt_yn "Start Actual Budget now? (y/n):" "y" START_AB + prompt_yn "Start Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_AB if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then - docker compose up -d && log_success "Actual Budget started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:5006" + echo " Access at: http://localhost:${WEB_PORT}" echo " Bank sync: simplefin.org (optional, paid)" echo "" } diff --git a/services/filebrowser.sh b/services/filebrowser.sh index 747a54a..ec9c97a 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -184,14 +184,56 @@ install_filebrowser() { require_docker || return 1 log_info "Installing FileBrowser Quantum..." + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "filebrowser" name/paths/port exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local FB_DIR="$DOCKER_DIR/filebrowser" + local INSTANCE_SUFFIX="" CONTAINER="filebrowser" + local WEB_PORT="8085" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FB_DIR" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $FB_DIR(-)" echo "[DRY-RUN] Would write docker-compose.yml and data/config.yaml" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$FB_DIR" ]; then + echo "" + echo " FileBrowser is already installed at $FB_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate FileBrowser instance alongside it (its own" + echo " server, config, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'shared'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/filebrowser-$_suffix" ]; then + log_warning "filebrowser-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + FB_DIR="$DOCKER_DIR/filebrowser-$_suffix" + CONTAINER="filebrowser-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $FB_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$FB_DIR/data" ensure_docker_dir_ownership "$FB_DIR" cd "$FB_DIR" || return 1 @@ -223,13 +265,13 @@ networks: fi cat > docker-compose.yml << FB_COMPOSE -name: filebrowser +name: $CONTAINER services: filebrowser: image: gtstef/filebrowser:stable - container_name: filebrowser - hostname: filebrowser + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - TZ=${SITE_TZ:-UTC} @@ -237,7 +279,7 @@ services: - ./data:/home/filebrowser/data - ${FB_PATH}:/files ports: - - "8085:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} FB_COMPOSE @@ -282,18 +324,21 @@ FB_CONFIG fi echo "" - log_success "FileBrowser Quantum configured at $FB_DIR" + log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FB_DIR (port $WEB_PORT)" - configure_caddy_for_service "FileBrowser" "filebrowser:80" "files" + configure_caddy_for_service "FileBrowser${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "files${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$FB_DIR" << MD -# FileBrowser Quantum +# FileBrowser Quantum${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Web-based file manager with multi-source support, office preview, and per-user access control. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own config, own +port) — not shared sources with another FileBrowser instance.") ## Access -- URL: http://localhost:8085 +- URL: http://localhost:${WEB_PORT} - Default login: admin / admin (change immediately!) ## Adding sources (extra directories) @@ -320,14 +365,14 @@ docker compose pull && docker compose down && docker compose up -d # update MD local START_FB="" - prompt_yn "Start FileBrowser Quantum now? (y/n):" "y" START_FB + prompt_yn "Start FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FB if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then docker compose up -d 2>/dev/null \ - && log_success "FileBrowser Quantum started" \ + && log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:8085" + echo " Access at: http://localhost:${WEB_PORT}" echo " Default login: admin / admin (change immediately!)" echo "" } diff --git a/services/fmd.sh b/services/fmd.sh index bc80529..8f9180f 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -196,17 +196,58 @@ register_service fmd utilities "Android device tracking — alternative to Googl install_fmd() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "fmd" name/paths/port exactly as before + # (zero behavior change for anyone with a single instance). Only asking to + # add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local FMD_DIR="$DOCKER_DIR/fmd" + local INSTANCE_SUFFIX="" CONTAINER="fmd" + local WEB_PORT="8084" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] FindMyDevice would:" - echo " - Create $FMD_DIR with docker-compose.yml + .env (data/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/fmd(-) with docker-compose.yml + .env (data/)" echo " - Generate a random admin password" - echo " - Expose port 8084" + echo " - Auto-scan for a free host port if this is an additional instance" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$FMD_DIR" ]; then + echo "" + echo " FindMyDevice is already installed at $FMD_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate FindMyDevice instance alongside it (its own" + echo " server, password, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/fmd-$_suffix" ]; then + log_warning "fmd-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + FMD_DIR="$DOCKER_DIR/fmd-$_suffix" + CONTAINER="fmd-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $FMD_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$FMD_DIR" ensure_docker_dir_ownership "$FMD_DIR" cd "$FMD_DIR" || return 1 @@ -238,20 +279,20 @@ networks: fi cat > docker-compose.yml << FMD_COMPOSE -name: fmd +name: $CONTAINER services: fmd: image: nulide/findmydevice - container_name: fmd - hostname: fmd + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD} volumes: - ./data:/fmd/data ports: - - "8084:8080" + - "${WEB_PORT}:8080" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} FMD_COMPOSE @@ -262,17 +303,20 @@ FMD_ENV mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" - log_success "FindMyDevice configured at $FMD_DIR" + log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FMD_DIR (port $WEB_PORT)" - configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd" + configure_caddy_for_service "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "fmd${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$FMD_DIR" << MD -# FindMyDevice (FMD) +# FindMyDevice (FMD)${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted Android device tracking — locate, lock, or wipe your device from the web UI. Alternative to Google's Find My Device. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own password, own +port) — not shared devices with another FindMyDevice instance.") -- Web UI: http://localhost:8084 +- Web UI: http://localhost:${WEB_PORT} - Admin password: stored in \`.env\` (\`FMD_ADMIN_PASSWORD\`) - App data: \`data/\` @@ -287,19 +331,19 @@ docker compose pull && docker compose up -d # update ## Mobile app Install **FindMyDevice** from **F-Droid** (not the Play Store version): -1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:8084\` +1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:${WEB_PORT}\` 2. Enter your admin password from \`.env\` 3. Grant location and accessibility permissions MD local START_FMD="" - prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD + prompt_yn "Start FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FMD if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then - docker compose up -d && log_success "FindMyDevice started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:8084" + echo " Access at: http://localhost:${WEB_PORT}" echo " Password: $FMD_PASS (saved in .env)" echo " Mobile app: FindMyDevice on F-Droid" echo "" diff --git a/services/homebox.sh b/services/homebox.sh index 9795a43..5c94ba2 100644 --- a/services/homebox.sh +++ b/services/homebox.sh @@ -185,14 +185,56 @@ install_homebox() { require_docker || return 1 log_info "Installing Homebox..." + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "homebox" name/paths/port exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local HB_DIR="$DOCKER_DIR/homebox" + local INSTANCE_SUFFIX="" CONTAINER="homebox" + local WEB_PORT="7745" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $HB_DIR" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $HB_DIR(-)" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$HB_DIR" ]; then + echo "" + echo " Homebox is already installed at $HB_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Homebox instance alongside it (its own" + echo " server, inventory, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'garage'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/homebox-$_suffix" ]; then + log_warning "homebox-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + HB_DIR="$DOCKER_DIR/homebox-$_suffix" + CONTAINER="homebox-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $HB_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$HB_DIR/data" ensure_docker_dir_ownership "$HB_DIR" cd "$HB_DIR" || return 1 @@ -221,13 +263,13 @@ networks: fi cat > docker-compose.yml << HB_COMPOSE -name: homebox +name: $CONTAINER services: homebox: image: ghcr.io/sysadminsmedia/homebox:latest - container_name: homebox - hostname: homebox + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - HBOX_LOG_LEVEL=info @@ -235,7 +277,7 @@ services: volumes: - ./data:/data ports: - - "7745:7745" + - "${WEB_PORT}:7745" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} HB_COMPOSE @@ -246,18 +288,21 @@ HB_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR" echo "" - log_success "Homebox configured at $HB_DIR" + log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $HB_DIR (port $WEB_PORT)" - configure_caddy_for_service "Homebox" "homebox:7745" "homebox" + configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" - write_readme "$HB_DIR" << 'MD' -# Homebox + write_readme "$HB_DIR" << MD +# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Home inventory and asset management. Track items, locations, labels, warranties, and attachments across your household. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own inventory, own +port) — not shared items with another Homebox instance.") ## Access -- URL: http://localhost:7745 +- URL: http://localhost:${WEB_PORT} - Register your account on first visit — the first user becomes the admin. ## Data @@ -265,27 +310,28 @@ warranties, and attachments across your household. ## 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) +- \`HBOX_LOG_LEVEL\` — log verbosity (info, debug, warn, error) +- \`HBOX_WEB_MAX_UPLOAD_SIZE\` — max attachment upload size in MB (default: 10) ## Manage -```bash +\`\`\`bash +cd $HB_DIR 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 + prompt_yn "Start Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} 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_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:7745" + echo " Access at: http://localhost:${WEB_PORT}" echo " Register your account on first visit." echo "" } diff --git a/services/immich.sh b/services/immich.sh index 63441d8..7eb1bca 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -189,23 +189,75 @@ register_service immich media "Self-hosted photo & video backup — like Google install_immich() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "immich" name/paths/port and + # immich_server/immich_machine_learning/immich_redis/immich_postgres + # container names exactly as before (zero behavior change for anyone with + # a single instance). Only asking to add a second one introduces suffixed + # naming — same pattern as services/mattermost.sh and services/wordpress.sh. + # Each instance gets its own dedicated Postgres (already the case — one + # per compose project) and its own model-cache volume (scoped by the + # per-instance Compose project name), so instances are fully isolated for + # backup/restore too, per CLAUDE.md's "Multi-instance services" section. local IMMICH_DIR="$DOCKER_DIR/immich" + local INSTANCE_SUFFIX="" PROJECT="immich" + local C_SERVER="immich_server" C_ML="immich_machine_learning" C_REDIS="immich_redis" C_DB="immich_postgres" + local WEB_PORT="2283" local DEFAULT_PHOTOS="$ACTUAL_HOME/photos" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Immich would:" - echo " - Create $IMMICH_DIR with docker-compose.yml + .env" - echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/immich(-) with docker-compose.yml + .env" + echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres (dedicated per instance)" echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper" echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate" echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible" echo " object storage instead of local disk (native IMMICH_STORAGE_ENGINE=s3 —" echo " NOT a FUSE mount, those are unreliable for Immich's access pattern)" - echo " - Expose port 2283" + echo " - Expose port 2283, auto-scanned for additional instances" echo " - Offer a Caddy reverse proxy and to start the stack" return 0 fi + if [ -d "$IMMICH_DIR" ]; then + echo "" + echo " Immich is already installed at $IMMICH_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Immich instance alongside it (its own" + echo " server, database, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/immich-$_suffix" ]; then + log_warning "immich-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + IMMICH_DIR="$DOCKER_DIR/immich-$_suffix" + PROJECT="immich-$_suffix" + C_SERVER="immich_server_$_suffix" + C_ML="immich_ml_$_suffix" + C_REDIS="immich_redis_$_suffix" + C_DB="immich_postgres_$_suffix" + DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $IMMICH_DIR (port $WEB_PORT)" + fi + fi + # ── Photo library setup ───────────────────────────────────────────────── echo "" echo " PHOTO LIBRARY SETUP" @@ -384,18 +436,18 @@ networks: " cat > docker-compose.yml << IMMICH_COMPOSE -name: immich +name: $PROJECT services: immich-server: - container_name: immich_server + container_name: $C_SERVER image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release} volumes: ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro env_file: - .env ports: - - 2283:2283 + - ${WEB_PORT}:2283 depends_on: - redis - database @@ -404,7 +456,7 @@ ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localt disable: false ${_CADDY_NET_BLOCK} immich-machine-learning: - container_name: immich_machine_learning + container_name: $C_ML image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release} volumes: - model-cache:/cache @@ -415,14 +467,14 @@ ${_CADDY_NET_BLOCK} disable: false redis: - container_name: immich_redis + container_name: $C_REDIS image: docker.io/valkey/valkey:9-bookworm healthcheck: test: valkey-cli ping || exit 1 restart: always database: - container_name: immich_postgres + container_name: $C_DB image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 environment: POSTGRES_PASSWORD: \${DB_PASSWORD} @@ -541,7 +593,7 @@ IMMICH_ENV IMPORT_HEAD cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS -IMMICH_URL="http://localhost:2283" +IMMICH_URL="http://localhost:${WEB_PORT}" SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" IMMICH_DIR="$IMMICH_DIR" IMPORT_VARS @@ -791,17 +843,20 @@ IMPORT_BODY log_success "Import helper written: $IMMICH_DIR/import-photos.sh" fi - log_success "Immich configured at $IMMICH_DIR" + log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $IMMICH_DIR (port $WEB_PORT)" - configure_caddy_for_service "Immich" "immich-server:2283" "immich" + configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$IMMICH_DIR" << MD -# Immich +# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted photo and video backup — like Google Photos but private. Mobile apps (iOS/Android) auto-upload in the background. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own dedicated +database, own port) — not shared photos with another Immich instance.") -- Web UI: http://localhost:2283 +- Web UI: http://localhost:${WEB_PORT} - Photo storage: $( [ "$USE_S3" = true ] && echo "S3 bucket \`$S3_BUCKET\` (thumbnails, encoded video, new uploads)" || echo "\`$UPLOAD_LOCATION\`" ) - App data (postgres, model cache): inside this folder - Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply. @@ -836,8 +891,8 @@ docker compose pull && docker compose up -d # update \`\`\` ## First launch -1. Open http://localhost:2283 and create your admin account. -2. Install the Immich mobile app and point it at \`http://:2283\`. +1. Open http://localhost:${WEB_PORT} and create your admin account. +2. Install the Immich mobile app and point it at \`http://:${WEB_PORT}\`. 3. (External library mode) Go to Admin → External Libraries → Create Library, set import path to \`/usr/src/app/external\`, and click Scan. @@ -855,13 +910,13 @@ docker compose pull && docker compose up -d # update MD local START_IMMICH="" - prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH + prompt_yn "Start Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_IMMICH if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then - docker compose up -d && log_success "Immich started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:2283" + echo " Access at: http://localhost:${WEB_PORT}" echo " First launch: create your admin account in the web UI." echo "" } diff --git a/services/jellyfin.sh b/services/jellyfin.sh index ad7086d..b1f176f 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -188,19 +188,67 @@ register_service jellyfin media "Free media server — movies, TV, music (Jellyf install_jellyfin() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "jellyfin" name/paths/ports exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh (and services/emby.sh, + # the same idea for a sibling media server). local JELLYFIN_DIR="$DOCKER_DIR/jellyfin" + local INSTANCE_SUFFIX="" CONTAINER="jellyfin" + local WEB_PORT="8096" local DEFAULT_MEDIA="$ACTUAL_HOME/media" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Jellyfin would:" - echo " - Create $JELLYFIN_DIR with docker-compose.yml + .env (config/ cache/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/jellyfin(-) with docker-compose.yml + .env (config/ cache/)" echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media" echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists" - echo " - Expose port 8096 (+ DLNA 1900/udp, discovery 7359/udp)" + echo " - Expose port 8096, auto-scanned for additional instances" + echo " - First instance only: DLNA 1900/udp + discovery 7359/udp (host-wide;" + echo " additional instances skip these to avoid a fixed-port conflict)" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$JELLYFIN_DIR" ]; then + echo "" + echo " Jellyfin is already installed at $JELLYFIN_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Jellyfin instance alongside it (its own" + echo " server, library, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/jellyfin-$_suffix" ]; then + log_warning "jellyfin-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix" + CONTAINER="jellyfin-$_suffix" + DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $JELLYFIN_DIR (port $WEB_PORT)" + log_warning "DLNA/discovery (1900/udp, 7359/udp) are fixed, host-wide ports already" + log_warning "claimed by the first instance — this instance skips them (web UI and" + log_warning "app-based streaming are unaffected; DLNA auto-discovery is not)." + fi + fi + local MEDIA_PATH="" prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" @@ -248,14 +296,25 @@ networks: " fi + # DLNA (1900/udp) and discovery (7359/udp) are fixed, host-wide UDP ports — + # only the first instance publishes them to avoid a bind conflict with an + # existing instance. Additional instances still work for the web UI and + # every app-based client; only DLNA/network auto-discovery is instance-1-only. + local _DISCOVERY_PORTS="" + if [ -z "$INSTANCE_SUFFIX" ]; then + _DISCOVERY_PORTS=' - "1900:1900/udp" + - "7359:7359/udp" +' + fi + cat > docker-compose.yml << JELLYFIN_COMPOSE -name: jellyfin +name: $CONTAINER services: jellyfin: image: jellyfin/jellyfin:latest - container_name: jellyfin - hostname: jellyfin + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - TZ=$TZ_VAL @@ -265,10 +324,8 @@ $HWACCEL_BLOCK - ./cache:/cache - \${MEDIA_PATH}:/media:ro ports: - - "8096:8096" - - "1900:1900/udp" - - "7359:7359/udp" -${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} + - "${WEB_PORT}:8096" +${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} JELLYFIN_COMPOSE cat > .env << JELLYFIN_ENV @@ -278,16 +335,22 @@ JELLYFIN_ENV mkdir -p config cache chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" - log_success "Jellyfin configured at $JELLYFIN_DIR" + log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JELLYFIN_DIR (port $WEB_PORT)" - configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin" + configure_caddy_for_service "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8096" "jellyfin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$JELLYFIN_DIR" << MD -# Jellyfin +# Jellyfin${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Free media server (movies, TV, music) — a no-paywall alternative to Emby. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own library, own +port) — not a shared library with another Jellyfin instance. DLNA and +network auto-discovery are only published for the first instance (fixed, +host-wide ports); this instance still works for the web UI and every +app-based client.") -- Web UI: http://localhost:8096 +- Web UI: http://localhost:${WEB_PORT} - Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media - App data: \`config/\` and \`cache/\` in this folder - Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. @@ -309,13 +372,13 @@ docker compose pull && docker compose up -d # update MD local START_JF="" - prompt_yn "Start Jellyfin now? (y/n):" "y" START_JF + prompt_yn "Start Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JF if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then - docker compose up -d && log_success "Jellyfin started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:8096" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/joplin.sh b/services/joplin.sh index b161743..7d0a91c 100644 --- a/services/joplin.sh +++ b/services/joplin.sh @@ -185,14 +185,61 @@ install_joplin() { require_docker || return 1 log_info "Installing Joplin Server..." + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "joplin"/"joplin-db" names/paths/port + # exactly as before (zero behavior change for anyone with a single + # instance). Only asking to add a second one introduces suffixed naming — + # same pattern as services/mattermost.sh and services/wordpress.sh. + # Each instance gets its own dedicated Postgres container (not shared) — + # see CLAUDE.md's "Multi-instance services" section for why: Kopia's + # generic backup stops the container to snapshot it, so a shared DB would + # back up/restore every instance's notes as one unit instead of per-instance. local JOPLIN_DIR="$DOCKER_DIR/joplin" + local INSTANCE_SUFFIX="" CONTAINER="joplin" DB_CONTAINER="joplin-db" + local WEB_PORT="22300" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $JOPLIN_DIR" - echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create \$DOCKER_DIR/joplin(-)" + echo "[DRY-RUN] Would write docker-compose.yml and .env (dedicated Postgres per instance)" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$JOPLIN_DIR" ]; then + echo "" + echo " Joplin Server is already installed at $JOPLIN_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Joplin Server instance alongside it (its own" + echo " server, database, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'work'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/joplin-$_suffix" ]; then + log_warning "joplin-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix" + CONTAINER="joplin-$_suffix" + DB_CONTAINER="joplin-db-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $JOPLIN_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$JOPLIN_DIR" ensure_docker_dir_ownership "$JOPLIN_DIR" cd "$JOPLIN_DIR" || return 1 @@ -203,7 +250,7 @@ install_joplin() { local DB_PASS="" [ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)" [ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)" - local BASE_URL="https://joplin.${SITE_DOMAIN}" + local BASE_URL="https://joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}" # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, @@ -229,24 +276,24 @@ networks: fi cat > docker-compose.yml << JOPLIN_COMPOSE -name: joplin +name: $CONTAINER services: joplin: image: joplin/server:latest - container_name: joplin - hostname: joplin + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped depends_on: - joplin-db env_file: .env ports: - - "22300:22300" + - "${WEB_PORT}:22300" ${_CADDY_NET_BLOCK} joplin-db: image: postgres:15-alpine - container_name: joplin-db - hostname: joplin-db + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER restart: unless-stopped env_file: .env volumes: @@ -278,19 +325,22 @@ JOPLIN_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR" echo "" - log_success "Joplin Server configured at $JOPLIN_DIR" + log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JOPLIN_DIR (port $WEB_PORT)" 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" + configure_caddy_for_service "Joplin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:22300" "joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$JOPLIN_DIR" << MD -# Joplin Server +# Joplin Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted sync server for the Joplin note-taking app. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own dedicated +database, own port) — not shared notes with another Joplin instance.") ## Access -- URL: http://localhost:22300 +- URL: http://localhost:${WEB_PORT} - Default admin: admin@localhost / admin (change immediately after first login!) ## Important @@ -319,14 +369,14 @@ docker compose pull && docker compose down && docker compose up -d # update MD local START_JOPLIN="" - prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN + prompt_yn "Start Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} 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_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:22300" + echo " Access at: http://localhost:${WEB_PORT}" echo " Default login: admin@localhost / admin (change immediately!)" echo "" } diff --git a/services/lyrion.sh b/services/lyrion.sh index 81b349a..33dcb86 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -196,19 +196,85 @@ register_service lyrion media "Music streaming server — Squeezebox, Chromecast install_lyrion() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "lyrion" name/paths/ports and + # network_mode: host exactly as before (zero behavior change for anyone + # with a single instance) — host networking is what makes Chromecast and + # Squeezebox UDP broadcast/multicast discovery work without manual setup. + # + # A second instance CANNOT also use network_mode: host — both would bind + # the same fixed host ports (9000/9090/3483) and collide outright, and + # Docker only allows one container on host networking to own a given port. + # So additional instances switch to bridge networking with auto-scanned, + # per-instance ports instead. The tradeoff: bridge mode means this + # instance loses the zero-config broadcast/multicast auto-discovery that + # host networking provides — Chromecasts and Squeezebox hardware won't + # find it automatically. Players still work, just not auto-discovered: + # point the Squeezer app / Squeezebox firmware at this server's address + # and port manually instead of relying on discovery. local LYRION_DIR="$DOCKER_DIR/lyrion" + local INSTANCE_SUFFIX="" CONTAINER="lyrion" + local USE_HOST_NETWORK=true + local WEB_PORT="9000" CLI_PORT="9090" PLAYER_PORT="3483" local DEFAULT_MUSIC="$ACTUAL_HOME/music" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Lyrion Music Server would:" - echo " - Create $LYRION_DIR with docker-compose.yml + .env (config/ playlists/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/lyrion(-) with docker-compose.yml + .env (config/ playlists/)" echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music" - echo " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)" - echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)" + echo " - First instance: network_mode: host (Chromecast/Squeezebox UDP discovery)" + echo " ports 9000 (web), 9090 (CLI), 3483 (players)" + echo " - Additional instances: bridge networking with auto-scanned ports —" + echo " loses zero-config discovery; players need the server address entered manually" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$LYRION_DIR" ]; then + echo "" + echo " Lyrion is already installed at $LYRION_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Lyrion instance alongside it (its own" + echo " server, library, and ports — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/lyrion-$_suffix" ]; then + log_warning "lyrion-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + LYRION_DIR="$DOCKER_DIR/lyrion-$_suffix" + CONTAINER="lyrion-$_suffix" + DEFAULT_MUSIC="$ACTUAL_HOME/music-$_suffix" + USE_HOST_NETWORK=false + + log_warning "Additional Lyrion instances use bridge networking (not host) so they" + log_warning "don't collide with the first instance's fixed ports. This instance" + log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address" + log_warning "manually in the Squeezer app / Squeezebox firmware instead." + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${CLI_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${PLAYER_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + CLI_PORT=$((CLI_PORT + 1)) + PLAYER_PORT=$((PLAYER_PORT + 1)) + done + log_info "New instance: $LYRION_DIR (web $WEB_PORT, CLI $CLI_PORT, player $PLAYER_PORT)" + fi + fi + local MUSIC_PATH="" prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}" @@ -221,17 +287,28 @@ install_lyrion() { TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + local _NETWORK_BLOCK=" network_mode: host +" + local _PORTS_BLOCK="" + if [ "$USE_HOST_NETWORK" != true ]; then + _NETWORK_BLOCK="" + _PORTS_BLOCK=" ports: + - \"${WEB_PORT}:9000\" + - \"${CLI_PORT}:9090\" + - \"${PLAYER_PORT}:3483\" +" + fi + cat > docker-compose.yml << LYRION_COMPOSE -name: lyrion +name: $CONTAINER services: lyrion: image: lmscommunity/lyrionmusicserver:stable - container_name: lyrion - hostname: lyrion + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped - network_mode: host - environment: +${_NETWORK_BLOCK}${_PORTS_BLOCK} environment: - HTTP_PORT=9000 - PUID=$UID_VAL - PGID=$GID_VAL @@ -251,19 +328,26 @@ LYRION_ENV mkdir -p config playlists chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR" - log_success "Lyrion Music Server configured at $LYRION_DIR" + log_success "Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $LYRION_DIR (port $WEB_PORT)" - configure_caddy_for_service "Lyrion" "9000" "lyrion" + configure_caddy_for_service "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${WEB_PORT}" "lyrion${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$LYRION_DIR" << MD -# Lyrion Music Server +# Lyrion Music Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast. Formerly known as Logitech Media Server (LMS). +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own library, own +ports) — not a shared library with another Lyrion instance. It runs on +**bridge networking**, not host networking, so it does NOT get zero-config +Chromecast/Squeezebox discovery — enter this server's address and port +manually in the Squeezer app or Squeezebox firmware instead of relying on +auto-discovery.") -- Web UI: http://localhost:9000 -- Player port: 3483 (Squeezeboxes / apps) -- CLI port: 9090 +- Web UI: http://localhost:${WEB_PORT} +- Player port: ${PLAYER_PORT} (Squeezeboxes / apps) +- CLI port: ${CLI_PORT} - Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music - App data: \`config/\` and \`playlists/\` @@ -277,21 +361,26 @@ docker compose pull && docker compose up -d # update \`\`\` ## Notes -- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices - works without manual port mapping. +$( [ "$USE_HOST_NETWORK" = true ] && echo "- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices + works without manual port mapping." || echo "- Uses bridge networking (auto-scanned ports) since the first instance already + owns the fixed host-networking ports. Discovery is manual for this instance." ) - Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`. - Add music libraries in the web UI under Settings → Music Library. MD local START_LMS="" - prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS + prompt_yn "Start Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_LMS if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then - docker compose up -d && log_success "Lyrion started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:9000" - echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" + echo " Access at: http://localhost:${WEB_PORT}" + if [ "$USE_HOST_NETWORK" = true ]; then + echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" + else + echo " Note: uses bridge networking — no auto-discovery, configure players manually" + fi echo "" } diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 0eb8a1c..4c5691d 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -196,17 +196,61 @@ register_service meshcentral utilities "Self-hosted remote device management ser install_meshcentral() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "meshcentral" name/paths/ports exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. Two ports (web + agent) + # move together so they stay easy to reason about instance-to-instance. local MC_DIR="$DOCKER_DIR/meshcentral" + local INSTANCE_SUFFIX="" CONTAINER="meshcentral" + local WEB_PORT="4430" AGENT_PORT="4433" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] MeshCentral would:" - echo " - Create $MC_DIR with docker-compose.yml + .env (data/ files/ backups/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/meshcentral(-) with docker-compose.yml + .env (data/ files/ backups/)" echo " - Prompt for hostname (domain or IP for agent connections)" - echo " - Expose port 4430 (HTTPS web) and 4433 (agent)" + echo " - Expose port 4430 (HTTPS web) and 4433 (agent), auto-scanned for additional instances" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$MC_DIR" ]; then + echo "" + echo " MeshCentral is already installed at $MC_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate MeshCentral instance alongside it (its own" + echo " server, devices, and ports — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'clients'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/meshcentral-$_suffix" ]; then + log_warning "meshcentral-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + MC_DIR="$DOCKER_DIR/meshcentral-$_suffix" + CONTAINER="meshcentral-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${AGENT_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + AGENT_PORT=$((AGENT_PORT + 1)) + done + log_info "New instance: $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)" + fi + fi + local MC_HOSTNAME="" prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME MC_HOSTNAME="${MC_HOSTNAME:-localhost}" @@ -239,13 +283,13 @@ networks: fi cat > docker-compose.yml << MC_COMPOSE -name: meshcentral +name: $CONTAINER services: meshcentral: image: ghcr.io/ylianst/meshcentral:latest - container_name: meshcentral - hostname: meshcentral + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - NODE_ENV=production @@ -260,8 +304,8 @@ services: - ./files:/opt/meshcentral/meshcentral-files - ./backups:/opt/meshcentral/meshcentral-backups ports: - - "4430:443" - - "4433:4433" + - "${WEB_PORT}:443" + - "${AGENT_PORT}:4433" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} MC_COMPOSE @@ -274,18 +318,21 @@ MC_ENV mkdir -p data files backups chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" - log_success "MeshCentral configured at $MC_DIR" + log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)" - configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh" + configure_caddy_for_service "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:443" "mesh${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$MC_DIR" << MD -# MeshCentral +# MeshCentral${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted remote device management — remotely access, manage, and monitor all your computers from a single web interface. Install agents on each device. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own devices, own +ports) — not shared devices with another MeshCentral instance.") -- Web UI: https://localhost:4430 (self-signed cert on first launch) -- Agent listener: port 4433 (devices connect here — forward this port if remote) +- Web UI: https://localhost:${WEB_PORT} (self-signed cert on first launch) +- Agent listener: port ${AGENT_PORT} (devices connect here — forward this port if remote) - Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes) - App data: \`data/\`, \`files/\`, \`backups/\` @@ -299,14 +346,14 @@ docker compose pull && docker compose up -d # update \`\`\` ## First launch -1. Open https://localhost:4430 (accept the self-signed cert warning) +1. Open https://localhost:${WEB_PORT} (accept the self-signed cert warning) 2. Create your admin account 3. Go to "My Devices" → "+ Add Device" → download the agent for each OS 4. Install the agent on every computer you want to manage ## Remote access For devices outside your LAN to connect: -- Forward **TCP port 4433** on your router to this server +- Forward **TCP port ${AGENT_PORT}** on your router to this server - Set \`MC_HOSTNAME\` in \`.env\` to your public domain/IP, then restart ## Docs @@ -314,13 +361,13 @@ https://meshcentral.com/docs/ MD local START_MC="" - prompt_yn "Start MeshCentral now? (y/n):" "y" START_MC + prompt_yn "Start MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_MC if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then - docker compose up -d && log_success "MeshCentral started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: https://localhost:4430 (accept self-signed cert)" + echo " Access at: https://localhost:${WEB_PORT} (accept self-signed cert)" echo " First visit: create your admin account" echo "" } diff --git a/services/ntfy.sh b/services/ntfy.sh index 5b92e3d..2d2708e 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -192,13 +192,57 @@ register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090 install_ntfy() { require_docker || return 1 log_info "Installing ntfy..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "ntfy" name/paths/port exactly as before + # (zero behavior change for anyone with a single instance — including + # crowdsec.sh/pstn-trunk.sh, which curl the default port directly). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local NTFY_DIR="$DOCKER_DIR/ntfy" + local INSTANCE_SUFFIX="" CONTAINER="ntfy" + local WEB_PORT="8090" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $NTFY_DIR" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $NTFY_DIR(-)" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$NTFY_DIR" ]; then + echo "" + echo " ntfy is already installed at $NTFY_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate ntfy instance alongside it (its own" + echo " server, topics, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'alerts'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/ntfy-$_suffix" ]; then + log_warning "ntfy-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix" + CONTAINER="ntfy-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $NTFY_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$NTFY_DIR" ensure_docker_dir_ownership "$NTFY_DIR" cd "$NTFY_DIR" || return 1 @@ -227,13 +271,13 @@ networks: fi cat > docker-compose.yml << NTFY_COMPOSE -name: ntfy +name: $CONTAINER services: ntfy: image: binwiederhier/ntfy:latest - container_name: ntfy - hostname: ntfy + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped command: serve environment: @@ -242,7 +286,7 @@ services: - ./cache:/var/cache/ntfy - ./config:/etc/ntfy ports: - - "8090:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} NTFY_COMPOSE @@ -258,7 +302,7 @@ NTFY_ENV 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}" + NTFY_BASE_URL="https://ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}" fi cat > config/server.yml << NTFY_CFG # ntfy server configuration — https://docs.ntfy.sh/config/ @@ -291,22 +335,25 @@ NTFY_CFG chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" echo "" - log_success "ntfy configured at $NTFY_DIR" + log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $NTFY_DIR (port $WEB_PORT)" - configure_caddy_for_service "ntfy" "ntfy:80" "ntfy" + configure_caddy_for_service "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$NTFY_DIR" << MD -# ntfy +# ntfy${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted push notification server. Send notifications from scripts to your phone or browser. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own topics, own +port) — not shared topics with another ntfy instance.") ## Access -- URL: http://localhost:8090 +- URL: http://localhost:${WEB_PORT} ## Usage -- Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\` -- Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic +- Send a notification: \`curl -d "Hello!" localhost:${WEB_PORT}/mytopic\` +- Subscribe on phone: ntfy app -> Add subscription -> localhost:${WEB_PORT}/mytopic ## Access model \`auth-default-access: read-write\` — anyone who knows a topic name can read @@ -335,15 +382,15 @@ docker compose logs -f # logs MD local START_NTFY="" - prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY + prompt_yn "Start ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_NTFY if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then - docker compose up -d 2>/dev/null && log_success "ntfy started" || log_warning "Failed to start" + docker compose up -d 2>/dev/null && log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start" fi - echo " Access at: http://localhost:8090" + echo " Access at: http://localhost:${WEB_PORT}" echo "" - echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" - echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" + echo " Send notification: curl -d \"Hello!\" localhost:${WEB_PORT}/mytopic" + echo " Subscribe on phone: ntfy app → Add subscription → localhost:${WEB_PORT}/mytopic" echo "" } diff --git a/services/rustdesk.sh b/services/rustdesk.sh index c2060cb..ed7d09d 100644 --- a/services/rustdesk.sh +++ b/services/rustdesk.sh @@ -100,21 +100,76 @@ register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk) install_rustdesk() { require_docker || return 1 log_info "Installing RustDesk server..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "rustdesk" name/paths/ports exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. The 6 ports + # (21115-21119 TCP + 21116 UDP) are fixed *inside* the container — the + # image doesn't expose an env var to change them — so an additional + # instance shifts the whole host-side block by a fixed offset instead of + # scanning port-by-port, the same approach services/traccar.sh uses for + # its device-protocol range. local RD_DIR="$DOCKER_DIR/rustdesk" + local INSTANCE_SUFFIX="" CONTAINER="rustdesk" + local PORT_OFFSET=0 if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $RD_DIR (rustdesk_data/)" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create \$DOCKER_DIR/rustdesk(-) (rustdesk_data/)" echo "[DRY-RUN] Would deploy rustdesk/rustdesk-server-s6:latest" - echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP" + echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP — whole block shifted for additional instances" echo "[DRY-RUN] Would prompt for server FQDN/IP (RELAY env var)" return 0 fi + if [ -d "$RD_DIR" ]; then + echo "" + echo " RustDesk is already installed at $RD_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate RustDesk relay instance alongside it (its own" + echo " server and ports — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'work'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/rustdesk-$_suffix" ]; then + log_warning "rustdesk-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + RD_DIR="$DOCKER_DIR/rustdesk-$_suffix" + CONTAINER="rustdesk-$_suffix" + + PORT_OFFSET=10 + while ss -tlnH "sport = :$((21115 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -ulnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21117 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21118 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21119 + PORT_OFFSET))" 2>/dev/null | grep -q .; do + PORT_OFFSET=$((PORT_OFFSET + 10)) + done + log_info "New instance: $RD_DIR (ports $((21115 + PORT_OFFSET))-$((21119 + PORT_OFFSET)))" + fi + fi + mkdir -p "$RD_DIR/rustdesk_data" ensure_docker_dir_ownership "$RD_DIR" cd "$RD_DIR" || return 1 local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + local P_NAT=$((21115 + PORT_OFFSET)) P_ID=$((21116 + PORT_OFFSET)) \ + P_RELAY=$((21117 + PORT_OFFSET)) P_WS=$((21118 + PORT_OFFSET)) P_WSS=$((21119 + PORT_OFFSET)) echo "" echo " RustDesk needs to know its own public hostname or IP." @@ -134,23 +189,23 @@ install_rustdesk() { prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc [ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0" - cat > docker-compose.yml << 'RD_COMPOSE' -name: rustdesk + cat > docker-compose.yml << RD_COMPOSE +name: $CONTAINER services: rustdesk: image: rustdesk/rustdesk-server-s6:latest - container_name: rustdesk - hostname: rustdesk + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped env_file: .env ports: - - "21115:21115" - - "21116:21116" - - "21116:21116/udp" - - "21117:21117" - - "21118:21118" - - "21119:21119" + - "${P_NAT}:21115" + - "${P_ID}:21116" + - "${P_ID}:21116/udp" + - "${P_RELAY}:21117" + - "${P_WS}:21118" + - "${P_WSS}:21119" volumes: - ./rustdesk_data:/data RD_COMPOSE @@ -161,8 +216,8 @@ TZ=$TZ_VAL # ── RustDesk server ─────────────────────────────────────────────────────────── # RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR). -# Include the port if it's non-standard: hostname:21117 -RELAY=$RELAY_HOST:21117 +# Include the port if it's non-standard: hostname:$P_RELAY +RELAY=$RELAY_HOST:$P_RELAY # ENCRYPTED_ONLY: 1 = only clients with the matching public key can connect. # After first startup, copy the key from ./rustdesk_data/id_ed25519.pub to @@ -177,13 +232,16 @@ RD_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$RD_DIR" - log_success "RustDesk configured at $RD_DIR" + log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $RD_DIR (ports $P_NAT-$P_WSS)" write_readme "$RD_DIR" << MD -# RustDesk — self-hosted remote desktop relay +# RustDesk${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — self-hosted remote desktop relay Open-source TeamViewer alternative. This is the server-side relay/rendezvous daemon. Clients use the RustDesk desktop/mobile app to connect. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own key, own port +block) — not shared clients with another RustDesk instance.") ## After starting: get the public key @@ -193,8 +251,8 @@ cat $RD_DIR/rustdesk_data/id_ed25519.pub Paste this key into each client: **Settings → Network → ID/Relay Server** -- ID Server: $RELAY_HOST -- Relay Server: $RELAY_HOST +- ID Server: $RELAY_HOST:$P_ID +- Relay Server: $RELAY_HOST:$P_RELAY - Key: ## Firewall / router rules required @@ -202,11 +260,11 @@ Paste this key into each client: Open these ports to this server's IP: | Port | Protocol | Purpose | |------|----------|---------| -| 21115 | TCP | NAT type test | -| 21116 | TCP+UDP | ID register / hole-punching | -| 21117 | TCP | Relay traffic | -| 21118 | TCP | WebSocket | -| 21119 | TCP | WebSocket HTTPS | +| $P_NAT | TCP | NAT type test | +| $P_ID | TCP+UDP | ID register / hole-punching | +| $P_RELAY | TCP | Relay traffic | +| $P_WS | TCP | WebSocket | +| $P_WSS | TCP | WebSocket HTTPS | ## Cross-VLAN setup Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN @@ -224,10 +282,10 @@ docker compose pull && docker compose up -d # update MD local START_RD="" - prompt_yn "Start RustDesk server now? (y/n):" "y" START_RD + prompt_yn "Start RustDesk server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_RD if [ "$START_RD" = "y" ] || [ "$START_RD" = "Y" ]; then docker compose up -d \ - && log_success "RustDesk started" \ + && log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" echo "" echo " After startup, get the public key:" @@ -237,7 +295,7 @@ MD echo "" echo " Relay host: $RELAY_HOST" - echo " Ports 21115-21119 must be open in your firewall/router." + echo " Ports $P_NAT-$P_WSS must be open in your firewall/router." echo "" } diff --git a/services/unifi.sh b/services/unifi.sh index c18626f..1da9a83 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -98,16 +98,72 @@ register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443 install_unifi() { require_docker || return 1 log_info "Installing UniFi Network Application..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "unifi-db"/"unifi-app" names/paths/ports + # exactly as before (zero behavior change for anyone with a single + # instance). Only asking to add a second one introduces suffixed naming — + # same pattern as services/mattermost.sh and services/wordpress.sh. + # Each instance gets its own dedicated MongoDB container (not shared) — + # see CLAUDE.md's "Multi-instance services" section for why: Kopia's + # generic backup stops the container to snapshot it, so a shared DB would + # back up/restore every instance's site data as one unit instead of + # per-instance. All 4 published ports move together per instance. local UNIFI_DIR="$DOCKER_DIR/unifi" + local INSTANCE_SUFFIX="" PROJECT="unifi" DB_CONTAINER="unifi-db" APP_CONTAINER="unifi-app" + local WEB_PORT="8443" INFORM_PORT="8080" STUN_PORT="3478" DISCOVERY_PORT="10001" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $UNIFI_DIR (mongo_db_data/, unifi_data/)" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create \$DOCKER_DIR/unifi(-) (mongo_db_data/, unifi_data/)" echo "[DRY-RUN] Would deploy mongo:4 + linuxserver/unifi-network-application:latest" echo "[DRY-RUN] Ports: 8443 (HTTPS web UI), 8080 (device inform), 3478/udp (STUN), 10001/udp (discovery)" + echo "[DRY-RUN] — all 4 auto-scanned/shifted together for additional instances" echo "[DRY-RUN] Would generate MongoDB credentials" return 0 fi + if [ -d "$UNIFI_DIR" ]; then + echo "" + echo " UniFi is already installed at $UNIFI_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate UniFi controller instance alongside it (its own" + echo " database, sites, and ports — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'guest-site'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/unifi-$_suffix" ]; then + log_warning "unifi-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + UNIFI_DIR="$DOCKER_DIR/unifi-$_suffix" + PROJECT="unifi-$_suffix" + DB_CONTAINER="unifi-db-$_suffix" + APP_CONTAINER="unifi-app-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${INFORM_PORT}" 2>/dev/null | grep -q . \ + || ss -ulnH "sport = :${STUN_PORT}" 2>/dev/null | grep -q . \ + || ss -ulnH "sport = :${DISCOVERY_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + INFORM_PORT=$((INFORM_PORT + 1)) + STUN_PORT=$((STUN_PORT + 1)) + DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) + done + log_info "New instance: $UNIFI_DIR (web $WEB_PORT, inform $INFORM_PORT, STUN $STUN_PORT, discovery $DISCOVERY_PORT)" + fi + fi + mkdir -p "$UNIFI_DIR" ensure_docker_dir_ownership "$UNIFI_DIR" cd "$UNIFI_DIR" || return 1 @@ -143,13 +199,13 @@ networks: # Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$ cat > docker-compose.yml << UNIFI_COMPOSE -name: unifi +name: $PROJECT services: unifi-db: image: mongo:4 - container_name: unifi-db - hostname: unifi-db + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER restart: unless-stopped env_file: .env volumes: @@ -162,8 +218,8 @@ services: unifi-app: image: lscr.io/linuxserver/unifi-network-application:latest - container_name: unifi-app - hostname: unifi-app + container_name: $APP_CONTAINER + hostname: $APP_CONTAINER restart: unless-stopped env_file: .env depends_on: @@ -171,10 +227,10 @@ services: volumes: - ./unifi_data:/config ports: - - "8443:8443" - - "8080:8080" - - "3478:3478/udp" - - "10001:10001/udp" + - "${WEB_PORT}:8443" + - "${INFORM_PORT}:8080" + - "${STUN_PORT}:3478/udp" + - "${DISCOVERY_PORT}:10001/udp" # Optional — uncomment as needed: # - "1900:1900/udp" # L2 discovery (may conflict with UPnP) # - "8843:8843" # guest portal HTTPS @@ -216,7 +272,7 @@ UNIFI_ENV mkdir -p mongo_db_data unifi_data ensure_docker_dir_ownership "$UNIFI_DIR" - log_success "UniFi configured at $UNIFI_DIR" + log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $UNIFI_DIR (web port $WEB_PORT)" # ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ── local _caddy_mode="none" @@ -232,15 +288,18 @@ UNIFI_ENV fi echo "" local CADDY_UNIFI="" - prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI + prompt_yn "Configure Caddy reverse proxy for UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}? (y/n):" "n" CADDY_UNIFI if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then local UNIFI_DOMAIN="" - local _def_domain="unifi.${SITE_DOMAIN:-example.com}" + local _def_domain="unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}" prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN if [ -n "$UNIFI_DOMAIN" ]; then - # 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" + # UniFi uses HTTPS internally — upstream must use https:// + skip verify. + # Local mode reaches the container over Docker networking (internal port + # never changes); remote mode reaches this host's published port, which + # is WEB_PORT (auto-scanned for additional instances). + local _upstream="https://${APP_CONTAINER}:8443" + [ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:${WEB_PORT}" local _site_block _site_block="$(cat << CBLOCK @@ -277,7 +336,7 @@ CBLOCK || log_warning "Caddy reload failed — check: docker logs caddy" else local _snippet_dir="$DOCKER_DIR/caddy-snippets" - local _snippet_file="$_snippet_dir/unifi.caddy" + local _snippet_file="$_snippet_dir/unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.caddy" mkdir -p "$_snippet_dir" printf '%s\n' "$_site_block" > "$_snippet_file" chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true @@ -290,25 +349,29 @@ CBLOCK fi write_readme "$UNIFI_DIR" << MD -# UniFi Network Application +# UniFi Network Application${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Ubiquiti network controller. Manages UniFi APs, switches, and gateways. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own dedicated database, own +sites/devices, own ports) — not shared adoption with another UniFi instance. +Devices can only be adopted by one controller at a time.") ## Access -- Web UI: **https://localhost:8443** (HTTPS, self-signed cert — accept the warning) +- Web UI: **https://localhost:${WEB_PORT}** (HTTPS, self-signed cert — accept the warning) - First run: complete the setup wizard and adopt your devices. ## Device adoption -Make sure devices can reach **http://:8080/inform** as the inform URL. +Make sure devices can reach **http://:${INFORM_PORT}/inform** as the inform URL. In the controller: Settings → System → Application Configuration → Override inform host. ## Ports | Port | Protocol | Purpose | |------|----------|---------| -| 8443 | TCP | HTTPS web UI | -| 8080 | TCP | Device inform / HTTP redirect | -| 3478 | UDP | STUN | -| 10001 | UDP | AP discovery | +| ${WEB_PORT} | TCP | HTTPS web UI | +| ${INFORM_PORT} | TCP | Device inform / HTTP redirect | +| ${STUN_PORT} | UDP | STUN | +| ${DISCOVERY_PORT} | UDP | AP discovery | ## Manage \`\`\`bash @@ -327,15 +390,15 @@ docker compose pull && docker compose up -d # update (wait for DB first) MD local START_UNIFI="" - prompt_yn "Start UniFi now? (y/n):" "y" START_UNIFI + prompt_yn "Start UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_UNIFI if [ "$START_UNIFI" = "y" ] || [ "$START_UNIFI" = "Y" ]; then docker compose up -d \ - && log_success "UniFi started (first startup takes ~60 s while DB initializes)" \ + && log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started (first startup takes ~60 s while DB initializes)" \ || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Web UI: https://localhost:8443 (accept the self-signed cert warning)" + echo " Web UI: https://localhost:${WEB_PORT} (accept the self-signed cert warning)" echo " MongoDB credentials saved to: $UNIFI_DIR/.env" echo "" } diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 82867df..70607de 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -202,16 +202,61 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V install_vaultwarden() { require_docker || return 1 log_info "Installing Vaultwarden..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "vaultwarden" name/paths/port exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. A real use case: + # separate personal and family/household vaults with independent admin + # tokens, domains, and SMTP. local VW_DIR="$DOCKER_DIR/vaultwarden" + local INSTANCE_SUFFIX="" CONTAINER="vaultwarden" + local WEB_PORT="8888" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $VW_DIR (vaultwarden_data/)" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $VW_DIR(-) (vaultwarden_data/)" echo "[DRY-RUN] Would deploy vaultwarden/server:latest" echo "[DRY-RUN] Would generate admin token and prompt for domain" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" echo "[DRY-RUN] Signups disabled by default (enable via admin panel)" return 0 fi + if [ -d "$VW_DIR" ]; then + echo "" + echo " Vaultwarden is already installed at $VW_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Vaultwarden instance alongside it (its own" + echo " server, vault, and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/vaultwarden-$_suffix" ]; then + log_warning "vaultwarden-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix" + CONTAINER="vaultwarden-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $VW_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$VW_DIR/vaultwarden_data" ensure_docker_dir_ownership "$VW_DIR" cd "$VW_DIR" || return 1 @@ -225,7 +270,7 @@ install_vaultwarden() { echo " can connect and password-reset emails link correctly." echo "" local VW_DOMAIN="" - local DEFAULT_DOMAIN="https://vault.${SITE_DOMAIN:-example.com}" + local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}" prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN [ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN" @@ -266,19 +311,19 @@ networks: fi cat > docker-compose.yml << VW_COMPOSE -name: vaultwarden +name: $CONTAINER services: vaultwarden: image: vaultwarden/server:latest - container_name: vaultwarden - hostname: vaultwarden + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped env_file: .env volumes: - ./vaultwarden_data:/data ports: - - "8888:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} VW_COMPOSE @@ -311,15 +356,18 @@ VW_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$VW_DIR" - log_success "Vaultwarden configured at $VW_DIR" + log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $VW_DIR (port $WEB_PORT)" - configure_caddy_for_service "Vaultwarden" "vaultwarden:80" "vault" + configure_caddy_for_service "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$VW_DIR" << MD -# Vaultwarden — Bitwarden-compatible password manager +# Vaultwarden${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — Bitwarden-compatible password manager Lightweight, self-hosted Bitwarden server. Works with all official Bitwarden clients: browser extension, desktop app, and mobile app. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own vault, own +port) — not shared credentials with another Vaultwarden instance.") ## Setup 1. Point your Bitwarden client to: $VW_DOMAIN @@ -353,10 +401,10 @@ docker compose pull && docker compose up -d # update MD local START_VW="" - prompt_yn "Start Vaultwarden now? (y/n):" "y" START_VW + prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then docker compose up -d \ - && log_success "Vaultwarden started" \ + && log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi From 9e06ed4b831acadefb608427b14c1337899691cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 23:55:49 +0000 Subject: [PATCH 2/2] Bake cross-service port collision avoidance into every service script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With 70+ services sharing a handful of common default ports (emby and jellyfin both default to 8096, changedetection and frigate both default to 5000, arm and nextcloud both default to 8080...), nothing previously checked whether a service's default port was actually free on the host. Whichever service installed second would silently write a compose file claiming an already-held port, only failing at `docker compose up` time. Adds two shared helpers to lib/common.sh: - port_in_use PORT [PROTO] — true if something's already listening - find_free_port VARNAME START [PROTO] — scans upward, writes back the first free port Every service that publishes a fixed host port now scans before writing docker-compose.yml, on every install (not just when adding an explicit additional instance). On a normal single-install host this is a silent no-op; it only changes behavior when something else already holds the port. - The 19 services already given multi-instance support this session had their port scan moved out of the "add instance" branch to run unconditionally, since the same collision risk exists on a plain first install. - 20 more services with previously-hardcoded ports gained scanning for the first time: archivebox, arm, calibre-web, changedetection, drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf, uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so generated peer configs keep the right Endpoint), homeassistant (bridge-mode only — host mode can only warn), frigate and frigate-audio (multi-port stacks, moved together). - caddy.sh is the deliberate exception: 80/443 stay fixed and only warn on collision, since silently moving Caddy itself would leave nothing listening where any client actually looks. - authelia.sh needs no change — it has no published host port at all. - Every service's standalone bootstrap fallback (sudo bash services/x.sh with no sibling files) got the same two helpers duplicated into its stub block, matching how every other shared helper is already handled there. Documents the full pattern in CLAUDE.md's new "Port collision avoidance" section, including the quoted-heredoc/backtick-escaping gotcha and the network_mode:host limitation (can only scan ports the app takes as a configurable env var). Verified via bash -n on every changed file, plus functional runs seeding occupied ports for each collision shape used here (single, paired, multi-port stacks) and confirming the scan/shift and generated compose/README output are correct — including the emby/jellyfin, nextcloud/arm, and frigate/changedetection collision scenarios that originally motivated this. --- CLAUDE.md | 110 +++++++++++++++++++++++++++++++++++ lib/common.sh | 39 +++++++++++++ services/actualbudget.sh | 28 +++++++-- services/archivebox.sh | 48 ++++++++++----- services/arm.sh | 30 ++++++++-- services/audiobookshelf.sh | 27 +++++++-- services/authelia.sh | 15 +++++ services/caddy.sh | 31 ++++++++++ services/calibre-web.sh | 38 +++++++++--- services/changedetection.sh | 39 ++++++++++--- services/drum-rhythm-game.sh | 43 ++++++++++---- services/emby.sh | 32 +++++++--- services/filebrowser.sh | 27 +++++++-- services/fmd.sh | 27 +++++++-- services/frigate-audio.sh | 48 ++++++++++++--- services/frigate.sh | 49 +++++++++++++--- services/gatus.sh | 30 ++++++++-- services/homeassistant.sh | 38 ++++++++++-- services/homebox.sh | 27 +++++++-- services/immich.sh | 27 +++++++-- services/iopaint.sh | 34 ++++++++++- services/jellyfin.sh | 28 +++++++-- services/joplin.sh | 27 +++++++-- services/koha.sh | 56 +++++++++++++----- services/lyrion.sh | 52 +++++++++++++---- services/mattermost.sh | 46 ++++++++++----- services/mealie.sh | 27 +++++++-- services/meshcentral.sh | 33 ++++++++--- services/n8n.sh | 38 +++++++++--- services/nextcloud.sh | 29 ++++++++- services/ntfy.sh | 27 +++++++-- services/onlyoffice.sh | 28 ++++++++- services/portainer.sh | 37 ++++++++++-- services/rustdesk.sh | 41 +++++++++---- services/stirling-pdf.sh | 28 ++++++++- services/syncthing.sh | 53 ++++++++++++++--- services/traccar.sh | 29 +++++++-- services/unifi.sh | 40 +++++++++---- services/uptimekuma.sh | 28 ++++++++- services/vaultwarden.sh | 27 +++++++-- services/wg-easy.sh | 47 +++++++++++---- services/wordpress.sh | 15 +++++ 42 files changed, 1269 insertions(+), 254 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5f1a27..e07aee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -596,6 +596,116 @@ sequence (a fake `docker`/`ss` shim standing in for a live daemon is fine) and confirm the second one's directory, container names, and ports are actually distinct before trusting the logic. +## Port collision avoidance + +With 70+ services in this repo, several ship the same default port — +`emby` and `jellyfin` both default to 8096, `changedetection` and `frigate` +both default to 5000, `arm` and `nextcloud` both default to 8080. Nothing +enforced those defaults were actually free on the host: whichever service +started its container second would fail to bind ("port is already +allocated") instead of landing on the next free port. Confirmed live: +installing `jellyfin` after `emby` (or vice versa) writes a +`docker-compose.yml` claiming a port the other service's container already +holds, and it only fails at `docker compose up` time — not at install time, +and not with any warning from the installer itself. + +**Every service that publishes a fixed host port must scan for a free one +before writing `docker-compose.yml` — on every install, not only when +adding an explicit additional instance of itself.** Two shared helpers in +`lib/common.sh` do the work: + +```bash +port_in_use PORT [PROTO] # true if something's already listening; PROTO defaults to tcp, pass "udp" for UDP-only ports +find_free_port VARNAME START [PROTO] # scans upward from START, writes the free port back into VARNAME +``` + +Single-port services just call `find_free_port`: + +```bash +local WEB_PORT="8080" +find_free_port WEB_PORT "$WEB_PORT" +... + - "${WEB_PORT}:8080" # host side scanned; container-internal side stays literal +``` + +Services with multiple ports that must move together (a web port + an +agent/RTSP/MQTT port, etc.) loop over `port_in_use` directly instead, the +same pattern `services/meshcentral.sh` and `services/unifi.sh` use: + +```bash +while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + AGENT_PORT=$((AGENT_PORT + 1)) +done +``` + +On a normal single-install host this is a silent no-op — the default port +is free, so the variable comes back unchanged and nothing about the +install looks any different. It only changes behavior when something else +already holds the port, which is exactly the case that used to fail at +container-startup instead of being handled at install time. + +**Standalone-mode stub.** Every service also carries a standalone +bootstrap fallback (`if [[ -f "$_COMMON" ]]; then source it; else +fi`, for `sudo bash services/.sh` with no sibling files sourced) that +duplicates the helpers it needs. `port_in_use`/`find_free_port` get the +same treatment — copy the same two function bodies into that `else` block, +matching how `log_info`, `prompt_text`, `configure_caddy_for_service`, etc. +are already duplicated there. + +**Only the host side of a `"HOST:CONTAINER"` port mapping changes.** The +container-internal port is fixed by the application itself and stays a +literal number; only the host-published side becomes `${WEB_PORT}` (or +whatever the variable is named). Watch for the same literal number showing +up elsewhere in the file needing the same treatment: `configure_caddy_for_service` +calls (container-internal side stays literal; a *bare*-port host-networking +upstream, like `services/lyrion.sh`'s first instance, does need the +variable), generated companion scripts (`services/koha.sh`'s +`post-setup.sh`, `services/rustdesk.sh`'s relay-host messaging), `.env` +values baked into client-facing config (`services/wg-easy.sh`'s `WG_PORT` +env — WireGuard bakes the port into every generated peer config's +`Endpoint =` line, so it must track the *actual* published port, not just +the host-side compose mapping), and every `echo`/README line that prints +`http://localhost:`. + +**Quoted (`<< 'MD'`) README heredocs don't interpolate — check before +editing.** A `write_readme ... << MD` (unquoted) heredoc already +interpolates `${WEB_PORT}` directly. A quoted `<< 'MD'` heredoc doesn't, +and converting it means escaping *every* backtick used for inline-code +formatting (`` \`...\` ``) — miss one and bash tries to execute it as a +command substitution the next time the heredoc is read, the same class of +bug the coturn.sh backtick incident was (see `services/coturn.sh`'s +`write_readme` call). For a README with only one or two backticks, +escaping them is fine. For one with many (`services/iopaint.sh`'s model +reference table), it's safer to leave the heredoc quoted and patch the +port into the *written* `README.md` afterward instead: +```bash +[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md" +``` + +**`network_mode: host` services can only scan what the app itself lets you +override.** Host networking has no port *remapping* — whatever the app +binds to on its fixed internal port is what's exposed, so `find_free_port` +only helps for ports the app takes as configurable env vars. +`services/lyrion.sh`'s `HTTP_PORT` env is genuinely configurable (LMS +honors it as the actual bind port under host networking too — see its +`_HTTP_PORT_INTERNAL` handling, which must track the scanned port rather +than assuming bridge-mode's fixed `9000`), but its CLI (9090) and player +(3483) ports are hardcoded in the image with no override — a collision +there can only be warned about with `port_in_use`, not silently fixed. +`services/homeassistant.sh` is the same shape: bridge mode (the default) +scans freely; host mode (opt-in, for LAN device discovery) can only warn. + +**Caddy is the one deliberate exception — never auto-scanned.** +`services/caddy.sh` keeps 80/443 fixed and only warns via `port_in_use` if +they're already taken. Every other service either points HTTPS clients at +Caddy implicitly (browsers assume 443) or gets routed through it by +domain; silently moving Caddy itself to a random port would leave nothing +listening at the address any client actually tries, which is strictly +worse than the collision it would be "fixing." If 80/443 are already +bound, that's a real conflict (another web server on the host) the user +needs to resolve directly. + ## Chaining into another service from within your own A service can call another service's `install_()` directly as a diff --git a/lib/common.sh b/lib/common.sh index 35de9a6..1d96289 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -542,6 +542,45 @@ write_readme() { chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true } +# ── Host port collision avoidance (shared by every service that publishes a +# fixed host port) ──────────────────────────────────────────────────────────── +# With 70+ services in this repo, several ship the same default port (e.g. +# emby and jellyfin both default to 8096; changedetection and frigate both +# default to 5000). Nothing enforced those defaults were actually free on the +# host, so whichever service started its container second would fail to bind +# ("port is already allocated") instead of just landing on the next free port. +# Confirmed live: installing jellyfin after emby (or vice versa) writes a +# docker-compose.yml claiming a port the other service's container already +# holds, and only fails at `docker compose up` time — not at install time. +# +# port_in_use PORT [PROTO] — PROTO defaults to tcp; pass "udp" for UDP-only +# ports. Returns 0 (true, in use) or 1 (free). +port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . +} + +# find_free_port VARNAME START [PROTO] +# Scans upward from START for a free host port and writes the result back +# into VARNAME. Single-port convenience wrapper around port_in_use — every +# service's install_() should run its default/candidate port through +# this (or a hand-rolled port_in_use loop for multiple ports that must move +# together, e.g. a web port + an agent port) before writing docker-compose.yml, +# not only when adding an explicit additional instance of itself. On a normal +# single-install host this is a silent no-op (the default port is free, so +# VARNAME comes back unchanged); it only changes behavior when something else +# already holds the port, which is exactly the case that used to fail at +# startup instead of at install time. +find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" +} + # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── # Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] # UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 694ecf9..4bb68ef 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -229,14 +244,17 @@ install_actualbudget() { INSTANCE_SUFFIX="$_suffix" AB_DIR="$DOCKER_DIR/actualbudget-$_suffix" CONTAINER="actualbudget-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $AB_DIR (port $WEB_PORT)" + log_info "New instance: $AB_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port (e.g. + # emby and jellyfin both default to 8096) — see CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$AB_DIR/data" ensure_docker_dir_ownership "$AB_DIR" cd "$AB_DIR" || return 1 diff --git a/services/archivebox.sh b/services/archivebox.sh index a7d3bce..1d85b3b 100644 --- a/services/archivebox.sh +++ b/services/archivebox.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -182,15 +197,22 @@ install_archivebox() { require_docker || return 1 local AB_DIR="$DOCKER_DIR/archivebox" + local WEB_PORT="8000" 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)" + echo " - Expose port 8000 (web UI), auto-scanned for a free host port" return 0 fi + # Scan for a free host port — this default (8000) isn't unique to + # ArchiveBox in this repo, so a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$AB_DIR" ensure_docker_dir_ownership "$AB_DIR" cd "$AB_DIR" || return 1 @@ -236,7 +258,7 @@ services: volumes: - ./data:/data ports: - - "8000:8000" + - "${WEB_PORT}:8000" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ABCOMPOSE @@ -254,47 +276,47 @@ ABENV configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive" - write_readme "$AB_DIR" << 'MD' + 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 +- Web UI: http://localhost:${WEB_PORT} ## Manage -```bash +\`\`\`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 +\`\`\`bash +# Via web UI — visit http://localhost:${WEB_PORT} 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 +\`\`\`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_success "ArchiveBox started — http://localhost:${WEB_PORT}" \ || log_warning "Start failed — check: docker compose logs" fi echo "" - echo " Web UI: http://localhost:8000" + echo " Web UI: http://localhost:${WEB_PORT}" echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add" echo "" } diff --git a/services/arm.sh b/services/arm.sh index 02e2ab1..faf6b92 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -198,6 +213,7 @@ install_arm() { local ARM_DIR="$DOCKER_DIR/arm" local DEFAULT_OUTPUT="$ACTUAL_HOME/ripped" + local WEB_PORT="8080" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] A.R.M. would:" @@ -205,7 +221,7 @@ install_arm() { echo " - Detect optical drives (/dev/sr*) — defaults to /dev/sr0" echo " - Create ripped output dirs (movies/ music/) under $DEFAULT_OUTPUT" echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER") with privileged: true" - echo " - Expose port 8080" + echo " - Expose port 8080 (auto-scanned for a free host port)" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi @@ -225,6 +241,12 @@ install_arm() { OPTICAL_DRIVES="/dev/sr0" fi + # Scan for a free host port — this default (8080) isn't unique to A.R.M. + # in this repo (nextcloud also defaults to 8080), so a plain install + # shouldn't silently claim a port another already-running service holds. + # See CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$ARM_DIR" ensure_docker_dir_ownership "$ARM_DIR" cd "$ARM_DIR" || return 1 @@ -275,7 +297,7 @@ services: - \${ARM_OUTPUT}/movies:/home/arm/media/completed - \${ARM_OUTPUT}/music:/home/arm/music ports: - - "8080:8080" + - "${WEB_PORT}:8080" devices: - /dev/sr0:/dev/sr0 # Add more optical drives as needed: @@ -303,7 +325,7 @@ ARM_ENV Auto-rips DVDs, Blu-rays, and CDs when you insert them — identifies the disc, fetches metadata, and transcodes to a usable format. -- Web UI: http://localhost:8080 (complete setup on first visit) +- Web UI: http://localhost:${WEB_PORT} (complete setup on first visit) - Ripped output: \`$ARM_OUTPUT\` → movies and music subdirs - App data: \`config/\` and \`logs/\` @@ -338,7 +360,7 @@ MD fi echo "" - echo " Access at: http://localhost:8080" + echo " Access at: http://localhost:${WEB_PORT}" echo " Complete setup in browser on first visit." echo "" } diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index e7be929..bcc295c 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -232,14 +247,16 @@ install_audiobookshelf() { ABS_DIR="$DOCKER_DIR/audiobookshelf-$_suffix" CONTAINER="audiobookshelf-$_suffix" DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $ABS_DIR (port $WEB_PORT)" + log_info "New instance: $ABS_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + local AUDIOBOOKS_PATH="" prompt_text "Path to audiobooks folder [$DEFAULT_AUDIOBOOKS]:" "$DEFAULT_AUDIOBOOKS" AUDIOBOOKS_PATH AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH/#\~/$ACTUAL_HOME}"; AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH%/}" diff --git a/services/authelia.sh b/services/authelia.sh index 169685d..c31c91a 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -46,6 +46,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 diff --git a/services/caddy.sh b/services/caddy.sh index 126cb65..1037ba2 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -233,6 +248,22 @@ install_caddy() { fi fi + # Unlike every other service, Caddy's ports (80/443) are NOT auto-scanned + # or shifted on a collision — see CLAUDE.md's "Port collision avoidance" + # section. Every other service in this repo either points HTTPS clients + # at Caddy implicitly (browsers assume 443) or gets its own reverse-proxy + # domain through it; silently moving Caddy to a random port would leave + # nothing at the address clients actually try, which is strictly worse + # than the collision itself. If 80/443 are already taken, that's a real + # conflict (another web server already bound to those ports) the user + # needs to resolve directly — warn loudly instead of masking it. + if port_in_use 80 || port_in_use 443; then + log_warning "Port 80 and/or 443 is already in use by another process." + log_warning "Caddy needs both to serve HTTPS — find what's using them" + log_warning "(sudo ss -tlnp 'sport = :80' / ':443') and stop it, or Caddy" + log_warning "will fail to start. Continuing anyway — this is not auto-fixed." + fi + mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" ensure_docker_dir_ownership "$CADDY_DIR" diff --git a/services/calibre-web.sh b/services/calibre-web.sh index 1f34607..3555698 100644 --- a/services/calibre-web.sh +++ b/services/calibre-web.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,20 @@ install_calibre-web() { log_info "Installing Calibre-Web..." local CW_DIR="$DOCKER_DIR/calibre-web" + local WEB_PORT="8083" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $CW_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$CW_DIR/config" "$CW_DIR/books" ensure_docker_dir_ownership "$CW_DIR" cd "$CW_DIR" || return 1 @@ -238,7 +260,7 @@ services: - ./config:/config - ./books:/books ports: - - "8083:8083" + - "${WEB_PORT}:8083" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} CW_COMPOSE @@ -253,20 +275,20 @@ CW_ENV configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books" - write_readme "$CW_DIR" << 'MD' + 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` +1. Open the UI (http://localhost:${WEB_PORT}) 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 +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). @@ -275,15 +297,15 @@ 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. +editing the \`./books:/books\` volume line in docker-compose.yml. ## Manage -```bash +\`\`\`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="" diff --git a/services/changedetection.sh b/services/changedetection.sh index 4a7776e..49189aa 100644 --- a/services/changedetection.sh +++ b/services/changedetection.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,21 @@ install_changedetection() { log_info "Installing Changedetection.io..." local CD_DIR="$DOCKER_DIR/changedetection" + local WEB_PORT="5000" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $CD_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — this default (5000) isn't unique to + # Changedetection.io in this repo (frigate also defaults to 5000), so a + # plain install shouldn't silently claim a port another already-running + # service holds. See CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$CD_DIR/data" ensure_docker_dir_ownership "$CD_DIR" cd "$CD_DIR" || return 1 @@ -230,7 +253,7 @@ services: hostname: changedetection restart: unless-stopped ports: - - "5000:5000" + - "${WEB_PORT}:5000" environment: - BASE_URL=\${BASE_URL} - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 @@ -263,7 +286,7 @@ CD_ENV configure_caddy_for_service "Changedetection" "changedetection:5000" "changes" - write_readme "$CD_DIR" << 'MD' + write_readme "$CD_DIR" << MD # Changedetection.io Monitor web pages for changes and receive notifications via email, Slack, @@ -271,20 +294,20 @@ Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar for JavaScript-heavy pages. ## Access -- URL: http://localhost:5000 +- URL: http://localhost:${WEB_PORT} - Optional password can be set in Settings → General within the UI. ## Manage -```bash +\`\`\`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` +Edit \`.env\` to change \`BASE_URL\` (used for notification links), +then restart: \`docker compose down && docker compose up -d\` MD local START_CD="" @@ -295,7 +318,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:5000" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/drum-rhythm-game.sh b/services/drum-rhythm-game.sh index 1a82296..9d467c6 100644 --- a/services/drum-rhythm-game.sh +++ b/services/drum-rhythm-game.sh @@ -42,6 +42,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -187,15 +202,23 @@ install_drum-rhythm-game() { local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game" local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git" + local WEB_PORT="8096" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] drum-rhythm-game would:" echo " - Clone $REPO_URL to $DRUM_DIR/html" echo " - Build the repo's own Dockerfile (nginx, gzip, /healthz) on port 8096" + echo " (auto-scanned for a free host port — 8096 is also emby/jellyfin's default)" echo " - Offer Authelia SSO protection via Caddy (no built-in auth)" return 0 fi + # Scan for a free host port — this default (8096) is also emby's and + # jellyfin's default, so a plain install shouldn't silently claim a port + # another already-running service holds. See CLAUDE.md's "Port collision + # avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$DRUM_DIR" ensure_docker_dir_ownership "$DRUM_DIR" cd "$DRUM_DIR" || return 1 @@ -248,7 +271,7 @@ services: hostname: drum-rhythm-game restart: unless-stopped ports: - - "8096:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} DRUM_COMPOSE @@ -268,7 +291,7 @@ DRUM_ENV fi configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK" - write_readme "$DRUM_DIR" << 'MD' + write_readme "$DRUM_DIR" << MD # Drum Rhythm Game Browser-based drum rhythm game — 18 genres covering 119 synth-orchestra @@ -281,36 +304,36 @@ Multiplayer take-turns mode, adjustable speed/volume, and a leaderboard — all state lives in browser localStorage. No server required; all audio is synthesized in-browser via the Web Audio API. -Built and served from the repo's own `Dockerfile` (nginx + gzip + a -`/healthz` endpoint) — only `index.html` ends up in the image, so the +Built and served from the repo's own \`Dockerfile\` (nginx + gzip + a +\`/healthz\` endpoint) — only \`index.html\` ends up in the image, so the repo's docs/license/dev files never get served. Source: https://github.com/outis1one/drum-rhythm-game ## Access -- URL: http://localhost:8096 +- URL: http://localhost:${WEB_PORT} ## Manage -```bash +\`\`\`bash cd ~/docker/drum-rhythm-game docker compose up -d # start docker compose down # stop docker compose logs -f # logs -``` +\`\`\` ## Update game -```bash +\`\`\`bash cd ~/docker/drum-rhythm-game git -C html pull docker compose up -d --build -``` +\`\`\` 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 --build \ - && log_success "Drum Rhythm Game started — http://localhost:8096" \ + && log_success "Drum Rhythm Game started — http://localhost:${WEB_PORT}" \ || log_warning "Start failed — check: docker compose logs" fi diff --git a/services/emby.sh b/services/emby.sh index 8e50f79..3dd3c09 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -246,17 +261,18 @@ install_emby() { EMBY_DIR="$DOCKER_DIR/emby-$_suffix" CONTAINER="emby-$_suffix" DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - while ss -tlnH "sport = :${HTTPS_PORT}" 2>/dev/null | grep -q .; do - HTTPS_PORT=$((HTTPS_PORT + 1)) - done - log_info "New instance: $EMBY_DIR (web port $WEB_PORT, https port $HTTPS_PORT)" + log_info "New instance: $EMBY_DIR" fi fi + # Scan for free ports unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed these default ports + # (e.g. jellyfin also defaults to 8096) — see CLAUDE.md's "Port collision + # avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + find_free_port HTTPS_PORT "$HTTPS_PORT" + local MUSIC_ONLY="" prompt_yn "Set this up as a music-only server (skip movies/TV)? (y/n):" "n" MUSIC_ONLY diff --git a/services/filebrowser.sh b/services/filebrowser.sh index ec9c97a..5bbcebb 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -226,14 +241,16 @@ install_filebrowser() { INSTANCE_SUFFIX="$_suffix" FB_DIR="$DOCKER_DIR/filebrowser-$_suffix" CONTAINER="filebrowser-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $FB_DIR (port $WEB_PORT)" + log_info "New instance: $FB_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$FB_DIR/data" ensure_docker_dir_ownership "$FB_DIR" cd "$FB_DIR" || return 1 diff --git a/services/fmd.sh b/services/fmd.sh index 8f9180f..454b9ac 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -240,14 +255,16 @@ install_fmd() { INSTANCE_SUFFIX="$_suffix" FMD_DIR="$DOCKER_DIR/fmd-$_suffix" CONTAINER="fmd-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $FMD_DIR (port $WEB_PORT)" + log_info "New instance: $FMD_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$FMD_DIR" ensure_docker_dir_ownership "$FMD_DIR" cd "$FMD_DIR" || return 1 diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index c4fc1b6..cfa969f 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -62,6 +62,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$_len" @@ -216,6 +231,7 @@ install_frigate-audio() { require_docker || return 1 local DIR="$DOCKER_DIR/frigate-audio" + local WEB_PORT="8971" DEBUG_PORT="5001" RTSP_PORT="8554" WEBRTC_PORT="8555" MQTT_PORT="1883" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] frigate-audio would:" @@ -223,9 +239,27 @@ install_frigate-audio() { echo " Prompt for camera RTSP credentials, IPs, MQTT password, ntfy server" echo " Generate docker-compose.yml, frigate config, mosquitto config, .env" echo " Bootstrap the Mosquitto password file" + echo " Ports 8971/5001/8554/8555/1883 auto-scanned/shifted together if occupied" return 0 fi + # Scan for free host ports, moving all 5 together — a plain install + # shouldn't silently claim a port another already-running service holds + # (e.g. 1883 is the standard MQTT port and could already be in use by a + # Home Assistant add-on or another broker). Container-internal ports and + # container-to-container references (mosquitto:1883, 127.0.0.1:5000/8554 + # inside the frigate container) are unaffected by the host-side ports + # scanned here. See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$DEBUG_PORT" \ + || port_in_use "$RTSP_PORT" || port_in_use "$WEBRTC_PORT" \ + || port_in_use "$WEBRTC_PORT" udp || port_in_use "$MQTT_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + DEBUG_PORT=$((DEBUG_PORT + 1)) + RTSP_PORT=$((RTSP_PORT + 1)) + WEBRTC_PORT=$((WEBRTC_PORT + 1)) + MQTT_PORT=$((MQTT_PORT + 1)) + done + echo "" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ Frigate + Mosquitto + frigate-notify (audio-ready stack) ║" @@ -434,11 +468,11 @@ COMPOSEEOF tmpfs: size: 1000000000 ports: - - "8971:8971" - - "5001:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" + - "${WEB_PORT}:8971" + - "${DEBUG_PORT}:5000" + - "${RTSP_PORT}:8554" + - "${WEBRTC_PORT}:8555/tcp" + - "${WEBRTC_PORT}:8555/udp" ${_CADDY_NET_BLOCK} healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"] interval: 10s @@ -452,7 +486,7 @@ ${_CADDY_NET_BLOCK} healthcheck: image: eclipse-mosquitto:2 restart: unless-stopped ports: - - "1883:1883" + - "${MQTT_PORT}:1883" volumes: - ./mosquitto/config:/mosquitto/config - ./mosquitto/data:/mosquitto/data @@ -760,7 +794,7 @@ FNEOF if [[ ${START_NOW:-n} =~ ^[Yy]$ ]]; then log_info "Starting frigate-audio stack..." if ( cd "$DIR" && docker compose up -d ); then - log_success "Stack started — Frigate UI: http://localhost:8971" + log_success "Stack started — Frigate UI: http://localhost:${WEB_PORT}" else log_warning "Start failed — check: cd $DIR && docker compose logs" fi diff --git a/services/frigate.sh b/services/frigate.sh index f497374..586fca9 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -497,12 +512,15 @@ install_frigate() { require_docker || return 1 local FRIGATE_DIR="$DOCKER_DIR/frigate" + local WEB_PORT="5000" RTSP_PORT="8554" WEBRTC_PORT="8555" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Frigate would:" echo " - Create $FRIGATE_DIR with docker-compose.yml + .env + config/config.yml" echo " - Auto-enable /dev/dri/renderD128 for GPU-assisted detection if present" echo " - Expose ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC)" + echo " — all 3 auto-scanned/shifted together if occupied (5000 is also" + echo " changedetection's default)" echo " - If already configured: show existing cameras and offer to keep," echo " back up + start fresh, add more, or remove some" echo " - Prompt to add cameras interactively (RTSP creds go in .env)" @@ -511,6 +529,19 @@ install_frigate() { return 0 fi + # Scan for free host ports, moving all 3 together — a plain install + # shouldn't silently claim a port another already-running service holds + # (changedetection.io also defaults to 5000). See CLAUDE.md's "Port + # collision avoidance" section. Internal go2rtc restream URLs elsewhere + # in this file (rtsp://127.0.0.1:8554/...) are container-internal + # loopback references, unaffected by the host-side port used here. + while port_in_use "$WEB_PORT" || port_in_use "$RTSP_PORT" \ + || port_in_use "$WEBRTC_PORT" || port_in_use "$WEBRTC_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + RTSP_PORT=$((RTSP_PORT + 1)) + WEBRTC_PORT=$((WEBRTC_PORT + 1)) + done + mkdir -p "$FRIGATE_DIR" ensure_docker_dir_ownership "$FRIGATE_DIR" cd "$FRIGATE_DIR" || return 1 @@ -605,10 +636,10 @@ $DEVICE_BLOCK tmpfs: size: 1000000000 ports: - - "5000:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" + - "${WEB_PORT}:5000" + - "${RTSP_PORT}:8554" + - "${WEBRTC_PORT}:8555/tcp" + - "${WEBRTC_PORT}:8555/udp" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} FRIGATE_COMPOSE @@ -709,9 +740,9 @@ FRIGATE_ENV AI-powered network video recorder with real-time object detection for security cameras. Detects people, cars, animals, and more. -- Web UI: http://localhost:5000 -- RTSP restream: port 8554 -- WebRTC: port 8555 +- Web UI: http://localhost:${WEB_PORT} +- RTSP restream: port ${RTSP_PORT} +- WebRTC: port ${WEBRTC_PORT} - Recordings: \`$FRIGATE_MEDIA\` - Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total) - Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables @@ -736,7 +767,7 @@ container startup. ## First steps 1. Review \`config/config.yml\` — adjust detection zones, masks, retention 2. Start Frigate: \`docker compose up -d\` -3. Open http://localhost:5000 to view cameras and configure detection zones +3. Open http://localhost:${WEB_PORT} to view cameras and configure detection zones ## Hardware acceleration - Intel/AMD GPU: uncomment the \`devices: [/dev/dri/renderD128]\` block @@ -761,7 +792,7 @@ MD fi echo "" - echo " Access at: http://localhost:5000" + echo " Access at: http://localhost:${WEB_PORT}" echo " Config: $FRIGATE_DIR/config/config.yml" echo "" } diff --git a/services/gatus.sh b/services/gatus.sh index 3a37854..ca12035 100644 --- a/services/gatus.sh +++ b/services/gatus.sh @@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -196,14 +211,21 @@ install_gatus() { require_docker || return 1 log_info "Installing Gatus..." local GATUS_DIR="$DOCKER_DIR/gatus" + local WEB_PORT="8086" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $GATUS_DIR (gatus_config/, gatus_data/)" echo "[DRY-RUN] Would deploy twinproduction/gatus:latest" - echo "[DRY-RUN] Port 8086 published, config at gatus_config/config.yaml" + echo "[DRY-RUN] Port 8086 published (auto-scanned for a free host port)," + echo "[DRY-RUN] config at gatus_config/config.yaml" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$GATUS_DIR/gatus_config" "$GATUS_DIR/gatus_data" ensure_docker_dir_ownership "$GATUS_DIR" cd "$GATUS_DIR" || return 1 @@ -244,7 +266,7 @@ services: restart: unless-stopped env_file: .env ports: - - "8086:8080" + - "${WEB_PORT}:8080" volumes: - ./gatus_config:/config - ./gatus_data:/data @@ -324,7 +346,7 @@ GATUS_CFG Clean, self-hosted status page. Polls HTTP, TCP, DNS, and ICMP endpoints. ## Access -- URL: http://localhost:8086 +- URL: http://localhost:${WEB_PORT} ## Configuration Edit \`gatus_config/config.yaml\` — changes are **hot-reloaded** without restarting. @@ -355,7 +377,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:8086" + echo " Access at: http://localhost:${WEB_PORT}" echo " Config: $GATUS_DIR/gatus_config/config.yaml (hot-reloaded)" echo "" } diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 9501ac4..4c454c2 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -189,9 +204,13 @@ install_homeassistant() { require_docker || return 1 log_info "Installing Home Assistant..." local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" + local WEB_PORT="8123" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" + echo "[DRY-RUN] Bridge mode would auto-scan for a free host port; host mode" + echo "[DRY-RUN] can only warn on a collision (Home Assistant's own port isn't" + echo "[DRY-RUN] configurable, and host networking has no remapping)" return 0 fi @@ -224,16 +243,25 @@ install_homeassistant() { HA_NET_LINES=" network_mode: host" HA_CADDY_NET_LINES="" echo " → Host networking selected (best device discovery)." + # Host mode has no port remapping and Home Assistant's own port isn't + # configurable via env var — a collision here can only be warned + # about, not silently fixed. See CLAUDE.md's "Port collision + # avoidance" section. + port_in_use "$WEB_PORT" && log_warning "Port $WEB_PORT is already in use by another process — Home Assistant won't be reachable until that's resolved (this port isn't configurable with host networking)." else + # Bridge mode can freely remap — scan for a free host port so a + # plain install doesn't silently claim a port another + # already-running service holds. + find_free_port WEB_PORT "$WEB_PORT" HA_NET_LINES=" ports: - - \"8123:8123\"" + - \"${WEB_PORT}:8123\"" if [ "$_CADDY_MODE" = "local" ]; then HA_CADDY_NET_LINES=" networks: - caddy_net" else HA_CADDY_NET_LINES="" fi - echo " → Bridge networking selected (port 8123 published)." + echo " → Bridge networking selected (port $WEB_PORT published)." fi local _CADDY_NET_SECTION="" @@ -292,7 +320,7 @@ HA_CONFIG log_success "Home Assistant configured at $HOMEASSISTANT_DIR" if [ "$HA_NETMODE" = "2" ]; then - configure_caddy_for_service "Home Assistant" "8123" "home" + configure_caddy_for_service "Home Assistant" "$WEB_PORT" "home" else configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" fi @@ -303,7 +331,7 @@ HA_CONFIG Home automation hub. Built-in auth — no Authelia needed. ## Access -- URL: http://localhost:8123 +- URL: http://localhost:${WEB_PORT} - First run: create your admin account through the onboarding wizard ## Manage @@ -322,7 +350,7 @@ MD docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start" fi - echo " Access at: http://localhost:8123" + echo " Access at: http://localhost:${WEB_PORT}" echo " First run: open the URL and create your admin account (onboarding)." echo " Note: first startup can take a minute while HA initializes." echo "" diff --git a/services/homebox.sh b/services/homebox.sh index 5c94ba2..a1c116f 100644 --- a/services/homebox.sh +++ b/services/homebox.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -227,14 +242,16 @@ install_homebox() { INSTANCE_SUFFIX="$_suffix" HB_DIR="$DOCKER_DIR/homebox-$_suffix" CONTAINER="homebox-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $HB_DIR (port $WEB_PORT)" + log_info "New instance: $HB_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$HB_DIR/data" ensure_docker_dir_ownership "$HB_DIR" cd "$HB_DIR" || return 1 diff --git a/services/immich.sh b/services/immich.sh index 7eb1bca..7197439 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -250,14 +265,16 @@ install_immich() { C_REDIS="immich_redis_$_suffix" C_DB="immich_postgres_$_suffix" DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $IMMICH_DIR (port $WEB_PORT)" + log_info "New instance: $IMMICH_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + # ── Photo library setup ───────────────────────────────────────────────── echo "" echo " PHOTO LIBRARY SETUP" diff --git a/services/iopaint.sh b/services/iopaint.sh index 1c3691c..9a458bf 100644 --- a/services/iopaint.sh +++ b/services/iopaint.sh @@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -194,15 +209,22 @@ install_iopaint() { log_info "Installing IOPaint..." local IOPAINT_DIR="$DOCKER_DIR/iopaint" + local WEB_PORT="8100" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $IOPAINT_DIR" echo "[DRY-RUN] Would prompt for model and GPU (CUDA) support" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" echo "[DRY-RUN] Would offer Authelia SSO" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$IOPAINT_DIR" ensure_docker_dir_ownership "$IOPAINT_DIR" cd "$IOPAINT_DIR" || return 1 @@ -308,7 +330,7 @@ services: --port=8080 --host=0.0.0.0 ports: - - "8100:8080" + - "${WEB_PORT}:8080" env_file: .env volumes: - ./models:/root/.cache @@ -340,7 +362,7 @@ services: --port=8080 --host=0.0.0.0 ports: - - "8100:8080" + - "${WEB_PORT}:8080" env_file: .env volumes: - ./models:/root/.cache @@ -467,6 +489,12 @@ docker compose pull && docker compose down && docker compose up -d - input/, output/ — optional file staging MD + # The README above is a quoted (non-interpolating) heredoc — dense with + # literal backticks for inline code spans, too risky to convert to an + # interpolating heredoc without escaping every one of them. Patch the + # port in afterward instead when it was scanned away from the default. + [ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md" + local START_IO="" prompt_yn "Start IOPaint now? (y/n):" "y" START_IO if [ "$START_IO" = "y" ] || [ "$START_IO" = "Y" ]; then @@ -476,7 +504,7 @@ MD fi echo "" - echo " URL: http://localhost:8100" + echo " URL: http://localhost:${WEB_PORT}" echo " Model: $IOPAINT_MODEL" echo " Device: $DEVICE_VAL" echo " Switch: edit MODEL= in $IOPAINT_DIR/.env and restart" diff --git a/services/jellyfin.sh b/services/jellyfin.sh index b1f176f..53187b4 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -43,6 +43,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -238,17 +253,20 @@ install_jellyfin() { JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix" CONTAINER="jellyfin-$_suffix" DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $JELLYFIN_DIR (port $WEB_PORT)" + log_info "New instance: $JELLYFIN_DIR" log_warning "DLNA/discovery (1900/udp, 7359/udp) are fixed, host-wide ports already" log_warning "claimed by the first instance — this instance skips them (web UI and" log_warning "app-based streaming are unaffected; DLNA auto-discovery is not)." fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port (e.g. + # emby also defaults to 8096) — see CLAUDE.md's "Port collision avoidance" + # section. + find_free_port WEB_PORT "$WEB_PORT" + local MEDIA_PATH="" prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" diff --git a/services/joplin.sh b/services/joplin.sh index 7d0a91c..7fa279c 100644 --- a/services/joplin.sh +++ b/services/joplin.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -232,14 +247,16 @@ install_joplin() { JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix" CONTAINER="joplin-$_suffix" DB_CONTAINER="joplin-db-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $JOPLIN_DIR (port $WEB_PORT)" + log_info "New instance: $JOPLIN_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$JOPLIN_DIR" ensure_docker_dir_ownership "$JOPLIN_DIR" cd "$JOPLIN_DIR" || return 1 diff --git a/services/koha.sh b/services/koha.sh index c68258e..ecd5db0 100644 --- a/services/koha.sh +++ b/services/koha.sh @@ -45,6 +45,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -196,6 +211,17 @@ install_koha() { require_docker || return 1 local KOHA_DIR="$DOCKER_DIR/koha" + local OPAC_PORT="8097" STAFF_PORT="8098" + + # Scan for free host ports, moving both together — a plain install + # shouldn't silently claim a port another already-running service holds. + # Run before any prompts so every message below (including DRY-RUN) + # reflects the real port. See CLAUDE.md's "Port collision avoidance" + # section. + while port_in_use "$OPAC_PORT" || port_in_use "$STAFF_PORT"; do + OPAC_PORT=$((OPAC_PORT + 1)) + STAFF_PORT=$((STAFF_PORT + 1)) + done echo "" echo "╔═══════════════════════════════════════════════════════════════════╗" @@ -211,7 +237,7 @@ install_koha() { echo " SETUP OVERVIEW:" echo " 1) Answer the questions below (collects library details + credentials)" echo " 2) Koha starts — takes 2–3 min on first boot" - echo " 3) Open http://localhost:8098 and complete the brief web installer (~2 min)" + echo " 3) Open http://localhost:${STAFF_PORT} and complete the brief web installer (~2 min)" echo " 4) Run $KOHA_DIR/post-setup.sh to auto-configure library, items, locations" echo "" @@ -219,8 +245,8 @@ install_koha() { echo "[DRY-RUN] Would create $KOHA_DIR with docker-compose.yml + config-main.env" echo "[DRY-RUN] Would deploy teogramm/koha + MariaDB (./data/) + Memcached + RabbitMQ" echo "[DRY-RUN] Would generate post-setup.sh for REST API configuration" - echo "[DRY-RUN] OPAC (patron UI): port 8097" - echo "[DRY-RUN] Staff/admin: port 8098" + echo "[DRY-RUN] OPAC (patron UI): port $OPAC_PORT" + echo "[DRY-RUN] Staff/admin: port $STAFF_PORT" return 0 fi @@ -399,8 +425,8 @@ services: env_file: - config-main.env ports: - - "8097:8080" - - "8098:8081" + - "${OPAC_PORT}:8080" + - "${STAFF_PORT}:8081" depends_on: - koha-db - koha-memcached @@ -485,13 +511,13 @@ KOHA_ENV cat > post-setup.sh << POSTSETUP #!/bin/bash -# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:8098 +# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:${STAFF_PORT} # Configures library, item types, and shelf locations via the Koha REST API. # Generated by ubuntu-post-install on $(date '+%F'). set -uo pipefail -KOHA_STAFF_URL="http://localhost:8098" +KOHA_STAFF_URL="http://localhost:${STAFF_PORT}" KOHA_DIR="${KOHA_DIR}" CONF="\${KOHA_DIR}/config-main.env" @@ -627,8 +653,8 @@ echo "════════════════════════ echo "" echo " Your Koha library is ready to use." echo "" -echo " OPAC (patron browsing): http://localhost:8097" -echo " Staff / admin: http://localhost:8098" +echo " OPAC (patron browsing): http://localhost:${OPAC_PORT}" +echo " Staff / admin: http://localhost:${STAFF_PORT}" echo "" echo " Next steps in Staff UI:" echo " 1. Administration → Patron categories → add patron types" @@ -658,8 +684,8 @@ POSTSETUP Full Integrated Library System for managing a physical book collection. ## Access -- **OPAC** (patron browsing): http://localhost:8097 -- **Staff / admin**: http://localhost:8098 +- **OPAC** (patron browsing): http://localhost:${OPAC_PORT} +- **Staff / admin**: http://localhost:${STAFF_PORT} ## Quick setup (4 steps) @@ -671,7 +697,7 @@ docker compose up -d Takes 2–3 minutes on first boot while Koha initialises. ### Step 2 — Complete the web installer -1. Open **http://localhost:8098** +1. Open **http://localhost:${STAFF_PORT}** 2. You may see a "Database connection" page first — wait 1–2 min and refresh 3. The installer wizard appears automatically: - **Language**: click "Install for language English" → Continue @@ -743,12 +769,12 @@ MD echo " └──────────────────────────────────────────────────────────────┘" echo "" echo " NEXT STEPS:" - echo " 1. Wait ~3 min, then open: http://localhost:8098" + echo " 1. Wait ~3 min, then open: http://localhost:${STAFF_PORT}" echo " 2. Complete the web installer (use password above when asked)" echo " 3. Run: sudo $KOHA_DIR/post-setup.sh" echo "" - echo " OPAC (patron UI): http://localhost:8097" - echo " Staff / admin: http://localhost:8098" + echo " OPAC (patron UI): http://localhost:${OPAC_PORT}" + echo " Staff / admin: http://localhost:${STAFF_PORT}" echo "" } diff --git a/services/lyrion.sh b/services/lyrion.sh index 33dcb86..f44e576 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -263,18 +278,29 @@ install_lyrion() { log_warning "don't collide with the first instance's fixed ports. This instance" log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address" log_warning "manually in the Squeezer app / Squeezebox firmware instead." - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${CLI_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${PLAYER_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - CLI_PORT=$((CLI_PORT + 1)) - PLAYER_PORT=$((PLAYER_PORT + 1)) - done - log_info "New instance: $LYRION_DIR (web $WEB_PORT, CLI $CLI_PORT, player $PLAYER_PORT)" + log_info "New instance: $LYRION_DIR" fi fi + # Port collision avoidance — see CLAUDE.md's "Port collision avoidance" + # section. Bridge-mode instances can fully auto-scan (all 3 ports move + # together). Host-mode (the first instance) can only auto-adjust + # WEB_PORT — HTTP_PORT is a real env var the image honors even under + # host networking — CLI_PORT/PLAYER_PORT are hardcoded inside the image + # with no override, so a collision there can only be warned about, not + # silently fixed. + if [ "$USE_HOST_NETWORK" = true ]; then + find_free_port WEB_PORT "$WEB_PORT" + port_in_use "$CLI_PORT" && log_warning "CLI port $CLI_PORT is already in use by another process — Lyrion's CLI interface won't be reachable until that's resolved (this port isn't configurable in the image)." + port_in_use "$PLAYER_PORT" && log_warning "Player port $PLAYER_PORT is already in use by another process — Squeezebox/app connections won't work until that's resolved (this port isn't configurable in the image)." + else + while port_in_use "$WEB_PORT" || port_in_use "$CLI_PORT" || port_in_use "$PLAYER_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + CLI_PORT=$((CLI_PORT + 1)) + PLAYER_PORT=$((PLAYER_PORT + 1)) + done + fi + local MUSIC_PATH="" prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}" @@ -287,9 +313,14 @@ install_lyrion() { TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + # Host mode has no ports: remapping — HTTP_PORT is the only thing that + # actually controls the bind port, so it must track the scanned WEB_PORT. + # Bridge mode keeps the container's internal port fixed at 9000 and lets + # the ports: line do the remapping instead. local _NETWORK_BLOCK=" network_mode: host " local _PORTS_BLOCK="" + local _HTTP_PORT_INTERNAL="$WEB_PORT" if [ "$USE_HOST_NETWORK" != true ]; then _NETWORK_BLOCK="" _PORTS_BLOCK=" ports: @@ -297,6 +328,7 @@ install_lyrion() { - \"${CLI_PORT}:9090\" - \"${PLAYER_PORT}:3483\" " + _HTTP_PORT_INTERNAL="9000" fi cat > docker-compose.yml << LYRION_COMPOSE @@ -309,7 +341,7 @@ services: hostname: $CONTAINER restart: unless-stopped ${_NETWORK_BLOCK}${_PORTS_BLOCK} environment: - - HTTP_PORT=9000 + - HTTP_PORT=$_HTTP_PORT_INTERNAL - PUID=$UID_VAL - PGID=$GID_VAL - TZ=$TZ_VAL diff --git a/services/mattermost.sh b/services/mattermost.sh index 486fe97..83bdfaf 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -251,25 +266,24 @@ install_mattermost() { MM_CONTAINER="mattermost-$_suffix" DB_CONTAINER="mattermost-$_suffix-db" COTURN_CONSUMER="mattermost-$_suffix" - - # Free-port scan — same pattern services/asterisk.sh uses for its - # web admin port. WEB_PORT is also set as Mattermost's own - # internal ListenAddress below (not just the host publish side), - # so configure_caddy_for_service's single upstream "name:port" - # string works unmodified in both local and remote-Caddy mode — - # it assumes host-published-port == container-internal-port, - # true for every other service in this repo and made true here - # too rather than special-casing the shared helper for one caller. - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do - CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1)) - done - log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)" + log_info "New instance: $DIR" fi fi + # Free-port scan — runs unconditionally, not just when adding an explicit + # additional instance, so a plain first install also can't collide with + # an unrelated service that already claimed these default ports. Same + # pattern services/asterisk.sh uses for its web admin port. WEB_PORT is + # also set as Mattermost's own internal ListenAddress below (not just the + # host publish side), so configure_caddy_for_service's single upstream + # "name:port" string works unmodified in both local and remote-Caddy mode — + # it assumes host-published-port == container-internal-port, true for + # every other service in this repo and made true here too rather than + # special-casing the shared helper for one caller. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + find_free_port CALLS_UDP_PORT "$CALLS_UDP_PORT" udp + log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..." if [ "$DRY_RUN" = true ]; then diff --git a/services/mealie.sh b/services/mealie.sh index a4a6911..e343054 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -230,14 +245,16 @@ install_mealie() { INSTANCE_SUFFIX="$_suffix" MEALIE_DIR="$DOCKER_DIR/mealie-$_suffix" CONTAINER="mealie-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $MEALIE_DIR (port $WEB_PORT)" + log_info "New instance: $MEALIE_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$MEALIE_DIR" ensure_docker_dir_ownership "$MEALIE_DIR" cd "$MEALIE_DIR" || return 1 diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 4c5691d..21b079d 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -241,16 +256,20 @@ install_meshcentral() { INSTANCE_SUFFIX="$_suffix" MC_DIR="$DOCKER_DIR/meshcentral-$_suffix" CONTAINER="meshcentral-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${AGENT_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - AGENT_PORT=$((AGENT_PORT + 1)) - done - log_info "New instance: $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)" + log_info "New instance: $MC_DIR" fi fi + # Scan for free ports unconditionally, moving both together — not just + # when adding an explicit additional instance. A plain first install can + # just as easily collide with an unrelated service that already claimed + # one of these default ports — see CLAUDE.md's "Port collision + # avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + AGENT_PORT=$((AGENT_PORT + 1)) + done + local MC_HOSTNAME="" prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME MC_HOSTNAME="${MC_HOSTNAME:-localhost}" diff --git a/services/n8n.sh b/services/n8n.sh index 896c4e6..adc8c18 100644 --- a/services/n8n.sh +++ b/services/n8n.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,20 @@ install_n8n() { log_info "Installing n8n..." local N8N_DIR="$DOCKER_DIR/n8n" + local WEB_PORT="5678" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $N8N_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$N8N_DIR/data" ensure_docker_dir_ownership "$N8N_DIR" cd "$N8N_DIR" || return 1 @@ -230,7 +252,7 @@ services: hostname: n8n restart: unless-stopped ports: - - "5678:5678" + - "${WEB_PORT}:5678" env_file: - .env environment: @@ -260,27 +282,27 @@ N8N_ENV configure_caddy_for_service "n8n" "n8n:5678" "n8n" - write_readme "$N8N_DIR" << 'MD' + 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 +- URL: http://localhost:${WEB_PORT} - On first run, n8n prompts you to create an owner account. ## Manage -```bash +\`\`\`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` +Edit \`.env\` to change \`WEBHOOK_URL\` or \`N8N_HOST\` after deployment, +then restart: \`docker compose down && docker compose up -d\` MD local START_N8N="" @@ -291,7 +313,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:5678" + echo " Access at: http://localhost:${WEB_PORT}" echo " Create your owner account on first visit." echo "" } diff --git a/services/nextcloud.sh b/services/nextcloud.sh index addd268..0620a96 100644 --- a/services/nextcloud.sh +++ b/services/nextcloud.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -180,12 +195,20 @@ install_nextcloud() { require_docker || return 1 log_info "Installing Nextcloud..." local DIR="$DOCKER_DIR/nextcloud" + local WEB_PORT="8080" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env" + echo "[DRY-RUN] Would auto-scan for a free host port (8080 default)" return 0 fi + # Scan for a free host port — this default (8080) isn't unique to + # Nextcloud in this repo (arm also defaults to 8080), so a plain install + # shouldn't silently claim a port another already-running service holds. + # See CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -253,7 +276,7 @@ services: - ./config:/var/www/html/config - ./custom_apps:/var/www/html/custom_apps ports: - - "8080:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK} db: image: mariadb:10.11 @@ -328,7 +351,7 @@ Self-hosted cloud storage with SMB/local file access. ## Access -- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080) +- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT}) - Admin: admin - Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\` @@ -366,7 +389,7 @@ Back up these directories: NCREADME echo "" - echo " Access URL: http://localhost:8080" + echo " Access URL: http://localhost:${WEB_PORT}" echo " Admin user: admin" echo " Admin pass: $NC_ADMIN_PASS" echo " Config dir: $DIR" diff --git a/services/ntfy.sh b/services/ntfy.sh index 2d2708e..62abde7 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -235,14 +250,16 @@ install_ntfy() { INSTANCE_SUFFIX="$_suffix" NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix" CONTAINER="ntfy-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $NTFY_DIR (port $WEB_PORT)" + log_info "New instance: $NTFY_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$NTFY_DIR" ensure_docker_dir_ownership "$NTFY_DIR" cd "$NTFY_DIR" || return 1 diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh index a50a44e..187ef0a 100644 --- a/services/onlyoffice.sh +++ b/services/onlyoffice.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -226,12 +241,19 @@ install_onlyoffice() { log_info "Installing OnlyOffice Document Server..." local DIR="$DOCKER_DIR/onlyoffice" + local WEB_PORT="8082" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env" + echo "[DRY-RUN] Would auto-scan for a free host port (8082 default)" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -281,7 +303,7 @@ services: - ./data:/var/www/onlyoffice/Data - ./fonts:/usr/share/fonts/truetype/custom ports: - - "8082:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} OOCOMPOSE @@ -333,7 +355,7 @@ Self-hosted document editing server, integrated with Nextcloud and FileBrowser Q ## Access -- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082) +- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT}) - The document server itself has no user-facing login page — it is accessed through Nextcloud or FileBrowser Quantum. @@ -381,7 +403,7 @@ OOREAD echo "" echo " OnlyOffice Document Server" - echo " Access URL: http://localhost:8082" + echo " Access URL: http://localhost:${WEB_PORT}" echo " JWT secret: $JWT_SECRET" echo " Config dir: $DIR" echo "" diff --git a/services/portainer.sh b/services/portainer.sh index 2ebd4dc..0920f6e 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -184,12 +199,22 @@ install_portainer() { require_docker || return 1 log_info "Installing Portainer..." local PORTAINER_DIR="$DOCKER_DIR/portainer" + local HTTP_PORT="9000" HTTPS_PORT="9443" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $PORTAINER_DIR" + echo "[DRY-RUN] Would auto-scan for free host ports (9000/9443 defaults)" return 0 fi + # Scan for free host ports, moving both together — a plain install + # shouldn't silently claim a port another already-running service holds. + # See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$HTTP_PORT" || port_in_use "$HTTPS_PORT"; do + HTTP_PORT=$((HTTP_PORT + 1)) + HTTPS_PORT=$((HTTPS_PORT + 1)) + done + mkdir -p "$PORTAINER_DIR" ensure_docker_dir_ownership "$PORTAINER_DIR" cd "$PORTAINER_DIR" || return 1 @@ -230,8 +255,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock - ./data:/data ports: - - "9000:9000" - - "9443:9443" + - "${HTTP_PORT}:9000" + - "${HTTPS_PORT}:9443" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} PORTAINER_COMPOSE @@ -239,7 +264,7 @@ PORTAINER_COMPOSE chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" echo "" - log_success "Portainer configured at $PORTAINER_DIR" + log_success "Portainer configured at $PORTAINER_DIR (HTTP $HTTP_PORT, HTTPS $HTTPS_PORT)" configure_caddy_for_service "Portainer" "portainer:9000" "portainer" @@ -249,8 +274,8 @@ PORTAINER_COMPOSE Web UI for managing Docker — containers, images, volumes, and networks. ## Access -- HTTPS: https://localhost:9443 -- HTTP: http://localhost:9000 +- HTTPS: https://localhost:${HTTPS_PORT} +- HTTP: http://localhost:${HTTP_PORT} - Create your admin account on first visit. ## Data @@ -272,7 +297,7 @@ MD docker compose up -d 2>/dev/null && log_success "Portainer started" || log_warning "Failed to start" fi - echo " Access at: https://localhost:9443" + echo " Access at: https://localhost:${HTTPS_PORT}" echo " Create admin account on first visit" echo "" } diff --git a/services/rustdesk.sh b/services/rustdesk.sh index ed7d09d..f5f4d14 100644 --- a/services/rustdesk.sh +++ b/services/rustdesk.sh @@ -56,6 +56,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -149,20 +164,24 @@ install_rustdesk() { INSTANCE_SUFFIX="$_suffix" RD_DIR="$DOCKER_DIR/rustdesk-$_suffix" CONTAINER="rustdesk-$_suffix" - - PORT_OFFSET=10 - while ss -tlnH "sport = :$((21115 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -ulnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21117 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21118 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21119 + PORT_OFFSET))" 2>/dev/null | grep -q .; do - PORT_OFFSET=$((PORT_OFFSET + 10)) - done - log_info "New instance: $RD_DIR (ports $((21115 + PORT_OFFSET))-$((21119 + PORT_OFFSET)))" + log_info "New instance: $RD_DIR" fi fi + # Scan for a free port block unconditionally, shifting the whole 6-port + # block together — not just when adding an explicit additional instance. + # A plain first install can just as easily collide with an unrelated + # service already bound to one of these default ports — see CLAUDE.md's + # "Port collision avoidance" section. + while port_in_use "$((21115 + PORT_OFFSET))" \ + || port_in_use "$((21116 + PORT_OFFSET))" \ + || port_in_use "$((21116 + PORT_OFFSET))" udp \ + || port_in_use "$((21117 + PORT_OFFSET))" \ + || port_in_use "$((21118 + PORT_OFFSET))" \ + || port_in_use "$((21119 + PORT_OFFSET))"; do + PORT_OFFSET=$((PORT_OFFSET + 10)) + done + mkdir -p "$RD_DIR/rustdesk_data" ensure_docker_dir_ownership "$RD_DIR" cd "$RD_DIR" || return 1 diff --git a/services/stirling-pdf.sh b/services/stirling-pdf.sh index 94a9d6a..c7420b7 100644 --- a/services/stirling-pdf.sh +++ b/services/stirling-pdf.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,20 @@ install_stirling-pdf() { log_info "Installing Stirling PDF..." local PDF_DIR="$DOCKER_DIR/stirling-pdf" + local WEB_PORT="8070" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $PDF_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$PDF_DIR" ensure_docker_dir_ownership "$PDF_DIR" cd "$PDF_DIR" || return 1 @@ -231,7 +253,7 @@ services: restart: unless-stopped env_file: .env ports: - - "8070:8080" + - "${WEB_PORT}:8080" volumes: - ./training-data:/usr/share/tessdata - ./extraConfigs:/configs @@ -275,7 +297,7 @@ PDF_ENV Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more. ## Access -- URL: http://localhost:8070 +- URL: http://localhost:${WEB_PORT} - No login required by default (security disabled) ## Enabling built-in auth @@ -314,7 +336,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:8070" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/syncthing.sh b/services/syncthing.sh index ff18faa..5eda20c 100644 --- a/services/syncthing.sh +++ b/services/syncthing.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -157,13 +172,28 @@ install_syncthing() { log_info "Installing Syncthing..." local DIR="$DOCKER_DIR/syncthing" + local WEB_PORT="8384" SYNC_PORT="22000" DISCOVERY_PORT="21027" 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" + echo "[DRY-RUN] All 3 auto-scanned/shifted together if occupied" return 0 fi + # Scan for free host ports, moving all 3 together — a plain install + # shouldn't silently claim a port another already-running service holds. + # Note: DISCOVERY_PORT (LAN broadcast) only works for zero-config local + # discovery at its standard number — shifting it away from 21027 means + # peers must be added manually by device ID instead of relying on + # auto-discovery. See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$SYNC_PORT" \ + || port_in_use "$SYNC_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + SYNC_PORT=$((SYNC_PORT + 1)) + DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) + done + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -209,10 +239,10 @@ services: - PGID=\${PGID:-$PGID} - TZ=\${SITE_TZ:-UTC} ports: - - "8384:8384" - - "22000:22000/tcp" - - "22000:22000/udp" - - "21027:21027/udp" + - "${WEB_PORT}:8384" + - "${SYNC_PORT}:22000/tcp" + - "${SYNC_PORT}:22000/udp" + - "${DISCOVERY_PORT}:21027/udp" volumes: - ./config:/var/syncthing/config - ./data:/var/syncthing @@ -237,15 +267,20 @@ ENV Continuous, decentralised file synchronisation between devices. ## Access -- Web UI: http://localhost:8384 +- Web UI: http://localhost:${WEB_PORT} - First run: go to **Settings → GUI** and set a username and password. +$( [ "$DISCOVERY_PORT" != "21027" ] && echo " +**Note:** the default ports were already in use, so this instance's ports +were shifted — see below. Local LAN auto-discovery only works at the +standard 21027/udp; with a shifted discovery port, add other devices +manually by device ID instead of relying on auto-discovery." ) ## 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" +sudo ufw allow ${SYNC_PORT}/tcp comment "Syncthing sync protocol" +sudo ufw allow ${SYNC_PORT}/udp comment "Syncthing sync protocol (QUIC)" +sudo ufw allow ${DISCOVERY_PORT}/udp comment "Syncthing local discovery" \`\`\` ## Manage @@ -266,7 +301,7 @@ MD || log_warning "Start failed — check: docker compose logs" fi - echo " Access at: http://localhost:8384" + echo " Access at: http://localhost:${WEB_PORT}" echo " First run: set a username and password in Settings → GUI" echo "" } diff --git a/services/traccar.sh b/services/traccar.sh index 337ce65..e41be28 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_yn() { local _q="$1" _def="$2" _var="$3" _r @@ -252,10 +267,6 @@ install_traccar() { AUTOHEAL_CONTAINER="traccar-$_suffix-autoheal" AUTOHEAL_LABEL="autoheal-traccar-$_suffix" - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - # Count existing traccar/traccar-* dirs (this new one isn't # created yet, so the first extra instance counts exactly 1 # existing dir -> offset 1 -> 6000-6150). @@ -265,10 +276,18 @@ install_traccar() { PROTO_MIN=$((5000 + _offset)) PROTO_MAX=$((5150 + _offset)) - log_info "New instance: $TRACCAR_DIR (web port $WEB_PORT, device protocols $PROTO_MIN-$PROTO_MAX)" + log_info "New instance: $TRACCAR_DIR (device protocols $PROTO_MIN-$PROTO_MAX)" fi fi + # Scan for a free web port unconditionally — not just when adding an + # explicit additional instance. A plain first install can just as easily + # collide with an unrelated service that already claimed this default + # port — see CLAUDE.md's "Port collision avoidance" section. The + # PROTO_MIN/PROTO_MAX device-protocol range above is a different, + # directory-count-based mechanism (not an ss scan) and is unaffected. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$TRACCAR_DIR" # Non-recursive on purpose — a rerun already has a `db/` full of Postgres's # own data files, owned by whatever uid the postgres container runs as diff --git a/services/unifi.sh b/services/unifi.sh index 1da9a83..6dc7bc9 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -150,20 +165,23 @@ install_unifi() { PROJECT="unifi-$_suffix" DB_CONTAINER="unifi-db-$_suffix" APP_CONTAINER="unifi-app-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${INFORM_PORT}" 2>/dev/null | grep -q . \ - || ss -ulnH "sport = :${STUN_PORT}" 2>/dev/null | grep -q . \ - || ss -ulnH "sport = :${DISCOVERY_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - INFORM_PORT=$((INFORM_PORT + 1)) - STUN_PORT=$((STUN_PORT + 1)) - DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) - done - log_info "New instance: $UNIFI_DIR (web $WEB_PORT, inform $INFORM_PORT, STUN $STUN_PORT, discovery $DISCOVERY_PORT)" + log_info "New instance: $UNIFI_DIR" fi fi + # Scan for free ports unconditionally, moving all 4 together — not just + # when adding an explicit additional instance. A plain first install can + # just as easily collide with an unrelated service that already claimed + # one of these default ports — see CLAUDE.md's "Port collision + # avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$INFORM_PORT" \ + || port_in_use "$STUN_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + INFORM_PORT=$((INFORM_PORT + 1)) + STUN_PORT=$((STUN_PORT + 1)) + DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) + done + mkdir -p "$UNIFI_DIR" ensure_docker_dir_ownership "$UNIFI_DIR" cd "$UNIFI_DIR" || return 1 diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index bcd1d46..dac8d28 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_yn() { local _q="$1" _def="$2" _var="$3" _r @@ -186,12 +201,19 @@ install_uptimekuma() { require_docker || return 1 log_info "Installing Uptime Kuma..." local UPTIME_DIR="$DOCKER_DIR/uptime-kuma" + local WEB_PORT="3001" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $UPTIME_DIR" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$UPTIME_DIR" ensure_docker_dir_ownership "$UPTIME_DIR" cd "$UPTIME_DIR" || return 1 @@ -232,7 +254,7 @@ services: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock:ro ports: - - "3001:3001" + - "${WEB_PORT}:3001" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} UPTIME_COMPOSE @@ -249,7 +271,7 @@ Self-hosted uptime/status monitoring dashboard. Monitor websites, servers, and Docker containers. ## Access -- URL: http://localhost:3001 +- URL: http://localhost:${WEB_PORT} - Create your admin account on first visit. ## Data @@ -278,7 +300,7 @@ MD docker compose up -d 2>/dev/null && log_success "Uptime Kuma started" || log_warning "Failed to start" fi - echo " Access at: http://localhost:3001" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 70607de..c7f3e8a 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -249,14 +264,16 @@ install_vaultwarden() { INSTANCE_SUFFIX="$_suffix" VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix" CONTAINER="vaultwarden-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $VW_DIR (port $WEB_PORT)" + log_info "New instance: $VW_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$VW_DIR/vaultwarden_data" ensure_docker_dir_ownership "$VW_DIR" cd "$VW_DIR" || return 1 diff --git a/services/wg-easy.sh b/services/wg-easy.sh index 2366bbc..edf0fc6 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # 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 @@ -198,6 +213,7 @@ install_wg-easy() { require_docker || return 1 local WGEASY_DIR="$DOCKER_DIR/wg-easy" + local WEB_PORT="51821" VPN_PORT="51820" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] wg-easy would:" @@ -205,14 +221,24 @@ install_wg-easy() { echo " - Auto-detect public IP for WG_HOST" echo " - Generate a random web UI password" echo " - Pin WG_DEFAULT_ADDRESS=10.8.0.x (subnet 10.8.0.0/24)" - echo " - Expose port 51821 (web UI) + 51820/udp (VPN)" - echo " - Require router port-forward: UDP 51820 → this server" + echo " - Expose port 51821 (web UI) + 51820/udp (VPN), both auto-scanned if occupied" + echo " - Require router port-forward: UDP → this server" echo " - Offer a Caddy reverse proxy and to start the container" echo " - Offer to also allow SSH from the VPN subnet (additive, doesn't remove public SSH)" echo " - Write sync-ssh-aliases.sh — generates ~/.ssh/config aliases for connected peers" return 0 fi + # Scan for free host ports, moving both together — a plain install + # shouldn't silently claim a port another already-running service holds. + # Whatever VPN_PORT ends up as is what needs forwarding on the router + # (the messaging below reflects the final value, not the 51820 default). + # See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + VPN_PORT=$((VPN_PORT + 1)) + done + mkdir -p "$WGEASY_DIR" ensure_docker_dir_ownership "$WGEASY_DIR" cd "$WGEASY_DIR" || return 1 @@ -287,11 +313,12 @@ services: - WG_DEFAULT_DNS=1.1.1.1 - WG_DEFAULT_ADDRESS=${WG_DEFAULT_ADDRESS} - WG_ALLOWED_IPS=0.0.0.0/0, ::/0 + - WG_PORT=${VPN_PORT} volumes: - ./config:/etc/wireguard ports: - - "51820:51820/udp" - - "51821:51821/tcp" + - "${VPN_PORT}:${VPN_PORT}/udp" + - "${WEB_PORT}:51821/tcp" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} WGEASY_COMPOSE @@ -416,8 +443,8 @@ SYNCEOF WireGuard VPN with a web UI for managing clients, generating QR codes, and monitoring connections. -- Web UI: http://localhost:51821 -- VPN: UDP port 51820 (forward this on your router) +- Web UI: http://localhost:${WEB_PORT} +- VPN: UDP port ${VPN_PORT} (forward this on your router) - Password: stored in \`.env\` (\`WG_PASSWORD\`) - VPN host: \`$WG_HOST\` (update \`WG_HOST\` in .env if your IP changes) - VPN subnet: \`$WG_SUBNET_CIDR\` @@ -433,10 +460,10 @@ docker compose pull && docker compose up -d # update \`\`\` ## Router setup -Forward **UDP port 51820** to this server's LAN IP for external VPN access. +Forward **UDP port ${VPN_PORT}** to this server's LAN IP for external VPN access. ## Adding clients -Open http://localhost:51821, log in with your password, click "+ New Client", +Open http://localhost:${WEB_PORT}, log in with your password, click "+ New Client", download or scan the QR code with the WireGuard app. ## Mesh — peers reach each other automatically @@ -471,11 +498,11 @@ MD fi echo "" - echo " Web UI: http://localhost:51821" + echo " Web UI: http://localhost:${WEB_PORT}" 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 " Router: forward UDP ${VPN_PORT} → this server for external VPN access" echo "" } diff --git a/services/wordpress.sh b/services/wordpress.sh index b638075..89b4c1a 100644 --- a/services/wordpress.sh +++ b/services/wordpress.sh @@ -57,6 +57,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"