- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
31 KiB
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, один раз. Адресат — владелец, и сообщение ровно того рода, который конвенция называет «может стать проблемой»: сервис называет пришедшего сам. Поля:capabilityaccess, перечень имён подставляемых заголовков. Значений в строке нет: логин — это ключ к чужому архиву, и спека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
Развилок не осталось: все закрыты владельцем на чекпоинте.