From f31cb56081f5b7de128e6e7b6046eab310f7aaec Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 16:07:41 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4:=20=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B3=20=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B2=20=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=20=E2=80=94=20=D0=B8=D0=BC=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0=20=D0=B4=D0=B2=D0=BE=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки «прогноз против факта», без подписи разброса. Страница про точность — без единого доказательства. Отдавалось 106 КБ вместо 241 КБ. Причина. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свой сервис backend и оба подключены к общей сети gendesign_shared. Изнутри фронта: backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА) tradein-backend → 172.18.0.6 BACKEND_URL=http://backend:8000 уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал 401 no authenticated user, и страница рендерилась пустой. Отказ тихий вдвойне. fetch не бросает — приходит валидный HTTP-ответ, просто чужой. И имя двоится, поэтому часть перегенераций попадала в правильный адрес: утром страница была с данными, к обеду без них, и это выглядело случайной поломкой, а не ошибкой конфигурации. DATABASE_URL болен тем же: @postgres:5432 мог уйти в базу ПТИЦЫ. Там спасало лишь несовпадение кредов — отказ вместо тихого чтения не тех данных. Полагаться на это нельзя: защита держится на том, что у чужой базы нет пользователя с нашим паролем. Переведён на однозначное имя во всех трёх сервисах. Гейт check-compose-ambiguous-hosts.py: пересечение имён сервисов обоих compose и запрет ссылаться на них как на хост. Селфтест по конвенции соседних гейтов — он провалился дважды на моих же фикстурах (в них не было общего имени, то есть ловить было нечего), и это ровно то, ради чего селфтест и нужен. Фальсификация на настоящем файле: возврат backend:8000 даёт точную строку 479, возврат @postgres — все четыре ссылки. --- .forgejo/workflows/ci.yml | 16 +++ scripts/check-compose-ambiguous-hosts.py | 162 +++++++++++++++++++++++ tradein-mvp/docker-compose.prod.yml | 16 ++- 3 files changed, 190 insertions(+), 4 deletions(-) create mode 100755 scripts/check-compose-ambiguous-hosts.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 66b80da9..baf5c736 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -82,6 +82,22 @@ jobs: python3 scripts/check-caddy-snippet-mounts.py --selftest python3 scripts/check-caddy-snippet-mounts.py + - name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя" + # ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы + # `backend`/`postgres`/`frontend` и оба сидят в общей сети + # gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент + # берёт любой. + # + # 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд + # ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ + # ленты сделок, без строк сверки и без подписи разброса — то есть без + # единого доказательства. Отказ тихий: fetch не бросает, приходит + # валидный чужой ответ; а из-за двоения часть перегенераций попадала + # в правильный адрес, и поломка выглядела случайной. + run: | + python3 scripts/check-compose-ambiguous-hosts.py --selftest + python3 scripts/check-compose-ambiguous-hosts.py + - name: "Guard: Caddyfile синтаксически валиден" # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит # на КАЖДОМ PR, стоит секунды, падение блокирует merge. diff --git a/scripts/check-compose-ambiguous-hosts.py b/scripts/check-compose-ambiguous-hosts.py new file mode 100755 index 00000000..669a55ff --- /dev/null +++ b/scripts/check-compose-ambiguous-hosts.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282). + +ПОЧЕМУ. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свои сервисы +`backend`, `postgres`, `frontend`, и оба подключены к общей сети +`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой: + + изнутри tradein-frontend: + backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА) + tradein-backend → 172.18.0.6 + +30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000` +уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated +user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки +«прогноз против факта» и без подписи разброса. Страница про точность — без +единого доказательства. + +Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP- +ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций +попадала в правильный адрес — утром страница отдавалась с данными, к обеду без +них, и выглядело это как случайная поломка, а не как ошибка конфигурации. + +У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу +ПТИЦЫ. Там спасало лишь несовпадение кредов — то есть отказ вместо тихого +чтения не тех данных. На такую защиту полагаться нельзя: она держится на том, +что у чужой базы нет пользователя с нашим паролем. + +ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть +двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост +— ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно: +подставить `container_name` (`tradein-backend`, `tradein-postgres`), он +однозначен по построению. + +ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в +контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба +compose-файла. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml" +PTICA_COMPOSE = REPO / "docker-compose.prod.yml" + +# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие. +_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M) +# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN). +_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+") + + +def service_names(text: str) -> set[str]: + """Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они + объявляются в своих секциях с тем же отступом, поэтому список заведомо шире + нужного — и это безопасная сторона, лишнее имя даст лишнюю проверку, а не + пропуск.""" + return set(_SERVICE_RE.findall(text)) + + +def violations(mera: str, ptica: str) -> list[str]: + ambiguous = service_names(mera) & service_names(ptica) + if not ambiguous: + return [] + found: list[str] = [] + for num, line in enumerate(mera.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + for host in _HOST_RE.findall(line): + if host in ambiguous: + found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}") + return found + + +def selftest() -> int: + """Гейт обязан уметь краснеть — иначе зелёный ничего не значит.""" + ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n" + + # У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение + # пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить». + # На этом селфтест и провалился в первой редакции. + bad = ( + "services:\n backend:\n image: x\n" + " frontend:\n environment:\n URL: \"http://backend:8000\"\n" + ) + if not violations(bad, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr) + return 1 + + bad_dsn = ( + "services:\n postgres:\n image: y\n" + " backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n" + ) + if not violations(bad_dsn, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr) + return 1 + + ok = ( + "services:\n backend:\n image: x\n" + " frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n" + ) + if violations(ok, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr) + return 1 + + # Закомментированная строка — не конфигурация. + commented = ( + "services:\n backend:\n image: x\n" + " frontend:\n environment:\n # URL: \"http://backend:8000\"\n" + ) + if violations(commented, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr) + return 1 + + print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть") + args = ap.parse_args() + + if args.selftest: + return selftest() + + for path in (MERA_COMPOSE, PTICA_COMPOSE): + if not path.exists(): + print(f"НЕ НАЙДЕН: {path}", file=sys.stderr) + return 1 + + mera = MERA_COMPOSE.read_text(encoding="utf-8") + ptica = PTICA_COMPOSE.read_text(encoding="utf-8") + + shared = service_names(mera) & service_names(ptica) + if not shared: + print("пересекающихся имён сервисов нет — проверять нечего") + return 0 + + found = violations(mera, ptica) + if not found: + print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет") + return 0 + + print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr) + for line in found: + print(f" {line}", file=sys.stderr) + print( + "\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n" + "в два адреса и клиент берёт любой. Подставьте container_name\n" + "(tradein-backend, tradein-postgres) — он однозначен.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 9e229983..d7ef9bb6 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -294,7 +294,7 @@ services: # Деградация источника в None — существующее состояние ответа (так же # ведёт себя таймаут), контракт API не меняется. ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true" - DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein" PUBLIC_URL: "https://gendsgn.ru/trade-in" CORS_ORIGINS: '["https://gendsgn.ru"]' ENVIRONMENT: "production" @@ -373,7 +373,7 @@ services: - path: ./backend/.env.runtime required: false environment: - DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein" ENVIRONMENT: "production" GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}" @@ -432,7 +432,7 @@ services: - path: ./backend/.env.runtime required: false environment: - DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein" ENVIRONMENT: "production" GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" # Этот процесс — не scheduler_main; false на всякий случай, если общий @@ -468,7 +468,15 @@ services: # только свой hostname-интерфейс и недоступен как tradein-frontend:3000. # Принудительно 0.0.0.0 чтобы Caddy достучался. HOSTNAME: "0.0.0.0" - BACKEND_URL: "http://backend:8000" # internal SSR + # ИМЯ ОБЯЗАНО БЫТЬ ОДНОЗНАЧНЫМ. Оба продукта назвали свой сервис + # `backend`, и оба подключены к общей сети gendesign_shared — + # изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6 + # и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает + # 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и + # подписи разброса, то есть страница про точность без доказательств. + # Отказ тихий: fetch не бросает, приходит валидный 401. + # `tradein-backend` — container_name, он однозначен. + BACKEND_URL: "http://tradein-backend:8000" # internal SSR depends_on: [backend] restart: unless-stopped networks: -- 2.45.3