From 9e06ed4b831acadefb608427b14c1337899691cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 23:55:49 +0000 Subject: [PATCH] Bake cross-service port collision avoidance into every service script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With 70+ services sharing a handful of common default ports (emby and jellyfin both default to 8096, changedetection and frigate both default to 5000, arm and nextcloud both default to 8080...), nothing previously checked whether a service's default port was actually free on the host. Whichever service installed second would silently write a compose file claiming an already-held port, only failing at `docker compose up` time. Adds two shared helpers to lib/common.sh: - port_in_use PORT [PROTO] — true if something's already listening - find_free_port VARNAME START [PROTO] — scans upward, writes back the first free port Every service that publishes a fixed host port now scans before writing docker-compose.yml, on every install (not just when adding an explicit additional instance). On a normal single-install host this is a silent no-op; it only changes behavior when something else already holds the port. - The 19 services already given multi-instance support this session had their port scan moved out of the "add instance" branch to run unconditionally, since the same collision risk exists on a plain first install. - 20 more services with previously-hardcoded ports gained scanning for the first time: archivebox, arm, calibre-web, changedetection, drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf, uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so generated peer configs keep the right Endpoint), homeassistant (bridge-mode only — host mode can only warn), frigate and frigate-audio (multi-port stacks, moved together). - caddy.sh is the deliberate exception: 80/443 stay fixed and only warn on collision, since silently moving Caddy itself would leave nothing listening where any client actually looks. - authelia.sh needs no change — it has no published host port at all. - Every service's standalone bootstrap fallback (sudo bash services/x.sh with no sibling files) got the same two helpers duplicated into its stub block, matching how every other shared helper is already handled there. Documents the full pattern in CLAUDE.md's new "Port collision avoidance" section, including the quoted-heredoc/backtick-escaping gotcha and the network_mode:host limitation (can only scan ports the app takes as a configurable env var). Verified via bash -n on every changed file, plus functional runs seeding occupied ports for each collision shape used here (single, paired, multi-port stacks) and confirming the scan/shift and generated compose/README output are correct — including the emby/jellyfin, nextcloud/arm, and frigate/changedetection collision scenarios that originally motivated this. --- CLAUDE.md | 110 +++++++++++++++++++++++++++++++++++ lib/common.sh | 39 +++++++++++++ services/actualbudget.sh | 28 +++++++-- services/archivebox.sh | 48 ++++++++++----- services/arm.sh | 30 ++++++++-- services/audiobookshelf.sh | 27 +++++++-- services/authelia.sh | 15 +++++ services/caddy.sh | 31 ++++++++++ services/calibre-web.sh | 38 +++++++++--- services/changedetection.sh | 39 ++++++++++--- services/drum-rhythm-game.sh | 43 ++++++++++---- services/emby.sh | 32 +++++++--- services/filebrowser.sh | 27 +++++++-- services/fmd.sh | 27 +++++++-- services/frigate-audio.sh | 48 ++++++++++++--- services/frigate.sh | 49 +++++++++++++--- services/gatus.sh | 30 ++++++++-- services/homeassistant.sh | 38 ++++++++++-- services/homebox.sh | 27 +++++++-- services/immich.sh | 27 +++++++-- services/iopaint.sh | 34 ++++++++++- services/jellyfin.sh | 28 +++++++-- services/joplin.sh | 27 +++++++-- services/koha.sh | 56 +++++++++++++----- services/lyrion.sh | 52 +++++++++++++---- services/mattermost.sh | 46 ++++++++++----- services/mealie.sh | 27 +++++++-- services/meshcentral.sh | 33 ++++++++--- services/n8n.sh | 38 +++++++++--- services/nextcloud.sh | 29 ++++++++- services/ntfy.sh | 27 +++++++-- services/onlyoffice.sh | 28 ++++++++- services/portainer.sh | 37 ++++++++++-- services/rustdesk.sh | 41 +++++++++---- services/stirling-pdf.sh | 28 ++++++++- services/syncthing.sh | 53 ++++++++++++++--- services/traccar.sh | 29 +++++++-- services/unifi.sh | 40 +++++++++---- services/uptimekuma.sh | 28 ++++++++- services/vaultwarden.sh | 27 +++++++-- services/wg-easy.sh | 47 +++++++++++---- services/wordpress.sh | 15 +++++ 42 files changed, 1269 insertions(+), 254 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5f1a27..e07aee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -596,6 +596,116 @@ sequence (a fake `docker`/`ss` shim standing in for a live daemon is fine) and confirm the second one's directory, container names, and ports are actually distinct before trusting the logic. +## Port collision avoidance + +With 70+ services in this repo, several ship the same default port — +`emby` and `jellyfin` both default to 8096, `changedetection` and `frigate` +both default to 5000, `arm` and `nextcloud` both default to 8080. Nothing +enforced those defaults were actually free on the host: whichever service +started its container second would fail to bind ("port is already +allocated") instead of landing on the next free port. Confirmed live: +installing `jellyfin` after `emby` (or vice versa) writes a +`docker-compose.yml` claiming a port the other service's container already +holds, and it only fails at `docker compose up` time — not at install time, +and not with any warning from the installer itself. + +**Every service that publishes a fixed host port must scan for a free one +before writing `docker-compose.yml` — on every install, not only when +adding an explicit additional instance of itself.** Two shared helpers in +`lib/common.sh` do the work: + +```bash +port_in_use PORT [PROTO] # true if something's already listening; PROTO defaults to tcp, pass "udp" for UDP-only ports +find_free_port VARNAME START [PROTO] # scans upward from START, writes the free port back into VARNAME +``` + +Single-port services just call `find_free_port`: + +```bash +local WEB_PORT="8080" +find_free_port WEB_PORT "$WEB_PORT" +... + - "${WEB_PORT}:8080" # host side scanned; container-internal side stays literal +``` + +Services with multiple ports that must move together (a web port + an +agent/RTSP/MQTT port, etc.) loop over `port_in_use` directly instead, the +same pattern `services/meshcentral.sh` and `services/unifi.sh` use: + +```bash +while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + AGENT_PORT=$((AGENT_PORT + 1)) +done +``` + +On a normal single-install host this is a silent no-op — the default port +is free, so the variable comes back unchanged and nothing about the +install looks any different. It only changes behavior when something else +already holds the port, which is exactly the case that used to fail at +container-startup instead of being handled at install time. + +**Standalone-mode stub.** Every service also carries a standalone +bootstrap fallback (`if [[ -f "$_COMMON" ]]; then source it; else +fi`, for `sudo bash services/.sh` with no sibling files sourced) that +duplicates the helpers it needs. `port_in_use`/`find_free_port` get the +same treatment — copy the same two function bodies into that `else` block, +matching how `log_info`, `prompt_text`, `configure_caddy_for_service`, etc. +are already duplicated there. + +**Only the host side of a `"HOST:CONTAINER"` port mapping changes.** The +container-internal port is fixed by the application itself and stays a +literal number; only the host-published side becomes `${WEB_PORT}` (or +whatever the variable is named). Watch for the same literal number showing +up elsewhere in the file needing the same treatment: `configure_caddy_for_service` +calls (container-internal side stays literal; a *bare*-port host-networking +upstream, like `services/lyrion.sh`'s first instance, does need the +variable), generated companion scripts (`services/koha.sh`'s +`post-setup.sh`, `services/rustdesk.sh`'s relay-host messaging), `.env` +values baked into client-facing config (`services/wg-easy.sh`'s `WG_PORT` +env — WireGuard bakes the port into every generated peer config's +`Endpoint =` line, so it must track the *actual* published port, not just +the host-side compose mapping), and every `echo`/README line that prints +`http://localhost:`. + +**Quoted (`<< 'MD'`) README heredocs don't interpolate — check before +editing.** A `write_readme ... << MD` (unquoted) heredoc already +interpolates `${WEB_PORT}` directly. A quoted `<< 'MD'` heredoc doesn't, +and converting it means escaping *every* backtick used for inline-code +formatting (`` \`...\` ``) — miss one and bash tries to execute it as a +command substitution the next time the heredoc is read, the same class of +bug the coturn.sh backtick incident was (see `services/coturn.sh`'s +`write_readme` call). For a README with only one or two backticks, +escaping them is fine. For one with many (`services/iopaint.sh`'s model +reference table), it's safer to leave the heredoc quoted and patch the +port into the *written* `README.md` afterward instead: +```bash +[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md" +``` + +**`network_mode: host` services can only scan what the app itself lets you +override.** Host networking has no port *remapping* — whatever the app +binds to on its fixed internal port is what's exposed, so `find_free_port` +only helps for ports the app takes as configurable env vars. +`services/lyrion.sh`'s `HTTP_PORT` env is genuinely configurable (LMS +honors it as the actual bind port under host networking too — see its +`_HTTP_PORT_INTERNAL` handling, which must track the scanned port rather +than assuming bridge-mode's fixed `9000`), but its CLI (9090) and player +(3483) ports are hardcoded in the image with no override — a collision +there can only be warned about with `port_in_use`, not silently fixed. +`services/homeassistant.sh` is the same shape: bridge mode (the default) +scans freely; host mode (opt-in, for LAN device discovery) can only warn. + +**Caddy is the one deliberate exception — never auto-scanned.** +`services/caddy.sh` keeps 80/443 fixed and only warns via `port_in_use` if +they're already taken. Every other service either points HTTPS clients at +Caddy implicitly (browsers assume 443) or gets routed through it by +domain; silently moving Caddy itself to a random port would leave nothing +listening at the address any client actually tries, which is strictly +worse than the collision it would be "fixing." If 80/443 are already +bound, that's a real conflict (another web server on the host) the user +needs to resolve directly. + ## Chaining into another service from within your own A service can call another service's `install_()` directly as a diff --git a/lib/common.sh b/lib/common.sh index 35de9a6..1d96289 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -542,6 +542,45 @@ write_readme() { chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true } +# ── Host port collision avoidance (shared by every service that publishes a +# fixed host port) ──────────────────────────────────────────────────────────── +# With 70+ services in this repo, several ship the same default port (e.g. +# emby and jellyfin both default to 8096; changedetection and frigate both +# default to 5000). Nothing enforced those defaults were actually free on the +# host, so whichever service started its container second would fail to bind +# ("port is already allocated") instead of just landing on the next free port. +# Confirmed live: installing jellyfin after emby (or vice versa) writes a +# docker-compose.yml claiming a port the other service's container already +# holds, and only fails at `docker compose up` time — not at install time. +# +# port_in_use PORT [PROTO] — PROTO defaults to tcp; pass "udp" for UDP-only +# ports. Returns 0 (true, in use) or 1 (free). +port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . +} + +# find_free_port VARNAME START [PROTO] +# Scans upward from START for a free host port and writes the result back +# into VARNAME. Single-port convenience wrapper around port_in_use — every +# service's install_() should run its default/candidate port through +# this (or a hand-rolled port_in_use loop for multiple ports that must move +# together, e.g. a web port + an agent port) before writing docker-compose.yml, +# not only when adding an explicit additional instance of itself. On a normal +# single-install host this is a silent no-op (the default port is free, so +# VARNAME comes back unchanged); it only changes behavior when something else +# already holds the port, which is exactly the case that used to fail at +# startup instead of at install time. +find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" +} + # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── # Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] # UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 694ecf9..4bb68ef 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -229,14 +244,17 @@ install_actualbudget() { INSTANCE_SUFFIX="$_suffix" AB_DIR="$DOCKER_DIR/actualbudget-$_suffix" CONTAINER="actualbudget-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $AB_DIR (port $WEB_PORT)" + log_info "New instance: $AB_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port (e.g. + # emby and jellyfin both default to 8096) — see CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$AB_DIR/data" ensure_docker_dir_ownership "$AB_DIR" cd "$AB_DIR" || return 1 diff --git a/services/archivebox.sh b/services/archivebox.sh index a7d3bce..1d85b3b 100644 --- a/services/archivebox.sh +++ b/services/archivebox.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -182,15 +197,22 @@ install_archivebox() { require_docker || return 1 local AB_DIR="$DOCKER_DIR/archivebox" + local WEB_PORT="8000" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] archivebox would:" echo " - Create $AB_DIR with docker-compose.yml" echo " - Initialize ArchiveBox data directory" - echo " - Expose port 8000 (web UI)" + echo " - Expose port 8000 (web UI), auto-scanned for a free host port" return 0 fi + # Scan for a free host port — this default (8000) isn't unique to + # ArchiveBox in this repo, so a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$AB_DIR" ensure_docker_dir_ownership "$AB_DIR" cd "$AB_DIR" || return 1 @@ -236,7 +258,7 @@ services: volumes: - ./data:/data ports: - - "8000:8000" + - "${WEB_PORT}:8000" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ABCOMPOSE @@ -254,47 +276,47 @@ ABENV configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive" - write_readme "$AB_DIR" << 'MD' + write_readme "$AB_DIR" << MD # ArchiveBox Self-hosted web archiving — saves full snapshots of web pages (HTML, screenshots, PDFs, WARC) like a personal Wayback Machine. -- Web UI: http://localhost:8000 +- Web UI: http://localhost:${WEB_PORT} ## Manage -```bash +\`\`\`bash cd ~/docker/archivebox docker compose up -d # start docker compose down # stop docker compose logs -f # logs docker compose pull && docker compose up -d # update -``` +\`\`\` ## Add URLs to archive -```bash -# Via web UI — visit http://localhost:8000 and use the Add page +\`\`\`bash +# Via web UI — visit http://localhost:${WEB_PORT} and use the Add page # Via CLI: echo "https://example.com" | docker compose run --rm archivebox add docker compose run --rm archivebox add --depth=1 https://example.com -``` +\`\`\` ## Create admin user -```bash +\`\`\`bash docker compose run --rm archivebox manage createsuperuser -``` +\`\`\` MD local START_AB="" prompt_yn "Start ArchiveBox now? (y/n):" "y" START_AB if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then docker compose up -d \ - && log_success "ArchiveBox started — http://localhost:8000" \ + && log_success "ArchiveBox started — http://localhost:${WEB_PORT}" \ || log_warning "Start failed — check: docker compose logs" fi echo "" - echo " Web UI: http://localhost:8000" + echo " Web UI: http://localhost:${WEB_PORT}" echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add" echo "" } diff --git a/services/arm.sh b/services/arm.sh index 02e2ab1..faf6b92 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -198,6 +213,7 @@ install_arm() { local ARM_DIR="$DOCKER_DIR/arm" local DEFAULT_OUTPUT="$ACTUAL_HOME/ripped" + local WEB_PORT="8080" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] A.R.M. would:" @@ -205,7 +221,7 @@ install_arm() { echo " - Detect optical drives (/dev/sr*) — defaults to /dev/sr0" echo " - Create ripped output dirs (movies/ music/) under $DEFAULT_OUTPUT" echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER") with privileged: true" - echo " - Expose port 8080" + echo " - Expose port 8080 (auto-scanned for a free host port)" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi @@ -225,6 +241,12 @@ install_arm() { OPTICAL_DRIVES="/dev/sr0" fi + # Scan for a free host port — this default (8080) isn't unique to A.R.M. + # in this repo (nextcloud also defaults to 8080), so a plain install + # shouldn't silently claim a port another already-running service holds. + # See CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$ARM_DIR" ensure_docker_dir_ownership "$ARM_DIR" cd "$ARM_DIR" || return 1 @@ -275,7 +297,7 @@ services: - \${ARM_OUTPUT}/movies:/home/arm/media/completed - \${ARM_OUTPUT}/music:/home/arm/music ports: - - "8080:8080" + - "${WEB_PORT}:8080" devices: - /dev/sr0:/dev/sr0 # Add more optical drives as needed: @@ -303,7 +325,7 @@ ARM_ENV Auto-rips DVDs, Blu-rays, and CDs when you insert them — identifies the disc, fetches metadata, and transcodes to a usable format. -- Web UI: http://localhost:8080 (complete setup on first visit) +- Web UI: http://localhost:${WEB_PORT} (complete setup on first visit) - Ripped output: \`$ARM_OUTPUT\` → movies and music subdirs - App data: \`config/\` and \`logs/\` @@ -338,7 +360,7 @@ MD fi echo "" - echo " Access at: http://localhost:8080" + echo " Access at: http://localhost:${WEB_PORT}" echo " Complete setup in browser on first visit." echo "" } diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index e7be929..bcc295c 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -232,14 +247,16 @@ install_audiobookshelf() { ABS_DIR="$DOCKER_DIR/audiobookshelf-$_suffix" CONTAINER="audiobookshelf-$_suffix" DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $ABS_DIR (port $WEB_PORT)" + log_info "New instance: $ABS_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + local AUDIOBOOKS_PATH="" prompt_text "Path to audiobooks folder [$DEFAULT_AUDIOBOOKS]:" "$DEFAULT_AUDIOBOOKS" AUDIOBOOKS_PATH AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH/#\~/$ACTUAL_HOME}"; AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH%/}" diff --git a/services/authelia.sh b/services/authelia.sh index 169685d..c31c91a 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -46,6 +46,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r diff --git a/services/caddy.sh b/services/caddy.sh index 126cb65..1037ba2 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -233,6 +248,22 @@ install_caddy() { fi fi + # Unlike every other service, Caddy's ports (80/443) are NOT auto-scanned + # or shifted on a collision — see CLAUDE.md's "Port collision avoidance" + # section. Every other service in this repo either points HTTPS clients + # at Caddy implicitly (browsers assume 443) or gets its own reverse-proxy + # domain through it; silently moving Caddy to a random port would leave + # nothing at the address clients actually try, which is strictly worse + # than the collision itself. If 80/443 are already taken, that's a real + # conflict (another web server already bound to those ports) the user + # needs to resolve directly — warn loudly instead of masking it. + if port_in_use 80 || port_in_use 443; then + log_warning "Port 80 and/or 443 is already in use by another process." + log_warning "Caddy needs both to serve HTTPS — find what's using them" + log_warning "(sudo ss -tlnp 'sport = :80' / ':443') and stop it, or Caddy" + log_warning "will fail to start. Continuing anyway — this is not auto-fixed." + fi + mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" ensure_docker_dir_ownership "$CADDY_DIR" diff --git a/services/calibre-web.sh b/services/calibre-web.sh index 1f34607..3555698 100644 --- a/services/calibre-web.sh +++ b/services/calibre-web.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,20 @@ install_calibre-web() { log_info "Installing Calibre-Web..." local CW_DIR="$DOCKER_DIR/calibre-web" + local WEB_PORT="8083" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $CW_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$CW_DIR/config" "$CW_DIR/books" ensure_docker_dir_ownership "$CW_DIR" cd "$CW_DIR" || return 1 @@ -238,7 +260,7 @@ services: - ./config:/config - ./books:/books ports: - - "8083:8083" + - "${WEB_PORT}:8083" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} CW_COMPOSE @@ -253,20 +275,20 @@ CW_ENV configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books" - write_readme "$CW_DIR" << 'MD' + write_readme "$CW_DIR" << MD # Calibre-Web Web-based ebook library with metadata editing, reading, and format conversion powered by Calibre. ## First-run setup -1. Open the UI (http://localhost:8083) and log in with admin / admin123 -2. When prompted for the database location, enter: `/books` +1. Open the UI (http://localhost:${WEB_PORT}) and log in with admin / admin123 +2. When prompted for the database location, enter: \`/books\` (point this at your existing Calibre library or an empty directory) 3. Change the default password immediately under Admin → Edit User ## Ebook conversion -The `DOCKER_MODS=linuxserver/mods:universal-calibre` environment variable +The \`DOCKER_MODS=linuxserver/mods:universal-calibre\` environment variable installs the full Calibre binary inside the container, enabling on-the-fly ebook conversion (e.g. EPUB → MOBI/AZW3). @@ -275,15 +297,15 @@ Place your Calibre library (or individual books) in: ~/docker/calibre-web/books/ If you already have a Calibre library elsewhere, mount that path instead by -editing the `./books:/books` volume line in docker-compose.yml. +editing the \`./books:/books\` volume line in docker-compose.yml. ## Manage -```bash +\`\`\`bash docker compose up -d docker compose down docker compose logs -f docker compose pull && docker compose down && docker compose up -d -``` +\`\`\` MD local START_CW="" diff --git a/services/changedetection.sh b/services/changedetection.sh index 4a7776e..49189aa 100644 --- a/services/changedetection.sh +++ b/services/changedetection.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,21 @@ install_changedetection() { log_info "Installing Changedetection.io..." local CD_DIR="$DOCKER_DIR/changedetection" + local WEB_PORT="5000" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $CD_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — this default (5000) isn't unique to + # Changedetection.io in this repo (frigate also defaults to 5000), so a + # plain install shouldn't silently claim a port another already-running + # service holds. See CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$CD_DIR/data" ensure_docker_dir_ownership "$CD_DIR" cd "$CD_DIR" || return 1 @@ -230,7 +253,7 @@ services: hostname: changedetection restart: unless-stopped ports: - - "5000:5000" + - "${WEB_PORT}:5000" environment: - BASE_URL=\${BASE_URL} - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 @@ -263,7 +286,7 @@ CD_ENV configure_caddy_for_service "Changedetection" "changedetection:5000" "changes" - write_readme "$CD_DIR" << 'MD' + write_readme "$CD_DIR" << MD # Changedetection.io Monitor web pages for changes and receive notifications via email, Slack, @@ -271,20 +294,20 @@ Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar for JavaScript-heavy pages. ## Access -- URL: http://localhost:5000 +- URL: http://localhost:${WEB_PORT} - Optional password can be set in Settings → General within the UI. ## Manage -```bash +\`\`\`bash docker compose up -d # start docker compose down # stop docker compose logs -f # logs docker compose pull && docker compose down && docker compose up -d # update -``` +\`\`\` ## Environment -Edit `.env` to change `BASE_URL` (used for notification links), -then restart: `docker compose down && docker compose up -d` +Edit \`.env\` to change \`BASE_URL\` (used for notification links), +then restart: \`docker compose down && docker compose up -d\` MD local START_CD="" @@ -295,7 +318,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:5000" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/drum-rhythm-game.sh b/services/drum-rhythm-game.sh index 1a82296..9d467c6 100644 --- a/services/drum-rhythm-game.sh +++ b/services/drum-rhythm-game.sh @@ -42,6 +42,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -187,15 +202,23 @@ install_drum-rhythm-game() { local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game" local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git" + local WEB_PORT="8096" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] drum-rhythm-game would:" echo " - Clone $REPO_URL to $DRUM_DIR/html" echo " - Build the repo's own Dockerfile (nginx, gzip, /healthz) on port 8096" + echo " (auto-scanned for a free host port — 8096 is also emby/jellyfin's default)" echo " - Offer Authelia SSO protection via Caddy (no built-in auth)" return 0 fi + # Scan for a free host port — this default (8096) is also emby's and + # jellyfin's default, so a plain install shouldn't silently claim a port + # another already-running service holds. See CLAUDE.md's "Port collision + # avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$DRUM_DIR" ensure_docker_dir_ownership "$DRUM_DIR" cd "$DRUM_DIR" || return 1 @@ -248,7 +271,7 @@ services: hostname: drum-rhythm-game restart: unless-stopped ports: - - "8096:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} DRUM_COMPOSE @@ -268,7 +291,7 @@ DRUM_ENV fi configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK" - write_readme "$DRUM_DIR" << 'MD' + write_readme "$DRUM_DIR" << MD # Drum Rhythm Game Browser-based drum rhythm game — 18 genres covering 119 synth-orchestra @@ -281,36 +304,36 @@ Multiplayer take-turns mode, adjustable speed/volume, and a leaderboard — all state lives in browser localStorage. No server required; all audio is synthesized in-browser via the Web Audio API. -Built and served from the repo's own `Dockerfile` (nginx + gzip + a -`/healthz` endpoint) — only `index.html` ends up in the image, so the +Built and served from the repo's own \`Dockerfile\` (nginx + gzip + a +\`/healthz\` endpoint) — only \`index.html\` ends up in the image, so the repo's docs/license/dev files never get served. Source: https://github.com/outis1one/drum-rhythm-game ## Access -- URL: http://localhost:8096 +- URL: http://localhost:${WEB_PORT} ## Manage -```bash +\`\`\`bash cd ~/docker/drum-rhythm-game docker compose up -d # start docker compose down # stop docker compose logs -f # logs -``` +\`\`\` ## Update game -```bash +\`\`\`bash cd ~/docker/drum-rhythm-game git -C html pull docker compose up -d --build -``` +\`\`\` MD local START_DRUM="" prompt_yn "Start drum-rhythm-game now? (y/n):" "y" START_DRUM if [ "$START_DRUM" = "y" ] || [ "$START_DRUM" = "Y" ]; then docker compose up -d --build \ - && log_success "Drum Rhythm Game started — http://localhost:8096" \ + && log_success "Drum Rhythm Game started — http://localhost:${WEB_PORT}" \ || log_warning "Start failed — check: docker compose logs" fi diff --git a/services/emby.sh b/services/emby.sh index 8e50f79..3dd3c09 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -246,17 +261,18 @@ install_emby() { EMBY_DIR="$DOCKER_DIR/emby-$_suffix" CONTAINER="emby-$_suffix" DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - while ss -tlnH "sport = :${HTTPS_PORT}" 2>/dev/null | grep -q .; do - HTTPS_PORT=$((HTTPS_PORT + 1)) - done - log_info "New instance: $EMBY_DIR (web port $WEB_PORT, https port $HTTPS_PORT)" + log_info "New instance: $EMBY_DIR" fi fi + # Scan for free ports unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed these default ports + # (e.g. jellyfin also defaults to 8096) — see CLAUDE.md's "Port collision + # avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + find_free_port HTTPS_PORT "$HTTPS_PORT" + local MUSIC_ONLY="" prompt_yn "Set this up as a music-only server (skip movies/TV)? (y/n):" "n" MUSIC_ONLY diff --git a/services/filebrowser.sh b/services/filebrowser.sh index ec9c97a..5bbcebb 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -226,14 +241,16 @@ install_filebrowser() { INSTANCE_SUFFIX="$_suffix" FB_DIR="$DOCKER_DIR/filebrowser-$_suffix" CONTAINER="filebrowser-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $FB_DIR (port $WEB_PORT)" + log_info "New instance: $FB_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$FB_DIR/data" ensure_docker_dir_ownership "$FB_DIR" cd "$FB_DIR" || return 1 diff --git a/services/fmd.sh b/services/fmd.sh index 8f9180f..454b9ac 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -240,14 +255,16 @@ install_fmd() { INSTANCE_SUFFIX="$_suffix" FMD_DIR="$DOCKER_DIR/fmd-$_suffix" CONTAINER="fmd-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $FMD_DIR (port $WEB_PORT)" + log_info "New instance: $FMD_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$FMD_DIR" ensure_docker_dir_ownership "$FMD_DIR" cd "$FMD_DIR" || return 1 diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index c4fc1b6..cfa969f 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -62,6 +62,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$_len" @@ -216,6 +231,7 @@ install_frigate-audio() { require_docker || return 1 local DIR="$DOCKER_DIR/frigate-audio" + local WEB_PORT="8971" DEBUG_PORT="5001" RTSP_PORT="8554" WEBRTC_PORT="8555" MQTT_PORT="1883" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] frigate-audio would:" @@ -223,9 +239,27 @@ install_frigate-audio() { echo " Prompt for camera RTSP credentials, IPs, MQTT password, ntfy server" echo " Generate docker-compose.yml, frigate config, mosquitto config, .env" echo " Bootstrap the Mosquitto password file" + echo " Ports 8971/5001/8554/8555/1883 auto-scanned/shifted together if occupied" return 0 fi + # Scan for free host ports, moving all 5 together — a plain install + # shouldn't silently claim a port another already-running service holds + # (e.g. 1883 is the standard MQTT port and could already be in use by a + # Home Assistant add-on or another broker). Container-internal ports and + # container-to-container references (mosquitto:1883, 127.0.0.1:5000/8554 + # inside the frigate container) are unaffected by the host-side ports + # scanned here. See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$DEBUG_PORT" \ + || port_in_use "$RTSP_PORT" || port_in_use "$WEBRTC_PORT" \ + || port_in_use "$WEBRTC_PORT" udp || port_in_use "$MQTT_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + DEBUG_PORT=$((DEBUG_PORT + 1)) + RTSP_PORT=$((RTSP_PORT + 1)) + WEBRTC_PORT=$((WEBRTC_PORT + 1)) + MQTT_PORT=$((MQTT_PORT + 1)) + done + echo "" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ Frigate + Mosquitto + frigate-notify (audio-ready stack) ║" @@ -434,11 +468,11 @@ COMPOSEEOF tmpfs: size: 1000000000 ports: - - "8971:8971" - - "5001:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" + - "${WEB_PORT}:8971" + - "${DEBUG_PORT}:5000" + - "${RTSP_PORT}:8554" + - "${WEBRTC_PORT}:8555/tcp" + - "${WEBRTC_PORT}:8555/udp" ${_CADDY_NET_BLOCK} healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"] interval: 10s @@ -452,7 +486,7 @@ ${_CADDY_NET_BLOCK} healthcheck: image: eclipse-mosquitto:2 restart: unless-stopped ports: - - "1883:1883" + - "${MQTT_PORT}:1883" volumes: - ./mosquitto/config:/mosquitto/config - ./mosquitto/data:/mosquitto/data @@ -760,7 +794,7 @@ FNEOF if [[ ${START_NOW:-n} =~ ^[Yy]$ ]]; then log_info "Starting frigate-audio stack..." if ( cd "$DIR" && docker compose up -d ); then - log_success "Stack started — Frigate UI: http://localhost:8971" + log_success "Stack started — Frigate UI: http://localhost:${WEB_PORT}" else log_warning "Start failed — check: cd $DIR && docker compose logs" fi diff --git a/services/frigate.sh b/services/frigate.sh index f497374..586fca9 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -497,12 +512,15 @@ install_frigate() { require_docker || return 1 local FRIGATE_DIR="$DOCKER_DIR/frigate" + local WEB_PORT="5000" RTSP_PORT="8554" WEBRTC_PORT="8555" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Frigate would:" echo " - Create $FRIGATE_DIR with docker-compose.yml + .env + config/config.yml" echo " - Auto-enable /dev/dri/renderD128 for GPU-assisted detection if present" echo " - Expose ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC)" + echo " — all 3 auto-scanned/shifted together if occupied (5000 is also" + echo " changedetection's default)" echo " - If already configured: show existing cameras and offer to keep," echo " back up + start fresh, add more, or remove some" echo " - Prompt to add cameras interactively (RTSP creds go in .env)" @@ -511,6 +529,19 @@ install_frigate() { return 0 fi + # Scan for free host ports, moving all 3 together — a plain install + # shouldn't silently claim a port another already-running service holds + # (changedetection.io also defaults to 5000). See CLAUDE.md's "Port + # collision avoidance" section. Internal go2rtc restream URLs elsewhere + # in this file (rtsp://127.0.0.1:8554/...) are container-internal + # loopback references, unaffected by the host-side port used here. + while port_in_use "$WEB_PORT" || port_in_use "$RTSP_PORT" \ + || port_in_use "$WEBRTC_PORT" || port_in_use "$WEBRTC_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + RTSP_PORT=$((RTSP_PORT + 1)) + WEBRTC_PORT=$((WEBRTC_PORT + 1)) + done + mkdir -p "$FRIGATE_DIR" ensure_docker_dir_ownership "$FRIGATE_DIR" cd "$FRIGATE_DIR" || return 1 @@ -605,10 +636,10 @@ $DEVICE_BLOCK tmpfs: size: 1000000000 ports: - - "5000:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" + - "${WEB_PORT}:5000" + - "${RTSP_PORT}:8554" + - "${WEBRTC_PORT}:8555/tcp" + - "${WEBRTC_PORT}:8555/udp" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} FRIGATE_COMPOSE @@ -709,9 +740,9 @@ FRIGATE_ENV AI-powered network video recorder with real-time object detection for security cameras. Detects people, cars, animals, and more. -- Web UI: http://localhost:5000 -- RTSP restream: port 8554 -- WebRTC: port 8555 +- Web UI: http://localhost:${WEB_PORT} +- RTSP restream: port ${RTSP_PORT} +- WebRTC: port ${WEBRTC_PORT} - Recordings: \`$FRIGATE_MEDIA\` - Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total) - Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables @@ -736,7 +767,7 @@ container startup. ## First steps 1. Review \`config/config.yml\` — adjust detection zones, masks, retention 2. Start Frigate: \`docker compose up -d\` -3. Open http://localhost:5000 to view cameras and configure detection zones +3. Open http://localhost:${WEB_PORT} to view cameras and configure detection zones ## Hardware acceleration - Intel/AMD GPU: uncomment the \`devices: [/dev/dri/renderD128]\` block @@ -761,7 +792,7 @@ MD fi echo "" - echo " Access at: http://localhost:5000" + echo " Access at: http://localhost:${WEB_PORT}" echo " Config: $FRIGATE_DIR/config/config.yml" echo "" } diff --git a/services/gatus.sh b/services/gatus.sh index 3a37854..ca12035 100644 --- a/services/gatus.sh +++ b/services/gatus.sh @@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -196,14 +211,21 @@ install_gatus() { require_docker || return 1 log_info "Installing Gatus..." local GATUS_DIR="$DOCKER_DIR/gatus" + local WEB_PORT="8086" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $GATUS_DIR (gatus_config/, gatus_data/)" echo "[DRY-RUN] Would deploy twinproduction/gatus:latest" - echo "[DRY-RUN] Port 8086 published, config at gatus_config/config.yaml" + echo "[DRY-RUN] Port 8086 published (auto-scanned for a free host port)," + echo "[DRY-RUN] config at gatus_config/config.yaml" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$GATUS_DIR/gatus_config" "$GATUS_DIR/gatus_data" ensure_docker_dir_ownership "$GATUS_DIR" cd "$GATUS_DIR" || return 1 @@ -244,7 +266,7 @@ services: restart: unless-stopped env_file: .env ports: - - "8086:8080" + - "${WEB_PORT}:8080" volumes: - ./gatus_config:/config - ./gatus_data:/data @@ -324,7 +346,7 @@ GATUS_CFG Clean, self-hosted status page. Polls HTTP, TCP, DNS, and ICMP endpoints. ## Access -- URL: http://localhost:8086 +- URL: http://localhost:${WEB_PORT} ## Configuration Edit \`gatus_config/config.yaml\` — changes are **hot-reloaded** without restarting. @@ -355,7 +377,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:8086" + echo " Access at: http://localhost:${WEB_PORT}" echo " Config: $GATUS_DIR/gatus_config/config.yaml (hot-reloaded)" echo "" } diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 9501ac4..4c454c2 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -189,9 +204,13 @@ install_homeassistant() { require_docker || return 1 log_info "Installing Home Assistant..." local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" + local WEB_PORT="8123" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" + echo "[DRY-RUN] Bridge mode would auto-scan for a free host port; host mode" + echo "[DRY-RUN] can only warn on a collision (Home Assistant's own port isn't" + echo "[DRY-RUN] configurable, and host networking has no remapping)" return 0 fi @@ -224,16 +243,25 @@ install_homeassistant() { HA_NET_LINES=" network_mode: host" HA_CADDY_NET_LINES="" echo " → Host networking selected (best device discovery)." + # Host mode has no port remapping and Home Assistant's own port isn't + # configurable via env var — a collision here can only be warned + # about, not silently fixed. See CLAUDE.md's "Port collision + # avoidance" section. + port_in_use "$WEB_PORT" && log_warning "Port $WEB_PORT is already in use by another process — Home Assistant won't be reachable until that's resolved (this port isn't configurable with host networking)." else + # Bridge mode can freely remap — scan for a free host port so a + # plain install doesn't silently claim a port another + # already-running service holds. + find_free_port WEB_PORT "$WEB_PORT" HA_NET_LINES=" ports: - - \"8123:8123\"" + - \"${WEB_PORT}:8123\"" if [ "$_CADDY_MODE" = "local" ]; then HA_CADDY_NET_LINES=" networks: - caddy_net" else HA_CADDY_NET_LINES="" fi - echo " → Bridge networking selected (port 8123 published)." + echo " → Bridge networking selected (port $WEB_PORT published)." fi local _CADDY_NET_SECTION="" @@ -292,7 +320,7 @@ HA_CONFIG log_success "Home Assistant configured at $HOMEASSISTANT_DIR" if [ "$HA_NETMODE" = "2" ]; then - configure_caddy_for_service "Home Assistant" "8123" "home" + configure_caddy_for_service "Home Assistant" "$WEB_PORT" "home" else configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" fi @@ -303,7 +331,7 @@ HA_CONFIG Home automation hub. Built-in auth — no Authelia needed. ## Access -- URL: http://localhost:8123 +- URL: http://localhost:${WEB_PORT} - First run: create your admin account through the onboarding wizard ## Manage @@ -322,7 +350,7 @@ MD docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start" fi - echo " Access at: http://localhost:8123" + echo " Access at: http://localhost:${WEB_PORT}" echo " First run: open the URL and create your admin account (onboarding)." echo " Note: first startup can take a minute while HA initializes." echo "" diff --git a/services/homebox.sh b/services/homebox.sh index 5c94ba2..a1c116f 100644 --- a/services/homebox.sh +++ b/services/homebox.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -227,14 +242,16 @@ install_homebox() { INSTANCE_SUFFIX="$_suffix" HB_DIR="$DOCKER_DIR/homebox-$_suffix" CONTAINER="homebox-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $HB_DIR (port $WEB_PORT)" + log_info "New instance: $HB_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$HB_DIR/data" ensure_docker_dir_ownership "$HB_DIR" cd "$HB_DIR" || return 1 diff --git a/services/immich.sh b/services/immich.sh index 7eb1bca..7197439 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -250,14 +265,16 @@ install_immich() { C_REDIS="immich_redis_$_suffix" C_DB="immich_postgres_$_suffix" DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $IMMICH_DIR (port $WEB_PORT)" + log_info "New instance: $IMMICH_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + # ── Photo library setup ───────────────────────────────────────────────── echo "" echo " PHOTO LIBRARY SETUP" diff --git a/services/iopaint.sh b/services/iopaint.sh index 1c3691c..9a458bf 100644 --- a/services/iopaint.sh +++ b/services/iopaint.sh @@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -194,15 +209,22 @@ install_iopaint() { log_info "Installing IOPaint..." local IOPAINT_DIR="$DOCKER_DIR/iopaint" + local WEB_PORT="8100" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $IOPAINT_DIR" echo "[DRY-RUN] Would prompt for model and GPU (CUDA) support" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" echo "[DRY-RUN] Would offer Authelia SSO" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$IOPAINT_DIR" ensure_docker_dir_ownership "$IOPAINT_DIR" cd "$IOPAINT_DIR" || return 1 @@ -308,7 +330,7 @@ services: --port=8080 --host=0.0.0.0 ports: - - "8100:8080" + - "${WEB_PORT}:8080" env_file: .env volumes: - ./models:/root/.cache @@ -340,7 +362,7 @@ services: --port=8080 --host=0.0.0.0 ports: - - "8100:8080" + - "${WEB_PORT}:8080" env_file: .env volumes: - ./models:/root/.cache @@ -467,6 +489,12 @@ docker compose pull && docker compose down && docker compose up -d - input/, output/ — optional file staging MD + # The README above is a quoted (non-interpolating) heredoc — dense with + # literal backticks for inline code spans, too risky to convert to an + # interpolating heredoc without escaping every one of them. Patch the + # port in afterward instead when it was scanned away from the default. + [ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md" + local START_IO="" prompt_yn "Start IOPaint now? (y/n):" "y" START_IO if [ "$START_IO" = "y" ] || [ "$START_IO" = "Y" ]; then @@ -476,7 +504,7 @@ MD fi echo "" - echo " URL: http://localhost:8100" + echo " URL: http://localhost:${WEB_PORT}" echo " Model: $IOPAINT_MODEL" echo " Device: $DEVICE_VAL" echo " Switch: edit MODEL= in $IOPAINT_DIR/.env and restart" diff --git a/services/jellyfin.sh b/services/jellyfin.sh index b1f176f..53187b4 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -43,6 +43,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -238,17 +253,20 @@ install_jellyfin() { JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix" CONTAINER="jellyfin-$_suffix" DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $JELLYFIN_DIR (port $WEB_PORT)" + log_info "New instance: $JELLYFIN_DIR" log_warning "DLNA/discovery (1900/udp, 7359/udp) are fixed, host-wide ports already" log_warning "claimed by the first instance — this instance skips them (web UI and" log_warning "app-based streaming are unaffected; DLNA auto-discovery is not)." fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port (e.g. + # emby also defaults to 8096) — see CLAUDE.md's "Port collision avoidance" + # section. + find_free_port WEB_PORT "$WEB_PORT" + local MEDIA_PATH="" prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" diff --git a/services/joplin.sh b/services/joplin.sh index 7d0a91c..7fa279c 100644 --- a/services/joplin.sh +++ b/services/joplin.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -232,14 +247,16 @@ install_joplin() { JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix" CONTAINER="joplin-$_suffix" DB_CONTAINER="joplin-db-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $JOPLIN_DIR (port $WEB_PORT)" + log_info "New instance: $JOPLIN_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$JOPLIN_DIR" ensure_docker_dir_ownership "$JOPLIN_DIR" cd "$JOPLIN_DIR" || return 1 diff --git a/services/koha.sh b/services/koha.sh index c68258e..ecd5db0 100644 --- a/services/koha.sh +++ b/services/koha.sh @@ -45,6 +45,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -196,6 +211,17 @@ install_koha() { require_docker || return 1 local KOHA_DIR="$DOCKER_DIR/koha" + local OPAC_PORT="8097" STAFF_PORT="8098" + + # Scan for free host ports, moving both together — a plain install + # shouldn't silently claim a port another already-running service holds. + # Run before any prompts so every message below (including DRY-RUN) + # reflects the real port. See CLAUDE.md's "Port collision avoidance" + # section. + while port_in_use "$OPAC_PORT" || port_in_use "$STAFF_PORT"; do + OPAC_PORT=$((OPAC_PORT + 1)) + STAFF_PORT=$((STAFF_PORT + 1)) + done echo "" echo "╔═══════════════════════════════════════════════════════════════════╗" @@ -211,7 +237,7 @@ install_koha() { echo " SETUP OVERVIEW:" echo " 1) Answer the questions below (collects library details + credentials)" echo " 2) Koha starts — takes 2–3 min on first boot" - echo " 3) Open http://localhost:8098 and complete the brief web installer (~2 min)" + echo " 3) Open http://localhost:${STAFF_PORT} and complete the brief web installer (~2 min)" echo " 4) Run $KOHA_DIR/post-setup.sh to auto-configure library, items, locations" echo "" @@ -219,8 +245,8 @@ install_koha() { echo "[DRY-RUN] Would create $KOHA_DIR with docker-compose.yml + config-main.env" echo "[DRY-RUN] Would deploy teogramm/koha + MariaDB (./data/) + Memcached + RabbitMQ" echo "[DRY-RUN] Would generate post-setup.sh for REST API configuration" - echo "[DRY-RUN] OPAC (patron UI): port 8097" - echo "[DRY-RUN] Staff/admin: port 8098" + echo "[DRY-RUN] OPAC (patron UI): port $OPAC_PORT" + echo "[DRY-RUN] Staff/admin: port $STAFF_PORT" return 0 fi @@ -399,8 +425,8 @@ services: env_file: - config-main.env ports: - - "8097:8080" - - "8098:8081" + - "${OPAC_PORT}:8080" + - "${STAFF_PORT}:8081" depends_on: - koha-db - koha-memcached @@ -485,13 +511,13 @@ KOHA_ENV cat > post-setup.sh << POSTSETUP #!/bin/bash -# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:8098 +# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:${STAFF_PORT} # Configures library, item types, and shelf locations via the Koha REST API. # Generated by ubuntu-post-install on $(date '+%F'). set -uo pipefail -KOHA_STAFF_URL="http://localhost:8098" +KOHA_STAFF_URL="http://localhost:${STAFF_PORT}" KOHA_DIR="${KOHA_DIR}" CONF="\${KOHA_DIR}/config-main.env" @@ -627,8 +653,8 @@ echo "════════════════════════ echo "" echo " Your Koha library is ready to use." echo "" -echo " OPAC (patron browsing): http://localhost:8097" -echo " Staff / admin: http://localhost:8098" +echo " OPAC (patron browsing): http://localhost:${OPAC_PORT}" +echo " Staff / admin: http://localhost:${STAFF_PORT}" echo "" echo " Next steps in Staff UI:" echo " 1. Administration → Patron categories → add patron types" @@ -658,8 +684,8 @@ POSTSETUP Full Integrated Library System for managing a physical book collection. ## Access -- **OPAC** (patron browsing): http://localhost:8097 -- **Staff / admin**: http://localhost:8098 +- **OPAC** (patron browsing): http://localhost:${OPAC_PORT} +- **Staff / admin**: http://localhost:${STAFF_PORT} ## Quick setup (4 steps) @@ -671,7 +697,7 @@ docker compose up -d Takes 2–3 minutes on first boot while Koha initialises. ### Step 2 — Complete the web installer -1. Open **http://localhost:8098** +1. Open **http://localhost:${STAFF_PORT}** 2. You may see a "Database connection" page first — wait 1–2 min and refresh 3. The installer wizard appears automatically: - **Language**: click "Install for language English" → Continue @@ -743,12 +769,12 @@ MD echo " └──────────────────────────────────────────────────────────────┘" echo "" echo " NEXT STEPS:" - echo " 1. Wait ~3 min, then open: http://localhost:8098" + echo " 1. Wait ~3 min, then open: http://localhost:${STAFF_PORT}" echo " 2. Complete the web installer (use password above when asked)" echo " 3. Run: sudo $KOHA_DIR/post-setup.sh" echo "" - echo " OPAC (patron UI): http://localhost:8097" - echo " Staff / admin: http://localhost:8098" + echo " OPAC (patron UI): http://localhost:${OPAC_PORT}" + echo " Staff / admin: http://localhost:${STAFF_PORT}" echo "" } diff --git a/services/lyrion.sh b/services/lyrion.sh index 33dcb86..f44e576 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -263,18 +278,29 @@ install_lyrion() { log_warning "don't collide with the first instance's fixed ports. This instance" log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address" log_warning "manually in the Squeezer app / Squeezebox firmware instead." - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${CLI_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${PLAYER_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - CLI_PORT=$((CLI_PORT + 1)) - PLAYER_PORT=$((PLAYER_PORT + 1)) - done - log_info "New instance: $LYRION_DIR (web $WEB_PORT, CLI $CLI_PORT, player $PLAYER_PORT)" + log_info "New instance: $LYRION_DIR" fi fi + # Port collision avoidance — see CLAUDE.md's "Port collision avoidance" + # section. Bridge-mode instances can fully auto-scan (all 3 ports move + # together). Host-mode (the first instance) can only auto-adjust + # WEB_PORT — HTTP_PORT is a real env var the image honors even under + # host networking — CLI_PORT/PLAYER_PORT are hardcoded inside the image + # with no override, so a collision there can only be warned about, not + # silently fixed. + if [ "$USE_HOST_NETWORK" = true ]; then + find_free_port WEB_PORT "$WEB_PORT" + port_in_use "$CLI_PORT" && log_warning "CLI port $CLI_PORT is already in use by another process — Lyrion's CLI interface won't be reachable until that's resolved (this port isn't configurable in the image)." + port_in_use "$PLAYER_PORT" && log_warning "Player port $PLAYER_PORT is already in use by another process — Squeezebox/app connections won't work until that's resolved (this port isn't configurable in the image)." + else + while port_in_use "$WEB_PORT" || port_in_use "$CLI_PORT" || port_in_use "$PLAYER_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + CLI_PORT=$((CLI_PORT + 1)) + PLAYER_PORT=$((PLAYER_PORT + 1)) + done + fi + local MUSIC_PATH="" prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}" @@ -287,9 +313,14 @@ install_lyrion() { TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + # Host mode has no ports: remapping — HTTP_PORT is the only thing that + # actually controls the bind port, so it must track the scanned WEB_PORT. + # Bridge mode keeps the container's internal port fixed at 9000 and lets + # the ports: line do the remapping instead. local _NETWORK_BLOCK=" network_mode: host " local _PORTS_BLOCK="" + local _HTTP_PORT_INTERNAL="$WEB_PORT" if [ "$USE_HOST_NETWORK" != true ]; then _NETWORK_BLOCK="" _PORTS_BLOCK=" ports: @@ -297,6 +328,7 @@ install_lyrion() { - \"${CLI_PORT}:9090\" - \"${PLAYER_PORT}:3483\" " + _HTTP_PORT_INTERNAL="9000" fi cat > docker-compose.yml << LYRION_COMPOSE @@ -309,7 +341,7 @@ services: hostname: $CONTAINER restart: unless-stopped ${_NETWORK_BLOCK}${_PORTS_BLOCK} environment: - - HTTP_PORT=9000 + - HTTP_PORT=$_HTTP_PORT_INTERNAL - PUID=$UID_VAL - PGID=$GID_VAL - TZ=$TZ_VAL diff --git a/services/mattermost.sh b/services/mattermost.sh index 486fe97..83bdfaf 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -251,25 +266,24 @@ install_mattermost() { MM_CONTAINER="mattermost-$_suffix" DB_CONTAINER="mattermost-$_suffix-db" COTURN_CONSUMER="mattermost-$_suffix" - - # Free-port scan — same pattern services/asterisk.sh uses for its - # web admin port. WEB_PORT is also set as Mattermost's own - # internal ListenAddress below (not just the host publish side), - # so configure_caddy_for_service's single upstream "name:port" - # string works unmodified in both local and remote-Caddy mode — - # it assumes host-published-port == container-internal-port, - # true for every other service in this repo and made true here - # too rather than special-casing the shared helper for one caller. - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do - CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1)) - done - log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)" + log_info "New instance: $DIR" fi fi + # Free-port scan — runs unconditionally, not just when adding an explicit + # additional instance, so a plain first install also can't collide with + # an unrelated service that already claimed these default ports. Same + # pattern services/asterisk.sh uses for its web admin port. WEB_PORT is + # also set as Mattermost's own internal ListenAddress below (not just the + # host publish side), so configure_caddy_for_service's single upstream + # "name:port" string works unmodified in both local and remote-Caddy mode — + # it assumes host-published-port == container-internal-port, true for + # every other service in this repo and made true here too rather than + # special-casing the shared helper for one caller. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + find_free_port CALLS_UDP_PORT "$CALLS_UDP_PORT" udp + log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..." if [ "$DRY_RUN" = true ]; then diff --git a/services/mealie.sh b/services/mealie.sh index a4a6911..e343054 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -230,14 +245,16 @@ install_mealie() { INSTANCE_SUFFIX="$_suffix" MEALIE_DIR="$DOCKER_DIR/mealie-$_suffix" CONTAINER="mealie-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $MEALIE_DIR (port $WEB_PORT)" + log_info "New instance: $MEALIE_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$MEALIE_DIR" ensure_docker_dir_ownership "$MEALIE_DIR" cd "$MEALIE_DIR" || return 1 diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 4c5691d..21b079d 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -241,16 +256,20 @@ install_meshcentral() { INSTANCE_SUFFIX="$_suffix" MC_DIR="$DOCKER_DIR/meshcentral-$_suffix" CONTAINER="meshcentral-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${AGENT_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - AGENT_PORT=$((AGENT_PORT + 1)) - done - log_info "New instance: $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)" + log_info "New instance: $MC_DIR" fi fi + # Scan for free ports unconditionally, moving both together — not just + # when adding an explicit additional instance. A plain first install can + # just as easily collide with an unrelated service that already claimed + # one of these default ports — see CLAUDE.md's "Port collision + # avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do + WEB_PORT=$((WEB_PORT + 1)) + AGENT_PORT=$((AGENT_PORT + 1)) + done + local MC_HOSTNAME="" prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME MC_HOSTNAME="${MC_HOSTNAME:-localhost}" diff --git a/services/n8n.sh b/services/n8n.sh index 896c4e6..adc8c18 100644 --- a/services/n8n.sh +++ b/services/n8n.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,20 @@ install_n8n() { log_info "Installing n8n..." local N8N_DIR="$DOCKER_DIR/n8n" + local WEB_PORT="5678" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $N8N_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$N8N_DIR/data" ensure_docker_dir_ownership "$N8N_DIR" cd "$N8N_DIR" || return 1 @@ -230,7 +252,7 @@ services: hostname: n8n restart: unless-stopped ports: - - "5678:5678" + - "${WEB_PORT}:5678" env_file: - .env environment: @@ -260,27 +282,27 @@ N8N_ENV configure_caddy_for_service "n8n" "n8n:5678" "n8n" - write_readme "$N8N_DIR" << 'MD' + write_readme "$N8N_DIR" << MD # n8n Workflow automation platform — connect all your self-hosted services with a visual editor. Create webhooks, scheduled jobs, and multi-step automations. ## Access -- URL: http://localhost:5678 +- URL: http://localhost:${WEB_PORT} - On first run, n8n prompts you to create an owner account. ## Manage -```bash +\`\`\`bash docker compose up -d # start docker compose down # stop docker compose logs -f # logs docker compose pull && docker compose down && docker compose up -d # update -``` +\`\`\` ## Environment -Edit `.env` to change `WEBHOOK_URL` or `N8N_HOST` after deployment, -then restart: `docker compose down && docker compose up -d` +Edit \`.env\` to change \`WEBHOOK_URL\` or \`N8N_HOST\` after deployment, +then restart: \`docker compose down && docker compose up -d\` MD local START_N8N="" @@ -291,7 +313,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:5678" + echo " Access at: http://localhost:${WEB_PORT}" echo " Create your owner account on first visit." echo "" } diff --git a/services/nextcloud.sh b/services/nextcloud.sh index addd268..0620a96 100644 --- a/services/nextcloud.sh +++ b/services/nextcloud.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -180,12 +195,20 @@ install_nextcloud() { require_docker || return 1 log_info "Installing Nextcloud..." local DIR="$DOCKER_DIR/nextcloud" + local WEB_PORT="8080" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env" + echo "[DRY-RUN] Would auto-scan for a free host port (8080 default)" return 0 fi + # Scan for a free host port — this default (8080) isn't unique to + # Nextcloud in this repo (arm also defaults to 8080), so a plain install + # shouldn't silently claim a port another already-running service holds. + # See CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -253,7 +276,7 @@ services: - ./config:/var/www/html/config - ./custom_apps:/var/www/html/custom_apps ports: - - "8080:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK} db: image: mariadb:10.11 @@ -328,7 +351,7 @@ Self-hosted cloud storage with SMB/local file access. ## Access -- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080) +- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT}) - Admin: admin - Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\` @@ -366,7 +389,7 @@ Back up these directories: NCREADME echo "" - echo " Access URL: http://localhost:8080" + echo " Access URL: http://localhost:${WEB_PORT}" echo " Admin user: admin" echo " Admin pass: $NC_ADMIN_PASS" echo " Config dir: $DIR" diff --git a/services/ntfy.sh b/services/ntfy.sh index 2d2708e..62abde7 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -235,14 +250,16 @@ install_ntfy() { INSTANCE_SUFFIX="$_suffix" NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix" CONTAINER="ntfy-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $NTFY_DIR (port $WEB_PORT)" + log_info "New instance: $NTFY_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$NTFY_DIR" ensure_docker_dir_ownership "$NTFY_DIR" cd "$NTFY_DIR" || return 1 diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh index a50a44e..187ef0a 100644 --- a/services/onlyoffice.sh +++ b/services/onlyoffice.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -226,12 +241,19 @@ install_onlyoffice() { log_info "Installing OnlyOffice Document Server..." local DIR="$DOCKER_DIR/onlyoffice" + local WEB_PORT="8082" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env" + echo "[DRY-RUN] Would auto-scan for a free host port (8082 default)" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -281,7 +303,7 @@ services: - ./data:/var/www/onlyoffice/Data - ./fonts:/usr/share/fonts/truetype/custom ports: - - "8082:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} OOCOMPOSE @@ -333,7 +355,7 @@ Self-hosted document editing server, integrated with Nextcloud and FileBrowser Q ## Access -- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082) +- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT}) - The document server itself has no user-facing login page — it is accessed through Nextcloud or FileBrowser Quantum. @@ -381,7 +403,7 @@ OOREAD echo "" echo " OnlyOffice Document Server" - echo " Access URL: http://localhost:8082" + echo " Access URL: http://localhost:${WEB_PORT}" echo " JWT secret: $JWT_SECRET" echo " Config dir: $DIR" echo "" diff --git a/services/portainer.sh b/services/portainer.sh index 2ebd4dc..0920f6e 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -184,12 +199,22 @@ install_portainer() { require_docker || return 1 log_info "Installing Portainer..." local PORTAINER_DIR="$DOCKER_DIR/portainer" + local HTTP_PORT="9000" HTTPS_PORT="9443" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $PORTAINER_DIR" + echo "[DRY-RUN] Would auto-scan for free host ports (9000/9443 defaults)" return 0 fi + # Scan for free host ports, moving both together — a plain install + # shouldn't silently claim a port another already-running service holds. + # See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$HTTP_PORT" || port_in_use "$HTTPS_PORT"; do + HTTP_PORT=$((HTTP_PORT + 1)) + HTTPS_PORT=$((HTTPS_PORT + 1)) + done + mkdir -p "$PORTAINER_DIR" ensure_docker_dir_ownership "$PORTAINER_DIR" cd "$PORTAINER_DIR" || return 1 @@ -230,8 +255,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock - ./data:/data ports: - - "9000:9000" - - "9443:9443" + - "${HTTP_PORT}:9000" + - "${HTTPS_PORT}:9443" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} PORTAINER_COMPOSE @@ -239,7 +264,7 @@ PORTAINER_COMPOSE chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" echo "" - log_success "Portainer configured at $PORTAINER_DIR" + log_success "Portainer configured at $PORTAINER_DIR (HTTP $HTTP_PORT, HTTPS $HTTPS_PORT)" configure_caddy_for_service "Portainer" "portainer:9000" "portainer" @@ -249,8 +274,8 @@ PORTAINER_COMPOSE Web UI for managing Docker — containers, images, volumes, and networks. ## Access -- HTTPS: https://localhost:9443 -- HTTP: http://localhost:9000 +- HTTPS: https://localhost:${HTTPS_PORT} +- HTTP: http://localhost:${HTTP_PORT} - Create your admin account on first visit. ## Data @@ -272,7 +297,7 @@ MD docker compose up -d 2>/dev/null && log_success "Portainer started" || log_warning "Failed to start" fi - echo " Access at: https://localhost:9443" + echo " Access at: https://localhost:${HTTPS_PORT}" echo " Create admin account on first visit" echo "" } diff --git a/services/rustdesk.sh b/services/rustdesk.sh index ed7d09d..f5f4d14 100644 --- a/services/rustdesk.sh +++ b/services/rustdesk.sh @@ -56,6 +56,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -149,20 +164,24 @@ install_rustdesk() { INSTANCE_SUFFIX="$_suffix" RD_DIR="$DOCKER_DIR/rustdesk-$_suffix" CONTAINER="rustdesk-$_suffix" - - PORT_OFFSET=10 - while ss -tlnH "sport = :$((21115 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -ulnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21117 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21118 + PORT_OFFSET))" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :$((21119 + PORT_OFFSET))" 2>/dev/null | grep -q .; do - PORT_OFFSET=$((PORT_OFFSET + 10)) - done - log_info "New instance: $RD_DIR (ports $((21115 + PORT_OFFSET))-$((21119 + PORT_OFFSET)))" + log_info "New instance: $RD_DIR" fi fi + # Scan for a free port block unconditionally, shifting the whole 6-port + # block together — not just when adding an explicit additional instance. + # A plain first install can just as easily collide with an unrelated + # service already bound to one of these default ports — see CLAUDE.md's + # "Port collision avoidance" section. + while port_in_use "$((21115 + PORT_OFFSET))" \ + || port_in_use "$((21116 + PORT_OFFSET))" \ + || port_in_use "$((21116 + PORT_OFFSET))" udp \ + || port_in_use "$((21117 + PORT_OFFSET))" \ + || port_in_use "$((21118 + PORT_OFFSET))" \ + || port_in_use "$((21119 + PORT_OFFSET))"; do + PORT_OFFSET=$((PORT_OFFSET + 10)) + done + mkdir -p "$RD_DIR/rustdesk_data" ensure_docker_dir_ownership "$RD_DIR" cd "$RD_DIR" || return 1 diff --git a/services/stirling-pdf.sh b/services/stirling-pdf.sh index 94a9d6a..c7420b7 100644 --- a/services/stirling-pdf.sh +++ b/services/stirling-pdf.sh @@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -186,13 +201,20 @@ install_stirling-pdf() { log_info "Installing Stirling PDF..." local PDF_DIR="$DOCKER_DIR/stirling-pdf" + local WEB_PORT="8070" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $PDF_DIR" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$PDF_DIR" ensure_docker_dir_ownership "$PDF_DIR" cd "$PDF_DIR" || return 1 @@ -231,7 +253,7 @@ services: restart: unless-stopped env_file: .env ports: - - "8070:8080" + - "${WEB_PORT}:8080" volumes: - ./training-data:/usr/share/tessdata - ./extraConfigs:/configs @@ -275,7 +297,7 @@ PDF_ENV Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more. ## Access -- URL: http://localhost:8070 +- URL: http://localhost:${WEB_PORT} - No login required by default (security disabled) ## Enabling built-in auth @@ -314,7 +336,7 @@ MD || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:8070" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/syncthing.sh b/services/syncthing.sh index ff18faa..5eda20c 100644 --- a/services/syncthing.sh +++ b/services/syncthing.sh @@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -157,13 +172,28 @@ install_syncthing() { log_info "Installing Syncthing..." local DIR="$DOCKER_DIR/syncthing" + local WEB_PORT="8384" SYNC_PORT="22000" DISCOVERY_PORT="21027" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" echo "[DRY-RUN] Would expose web UI on 8384, sync protocol on 22000 (tcp+udp), discovery on 21027/udp" + echo "[DRY-RUN] All 3 auto-scanned/shifted together if occupied" return 0 fi + # Scan for free host ports, moving all 3 together — a plain install + # shouldn't silently claim a port another already-running service holds. + # Note: DISCOVERY_PORT (LAN broadcast) only works for zero-config local + # discovery at its standard number — shifting it away from 21027 means + # peers must be added manually by device ID instead of relying on + # auto-discovery. See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$SYNC_PORT" \ + || port_in_use "$SYNC_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + SYNC_PORT=$((SYNC_PORT + 1)) + DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) + done + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -209,10 +239,10 @@ services: - PGID=\${PGID:-$PGID} - TZ=\${SITE_TZ:-UTC} ports: - - "8384:8384" - - "22000:22000/tcp" - - "22000:22000/udp" - - "21027:21027/udp" + - "${WEB_PORT}:8384" + - "${SYNC_PORT}:22000/tcp" + - "${SYNC_PORT}:22000/udp" + - "${DISCOVERY_PORT}:21027/udp" volumes: - ./config:/var/syncthing/config - ./data:/var/syncthing @@ -237,15 +267,20 @@ ENV Continuous, decentralised file synchronisation between devices. ## Access -- Web UI: http://localhost:8384 +- Web UI: http://localhost:${WEB_PORT} - First run: go to **Settings → GUI** and set a username and password. +$( [ "$DISCOVERY_PORT" != "21027" ] && echo " +**Note:** the default ports were already in use, so this instance's ports +were shifted — see below. Local LAN auto-discovery only works at the +standard 21027/udp; with a shifted discovery port, add other devices +manually by device ID instead of relying on auto-discovery." ) ## Firewall ports (for LAN sync) Open these on the host firewall so other Syncthing devices can reach this node: \`\`\` -sudo ufw allow 22000/tcp comment "Syncthing sync protocol" -sudo ufw allow 22000/udp comment "Syncthing sync protocol (QUIC)" -sudo ufw allow 21027/udp comment "Syncthing local discovery" +sudo ufw allow ${SYNC_PORT}/tcp comment "Syncthing sync protocol" +sudo ufw allow ${SYNC_PORT}/udp comment "Syncthing sync protocol (QUIC)" +sudo ufw allow ${DISCOVERY_PORT}/udp comment "Syncthing local discovery" \`\`\` ## Manage @@ -266,7 +301,7 @@ MD || log_warning "Start failed — check: docker compose logs" fi - echo " Access at: http://localhost:8384" + echo " Access at: http://localhost:${WEB_PORT}" echo " First run: set a username and password in Settings → GUI" echo "" } diff --git a/services/traccar.sh b/services/traccar.sh index 337ce65..e41be28 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_yn() { local _q="$1" _def="$2" _var="$3" _r @@ -252,10 +267,6 @@ install_traccar() { AUTOHEAL_CONTAINER="traccar-$_suffix-autoheal" AUTOHEAL_LABEL="autoheal-traccar-$_suffix" - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - # Count existing traccar/traccar-* dirs (this new one isn't # created yet, so the first extra instance counts exactly 1 # existing dir -> offset 1 -> 6000-6150). @@ -265,10 +276,18 @@ install_traccar() { PROTO_MIN=$((5000 + _offset)) PROTO_MAX=$((5150 + _offset)) - log_info "New instance: $TRACCAR_DIR (web port $WEB_PORT, device protocols $PROTO_MIN-$PROTO_MAX)" + log_info "New instance: $TRACCAR_DIR (device protocols $PROTO_MIN-$PROTO_MAX)" fi fi + # Scan for a free web port unconditionally — not just when adding an + # explicit additional instance. A plain first install can just as easily + # collide with an unrelated service that already claimed this default + # port — see CLAUDE.md's "Port collision avoidance" section. The + # PROTO_MIN/PROTO_MAX device-protocol range above is a different, + # directory-count-based mechanism (not an ss scan) and is unaffected. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$TRACCAR_DIR" # Non-recursive on purpose — a rerun already has a `db/` full of Postgres's # own data files, owned by whatever uid the postgres container runs as diff --git a/services/unifi.sh b/services/unifi.sh index 1da9a83..6dc7bc9 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -150,20 +165,23 @@ install_unifi() { PROJECT="unifi-$_suffix" DB_CONTAINER="unifi-db-$_suffix" APP_CONTAINER="unifi-app-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ - || ss -tlnH "sport = :${INFORM_PORT}" 2>/dev/null | grep -q . \ - || ss -ulnH "sport = :${STUN_PORT}" 2>/dev/null | grep -q . \ - || ss -ulnH "sport = :${DISCOVERY_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - INFORM_PORT=$((INFORM_PORT + 1)) - STUN_PORT=$((STUN_PORT + 1)) - DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) - done - log_info "New instance: $UNIFI_DIR (web $WEB_PORT, inform $INFORM_PORT, STUN $STUN_PORT, discovery $DISCOVERY_PORT)" + log_info "New instance: $UNIFI_DIR" fi fi + # Scan for free ports unconditionally, moving all 4 together — not just + # when adding an explicit additional instance. A plain first install can + # just as easily collide with an unrelated service that already claimed + # one of these default ports — see CLAUDE.md's "Port collision + # avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$INFORM_PORT" \ + || port_in_use "$STUN_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + INFORM_PORT=$((INFORM_PORT + 1)) + STUN_PORT=$((STUN_PORT + 1)) + DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) + done + mkdir -p "$UNIFI_DIR" ensure_docker_dir_ownership "$UNIFI_DIR" cd "$UNIFI_DIR" || return 1 diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index bcd1d46..dac8d28 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_yn() { local _q="$1" _def="$2" _var="$3" _r @@ -186,12 +201,19 @@ install_uptimekuma() { require_docker || return 1 log_info "Installing Uptime Kuma..." local UPTIME_DIR="$DOCKER_DIR/uptime-kuma" + local WEB_PORT="3001" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $UPTIME_DIR" + echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi + # Scan for a free host port — a plain install shouldn't silently claim a + # port another already-running service holds. See CLAUDE.md's "Port + # collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$UPTIME_DIR" ensure_docker_dir_ownership "$UPTIME_DIR" cd "$UPTIME_DIR" || return 1 @@ -232,7 +254,7 @@ services: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock:ro ports: - - "3001:3001" + - "${WEB_PORT}:3001" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} UPTIME_COMPOSE @@ -249,7 +271,7 @@ Self-hosted uptime/status monitoring dashboard. Monitor websites, servers, and Docker containers. ## Access -- URL: http://localhost:3001 +- URL: http://localhost:${WEB_PORT} - Create your admin account on first visit. ## Data @@ -278,7 +300,7 @@ MD docker compose up -d 2>/dev/null && log_success "Uptime Kuma started" || log_warning "Failed to start" fi - echo " Access at: http://localhost:3001" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 70607de..c7f3e8a 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" @@ -249,14 +264,16 @@ install_vaultwarden() { INSTANCE_SUFFIX="$_suffix" VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix" CONTAINER="vaultwarden-$_suffix" - - while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do - WEB_PORT=$((WEB_PORT + 1)) - done - log_info "New instance: $VW_DIR (port $WEB_PORT)" + log_info "New instance: $VW_DIR" fi fi + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + mkdir -p "$VW_DIR/vaultwarden_data" ensure_docker_dir_ownership "$VW_DIR" cd "$VW_DIR" || return 1 diff --git a/services/wg-easy.sh b/services/wg-easy.sh index 2366bbc..edf0fc6 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r @@ -198,6 +213,7 @@ install_wg-easy() { require_docker || return 1 local WGEASY_DIR="$DOCKER_DIR/wg-easy" + local WEB_PORT="51821" VPN_PORT="51820" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] wg-easy would:" @@ -205,14 +221,24 @@ install_wg-easy() { echo " - Auto-detect public IP for WG_HOST" echo " - Generate a random web UI password" echo " - Pin WG_DEFAULT_ADDRESS=10.8.0.x (subnet 10.8.0.0/24)" - echo " - Expose port 51821 (web UI) + 51820/udp (VPN)" - echo " - Require router port-forward: UDP 51820 → this server" + echo " - Expose port 51821 (web UI) + 51820/udp (VPN), both auto-scanned if occupied" + echo " - Require router port-forward: UDP → this server" echo " - Offer a Caddy reverse proxy and to start the container" echo " - Offer to also allow SSH from the VPN subnet (additive, doesn't remove public SSH)" echo " - Write sync-ssh-aliases.sh — generates ~/.ssh/config aliases for connected peers" return 0 fi + # Scan for free host ports, moving both together — a plain install + # shouldn't silently claim a port another already-running service holds. + # Whatever VPN_PORT ends up as is what needs forwarding on the router + # (the messaging below reflects the final value, not the 51820 default). + # See CLAUDE.md's "Port collision avoidance" section. + while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do + WEB_PORT=$((WEB_PORT + 1)) + VPN_PORT=$((VPN_PORT + 1)) + done + mkdir -p "$WGEASY_DIR" ensure_docker_dir_ownership "$WGEASY_DIR" cd "$WGEASY_DIR" || return 1 @@ -287,11 +313,12 @@ services: - WG_DEFAULT_DNS=1.1.1.1 - WG_DEFAULT_ADDRESS=${WG_DEFAULT_ADDRESS} - WG_ALLOWED_IPS=0.0.0.0/0, ::/0 + - WG_PORT=${VPN_PORT} volumes: - ./config:/etc/wireguard ports: - - "51820:51820/udp" - - "51821:51821/tcp" + - "${VPN_PORT}:${VPN_PORT}/udp" + - "${WEB_PORT}:51821/tcp" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} WGEASY_COMPOSE @@ -416,8 +443,8 @@ SYNCEOF WireGuard VPN with a web UI for managing clients, generating QR codes, and monitoring connections. -- Web UI: http://localhost:51821 -- VPN: UDP port 51820 (forward this on your router) +- Web UI: http://localhost:${WEB_PORT} +- VPN: UDP port ${VPN_PORT} (forward this on your router) - Password: stored in \`.env\` (\`WG_PASSWORD\`) - VPN host: \`$WG_HOST\` (update \`WG_HOST\` in .env if your IP changes) - VPN subnet: \`$WG_SUBNET_CIDR\` @@ -433,10 +460,10 @@ docker compose pull && docker compose up -d # update \`\`\` ## Router setup -Forward **UDP port 51820** to this server's LAN IP for external VPN access. +Forward **UDP port ${VPN_PORT}** to this server's LAN IP for external VPN access. ## Adding clients -Open http://localhost:51821, log in with your password, click "+ New Client", +Open http://localhost:${WEB_PORT}, log in with your password, click "+ New Client", download or scan the QR code with the WireGuard app. ## Mesh — peers reach each other automatically @@ -471,11 +498,11 @@ MD fi echo "" - echo " Web UI: http://localhost:51821" + echo " Web UI: http://localhost:${WEB_PORT}" echo " Password: $WG_PASSWORD (saved in .env)" [[ -n "$WG_PASSWORD_HASH" ]] && echo " Auth: bcrypt hash configured (v14+ compatible)" \ || echo " Auth: WARNING — bcrypt hash generation failed; see README" - echo " Router: forward UDP 51820 → this server for external VPN access" + echo " Router: forward UDP ${VPN_PORT} → this server for external VPN access" echo "" } diff --git a/services/wordpress.sh b/services/wordpress.sh index b638075..89b4c1a 100644 --- a/services/wordpress.sh +++ b/services/wordpress.sh @@ -57,6 +57,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + generate_password() { local _len="${1:-32}" tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"