#!/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" usage() { cat <<'USAGE' Usage: ./scripts/validate-proxy-alignment.sh --compose-file --env-file [options] Validate proxy/header alignment for a cloud instance, with additional checks based on EXTERNAL_PROXY_MODE. Options: --compose-file Compose file to use (required) --env-file Env file to use (required) --app-service Nextcloud app service name (default: app) -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() { local value="${1:-}" echo "${value}" | xargs } 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 "$@" } failures=0 report_ok() { printf '[ok] %s\n' "$1" } report_warn() { printf '[warn] %s\n' "$1" } report_fail() { printf '[fail] %s\n' "$1" failures=$((failures + 1)) } expect_equal() { local label="$1" local expected="$2" local actual="$3" if [[ "${actual}" == "${expected}" ]]; then report_ok "${label}: ${actual}" else report_fail "${label}: expected '${expected}', got '${actual:-}'" fi } expect_nonempty() { local label="$1" local actual="$2" if [[ -n "${actual}" ]]; then report_ok "${label}: ${actual}" else report_fail "${label}: missing" fi } 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 ;; -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 if ! occ_exec status >/dev/null 2>&1; then echo "Nextcloud OCC is not ready in service '${app_service}'." exit 1 fi compose_name="$(basename "${compose_file}")" stack_mode="manual" if [[ "${compose_name}" == *".nossl.yml" ]]; then stack_mode="nossl" elif [[ "${compose_name}" == *".yml" ]]; then stack_mode="ssl" fi profiles="$(trim_csv "$(read_kv "COMPOSE_PROFILES" || true)")" external_proxy_mode="$(trim "$(read_kv "EXTERNAL_PROXY_MODE" || true)")" external_proxy_mode="${external_proxy_mode:-none}" nextcloud_public_url="$(trim "$(read_kv "NEXTCLOUD_PUBLIC_URL" || true)")" devprod_http_bind_host="$(trim "$(read_kv "DEVPROD_HTTP_BIND_HOST" || true)")" broker_bind_host="$(trim "$(read_kv "DEVPROD_BROKER_BIND_HOST" || true)")" collabora_bind_host="$(trim "$(read_kv "COLLABORA_BIND_HOST" || true)")" talk_recording_bind_host="$(trim "$(read_kv "TALK_RECORDING_BIND_HOST" || true)")" talk_signaling_bind_host="$(trim "$(read_kv "TALK_SIGNALING_BIND_HOST" || true)")" talk_signaling_port="$(trim "$(read_kv "TALK_SIGNALING_PORT" || true)")" talk_signaling_container_port="$(trim "$(read_kv "TALK_SIGNALING_CONTAINER_PORT" || true)")" talk_signaling_upstream_host="$(trim "$(read_kv "TALK_SIGNALING_UPSTREAM_HOST" || true)")" talk_signaling_upstream_port="$(trim "$(read_kv "TALK_SIGNALING_UPSTREAM_PORT" || true)")" talk_signaling_bind_host="${talk_signaling_bind_host:-0.0.0.0}" talk_signaling_port="${talk_signaling_port:-8081}" talk_signaling_container_port="${talk_signaling_container_port:-8081}" trusted_proxies="$(occ_exec config:system:get trusted_proxies 2>/dev/null | tr -d '\r' || true)" forwarded_for_headers="$(occ_exec config:system:get forwarded_for_headers 2>/dev/null | tr -d '\r' || true)" overwrite_protocol="$(occ_exec config:system:get overwriteprotocol 2>/dev/null | tr -d '\r' || true)" apache_remoteip_header="$( compose_cmd exec -T "${app_service}" sh -lc \ "awk 'toupper(\$1)==\"REMOTEIPHEADER\" {print \$2; exit}' /etc/apache2/conf-enabled/remoteip.conf 2>/dev/null" \ | tr -d '\r' || true )" echo "Proxy alignment validation" echo echo "Runtime:" echo " compose_file=${compose_file}" echo " env_file=${env_file}" echo " stack_mode=${stack_mode}" echo " external_proxy_mode=${external_proxy_mode}" echo " profiles=${profiles:-}" echo case "${external_proxy_mode}" in none|manual|global-caddy) report_ok "EXTERNAL_PROXY_MODE accepted: ${external_proxy_mode}" ;; *) report_fail "EXTERNAL_PROXY_MODE must be one of: none, manual, global-caddy (got '${external_proxy_mode}')" ;; esac expect_nonempty "NEXTCLOUD_PUBLIC_URL" "${nextcloud_public_url}" expect_nonempty "Nextcloud trusted_proxies" "${trusted_proxies}" if [[ "${forwarded_for_headers}" == *"HTTP_X_FORWARDED_FOR"* ]]; then report_ok "Nextcloud forwarded_for_headers includes HTTP_X_FORWARDED_FOR" else report_fail "Nextcloud forwarded_for_headers must include HTTP_X_FORWARDED_FOR" fi expect_equal "Apache RemoteIPHeader" "X-Forwarded-For" "${apache_remoteip_header}" expect_equal "Nextcloud overwriteprotocol" "https" "${overwrite_protocol}" if [[ "${stack_mode}" == "ssl" ]]; then if [[ "${external_proxy_mode}" != "none" ]]; then report_fail "SSL stack should use EXTERNAL_PROXY_MODE=none" else report_ok "SSL stack proxy mode matches bundled TLS expectations" fi fi if [[ "${stack_mode}" == "nossl" ]]; then if [[ "${external_proxy_mode}" == "none" ]]; then report_warn "No-SSL stack is using EXTERNAL_PROXY_MODE=none; manual proxy assumptions may be incomplete" fi if [[ "${external_proxy_mode}" == "global-caddy" ]]; then expect_equal "DEVPROD_HTTP_BIND_HOST" "0.0.0.0" "${devprod_http_bind_host}" expect_equal "DEVPROD_BROKER_BIND_HOST" "0.0.0.0" "${broker_bind_host}" expect_equal "TALK_SIGNALING_BIND_HOST" "0.0.0.0" "${talk_signaling_bind_host}" expect_equal "TALK_SIGNALING_UPSTREAM_HOST" "host.docker.internal" "${talk_signaling_upstream_host}" if profile_enabled "${profiles}" "talk" || profile_enabled "${profiles}" "turn" || profile_enabled "${profiles}" "janus"; then expect_equal "TALK_SIGNALING_CONTAINER_PORT" "8081" "${talk_signaling_container_port}" expect_equal "TALK_SIGNALING_UPSTREAM_PORT" "${talk_signaling_port}" "${talk_signaling_upstream_port}" fi if profile_enabled "${profiles}" "office"; then expect_equal "COLLABORA_BIND_HOST" "0.0.0.0" "${collabora_bind_host}" fi if profile_enabled "${profiles}" "recording"; then expect_equal "TALK_RECORDING_BIND_HOST" "0.0.0.0" "${talk_recording_bind_host}" fi fi fi echo if [[ "${failures}" -eq 0 ]]; then echo "Proxy alignment verified." else echo "Proxy alignment validation failed with ${failures} issue(s)." fi exit "${failures}"