## Context Сервис не ведёт входа сам: кто пришёл, называет обратный прокси заголовками `Remote-User`, `Remote-Name` и `Remote-Email`, а сервис верит им, когда соединение открыто с адреса из перечня `[auth] trusted_proxies`. Имена заголовков объявлены константами транспорта в `internal/controller/http/identity.go`, и там же записано, что настройкой их не делают. Барьер описан в `docs/security.md`, «Периметр», и нормирован спекой `access`. На машине разработчика прокси нет, браузер заголовков не ставит, и приложение локально не открылось бы вовсе. На это место сегодня встаёт подставной прокси `go run ./cmd/devtools proxy`: отдельный процесс, слушающий свой порт, ставящий три заголовка и переправляющий запрос сервису. Локальный запуск поэтому идёт в два процесса, приложение открывают по адресу прокси, а перечень доверенных адресов в конфиге приходится переставлять на петлевой. Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге. Изменение переносит подстановку заголовков в сам сервис и закрывает её предохранителем. Ограничение, из которого читается всё остальное: **подстановка заголовков — это настройка, которой сервис называет пришедшего сам, никого не спросив.** Всякий, до кого она дотянется, получит чужой архив. Дизайн поэтому наполовину состоит из того, что подстановке запрещено. ## Goals / Non-Goals **Goals:** - Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем назвался пришедший, — отличает тестовый прогон от боевого содержимым файла настроек, и ничем больше. - Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок неотличим от пришедшего от Caddy к моменту, когда его читает узнавание. - Настройка, при которой отладочный вход становится открытым входом, роняет старт, а не работает молча. - Отладочный запуск виден владельцу в журнале. **Non-Goals:** - Уровень журнала, тексты внутренних отказов в ответах, подмена распознавателя, снятие ограничителя частоты, послабления проверок конфига — ничего этого новый признак не включает (см. решение 7). - Своего входа, ролей и проверки допуска сервис по-прежнему не заводит: подстановка называет пришедшего, а не судит его. - Автотесты и разработческие сценарии, которым нужна учётная запись, продолжают ставить заголовок сами и в подстановке не нуждаются. - Граница работы — заголовки входа. Подмена распознавания в конфиг не переезжает: `internal/adapter/recognizer/memory.go` подставляется правкой кода, и решением владельца так и остаётся. ## Decisions ### 1. Где подставляются заголовки: отдельный слой перед узнаванием **Решено:** отдельный слой цепочки корня приложения, стоящий **перед** `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки запроса и ничего больше не делает: учётной записи не заводит, отказов не выдаёт, в контекст не пишет. Узнавание получает запрос, неотличимый от пришедшего с сервера. Слой собирается только при включённом предохранителе и непустой имитации. При выключенном — цепочка та же, что сегодня, без единого лишнего звена. Рассмотрено и отвергнуто: - **Подстановка внутри `TrustedHeaderIdentity`** — узнавание получило бы второй источник значений и ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный двойник, и это ровно то, чего изменение обязано избежать. - **Подстановка в точке входа, до сборки цепочки** — подставлять нечего: значения ставятся на запрос, а не на сервис. - **Слой снаружи ограничителя частоты** — ограничитель считает по адресу спрашивающего, и заголовки входа его не касаются. Ставить слой снаружи значит трогать порядок, который сегодня верен, без единой причины. Что человек увидит иначе: приложение открывается по адресу сервиса, второго порта нет, и в журнале запроса стоят те же строки узнавания, что на сервере. ### 2. Область слоя: только корень приложения Слой вешается туда же, где висит узнавание, — на цепочку корня приложения, — и область его выводится из того же перечня адресов, а не перечисляется вторым списком. Проба здоровья, метрики и раздача приложения подстановки не видят: там учётной записи нет и не нужно. ### 3. `debug = false` при непустой имитации: отказ старта **Решено:** старт роняется с именем ключа. Не предупреждение и не молчаливое игнорирование. Причина: состояние «имитация заполнена, предохранитель выключен» не читается никак, и обе его прочтения — поломка. - Человек забыл включить предохранитель. Сервис поднимется, никого не узнает, и человек будет искать поломку во входе, в прокси и в перечне адресов — везде, кроме одного булева ключа. Предупреждение в журнале его не спасёт: журнал локального запуска читают тогда, когда уже что-то сломалось. - Человек забыл убрать имитацию из боевого файла. Тогда в выкладке лежит конфиг, которому недостаёт одного слова, чтобы открыть архив всем. Отказ старта — тот случай, когда поломку ловят на порядок раньше, чем она стоит денег. Рассмотрено и отвергнуто: - **Молчаливое игнорирование** — прямо против тона проекта: инвариант «Принятая запись не теряется молча» описывает ту же болезнь на соседнем предмете. - **Предупреждение в журнале** — оставляет заполненную имитацию в боевом файле жить сколько угодно. Обратная сторона: `debug = true` при пустой имитации отказом **не** считается. Это законный запуск — признак сам по себе ничего не включает. ### 4. `debug = true` в бою: чем опасно и что ограничивает подстановку Опасность названа моделью угроз прямо: всякий, кто дотянулся до сервиса, называет себя кем угодно и получает чужой архив. Подстановка делает это хуже барьера `trusted_proxies` по двум причинам. Первая: подставляется по **отсутствию** заголовка, то есть запрос, пришедший мимо прокси, — именно тот, который сегодня остаётся неузнанным, — получит имя. Вторая: требование к Caddy «заголовки `Remote-*` перезаписывать, а не пропускать» здесь не помогает вовсе, потому что перезаписывать нечего. **Решено — одно ограничение: подставленный заголовок проходит тот же барьер, что и настоящий.** Слой подставляет значения только тогда, когда адрес соединения попадает в `trusted_proxies`. Судит он его **той же функцией**, какой судит узнавание: разбор адреса пира и сверка с перечнем берутся из одного дома (`internal/controller/http/identity.go`), а не пишутся вторым списком, — так же, как решение 6 поступает с именами заголовков. Второй сверщик разошёлся бы с первым молча: узнавание разворачивает IPv4 в оболочке IPv6, и слой, написавший свою сверку, отличался бы от него ровно на тех адресах, ради которых заводится. Это не предохранитель, а согласованность с барьером узнавания: от конфига она не требует ничего и круга тех, кто мог назваться кем угодно, не расширяет — он и так очерчен перечнем. **Адресного предохранителя не делаем.** Решение владельца на чекпоинте, дословно: «предохранитель по адресам не делаем, полагаемся только на параметр debug». Рассматривалось требование, чтобы при включённом предохранителе перечень доверенных адресов состоял только из петлевых записей; оно снято вместе с предикатом «петлевая запись», который заводился ровно ради него. Что этим куплено: отладочный вход работает **внутри контейнера** — адрес пира там принадлежит сети докера, и она же стоит в боевом перечне, — а локальный прогон не переставляет перечень доверенных адресов на петлевой: петлевые записи добавляются к тем, что в нём уже стоят. Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит машина. Сервис, поднятый в бою с включённым предохранителем и заполненной имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, — а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос, пришедший обычным путём**. Между боевой выкладкой и открытым входом остаётся три вещи, и других нет: умолчание предохранителя «выключено»; отказ старта при заполненной имитации без предохранителя (решение 3); боевой конфиг, который рендерится шаблоном Ansible, а не копируется с машины разработчика. Что человек увидит иначе: отладочный вход работает везде, где адрес соединения попадает в перечень доверенных, — и на машине разработчика, и внутри контейнера. Перечень при этом остаётся тем, что он есть: границей доверия, а не признаком отладки. Рассмотрено и отвергнуто: - **Требовать петлевой перечень при включённом предохранителе** — снято решением владельца, причина выше. Держалось предикатом «петлевая запись», который заводился ради этой одной проверки. - **Принудительно слушать петлевой адрес при включённом предохранителе** — меняет поведение молча (адрес прослушивания задан числом порта, и человек увидел бы «порт занят никем»), и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не петлю. - **Новый ключ `[server] listen`** — публичная поверхность настроек ради предохранителя, которого решением владельца нет. - **Вырезать подстановку из боевой сборки тегом сборки** — сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег, забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3. ### 5. Уже пришедший заголовок: подставляем только при отсутствии `Remote-User` **Решено:** решение принимает **наличие `Remote-User`**, а не его значение. Заголовок есть в любом числе значений — слой не трогает запрос вовсе и передаёт его дальше как есть. Заголовка нет ни одного — слой ставит всю тройку `Remote-*`, которой распоряжается: названные имитацией — значением из конфига, не названные — удаляет. Почему по наличию, а не по значению: узнавание отвергает запрос с более чем одним `Remote-User`, и это его главная защита от прокси, который заголовок добавляет вместо замены. Слой, который «дополнил бы пустое место», сам создал бы второе значение и превратил бы законный отказ в отладочный проход. Почему удаляем не названные имитацией: запрос без `Remote-User`, но с `Remote-Email`, иначе собрал бы личность из конфига и из присланного — логин свой, почта чужая. Слой владеет тройкой целиком или не трогает её вовсе. Рассмотрено и отвергнуто: - **Заменять всегда** — сервис, стирающий сказанное прокси, перестаёт быть похожим на боевой, и ветки «пришло два значения» и «заголовок с недоверенного адреса» становятся локально невоспроизводимыми. - **Подставлять по пустому значению `Remote-User`** — пустое значение штатно шлёт прокси там, где никого не назвал; подстановка на этом месте назвала бы человеком того, кому провайдер отказал. ### 6. Имена заголовков: ключами конфига, но перечень порождается константами Цена названа прямо: сегодня имена — константы транспорта, и у них один дом. Секция, где имя заголовка служит именем ключа, даёт этим именам **второе упоминание**, а второе упоминание расходится с первым молча. **Решено:** имя ключа секции — имя заголовка, но набор принимаемых имён порождается теми же константами транспорта. Ключ, не совпавший ни с одной из них, роняет старт и называет принимаемые имена. Сравнение идёт по каноническому виду имени заголовка, потому что в HTTP имя нечувствительно к регистру, а в TOML ключ чувствителен. Так второго **дома** не появляется: дом остаётся один, а конфиг лишь называет его содержимое, и совпадение сверяет машина, а не внимательность. Рассмотрено и отвергнуто: - **Произвольная карта имён** — опечатка `Remote-Usr` даёт «сервис меня не узнаёт» без единого следа, а выгоды нет: сервис читает ровно три имени и четвёртое читать не умеет. - **Свои имена ключей вместо имён заголовков** (`login`, `name`, `email`) — самое дешёвое для инварианта, но теряет то, ради чего секция заводится: конфиг перестаёт выглядеть как то, что ставит Caddy, и владелец назвал форму прямо. Оставлено вариантом на случай, если человек решит платить иначе. Замечание по форме: в TOML пара пишется через `=`, а не через `:`. Пример владельца записывается так: ```toml [auth.test_headers] Remote-User = "foobar" Remote-Email = "foobar@example.com" ``` ### 7. Что означает `[server] debug` и чего он не включает Признак означает одно: **этот прогон идёт на машине разработчика, и сервису позволено подставить то, что в бою даёт окружение.** Сегодня подставляется ровно одна вещь — заголовки входа. Чего признак **не** включает и включать не должен без отдельного решения человека: - уровень журнала (он зашит и настройкой не является); - тексты внутренних отказов и следы стека в ответах; - вывод тел запросов и ответов внешних сервисов; - снятие или послабление ограничителя частоты; - подмену распознавателя памятью — это подстановка в коде, `internal/adapter/recognizer/memory.go`, и настройкой она не делается; - послабление любой проверки старта; - открытие любого адреса неузнанному. Правило для следующей задачи: новое поведение вешается на этот ключ только решением владельца и получает **свою** строку в спеке `access` или в спеке своей capability. Ключ — предохранитель с закрытым перечнем следствий, а не режим с открытым. ### 8. Где живёт проверка Проверка охватывает две секции разом — `[server] debug` против `[auth] test_headers` и `[auth] trusted_proxies`, — и потому не помещается ни в `AuthConfig.Validate()`, ни в проверку секции сервера. **Решено:** метод на корневой `Config`, названный своим предметом, зовётся из `cmd/transcriber` рядом с тремя имеющимися проверками. Закрыть расхождение «единого места проверки нет», названное в `docs/conventions/config.md`, он не пытается: это отдельная работа. Рассмотрено и отвергнуто: передавать признак отладки внутрь `AuthConfig.Validate()` — секция начала бы знать о чужой секции ради одного булева значения. ### 9. Журнал - **Старт с включённой подстановкой** — уровень `WARN`, один раз. Адресат — владелец, и сообщение ровно того рода, который конвенция называет «может стать проблемой»: сервис называет пришедшего сам. Поля: `capability` `access`, перечень **имён** подставляемых заголовков. Значений в строке нет: логин — это ключ к чужому архиву, и спека `access` запрещает его печатать наравне с адресом почты. - **Запрос, которому заголовки подставлены** — уровень `DEBUG`. Случай штатный и частый: так выглядит каждый запрос локального прогона, и `INFO` затопил бы журнал. Поля те же, что у узнавания: `http.peer_addr`, `capability`, `transport`. - **Запрос, которому подставить нельзя** (предохранитель включён, но адрес пира не доверенный) — уровень `WARN`, своей строкой. Поля: `http.peer_addr`, `capability`, `transport`; подставляемых значений в ней нет. Прежняя посылка «узнавание уже пишет здесь предупреждение» неверна, и это проверено по коду. Ветка узнавания выбирается по числу значений `Remote-User` **до** сверки адреса пира: заголовка нет — пишется `Debug("Request carries no login header")`, и запрос уходит дальше. Предупреждение `Login header came from an untrusted peer` достижимо только тогда, когда заголовок **есть**, а подстановка работает ровно при его отсутствии. На отказном пути подстановки предупреждения не бывает никогда. Без своей строки самый частый локальный отказ остаётся без следа: браузер идёт на `localhost`, тот разрешается в `::1`, а в `trusted_proxies` по образцу стоит `127.0.0.1`. Человек видит `401` на всём приложении, заполненную секцию имитации, включённый предохранитель — и ни одной строки о том, что подстановка не сработала. Отсюда же требование к образцу конфига: рядом с `127.0.0.1` в перечне обязан стоять `::1`. - Строка `Account created from login header` работает как работала: первый запрос локального прогона заводит учётную запись и виден на `INFO`. ### 10. Подкоманда `cmd/devtools proxy` убирается целиком **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа входить локально не остаётся тоже. Цена названа и принята: поломки контура — два значения `Remote-User`, заголовок с недоверенного адреса, цепочка `X-Forwarded-For` — местным инструментом больше не воспроизводятся. Воспроизводит их теперь только автотест, ставящий заголовок сам. Уходит вместе с подкомандой: её флаги, шапка пакета `cmd/devtools/main.go` — она сегодня говорит «подкоманд две» и описывает прокси, — строка подкоманды в `usage()` и упоминания прокси в документах. Адреса перечислены шагом 4.4 `tasks.md`. Рассмотрено и отвергнуто владельцем: - **Оставить как есть** — два способа войти локально, и документация обязана каждый раз говорить, какой из них чей. - **Оставить, сузив назначение** до воспроизведения поломок контура — тот же второй способ, только не названный в инструкции локального запуска. ## Risks / Trade-offs - **Отладочный вход уезжает в боевой конфиг** → умолчание предохранителя «выключено» и отказ старта при заполненной имитации без предохранителя (решение 3). Проверка идёт на старте, до приёма трафика, и ловит она ровно этот случай — заполненную имитацию при выключенном предохранителе. - **Разработчик оставил `debug = true` и выложился** → выкладка рендерит конфиг из шаблона Ansible, а не копирует локальный файл. Другой защиты нет, и это принято решением владельца (решение 4): боевой сервис с включённым предохранителем и заполненной имитацией отдаст архив всякому, чей запрос пришёл через обратный прокси, то есть всякому, кто пришёл обычным путём. - **Имя заголовка в конфиге разошлось с константой транспорта** → перечень принимаемых имён порождается константами, неизвестный ключ роняет старт (решение 6). - **Поломки контура перестали воспроизводиться локально** → подкоманда `proxy` убрана (решение 10); остаются автотесты, которые ставят заголовок сами. - **Ключ `debug` обрастёт чужими следствиями** → закрытый перечень в решении 7 и правило «новое следствие — своя строка в спеке». - **Тестовый пользователь копится в базе.** Каждый новый логин в имитации заводит учётную запись, а удалять учётные записи сервис не умеет. Локальная база ронется и пересоздаётся свободно; в бою подстановка выключена, а включённая была бы поломкой много хуже лишней учётной записи. ## Migration Plan Шага схемы изменение не требует, данных не трогает. Существующий конфиг продолжает работать без правки: умолчание предохранителя — «выключено», секция имитации по умолчанию пуста, и цепочка слоёв при этом та же, что сегодня. Откат — удаление двух ключей из конфига. ## Open Questions Развилок не осталось: все закрыты владельцем на чекпоинте.