#!/usr/bin/env bash set -euo pipefail env_file="" mode="nossl" compose_file="" skip_when_running="0" usage() { cat < [--mode ssl|nossl] [--compose-file ] [--skip-when-running] Auto-selects free host listen ports for enabled service profiles and updates the env file in-place. USAGE } while [[ $# -gt 0 ]]; do case "$1" in --env-file) env_file="${2:-}" shift 2 ;; --env-file=*) env_file="${1#*=}" shift ;; --mode) mode="${2:-}" shift 2 ;; --mode=*) mode="${1#*=}" shift ;; --compose-file) compose_file="${2:-}" shift 2 ;; --compose-file=*) compose_file="${1#*=}" shift ;; --skip-when-running) skip_when_running="1" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage exit 1 ;; esac done if [[ -z "${env_file}" ]]; then echo "Missing --env-file" >&2 usage exit 1 fi if [[ ! -f "${env_file}" ]]; then echo "Env file not found: ${env_file}" >&2 exit 1 fi if [[ "${mode}" != "ssl" && "${mode}" != "nossl" ]]; then echo "Invalid mode: ${mode}" >&2 exit 1 fi read_kv() { local key="$1" local line line="$(grep -m1 -E "^${key}=" "${env_file}" || true)" if [[ -z "${line}" ]]; then return 1 fi echo "${line#*=}" } set_kv() { local key="$1" local value="$2" local esc esc="${value//\\/\\\\}" esc="${esc//&/\\&}" esc="${esc//|/\\|}" if grep -q -E "^${key}=" "${env_file}"; then sed -i -E "s|^${key}=.*|${key}=${esc}|" "${env_file}" else echo "${key}=${value}" >> "${env_file}" fi } is_valid_port() { local port="$1" [[ "${port}" =~ ^[0-9]+$ ]] || return 1 (( port >= 1 && port <= 65535 )) } normalize_bind_host() { local host="$1" if [[ -z "${host}" || "${host}" == "0.0.0.0" || "${host}" == "::" ]]; then echo "*" else echo "${host}" fi } port_in_use() { local host="$1" local port="$2" if ! command -v ss >/dev/null 2>&1; then return 1 fi if [[ -n "${host}" && "${host}" != "0.0.0.0" && "${host}" != "::" ]]; then if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|[\\[\\]:])${host}:${port}$|:${port}$"; then return 0 fi if ss -H -lun 2>/dev/null | awk '{print $5}' | grep -Eq "(^|[\\[\\]:])${host}:${port}$|:${port}$"; then return 0 fi else if ss -H -ltn "( sport = :${port} )" 2>/dev/null | grep -q .; then return 0 fi if ss -H -lun "( sport = :${port} )" 2>/dev/null | grep -q .; then return 0 fi fi return 1 } declare -a reserved_bindings=() is_reserved() { local host="$1" local port="$2" local normalized host_part port_part entry normalized="$(normalize_bind_host "${host}")" for entry in "${reserved_bindings[@]}"; do host_part="${entry%%:*}" port_part="${entry##*:}" if [[ "${port_part}" != "${port}" ]]; then continue fi if [[ "${normalized}" == "*" || "${host_part}" == "*" || "${normalized}" == "${host_part}" ]]; then return 0 fi done return 1 } reserve_binding() { local host="$1" local port="$2" reserved_bindings+=("$(normalize_bind_host "${host}"):${port}") } port_unavailable() { local host="$1" local port="$2" if is_reserved "${host}" "${port}"; then return 0 fi if port_in_use "${host}" "${port}"; then return 0 fi return 1 } find_free_port() { local host="$1" local start_port="$2" local candidate for ((candidate=start_port; candidate<=65535; candidate++)); do if ! port_unavailable "${host}" "${candidate}"; then echo "${candidate}" return 0 fi done return 1 } assign_port_key() { local key="$1" local host="$2" local default_port="$3" local label="$4" local current_value desired start_port selected old_value current_value="$(read_kv "${key}" || true)" old_value="${current_value}" if ! is_valid_port "${current_value}"; then current_value="${default_port}" fi desired="${current_value}" start_port="${default_port}" if is_valid_port "${desired}"; then start_port="${desired}" fi if port_unavailable "${host}" "${desired}"; then selected="$(find_free_port "${host}" "${start_port}" || true)" if [[ -z "${selected}" ]]; then echo "Unable to find a free host port for ${key} (${label})" >&2 exit 1 fi set_kv "${key}" "${selected}" echo "Auto-selected ${key}=${selected} (${label})" desired="${selected}" elif [[ "${old_value}" != "${desired}" ]]; then # Normalize invalid/missing values to the chosen default/current. set_kv "${key}" "${desired}" fi reserve_binding "${host}" "${desired}" } reserve_existing_port_key() { local key="$1" local host="$2" local current_value current_value="$(read_kv "${key}" || true)" if is_valid_port "${current_value}"; then reserve_binding "${host}" "${current_value}" fi } service_running() { local service="$1" if [[ "${skip_when_running}" != "1" || -z "${compose_file}" || ! -f "${compose_file}" ]]; then return 1 fi docker compose -f "${compose_file}" --env-file "${env_file}" ps --status running -q "${service}" 2>/dev/null | grep -q . } assign_service_port_key() { local service="$1" local key="$2" local host="$3" local default_port="$4" local label="$5" if service_running "${service}"; then reserve_existing_port_key "${key}" "${host}" return 0 fi assign_port_key "${key}" "${host}" "${default_port}" "${label}" } normalize_running_stack_conflicts() { local compose_profiles current_http_port current_talk_signaling_port replacement_http_port compose_profiles="$(trim_csv "$(read_kv "COMPOSE_PROFILES" || true)")" if [[ "${mode}" != "nossl" ]]; then return 0 fi if [[ ",${compose_profiles}," != *",talk,"* && ",${compose_profiles}," != *",turn,"* && ",${compose_profiles}," != *",janus,"* ]]; then return 0 fi current_http_port="$(read_kv "DEVPROD_HTTP_PORT" || true)" current_talk_signaling_port="$(read_kv "TALK_SIGNALING_PORT" || true)" if ! is_valid_port "${current_http_port}" || ! is_valid_port "${current_talk_signaling_port}"; then return 0 fi if [[ "${current_http_port}" != "${current_talk_signaling_port}" ]]; then return 0 fi reserve_binding "0.0.0.0" "${current_talk_signaling_port}" replacement_http_port="$(find_free_port "127.0.0.1" "${current_http_port}" || true)" if [[ -z "${replacement_http_port}" ]]; then echo "Unable to find a replacement DEVPROD_HTTP_PORT while avoiding TALK_SIGNALING_PORT=${current_talk_signaling_port}" >&2 exit 1 fi set_kv "DEVPROD_HTTP_PORT" "${replacement_http_port}" echo "Adjusted DEVPROD_HTTP_PORT=${replacement_http_port} to avoid TALK_SIGNALING_PORT=${current_talk_signaling_port} on a running no-SSL cloud." } profile_enabled() { local name="$1" [[ ",${compose_profiles}," == *",${name},"* ]] } trim_csv() { local raw="$1" local cleaned cleaned="$(echo "${raw}" | tr -d '[:space:]')" cleaned="${cleaned#,}" cleaned="${cleaned%,}" echo "${cleaned}" } bind_host_for() { local key="$1" local default_host="$2" local value value="$(read_kv "${key}" || true)" if [[ -z "${value}" ]]; then echo "${default_host}" else echo "${value}" fi } compute_public_url() { local domain="$1" local https_port="$2" if [[ -z "${domain}" ]]; then echo "" return fi if [[ "${https_port}" == "443" ]]; then echo "https://${domain}" else echo "https://${domain}:${https_port}" fi } if [[ "${skip_when_running}" == "1" && -n "${compose_file}" && -f "${compose_file}" ]]; then existing_stack_ids="$(docker compose -f "${compose_file}" --env-file "${env_file}" ps -q 2>/dev/null || true)" if [[ -n "${existing_stack_ids}" ]]; then normalize_running_stack_conflicts echo "Compose project already running; checking ports for newly enabled services only." fi fi refresh_public_urls_from_https_port() { local nextcloud_domain broker_domain collabora_domain signaling_domain https_port local nextcloud_public_url broker_public_url collabora_public_url signaling_public_url nextcloud_domain="$(read_kv "NEXTCLOUD_DOMAIN" || true)" broker_domain="$(read_kv "BROKER_DOMAIN" || true)" collabora_domain="$(read_kv "COLLABORA_DOMAIN" || true)" signaling_domain="$(read_kv "SIGNALING_DOMAIN" || true)" https_port="$(read_kv "CADDY_HTTPS_PORT" || true)" if ! is_valid_port "${https_port}"; then return fi nextcloud_public_url="$(compute_public_url "${nextcloud_domain}" "${https_port}")" broker_public_url="$(compute_public_url "${broker_domain}" "${https_port}")" collabora_public_url="$(compute_public_url "${collabora_domain}" "${https_port}")" signaling_public_url="$(compute_public_url "${signaling_domain}" "${https_port}")" if [[ -n "${nextcloud_public_url}" ]]; then set_kv "NEXTCLOUD_PUBLIC_URL" "${nextcloud_public_url}" set_kv "OIDC_REDIRECT_URI_ALLOWLIST" "${nextcloud_public_url}/apps/user_oidc/code" fi if [[ -n "${broker_public_url}" ]]; then set_kv "OIDC_ISSUER" "${broker_public_url}" fi if [[ -n "${collabora_public_url}" ]]; then set_kv "COLLABORA_PUBLIC_URL" "${collabora_public_url}" fi if [[ -n "${signaling_public_url}" ]]; then set_kv "TALK_SIGNALING_PUBLIC_URL" "${signaling_public_url}" fi } sync_stun_server_to_turn_port() { local talk_domain nextcloud_domain signaling_domain talk_turn_host talk_janus_stun_server stun_server turn_server turn_port stun_host turn_host host_fqdn host_short host_fqdn_local host_short_local nextcloud_domain="$(read_kv "NEXTCLOUD_DOMAIN" || true)" signaling_domain="$(read_kv "SIGNALING_DOMAIN" || true)" talk_domain="${signaling_domain:-${nextcloud_domain}}" talk_turn_host="$(read_kv "TALK_TURN_HOST" || true)" if [[ -z "${talk_turn_host}" ]]; then talk_turn_host="${nextcloud_domain}" if [[ -n "${talk_turn_host}" ]]; then set_kv "TALK_TURN_HOST" "${talk_turn_host}" fi fi turn_port="$(read_kv "TALK_TURN_PORT" || true)" talk_janus_stun_server="$(read_kv "TALK_JANUS_STUN_SERVER" || true)" stun_server="$(read_kv "TALK_STUN_SERVER" || true)" turn_server="$(read_kv "TALK_TURN_SERVER" || true)" host_fqdn="$(hostname -f 2>/dev/null || true)" host_short="$(hostname -s 2>/dev/null || true)" host_fqdn_local="${host_fqdn}.local" host_short_local="${host_short}.local" if [[ -z "${talk_turn_host}" ]] || ! is_valid_port "${turn_port}"; then return fi if [[ -z "$(read_kv "TALK_JANUS_STUN_PORT" || true)" || "$(read_kv "TALK_JANUS_STUN_PORT" || true)" == "3478" || "${talk_janus_stun_server}" == "${talk_domain}" || "${talk_janus_stun_server}" == "${talk_turn_host}" || "${talk_janus_stun_server}" == "${host_fqdn}" || "${talk_janus_stun_server}" == "${host_short}" || "${talk_janus_stun_server}" == "${host_fqdn_local}" || "${talk_janus_stun_server}" == "${host_short_local}" || "${talk_janus_stun_server}" == "localhost" || "${talk_janus_stun_server}" == "127.0.0.1" || "${talk_janus_stun_server}" == "stun.nextcloud.com" ]]; then set_kv "TALK_JANUS_STUN_PORT" "${turn_port}" fi if [[ -z "${talk_janus_stun_server}" || "${talk_janus_stun_server}" == "${talk_domain}" || "${talk_janus_stun_server}" == "${talk_turn_host}" || "${talk_janus_stun_server}" == "${host_fqdn}" || "${talk_janus_stun_server}" == "${host_short}" || "${talk_janus_stun_server}" == "${host_fqdn_local}" || "${talk_janus_stun_server}" == "${host_short_local}" || "${talk_janus_stun_server}" == "localhost" || "${talk_janus_stun_server}" == "127.0.0.1" || "${talk_janus_stun_server}" == "stun.nextcloud.com" ]]; then set_kv "TALK_JANUS_STUN_SERVER" "${talk_turn_host}" fi if [[ -z "${turn_server}" ]]; then set_kv "TALK_TURN_SERVER" "${talk_turn_host}:${turn_port}" else turn_host="${turn_server%%:*}" if [[ "${turn_host}" == "${talk_domain}" || "${turn_host}" == "${talk_turn_host}" || "${turn_host}" == "${host_fqdn}" || "${turn_host}" == "${host_short}" || "${turn_host}" == "${host_fqdn_local}" || "${turn_host}" == "${host_short_local}" || "${turn_host}" == "localhost" || "${turn_host}" == "127.0.0.1" ]]; then set_kv "TALK_TURN_SERVER" "${talk_turn_host}:${turn_port}" fi fi if [[ -z "${stun_server}" || "${stun_server}" == "stun.nextcloud.com:443" ]]; then set_kv "TALK_STUN_SERVER" "${talk_turn_host}:${turn_port}" return fi stun_host="${stun_server%%:*}" if [[ "${stun_host}" == "${talk_domain}" || "${stun_host}" == "${talk_turn_host}" || "${stun_host}" == "${host_fqdn}" || "${stun_host}" == "${host_short}" || "${stun_host}" == "${host_fqdn_local}" || "${stun_host}" == "${host_short_local}" || "${stun_host}" == "localhost" || "${stun_host}" == "127.0.0.1" ]]; then set_kv "TALK_STUN_SERVER" "${talk_turn_host}:${turn_port}" fi } compose_profiles="$(trim_csv "$(read_kv "COMPOSE_PROFILES" || true)")" talk_profile_active="0" if profile_enabled "talk" || profile_enabled "turn" || profile_enabled "janus"; then talk_profile_active="1" talk_signaling_bind_host="$(bind_host_for "TALK_SIGNALING_BIND_HOST" "0.0.0.0")" set_kv "TALK_SIGNALING_BIND_HOST" "${talk_signaling_bind_host}" assign_service_port_key "talk_hpb" "TALK_SIGNALING_PORT" "${talk_signaling_bind_host}" "8081" "Talk signaling" set_kv "TALK_SIGNALING_CONTAINER_PORT" "8081" if [[ -z "$(read_kv "TALK_ETURNAL_EPMD_PORT" || true)" ]]; then set_kv "TALK_ETURNAL_EPMD_PORT" "3470" fi set_kv "TALK_SIGNALING_UPSTREAM_PORT" "$(read_kv "TALK_SIGNALING_PORT" || true)" fi ssl_http_before="$(read_kv "CADDY_HTTP_PORT" || true)" ssl_https_before="$(read_kv "CADDY_HTTPS_PORT" || true)" if [[ "${mode}" == "nossl" ]]; then assign_service_port_key "app" "DEVPROD_HTTP_PORT" "$(bind_host_for "DEVPROD_HTTP_BIND_HOST" "127.0.0.1")" "8081" "Nextcloud HTTP" assign_service_port_key "broker" "DEVPROD_BROKER_PORT" "$(bind_host_for "DEVPROD_BROKER_BIND_HOST" "127.0.0.1")" "3001" "OIDC broker" else assign_port_key "CADDY_HTTP_PORT" "0.0.0.0" "80" "Caddy HTTP" assign_port_key "CADDY_HTTPS_PORT" "0.0.0.0" "443" "Caddy HTTPS" fi if profile_enabled "external-auth"; then assign_service_port_key "external_auth" "EXTERNAL_AUTH_PORT" "127.0.0.1" "3191" "External auth" fi if profile_enabled "office"; then assign_service_port_key "collabora" "COLLABORA_PORT" "$(bind_host_for "COLLABORA_BIND_HOST" "127.0.0.1")" "9980" "Collabora" fi if [[ "${talk_profile_active}" == "1" ]]; then talk_turn_bind_host="$(read_kv "TALK_TURN_BIND_HOST" || true)" talk_turn_bind_host="${talk_turn_bind_host:-0.0.0.0}" talk_signaling_port="$(read_kv "TALK_SIGNALING_PORT" || true)" if [[ -n "${talk_signaling_port}" ]] && port_in_use "" "${talk_signaling_port}"; then echo "Note: Talk signaling uses host port ${talk_signaling_port} and it appears in use; this may be expected if talk_hpb is already running." fi assign_service_port_key "talk_hpb" "TALK_TURN_PORT" "${talk_turn_bind_host}" "3478" "Talk TURN" sync_stun_server_to_turn_port fi if profile_enabled "recording"; then assign_service_port_key "talk_recording" "TALK_RECORDING_PORT" "$(bind_host_for "TALK_RECORDING_BIND_HOST" "127.0.0.1")" "1234" "Talk recording" fi ssl_http_after="$(read_kv "CADDY_HTTP_PORT" || true)" ssl_https_after="$(read_kv "CADDY_HTTPS_PORT" || true)" if [[ "${mode}" == "ssl" && ( "${ssl_http_before}" != "${ssl_http_after}" || "${ssl_https_before}" != "${ssl_https_after}" ) ]]; then refresh_public_urls_from_https_port echo "Updated public HTTPS URL settings to match Caddy HTTPS port ${ssl_https_after}." fi exit 0