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 606ed25..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; } @@ -186,16 +201,60 @@ 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" + 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 @@ -226,15 +285,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 +307,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 +338,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/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 747a54a..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; } @@ -184,14 +199,58 @@ 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" + 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 @@ -223,13 +282,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 +296,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 +341,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 +382,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..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 @@ -196,17 +211,60 @@ 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" + 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 @@ -238,20 +296,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 +320,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 +348,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/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 9795a43..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; } @@ -185,14 +200,58 @@ 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" + 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 @@ -221,13 +280,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 +294,7 @@ services: volumes: - ./data:/data ports: - - "7745:7745" + - "${WEB_PORT}:7745" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} HB_COMPOSE @@ -246,18 +305,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 +327,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..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; } @@ -189,23 +204,77 @@ 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" + 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" @@ -384,18 +453,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 +473,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 +484,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 +610,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 +860,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 +908,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 +927,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/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 ad7086d..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; } @@ -188,19 +203,70 @@ 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" + 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%/}" @@ -248,14 +314,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 +342,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 +353,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 +390,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..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; } @@ -185,14 +200,63 @@ 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" + 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 @@ -203,7 +267,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 +293,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 +342,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 +386,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/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 81b349a..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 @@ -196,19 +211,96 @@ 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." + 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%/}" @@ -221,18 +313,35 @@ 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: + - \"${WEB_PORT}:9000\" + - \"${CLI_PORT}:9090\" + - \"${PLAYER_PORT}:3483\" +" + _HTTP_PORT_INTERNAL="9000" + 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: - - HTTP_PORT=9000 +${_NETWORK_BLOCK}${_PORTS_BLOCK} environment: + - HTTP_PORT=$_HTTP_PORT_INTERNAL - PUID=$UID_VAL - PGID=$GID_VAL - TZ=$TZ_VAL @@ -251,19 +360,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 +393,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/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 0eb8a1c..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 @@ -196,17 +211,65 @@ 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" + 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}" @@ -239,13 +302,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 +323,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 +337,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 +365,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 +380,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/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 5b92e3d..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 @@ -192,13 +207,59 @@ 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" + 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 @@ -227,13 +288,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 +303,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 +319,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 +352,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 +399,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/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 c2060cb..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 @@ -100,21 +115,80 @@ 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" + 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 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 +208,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 +235,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 +251,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 +270,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 +279,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 +301,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 +314,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/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 c18626f..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" @@ -98,16 +113,75 @@ 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" + 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 @@ -143,13 +217,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 +236,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 +245,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 +290,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 +306,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 +354,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 +367,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 +408,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/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 82867df..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" @@ -202,16 +217,63 @@ 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" + 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 @@ -225,7 +287,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 +328,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 +373,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 +418,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 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"