#!/usr/bin/env bash set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" source "${script_dir}/lib-compose-project.sh" compose_file="" env_file="" app_service="app" verify_only="0" usage() { cat <<'USAGE' Usage: ./scripts/ensure-nextcloud-talk-config.sh --compose-file --env-file [options] Ensures Nextcloud Talk HPB/STUN/TURN/recording configuration matches env values, then verifies the expected backends are present. Options: --compose-file Docker compose file to use (required) --env-file Env file to use (required) --app-service Nextcloud app service name (default: app) --verify-only Do not attempt repairs; only verify current state -h, --help Show this help USAGE } read_kv() { local key="$1" local line line="$(grep -m1 -E "^${key}=" "${env_file}" || true)" if [[ -z "${line}" ]]; then return 1 fi echo "${line#*=}" } trim_csv() { local raw="${1:-}" local cleaned cleaned="$(echo "${raw}" | tr -d '[:space:]')" cleaned="${cleaned#,}" cleaned="${cleaned%,}" echo "${cleaned}" } profile_enabled() { local profiles="$1" local name="$2" [[ ",${profiles}," == *",${name},"* ]] } ensure_docker_access() { local docker_path="" docker_path="$(type -P docker || true)" if [[ -z "${docker_path}" ]]; then echo "docker is required" exit 1 fi if "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=("${docker_path}") return 0 fi if ! command -v sudo >/dev/null 2>&1; then echo "docker is installed but this user cannot access it, and sudo is unavailable." exit 1 fi if sudo -n "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=(sudo "${docker_path}") return 0 fi echo "docker requires elevated privileges on this machine." echo "You may be prompted for your sudo password." if sudo "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=(sudo "${docker_path}") return 0 fi echo "Unable to access docker, even with sudo." exit 1 } docker() { "${DOCKER_CMD[@]}" "$@" } compose_cmd() { docker compose -f "${compose_file}" --env-file "${env_file}" "$@" } occ_exec() { compose_cmd exec -T --user www-data "${app_service}" php occ "$@" } occ_has_command() { local command_name="${1:-}" if [[ -z "${command_name}" ]]; then return 1 fi occ_exec list 2>/dev/null | grep -qE "^[[:space:]]+${command_name}([[:space:]]|$)" } occ_exec_quiet_retry() { local rc=1 local max_attempts="${OCC_RETRY_ATTEMPTS:-6}" local retry_sleep="${OCC_RETRY_SLEEP_SECONDS:-4}" local attempt if [[ $# -eq 0 ]]; then return 1 fi for attempt in $(seq 1 "${max_attempts}"); do occ_exec "$@" >/dev/null 2>&1 rc=$? if [[ "${rc}" -eq 0 ]]; then return 0 fi sleep "${retry_sleep}" done return "${rc}" } capture_occ_retry() { local out_file="$1" shift local rc=1 local max_attempts="${OCC_RETRY_ATTEMPTS:-6}" local retry_sleep="${OCC_RETRY_SLEEP_SECONDS:-4}" local attempt : > "${out_file}" if [[ $# -eq 0 ]]; then return 1 fi for attempt in $(seq 1 "${max_attempts}"); do if occ_exec "$@" >"${out_file}" 2>/dev/null; then return 0 fi rc=$? if [[ "${attempt}" -lt "${max_attempts}" ]]; then sleep "${retry_sleep}" fi done return "${rc}" } capture_occ() { local out_file="$1" shift occ_exec "$@" >"${out_file}" 2>/dev/null || true } count_unique_urls() { local file_path="$1" grep -Eo 'https?://[^[:space:]|]+' "${file_path}" 2>/dev/null | sort -u | wc -l | tr -d '[:space:]' } json_escape() { local value="${1:-}" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//$'\n'/}" printf '%s' "${value}" } normalize_recording_url() { local value="${1:-}" value="$(echo "${value}" | tr -d '\r' | xargs)" value="${value%/}" if [[ -z "${value}" ]]; then echo "" return 0 fi echo "${value}/" } recording_servers_config_json() { local recording_url="${1:-}" local recording_secret="${2:-}" local escaped_url escaped_secret escaped_url="$(json_escape "${recording_url}")" escaped_secret="$(json_escape "${recording_secret}")" printf '{"servers":[{"server":"%s","verify":true}],"secret":"%s"}' "${escaped_url}" "${escaped_secret}" } detect_talk_occ_prefix() { local attempts="${1:-8}" local delay_seconds="${2:-3}" local prefix="" local _attempt local list_file="" list_file="$(mktemp)" for _attempt in $(seq 1 "${attempts}"); do if capture_occ_retry "${list_file}" list; then if grep -qE "^[[:space:]]+talk:signaling:list([[:space:]]|$)" "${list_file}"; then prefix="talk" elif grep -qE "^[[:space:]]+spreed:signaling:list([[:space:]]|$)" "${list_file}"; then prefix="spreed" else prefix="" fi else prefix="" fi if [[ -n "${prefix}" ]]; then rm -f "${list_file}" >/dev/null 2>&1 || true echo "${prefix}" return 0 fi if [[ "${_attempt}" -lt "${attempts}" ]]; then sleep "${delay_seconds}" fi done rm -f "${list_file}" >/dev/null 2>&1 || true return 1 } detect_talk_recording_prefix() { local attempts="${1:-8}" local delay_seconds="${2:-3}" local prefix="" local _attempt local list_file="" list_file="$(mktemp)" for _attempt in $(seq 1 "${attempts}"); do if capture_occ_retry "${list_file}" list; then if grep -qE "^[[:space:]]+talk:recording:add([[:space:]]|$)" "${list_file}"; then prefix="talk" elif grep -qE "^[[:space:]]+spreed:recording:add([[:space:]]|$)" "${list_file}"; then prefix="spreed" else prefix="" fi else prefix="" fi if [[ -n "${prefix}" ]]; then rm -f "${list_file}" >/dev/null 2>&1 || true echo "${prefix}" return 0 fi if [[ "${_attempt}" -lt "${attempts}" ]]; then sleep "${delay_seconds}" fi done rm -f "${list_file}" >/dev/null 2>&1 || true return 1 } wait_for_occ_command_listing() { local attempts="${1:-8}" local delay_seconds="${2:-3}" local list_file="" local _attempt list_file="$(mktemp)" for _attempt in $(seq 1 "${attempts}"); do if capture_occ_retry "${list_file}" list; then rm -f "${list_file}" >/dev/null 2>&1 || true return 0 fi if [[ "${_attempt}" -lt "${attempts}" ]]; then sleep "${delay_seconds}" fi done rm -f "${list_file}" >/dev/null 2>&1 || true return 1 } delete_extra_ids() { local file_path="$1" local keep="$2" local delete_cmd="$3" mapfile -t delete_ids < <( awk -v keep="${keep}" ' { if (keep != "" && index($0, keep) > 0) { next; } if (match($0, /\|[[:space:]]*([0-9]+)[[:space:]]*\|/, m)) { print m[1]; next; } if (match($0, /^[[:space:]]*([0-9]+)[[:space:]]+/, m)) { print m[1]; next; } } ' "${file_path}" 2>/dev/null | sort -u ) local item_id for item_id in "${delete_ids[@]}"; do if [[ -n "${item_id}" ]]; then occ_exec "${delete_cmd}" "${item_id}" >/dev/null 2>&1 || true fi done } while [[ $# -gt 0 ]]; do case "$1" in --compose-file) compose_file="${2:-}" shift 2 ;; --compose-file=*) compose_file="${1#*=}" shift ;; --env-file) env_file="${2:-}" shift 2 ;; --env-file=*) env_file="${1#*=}" shift ;; --app-service) app_service="${2:-}" shift 2 ;; --app-service=*) app_service="${1#*=}" shift ;; --verify-only) verify_only="1" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" usage exit 1 ;; esac done if [[ -z "${compose_file}" || -z "${env_file}" ]]; then usage exit 1 fi if [[ "${compose_file}" != /* ]]; then compose_file="${repo_root}/${compose_file}" fi if [[ "${env_file}" != /* ]]; then env_file="${repo_root}/${env_file}" fi if [[ ! -f "${compose_file}" ]]; then echo "Missing compose file: ${compose_file}" exit 1 fi if [[ ! -f "${env_file}" ]]; then echo "Missing env file: ${env_file}" exit 1 fi export_compose_project_name_from_env "${env_file}" || true ensure_docker_access profiles="$(trim_csv "$(read_kv "COMPOSE_PROFILES" || true)")" talk_profile_enabled="0" turn_profile_enabled="0" recording_profile_enabled="0" if profile_enabled "${profiles}" "talk" || profile_enabled "${profiles}" "janus"; then talk_profile_enabled="1" fi if profile_enabled "${profiles}" "turn"; then turn_profile_enabled="1" talk_profile_enabled="1" fi if profile_enabled "${profiles}" "recording"; then recording_profile_enabled="1" talk_profile_enabled="1" fi if [[ "${talk_profile_enabled}" != "1" && "${recording_profile_enabled}" != "1" ]]; then echo "Talk profiles not enabled in ${env_file}; nothing to do." exit 0 fi if ! occ_exec status >/dev/null 2>&1; then echo "Nextcloud OCC is not ready in service '${app_service}'." exit 1 fi if ! wait_for_occ_command_listing 8 3; then echo "Nextcloud OCC command listing is not ready in service '${app_service}'." exit 1 fi status=0 limited_recording_verify="0" talk_signaling_public_url="$(read_kv "TALK_SIGNALING_PUBLIC_URL" || true)" talk_signaling_secret="$(read_kv "TALK_SIGNALING_SECRET" || true)" talk_stun_server="$(read_kv "TALK_STUN_SERVER" || true)" talk_turn_server="$(read_kv "TALK_TURN_SERVER" || true)" talk_domain="$(read_kv "SIGNALING_DOMAIN" || true)" if [[ -z "${talk_domain}" ]]; then talk_domain="$(read_kv "TALK_DOMAIN" || true)" fi talk_turn_host="$(read_kv "TALK_TURN_HOST" || true)" if [[ -z "${talk_turn_host}" ]]; then talk_turn_host="$(read_kv "NEXTCLOUD_DOMAIN" || true)" fi talk_turn_port="$(read_kv "TALK_TURN_PORT" || true)" talk_turn_port="${talk_turn_port:-3478}" talk_turn_secret="$(read_kv "TALK_TURN_SECRET" || true)" if [[ -z "${talk_turn_server}" ]]; then talk_turn_server="${talk_turn_host}:${talk_turn_port}" fi talk_recording_url="$(read_kv "TALK_RECORDING_URL" || true)" talk_recording_url="${talk_recording_url:-http://talk_recording:1234}" talk_recording_secret="$(read_kv "TALK_RECORDING_SHARED_SECRET" || true)" talk_recording_url_normalized="$(normalize_recording_url "${talk_recording_url}")" if [[ "${verify_only}" != "1" ]]; then occ_exec_quiet_retry app:enable --force spreed >/dev/null 2>&1 || true occ_exec_quiet_retry app:install spreed >/dev/null 2>&1 || true occ_exec_quiet_retry app:enable --force spreed >/dev/null 2>&1 || true fi talk_prefix="$(detect_talk_occ_prefix 8 3 || true)" recording_prefix="$(detect_talk_recording_prefix 8 3 || true)" recording_list_cmd="" if occ_has_command "talk:recording:list"; then recording_list_cmd="talk:recording:list" elif occ_has_command "spreed:recording:list"; then recording_list_cmd="spreed:recording:list" fi echo "Ensuring Nextcloud Talk configuration..." echo " compose_file=${compose_file}" echo " env_file=${env_file}" echo " talk_profile_enabled=${talk_profile_enabled}" echo " turn_profile_enabled=${turn_profile_enabled}" echo " recording_profile_enabled=${recording_profile_enabled}" echo " talk_prefix=${talk_prefix:-}" echo " recording_prefix=${recording_prefix:-}" if [[ -z "${talk_prefix}" ]]; then echo "Talk OCC namespace not found (expected talk:* or spreed:*)." exit 1 fi if [[ "${verify_only}" != "1" ]] && occ_has_command "${talk_prefix}:signaling:verify-keys"; then occ_exec "${talk_prefix}:signaling:verify-keys" --update >/dev/null 2>&1 || true fi tmp_dir="$(mktemp -d)" trap 'rm -rf "${tmp_dir}"' EXIT if [[ "${talk_profile_enabled}" == "1" ]]; then if [[ -z "${talk_signaling_public_url}" || -z "${talk_signaling_secret}" ]]; then echo "Missing TALK_SIGNALING_PUBLIC_URL or TALK_SIGNALING_SECRET while Talk profile is enabled." status=1 else signaling_file="${tmp_dir}/signaling.txt" capture_occ "${signaling_file}" "${talk_prefix}:signaling:list" if ! grep -Fq "${talk_signaling_public_url}" "${signaling_file}" 2>/dev/null; then if [[ "${verify_only}" != "1" ]]; then occ_exec "${talk_prefix}:signaling:add" "${talk_signaling_public_url}" "${talk_signaling_secret}" >/dev/null 2>&1 || true fi fi capture_occ "${signaling_file}" "${talk_prefix}:signaling:list" if occ_has_command "${talk_prefix}:signaling:delete"; then delete_extra_ids "${signaling_file}" "${talk_signaling_public_url}" "${talk_prefix}:signaling:delete" capture_occ "${signaling_file}" "${talk_prefix}:signaling:list" fi if ! grep -Fq "${talk_signaling_public_url}" "${signaling_file}" 2>/dev/null; then echo "Talk signaling backend missing after ensure: ${talk_signaling_public_url}" status=1 fi signaling_count="$(count_unique_urls "${signaling_file}")" if [[ -n "${signaling_count}" && "${signaling_count}" -gt 1 ]]; then echo "Multiple signaling backends still configured (${signaling_count})." status=1 fi if [[ -n "${talk_stun_server}" ]]; then stun_file="${tmp_dir}/stun.txt" capture_occ "${stun_file}" "${talk_prefix}:stun:list" if ! grep -Fq "${talk_stun_server}" "${stun_file}" 2>/dev/null; then if [[ "${verify_only}" != "1" ]]; then occ_exec "${talk_prefix}:stun:add" "${talk_stun_server}" >/dev/null 2>&1 || true fi fi capture_occ "${stun_file}" "${talk_prefix}:stun:list" if occ_has_command "${talk_prefix}:stun:delete"; then delete_extra_ids "${stun_file}" "${talk_stun_server}" "${talk_prefix}:stun:delete" capture_occ "${stun_file}" "${talk_prefix}:stun:list" fi if ! grep -Fq "${talk_stun_server}" "${stun_file}" 2>/dev/null; then echo "Talk STUN server missing after ensure: ${talk_stun_server}" status=1 fi else echo "Note: TALK_STUN_SERVER is empty; STUN verification skipped." fi fi fi if [[ "${turn_profile_enabled}" == "1" ]]; then if [[ -z "${talk_turn_secret}" || -z "${talk_domain}" ]]; then echo "Missing TALK_TURN_SECRET or SIGNALING_DOMAIN/TALK_DOMAIN while TURN profile is enabled." status=1 else turn_file="${tmp_dir}/turn.txt" capture_occ "${turn_file}" "${talk_prefix}:turn:list" if ! grep -Fq "${talk_turn_server}" "${turn_file}" 2>/dev/null; then if [[ "${verify_only}" != "1" ]]; then occ_exec "${talk_prefix}:turn:add" turn "${talk_turn_server}" udp,tcp --secret "${talk_turn_secret}" >/dev/null 2>&1 || true fi fi capture_occ "${turn_file}" "${talk_prefix}:turn:list" if occ_has_command "${talk_prefix}:turn:delete"; then delete_extra_ids "${turn_file}" "${talk_turn_server}" "${talk_prefix}:turn:delete" capture_occ "${turn_file}" "${talk_prefix}:turn:list" fi if ! grep -Fq "${talk_turn_server}" "${turn_file}" 2>/dev/null; then echo "Talk TURN server missing after ensure: ${talk_turn_server}" status=1 fi fi fi if [[ "${recording_profile_enabled}" == "1" ]]; then if [[ -z "${talk_recording_secret}" ]]; then echo "Missing TALK_RECORDING_SHARED_SECRET while recording profile is enabled." status=1 else recording_apply_status=0 if [[ -n "${recording_prefix}" ]]; then if [[ "${verify_only}" != "1" ]]; then occ_exec "${recording_prefix}:recording:add" "${talk_recording_url}" "${talk_recording_secret}" >/dev/null 2>&1 recording_apply_status=$? if [[ "${recording_apply_status}" -ne 0 ]]; then occ_exec "${recording_prefix}:recording:set" "${talk_recording_url}" "${talk_recording_secret}" >/dev/null 2>&1 recording_apply_status=$? fi fi if [[ -n "${recording_list_cmd}" ]]; then recording_file="${tmp_dir}/recording.txt" capture_occ "${recording_file}" "${recording_list_cmd}" if ! grep -Fq "${talk_recording_url}" "${recording_file}" 2>/dev/null \ && ! grep -Fq "${talk_recording_url_normalized}" "${recording_file}" 2>/dev/null; then echo "Talk recording backend missing after ensure: ${talk_recording_url}" status=1 fi else limited_recording_verify="1" echo "Note: recording list command unavailable; verification limited to add/set command success." if [[ "${verify_only}" == "1" || "${recording_apply_status}" -ne 0 ]]; then status=1 fi fi else limited_recording_verify="1" echo "Note: Talk recording OCC commands unavailable; falling back to spreed app config." recording_config_json="$(recording_servers_config_json "${talk_recording_url_normalized}" "${talk_recording_secret}")" if [[ "${verify_only}" != "1" ]]; then occ_exec config:app:set spreed recording_servers --value="${recording_config_json}" >/dev/null 2>&1 || true occ_exec config:app:set spreed call_recording --value="yes" >/dev/null 2>&1 || true fi recording_config_value="$(occ_exec config:app:get spreed recording_servers 2>/dev/null | tr -d '\r' || true)" if [[ "${recording_config_value}" != *"${talk_recording_url_normalized}"* ]]; then echo "Talk recording backend missing after config fallback: ${talk_recording_url_normalized}" status=1 fi fi fi fi echo "Talk verification summary:" echo " signaling_url=${talk_signaling_public_url:-}" echo " turn_server=${talk_turn_server:-}" echo " recording_url=${talk_recording_url:-}" echo " limited_recording_verify=${limited_recording_verify}" if [[ "${status}" -eq 0 ]]; then echo "Talk configuration verified." else echo "Talk configuration verification failed." fi exit "${status}"