Files
transcriber/openspec/changes/archive/2026-08-23-config-test-headers-login/design.md
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

31 KiB
Raw Blame History

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 пара пишется через =, а не через :. Пример владельца записывается так:

[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

Развилок не осталось: все закрыты владельцем на чекпоинте.