# Lucidata — край (TLS, BasicAuth, CSRF-гвард, заголовки безопасности, SPA,
# обратный прокси, балансировка) для клиентского бандла.
#
# ЭТО ШАБЛОН. Плейсхолдеры домена подставляет ./deploy.sh, результат кладётся
# в ./generated/Caddyfile — именно он монтируется в контейнер. Правьте ЭТОТ
# файл, а не generated/: generated/ пересоздаётся при каждом запуске.
#
# ПЛЕЙСХОЛДЕРОВ ДВА, и путать их нельзя:
#   * имя сайта                — только имя хоста (адрес блока, сравнение Host);
#   * публичное происхождение  — схема + имя + ПОРТ, если край опубликован не на
#     443 (сравнение заголовка Origin и цель редиректа с 80).
# Порт в происхождении обязателен: браузер, открывший https://host:8443, шлёт
# Origin: https://host:8443, и сравнение без порта отвергло бы КАЖДЫЙ POST с
# кодом 403 — то есть вход в систему.
#
# Отличия от deploy/Caddyfile.ha в репозитории — три, и каждое вынужденное:
#
#   1. УДАЛЁН глобальный блок `{ email {$ACME_EMAIL} }`. Проверено прогоном
#      `caddy validate --adapter caddyfile`: при пустом ACME_EMAIL адаптер
#      отказывается разбирать конфиг целиком («wrong argument count … after
#      'email'»), то есть заказчик со СВОИМ сертификатом получал бы край,
#      который вообще не стартует. Режим TLS теперь выбирает deploy.sh и пишет
#      его в отдельный файл;
#   2. первой строкой сайта — `import /etc/caddy/tls.conf`. Файл всегда
#      существует (его пишет deploy.sh) и содержит одно из:
#        tls /etc/caddy/ssl/ssl.pem /etc/caddy/ssl/ssl.key   — сертификат
#          заказчика; Caddy пишет в лог «skipping automatic certificate
#          management» и к Let's Encrypt не ходит вовсе;
#        tls you@example.ru                                  — автоматический
#          ACME (нужен исходящий 443 к CA и входящие 80/443 из интернета);
#        tls internal                                        — самоподписанный
#          локальный CA Caddy, только для внутренней проверки.
#      Отсутствующий файл импорта = жёсткий отказ адаптера, поэтому deploy.sh
#      создаёт его ВСЕГДА;
#   3. редирект с http на https задан ЯВНЫМ блоком (см. ниже), а Origin
#      сравнивается с публичным адресом, а не с голым именем хоста. Причина —
#      установка на нестандартном порту (EDGE_HTTPS_PORT в .env): при
#      автоматическом редиректе браузер уезжал на https://host/ без порта, а
#      сравнение Origin без порта давало 403 на любой POST.
#
# Тот же механизм у BasicAuth: `import /etc/caddy/basicauth.conf` ниже. По
# умолчанию deploy.sh пишет туда один комментарий (валидный конфиг, гейта нет),
# потому что общий пароль неатрибутируем в аудите и ломает встраивание
# дашбордов: из-под BasicAuth выведен только /api/v1/mcp*, а /api/v1/embed/* и
# /api/embed/* — нет. Включается LUCIDATA_BASIC_AUTH=1 в .env.
#
# ПОЧЕМУ КАЖДАЯ ПРОВЕРКА ЗДОРОВЬЯ НЕСЁТ `health_port`. Проба раньше уходила на
# :8088 — тот же пул потоков gunicorn, который обслуживает дашборды. При
# насыщении проверка таймаутила, caddy помечал ЖИВОЙ узел down и отвечал 503:
# 43,4 % отказов в прогоне на 300 VU, при НУЛЕ 5xx в журнале самого узла.
# Лечение состоит из трёх частей, и нужны все три:
#   1. health_port 8087 — выделенный probe-listener бэкенда: свой сокет, свои
#      потоки, ответ из фонового снимка. Он не может встать в очередь за
#      запросом и не берёт курсор DuckDB, поэтому занятый узел отвечает за
#      миллисекунды. Порт записан как {$LUCIDATA_PROBE_PORT:8087} — та же
#      переменная, что у бэкендов и у HEALTHCHECK контейнера;
#   2. health_fails 3 — ТЕРПИМОСТЬ. Одна пропущенная проверка — это икота, а не
#      авария; три подряд (15 с) — авария;
#   3. max_fails / fail_duration — ПАССИВНОЕ здоровье. Убитый контейнер
#      отказывает в соединении, поэтому первые же проксированные запросы падают
#      мгновенно и caddy перестаёт на него ходить задолго до того, как сдастся
#      активная проверка. RTO несёт именно пассивное здоровье.
#
# Активная проверка опрашивает /api/v1/readyz, а НЕ /api/v1/health:
# /api/v1/health отвечает 200, пока существует процесс, поэтому маршрутизация
# по нему означала бы отправку трафика на узел, который сливается, потерял
# хранилище метаданных или чей склад перестал отвечать. /api/v1/readyz —
# поверхность, знающая про слив, и она не аутентифицирована by design, поэтому
# проверке не нужны ни BasicAuth, ни cookie.

# ГЛОБАЛЬНЫЕ НАСТРОЙКИ. Ровно одна, и она вынужденная: HTTP/3 выключен.
# Caddy по умолчанию слушает QUIC на 443/udp и рекламирует его заголовком
# `alt-svc: h3=":443"`, а docker-compose.yml публикует наружу только TCP
# (443/udp у заказчика обычно и закрыт на межсетевом экране). Приёмка
# 2026-09-14 это и поймала: браузер честно пробовал QUIC, получал отказ и
# откатывался на HTTP/2 — лишний круг на каждом новом клиенте. Реклама того,
# чего нет, убрана; если когда-нибудь опубликуете 443/udp, снимите эту строку.
{
	servers {
		protocols h1 h2
	}
}

# Редирект с http на https ЯВНЫЙ, а не автоматический. Автоматический Caddy
# строит по порту, который слушает САМ (443 внутри контейнера), и на установке,
# опубликованной на нестандартном порту, отправлял бы браузер на https://host/
# — туда, где никто не слушает. Здесь цель берётся из публичного адреса, а его
# знает установщик.
http://__DOMAIN_NAME__ {
	redir __SITE_ORIGIN__{uri} permanent
}

__DOMAIN_NAME__ {
	# Режим TLS выбирает deploy.sh — см. шапку файла.
	import /etc/caddy/tls.conf

	encode gzip

	# Собственный журнал доступа края — JSON в stderr, по строке на запрос.
	# Именно stderr, а не файл: журналы собираются со стандартных потоков
	# контейнера, файл внутри контейнера был бы не виден и требовал бы своего
	# тома. Ротацию задаёт секция `logging:` этого сервиса в docker-compose.yml
	# (json-file, 20 МБ x 5 файлов).
	#
	# Учётные данные BasicAuth в журнал не попадают: json-энкодер Caddy по
	# умолчанию пишет Authorization и Cookie как REDACTED.
	#
	# А вот request.uri НЕ редактируется — он пишется дословно, вместе со
	# строкой запроса. Поэтому тикет комнаты real-time едет в первом кадре
	# WebSocket, а не в `?token=…`. Держите это правило при добавлении
	# маршрутов: ни один секрет не должен попадать в URL, проходящий через
	# этот блок.
	log {
		output stderr
		format json
	}

	header {
		-Server
		Strict-Transport-Security "max-age=31536000;"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
	}

	# Внешняя поверхность MCP — единственное исключение из BasicAuth: у неё своя
	# аутентификация по Bearer-PAT, закрытая по умолчанию на стороне сервера
	# (LUCIDATA_MCP_HTTP=0 -> 404). Проксируется так же, как основной @api.
	handle /api/v1/mcp* {
		reverse_proxy app:8088 {
			# {scheme}, а не жёсткое https: на контуре без TLS жёсткое значение —
			# это ложь прокси о себе, и бэкенд построил бы публичный MCP-URL как
			# https там, где TLS нет.
			header_up X-Forwarded-Proto {scheme}
			lb_policy least_conn
			lb_try_duration 5s
			health_uri /api/v1/readyz
			health_port {$LUCIDATA_PROBE_PORT:8087}
			health_status 2xx
			health_interval 5s
			health_timeout 2s
			health_passes 1
			health_fails 3
			max_fails 2
			fail_duration 10s
		}
	}

	# Поверхность real-time (WebSocket). Отдельный handle ПЕРЕД балансируемым
	# @api по трём причинам:
	#   1. это другой upstream — контейнер `rt` (uvicorn), а не узел приложения
	#      (gunicorn, WSGI, WebSocket не умеет вовсе);
	#   2. сокет живёт долго, а fan-out между процессами — работа Redis, не
	#      прокси;
	#   3. CSRF-гвард ниже покрывает только POST/PUT/PATCH/DELETE, а апгрейд
	#      WebSocket — это GET, поэтому ПРОВЕРКА ORIGIN ЗДЕСЬ ЯВНАЯ. Без неё
	#      любой сайт мог бы открыть сокет в браузере вошедшего пользователя и
	#      читать его комнаты.
	# ТОЧНЫЙ путь. Всё остальное под /api/v1/rt/ — обычные маршруты веб-яруса и
	# должны уходить в балансируемый @api ниже.
	@rt path /api/v1/rt
	handle @rt {
		@bad_origin {
			header_regexp Origin .+
			not header Origin __SITE_ORIGIN__
		}
		respond @bad_origin "cross-origin websocket blocked" 403
		import /etc/caddy/basicauth.conf
		reverse_proxy rt:8089 {
			header_up X-Forwarded-Proto {scheme}
			health_uri /api/v1/rt/healthz
			health_status 2xx
			health_interval 10s
			health_timeout 3s
		}
	}

	handle {
		import /etc/caddy/basicauth.conf

		route {
			@csrf {
				method POST PUT PATCH DELETE
				header_regexp Origin .+
				not header Origin __SITE_ORIGIN__
			}
			respond @csrf "cross-origin request blocked" 403

			# API бэкенда. ТОЛЬКО /api/* — /embed и /embed/view?token=… это
			# маршруты SPA, обработчика для них у бэкенда нет; все ЭНДПОИНТЫ
			# встраивания живут под /api/(v1/)embed.
			#
			# Узел приложения ОДИН (см. docker-compose.yml, сервис `app`), но
			# проверки здоровья остаются: SIGTERM переводит readyz в 503, пока
			# узел дорабатывает запросы в полёте, и край отдаёт честные 502/503
			# вместо обрыва соединения. Чего он делать НЕ должен — принимать
			# ЗАНЯТЫЙ узел за мёртвый: за это отвечают probe-порт и health_fails.
			@api path /api/*
			reverse_proxy @api app:8088 {
				header_up X-Forwarded-Proto {scheme}
				lb_policy least_conn
				lb_try_duration 5s
				health_uri /api/v1/readyz
				health_port {$LUCIDATA_PROBE_PORT:8087}
				health_status 2xx
				health_interval 5s
				health_timeout 2s
				health_passes 1
				health_fails 3
				max_fails 2
				fail_duration 10s
			}

			# SPA: отдаём статическую сборку, всё неизвестное — на index.html
			# для клиентской маршрутизации.
			root * /srv
			try_files {path} /index.html
			file_server
		}
	}

	handle_errors {
		respond "service error ({err.status_code})" {err.status_code}
	}
}
